fix: block ISATAP SSRF bypass via shared host/ip guard

This commit is contained in:
Peter Steinberger
2026-02-19 09:59:34 +01:00
parent 4cd5fad14b
commit d51929ecb5
9 changed files with 72 additions and 96 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { normalizeFingerprint } from "../tls/fingerprint.js";
import { isPrivateIpAddress } from "./ssrf.js";
import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js";
const privateIpCases = [
"::ffff:127.0.0.1",
@@ -24,6 +24,8 @@ const privateIpCases = [
"fe80::1%lo0",
"fd00::1",
"fec0::1",
"2001:db8:1234::5efe:127.0.0.1",
"2001:db8:1234:1:200:5efe:7f00:1",
];
const publicIpCases = [
@@ -34,6 +36,8 @@ const publicIpCases = [
"64:ff9b:1::8.8.8.8",
"2002:0808:0808::",
"2001:0000:0:0:0:0:f7f7:f7f7",
"2001:db8:1234::5efe:8.8.8.8",
"2001:db8:1234:1:1111:5efe:7f00:1",
];
const malformedIpv6Cases = ["::::", "2001:db8::gggg"];
@@ -59,3 +63,15 @@ describe("normalizeFingerprint", () => {
expect(normalizeFingerprint("aa:bb:cc")).toBe("aabbcc");
});
});
describe("isBlockedHostnameOrIp", () => {
it("blocks localhost.localdomain and metadata hostname aliases", () => {
expect(isBlockedHostnameOrIp("localhost.localdomain")).toBe(true);
expect(isBlockedHostnameOrIp("metadata.google.internal")).toBe(true);
});
it("blocks private transition addresses via shared IP classifier", () => {
expect(isBlockedHostnameOrIp("2001:db8:1234::5efe:127.0.0.1")).toBe(true);
expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false);
});
});

View File

@@ -24,7 +24,11 @@ export type SsrFPolicy = {
hostnameAllowlist?: string[];
};
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]);
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"localhost.localdomain",
"metadata.google.internal",
]);
function normalizeHostnameSet(values?: string[]): Set<string> {
if (!values || values.length === 0) {
@@ -195,6 +199,12 @@ const EMBEDDED_IPV4_RULES: EmbeddedIpv4Rule[] = [
matches: (hextets) => hextets[0] === 0x2001 && hextets[1] === 0x0000,
extract: (hextets) => [hextets[6] ^ 0xffff, hextets[7] ^ 0xffff],
},
{
// ISATAP IID format: 000000ug00000000:5efe:w.x.y.z (RFC 5214 section 6.1).
// Match only the IID marker bits to avoid over-broad :5efe: detection.
matches: (hextets) => (hextets[4] & 0xfcff) === 0 && hextets[5] === 0x5efe,
extract: (hextets) => [hextets[6], hextets[7]],
},
];
function extractIpv4FromEmbeddedIpv6(hextets: number[]): number[] | null {
@@ -316,6 +326,14 @@ export function isBlockedHostname(hostname: string): boolean {
);
}
export function isBlockedHostnameOrIp(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
if (!normalized) {
return false;
}
return isBlockedHostname(normalized) || isPrivateIpAddress(normalized);
}
export function createPinnedLookup(params: {
hostname: string;
addresses: string[];

View File

@@ -26,6 +26,7 @@ describe("extractLinksFromMessage", () => {
it("blocks localhost and common loopback addresses", () => {
expect(extractLinksFromMessage("http://localhost/secret")).toEqual([]);
expect(extractLinksFromMessage("http://localhost.localdomain/secret")).toEqual([]);
expect(extractLinksFromMessage("http://foo.localhost/secret")).toEqual([]);
expect(extractLinksFromMessage("http://service.local/secret")).toEqual([]);
expect(extractLinksFromMessage("http://service.internal/secret")).toEqual([]);
@@ -53,6 +54,7 @@ describe("extractLinksFromMessage", () => {
it("blocks private and mapped IPv6 addresses", () => {
expect(extractLinksFromMessage("http://[::ffff:127.0.0.1]/secret")).toEqual([]);
expect(extractLinksFromMessage("http://[2001:db8:1234::5efe:127.0.0.1]/secret")).toEqual([]);
expect(extractLinksFromMessage("http://[fe80::1]/secret")).toEqual([]);
expect(extractLinksFromMessage("http://[fc00::1]/secret")).toEqual([]);
});

View File

@@ -1,4 +1,4 @@
import { isBlockedHostname, isPrivateIpAddress } from "../infra/net/ssrf.js";
import { isBlockedHostnameOrIp } from "../infra/net/ssrf.js";
import { DEFAULT_MAX_LINKS } from "./defaults.js";
// Remove markdown link syntax so only bare URLs are considered.
@@ -22,7 +22,7 @@ function isAllowedUrl(raw: string): boolean {
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return false;
}
if (isBlockedHost(parsed.hostname)) {
if (isBlockedHostnameOrIp(parsed.hostname)) {
return false;
}
return true;
@@ -31,16 +31,6 @@ function isAllowedUrl(raw: string): boolean {
}
}
/** Block loopback, private, link-local, and metadata addresses. */
function isBlockedHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return (
normalized === "localhost.localdomain" ||
isBlockedHostname(normalized) ||
isPrivateIpAddress(normalized)
);
}
export function extractLinksFromMessage(message: string, opts?: { maxLinks?: number }): string[] {
const source = message?.trim();
if (!source) {

View File

@@ -181,7 +181,12 @@ export {
} from "../infra/http-body.js";
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
export { SsrFBlockedError, isBlockedHostname, isPrivateIpAddress } from "../infra/net/ssrf.js";
export {
SsrFBlockedError,
isBlockedHostname,
isBlockedHostnameOrIp,
isPrivateIpAddress,
} from "../infra/net/ssrf.js";
export type { LookupFn, SsrFPolicy } from "../infra/net/ssrf.js";
export { rawDataToString } from "../infra/ws.js";
export { isWSLSync, isWSL2Sync, isWSLEnv } from "../infra/wsl.js";