Skip to content

Commit f4331d1

Browse files
committed
fix(ssrf): harden trusted-host loopback checks
1 parent bc7133a commit f4331d1

4 files changed

Lines changed: 101 additions & 137 deletions

File tree

scripts/proof/proof-ssrf-loopback.mts

Lines changed: 0 additions & 86 deletions
This file was deleted.

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,52 @@ describe("createPinnedDispatcher", () => {
276276
).toThrow(/private|internal|blocked/i);
277277
});
278278

279+
it("rejects a trusted private hostname override rebound to loopback", () => {
280+
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
281+
const pinned: PinnedHostname = {
282+
hostname: "model.lan",
283+
addresses: ["192.168.1.25"],
284+
lookup,
285+
};
286+
287+
expect(() =>
288+
createPinnedDispatcher(
289+
pinned,
290+
{
291+
mode: "direct",
292+
pinnedHostname: {
293+
hostname: "model.lan",
294+
addresses: ["64:ff9b::127.0.0.1"],
295+
},
296+
},
297+
{ allowedHostnames: ["model.lan"] },
298+
),
299+
).toThrow(/private|internal|blocked/i);
300+
});
301+
302+
it("allows an explicitly trusted localhost.localdomain override", () => {
303+
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
304+
const pinned: PinnedHostname = {
305+
hostname: "localhost.localdomain",
306+
addresses: ["127.0.0.1"],
307+
lookup,
308+
};
309+
310+
expect(() =>
311+
createPinnedDispatcher(
312+
pinned,
313+
{
314+
mode: "direct",
315+
pinnedHostname: {
316+
hostname: "localhost.localdomain",
317+
addresses: ["127.0.0.1"],
318+
},
319+
},
320+
{ allowedHostnames: ["localhost.localdomain"] },
321+
),
322+
).not.toThrow();
323+
});
324+
279325
it("keeps env proxy route while pinning the direct no-proxy path", () => {
280326
const lookup = vi.fn() as unknown as PinnedHostname["lookup"];
281327
const pinned: PinnedHostname = {

src/infra/net/ssrf.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -411,20 +411,38 @@ function assertAllowedResolvedAddressesOrThrow(
411411
}
412412
}
413413

414+
function isLoopbackIpAddressIncludingEmbeddedIpv4(address: string): boolean {
415+
// Keep this stricter SSRF classifier local: locality/auth callers intentionally
416+
// recognize only canonical loopback forms, while DNS answers need all encodings.
417+
if (isLoopbackIpAddress(address)) {
418+
return true;
419+
}
420+
const parsed = parseCanonicalIpAddress(address);
421+
if (!parsed || isIpv4Address(parsed)) {
422+
return false;
423+
}
424+
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(parsed);
425+
return embeddedIpv4?.range() === "loopback";
426+
}
427+
428+
function isExplicitLoopbackHostname(hostname: string): boolean {
429+
return (
430+
hostname === "localhost" ||
431+
hostname === "localhost.localdomain" ||
432+
hostname.endsWith(".localhost") ||
433+
isLoopbackIpAddressIncludingEmbeddedIpv4(hostname)
434+
);
435+
}
436+
414437
function assertAllowedTrustedHostnameResolvedAddressesOrThrow(
415438
results: readonly LookupAddress[],
416439
hostname: string,
417440
): void {
418-
const isLoopbackAllowed =
419-
hostname === "localhost" ||
420-
hostname === "127.0.0.1" ||
421-
hostname === "::1" ||
422-
hostname.endsWith(".localhost") ||
423-
isLoopbackIpAddress(hostname);
441+
const isLoopbackAllowed = isExplicitLoopbackHostname(hostname);
424442

425443
for (const entry of results) {
426444
if (
427-
(!isLoopbackAllowed && isLoopbackIpAddress(entry.address)) ||
445+
(!isLoopbackAllowed && isLoopbackIpAddressIncludingEmbeddedIpv4(entry.address)) ||
428446
isLinkLocalIpAddress(entry.address) ||
429447
isCloudMetadataIpAddress(entry.address)
430448
) {

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

Lines changed: 30 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -459,60 +459,46 @@ describe("ssrfPolicyFromHttpBaseUrlAllowedOrigin — SDK boundary safety", () =>
459459
).rejects.toThrow(SsrFBlockedError);
460460
});
461461

462-
it("rebinding a trusted private origin to a loopback IP is rejected", async () => {
463-
const baseUrl = "http://lan-llm.corp.internal:11434/v1";
464-
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
465-
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
466-
467-
await expect(
468-
resolvePinnedHostnameWithPolicy("lan-llm.corp.internal", {
469-
policy: policyForUrl,
470-
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
471-
}),
472-
).rejects.toThrow(SsrFBlockedError);
473-
});
474-
475-
it("rebinding a trusted private origin to IPv6 loopback is rejected", async () => {
462+
it.each([
463+
["IPv4 loopback", "127.0.0.1", 4],
464+
["IPv6 loopback", "::1", 6],
465+
["IPv4-mapped IPv6 loopback", "::ffff:127.0.0.1", 6],
466+
["NAT64-embedded IPv4 loopback", "64:ff9b::127.0.0.1", 6],
467+
["ISATAP-embedded IPv4 loopback", "2001:4860:1::5efe:7f00:1", 6],
468+
] as const)("rejects a trusted private origin rebound to %s", async (_name, address, family) => {
476469
const baseUrl = "http://lan-llm.corp.internal:11434/v1";
477470
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
478471
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
479472

480473
await expect(
481474
resolvePinnedHostnameWithPolicy("lan-llm.corp.internal", {
482475
policy: policyForUrl,
483-
lookupFn: createLookupFn([{ address: "::1", family: 6 }]),
476+
lookupFn: createLookupFn([{ address, family }]),
484477
}),
485478
).rejects.toThrow(SsrFBlockedError);
486479
});
487480

488-
it("explicit localhost origin resolving to 127.0.0.1 is still allowed", async () => {
489-
// When the trusted origin IS localhost itself (not a rebinding), the loopback
490-
// resolution must be permitted so local-dev provider setups keep working.
491-
const baseUrl = "http://localhost:11434/v1";
492-
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
493-
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
494-
495-
// Must resolve without throwing — loopback allowed because hostname === "localhost"
496-
await expect(
497-
resolvePinnedHostnameWithPolicy("localhost", {
498-
policy: policyForUrl,
499-
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
500-
}),
501-
).resolves.toBeDefined();
502-
});
503-
504-
it("explicit 127.0.0.1 origin resolving to loopback is still allowed", async () => {
505-
// A plugin that explicitly targets 127.0.0.1 directly as its base URL
506-
// must not be blocked — the operator chose loopback intentionally.
507-
const baseUrl = "http://127.0.0.1:11434/v1";
508-
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
509-
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
481+
it.each([
482+
["localhost", "127.0.0.1", 4],
483+
["localhost.localdomain", "127.0.0.1", 4],
484+
["api.localhost", "::1", 6],
485+
["127.0.0.1", "127.0.0.1", 4],
486+
["[::1]", "::1", 6],
487+
["[64:ff9b::127.0.0.1]", "64:ff9b::127.0.0.1", 6],
488+
] as const)(
489+
"allows an explicit %s origin to resolve to loopback",
490+
async (host, address, family) => {
491+
const baseUrl = `http://${host}:11434/v1`;
492+
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
493+
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
494+
const hostname = new URL(baseUrl).hostname.replace(/^\[|\]$/g, "");
510495

511-
await expect(
512-
resolvePinnedHostnameWithPolicy("127.0.0.1", {
513-
policy: policyForUrl,
514-
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
515-
}),
516-
).resolves.toBeDefined();
517-
});
496+
await expect(
497+
resolvePinnedHostnameWithPolicy(hostname, {
498+
policy: policyForUrl,
499+
lookupFn: createLookupFn([{ address, family }]),
500+
}),
501+
).resolves.toBeDefined();
502+
},
503+
);
518504
});

0 commit comments

Comments
 (0)