From 81be5bd7985a6058d778b90b167d2a474f7b8134 Mon Sep 17 00:00:00 2001 From: lxcario Date: Thu, 25 Jun 2026 18:23:12 +0300 Subject: [PATCH] fix(target-url): treat trailing-dot hostnames as loopback in SSRF guard assertNotLocal lowercased the hostname but did not strip a trailing dot, so http://localhost. (the FQDN form of localhost, RFC 6761) and http://localhost%2e bypassed the host === 'localhost' loopback check. IP literals are already dot-normalized by the WHATWG URL parser, so only named hosts were affected. Strips one trailing dot before the comparison. Adds 4 regression tests (3 blocked variants + 1 public-FQDN no-false-positive). --- src/lib/target-url.spec.ts | 25 +++++++++++++++++++++++++ src/lib/target-url.ts | 9 ++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/lib/target-url.spec.ts b/src/lib/target-url.spec.ts index 96f705b..4998bc3 100644 --- a/src/lib/target-url.spec.ts +++ b/src/lib/target-url.spec.ts @@ -210,6 +210,31 @@ describe('assertNotLocal — IPv6 hardening (SSRF bypass guard)', () => { }); }); +describe('assertNotLocal — trailing-dot FQDN normalization (SSRF bypass guard)', () => { + // `localhost.` is the fully-qualified form of `localhost` (RFC 6761 reserves + // both to resolve to loopback). It previously bypassed the + // `host === 'localhost'` check because the WHATWG URL parser keeps the + // trailing dot on named hosts (IP literals are dot-normalized, named hosts + // are not). + it('blocks http://localhost. (trailing-dot loopback)', () => { + expectBlocked('http://localhost.'); + }); + + it('blocks http://localhost.:8080 (trailing-dot loopback with port)', () => { + expectBlocked('http://localhost.:8080'); + }); + + it('blocks http://localhost%2e (percent-encoded trailing dot)', () => { + expectBlocked('http://localhost%2e'); + }); + + // A legitimate public FQDN with a trailing dot must still be allowed + // (no false positive from the dot strip). + it('allows https://example.com. (public FQDN with trailing dot)', () => { + expectAllowed('https://example.com.'); + }); +}); + describe('assertNotLocal — allowed public URLs', () => { it('allows https://example.com', () => { expectAllowed('https://example.com'); diff --git a/src/lib/target-url.ts b/src/lib/target-url.ts index 030fc93..895c0f0 100644 --- a/src/lib/target-url.ts +++ b/src/lib/target-url.ts @@ -41,7 +41,14 @@ export function assertNotLocal(rawUrl: string): void { throw localTargetError('target-url', 'must use http or https scheme'); } - const host = parsed.hostname.toLowerCase(); + // Normalize a single trailing dot in the hostname. `localhost.` is the + // fully-qualified form of `localhost` (RFC 6761 reserves both to resolve to + // loopback), so `http://localhost.` must be rejected just like + // `http://localhost`. Without this strip, the trailing-dot form (also + // reachable via `localhost%2e`) slips past the `host === 'localhost'` check. + // IP literals are already dot-normalized by the WHATWG URL parser, so this + // only affects named hosts. + const host = parsed.hostname.toLowerCase().replace(/\.$/, ''); // Loopback / unspecified. if (host === 'localhost' || host === '0.0.0.0') {