Skip to content

Commit dd4557e

Browse files
committed
fix: block unspecified trusted DNS targets
1 parent 8656c3b commit dd4557e

5 files changed

Lines changed: 115 additions & 0 deletions

File tree

src/infra/net/fetch-guard.ssrf.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,30 @@ describe("fetchWithSsrFGuard hardening", () => {
860860
expect(fetchImpl).not.toHaveBeenCalled();
861861
});
862862

863+
it.each([
864+
["IPv4 unspecified", "0.0.0.0", 4],
865+
["IPv4 unspecified range", "0.42.42.42", 4],
866+
["IPv6 unspecified", "::", 6],
867+
["IPv4-mapped IPv6 unspecified", "::ffff:0.0.0.0", 6],
868+
["NAT64-embedded IPv4 unspecified", "64:ff9b::0.0.0.0", 6],
869+
] as const)(
870+
"blocks exact-origin private DNS when it resolves to %s",
871+
async (_name, address, family) => {
872+
const lookupFn: LookupFn = vi.fn(async () => [{ address, family }]) as unknown as LookupFn;
873+
const fetchImpl = vi.fn(async () => okResponse());
874+
875+
await expect(
876+
fetchWithSsrFGuard({
877+
url: "http://model.lan:11434/v1/models",
878+
fetchImpl,
879+
lookupFn,
880+
policy: { allowedOrigins: ["http://model.lan:11434"] },
881+
}),
882+
).rejects.toThrow(/private|internal|blocked/i);
883+
expect(fetchImpl).not.toHaveBeenCalled();
884+
},
885+
);
886+
863887
it("allows a configured IPv6 unique-local exact origin through the guard", async () => {
864888
const fetchImpl = vi.fn(async () => okResponse());
865889

src/infra/net/ssrf.dispatcher.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,29 @@ describe("createPinnedDispatcher", () => {
299299
).toThrow(/private|internal|blocked/i);
300300
});
301301

302+
it("rejects a trusted private hostname override rebound to an unspecified address", () => {
303+
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
304+
const pinned: PinnedHostname = {
305+
hostname: "model.lan",
306+
addresses: ["192.168.1.25"],
307+
lookup,
308+
};
309+
310+
expect(() =>
311+
createPinnedDispatcher(
312+
pinned,
313+
{
314+
mode: "direct",
315+
pinnedHostname: {
316+
hostname: "model.lan",
317+
addresses: ["0.0.0.0"],
318+
},
319+
},
320+
{ allowedHostnames: ["model.lan"] },
321+
),
322+
).toThrow(/private|internal|blocked/i);
323+
});
324+
302325
it("allows an explicitly trusted localhost.localdomain override", () => {
303326
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
304327
const pinned: PinnedHostname = {

src/infra/net/ssrf.pinning.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,4 +263,35 @@ describe("ssrf pinning", () => {
263263
expect(pinned.addresses).toEqual(["127.0.0.1"]);
264264
expect(lookup).toHaveBeenCalledTimes(1);
265265
});
266+
267+
it.each([
268+
["IPv4 unspecified", "0.0.0.0", 4],
269+
["IPv4 unspecified range", "0.42.42.42", 4],
270+
["IPv6 unspecified", "::", 6],
271+
["IPv4-mapped IPv6 unspecified", "::ffff:0.0.0.0", 6],
272+
["NAT64-embedded IPv4 unspecified", "64:ff9b::0.0.0.0", 6],
273+
] as const)(
274+
"rejects a trusted private hostname rebound to %s",
275+
async (_name, address, family) => {
276+
const lookup = vi.fn(async () => [{ address, family }]) as unknown as LookupFn;
277+
278+
await expect(
279+
resolvePinnedHostnameWithPolicy("model.lan", {
280+
lookupFn: lookup,
281+
policy: { allowedHostnames: ["model.lan"] },
282+
}),
283+
).rejects.toThrow(SsrFBlockedError);
284+
},
285+
);
286+
287+
it("does not allow explicit localhost trust to resolve through an unspecified address", async () => {
288+
const lookup = vi.fn(async () => [{ address: "0.0.0.0", family: 4 }]) as unknown as LookupFn;
289+
290+
await expect(
291+
resolvePinnedHostnameWithPolicy("localhost", {
292+
lookupFn: lookup,
293+
policy: { allowedHostnames: ["localhost"] },
294+
}),
295+
).rejects.toThrow(SsrFBlockedError);
296+
});
266297
});

src/infra/net/ssrf.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,23 @@ function isLoopbackIpAddressIncludingEmbeddedIpv4(address: string): boolean {
425425
return embeddedIpv4?.range() === "loopback";
426426
}
427427

428+
function isUnspecifiedIpAddressIncludingEmbeddedIpv4(address: string): boolean {
429+
const parsed = parseCanonicalIpAddress(address);
430+
if (!parsed) {
431+
return false;
432+
}
433+
if (isIpv4Address(parsed)) {
434+
return parsed.range() === "unspecified";
435+
}
436+
if (parsed.range() === "unspecified") {
437+
return true;
438+
}
439+
if (parsed.range() === "loopback") {
440+
return false;
441+
}
442+
return extractEmbeddedIpv4FromIpv6(parsed)?.range() === "unspecified";
443+
}
444+
428445
function isExplicitLoopbackHostname(hostname: string): boolean {
429446
return (
430447
hostname === "localhost" ||
@@ -442,6 +459,7 @@ function assertAllowedTrustedHostnameResolvedAddressesOrThrow(
442459

443460
for (const entry of results) {
444461
if (
462+
isUnspecifiedIpAddressIncludingEmbeddedIpv4(entry.address) ||
445463
(!isLoopbackAllowed && isLoopbackIpAddressIncludingEmbeddedIpv4(entry.address)) ||
446464
isLinkLocalIpAddress(entry.address) ||
447465
isCloudMetadataIpAddress(entry.address)

src/plugin-sdk/ssrf-policy.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,25 @@ describe("ssrfPolicyFromHttpBaseUrlAllowedOrigin — SDK boundary safety", () =>
478478
).rejects.toThrow(SsrFBlockedError);
479479
});
480480

481+
it.each([
482+
["IPv4 unspecified", "0.0.0.0", 4],
483+
["IPv4 unspecified range", "0.42.42.42", 4],
484+
["IPv6 unspecified", "::", 6],
485+
["IPv4-mapped IPv6 unspecified", "::ffff:0.0.0.0", 6],
486+
["NAT64-embedded IPv4 unspecified", "64:ff9b::0.0.0.0", 6],
487+
] as const)("rejects a trusted private origin rebound to %s", async (_name, address, family) => {
488+
const baseUrl = "http://lan-llm.corp.internal:11434/v1";
489+
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
490+
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
491+
492+
await expect(
493+
resolvePinnedHostnameWithPolicy("lan-llm.corp.internal", {
494+
policy: policyForUrl,
495+
lookupFn: createLookupFn([{ address, family }]),
496+
}),
497+
).rejects.toThrow(SsrFBlockedError);
498+
});
499+
481500
it.each([
482501
["localhost", "127.0.0.1", 4],
483502
["localhost.localdomain", "127.0.0.1", 4],

0 commit comments

Comments
 (0)