Skip to content

Commit 9102430

Browse files
authored
fix(target-url): treat trailing-dot hostnames as loopback in SSRF guard (#37)
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).
1 parent 547b5be commit 9102430

2 files changed

Lines changed: 33 additions & 1 deletion

File tree

src/lib/target-url.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,31 @@ describe('assertNotLocal — IPv6 hardening (SSRF bypass guard)', () => {
210210
});
211211
});
212212

213+
describe('assertNotLocal — trailing-dot FQDN normalization (SSRF bypass guard)', () => {
214+
// `localhost.` is the fully-qualified form of `localhost` (RFC 6761 reserves
215+
// both to resolve to loopback). It previously bypassed the
216+
// `host === 'localhost'` check because the WHATWG URL parser keeps the
217+
// trailing dot on named hosts (IP literals are dot-normalized, named hosts
218+
// are not).
219+
it('blocks http://localhost. (trailing-dot loopback)', () => {
220+
expectBlocked('http://localhost.');
221+
});
222+
223+
it('blocks http://localhost.:8080 (trailing-dot loopback with port)', () => {
224+
expectBlocked('http://localhost.:8080');
225+
});
226+
227+
it('blocks http://localhost%2e (percent-encoded trailing dot)', () => {
228+
expectBlocked('http://localhost%2e');
229+
});
230+
231+
// A legitimate public FQDN with a trailing dot must still be allowed
232+
// (no false positive from the dot strip).
233+
it('allows https://example.com. (public FQDN with trailing dot)', () => {
234+
expectAllowed('https://example.com.');
235+
});
236+
});
237+
213238
describe('assertNotLocal — allowed public URLs', () => {
214239
it('allows https://example.com', () => {
215240
expectAllowed('https://example.com');

src/lib/target-url.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@ export function assertNotLocal(rawUrl: string): void {
4141
throw localTargetError('target-url', 'must use http or https scheme');
4242
}
4343

44-
const host = parsed.hostname.toLowerCase();
44+
// Normalize a single trailing dot in the hostname. `localhost.` is the
45+
// fully-qualified form of `localhost` (RFC 6761 reserves both to resolve to
46+
// loopback), so `http://localhost.` must be rejected just like
47+
// `http://localhost`. Without this strip, the trailing-dot form (also
48+
// reachable via `localhost%2e`) slips past the `host === 'localhost'` check.
49+
// IP literals are already dot-normalized by the WHATWG URL parser, so this
50+
// only affects named hosts.
51+
const host = parsed.hostname.toLowerCase().replace(/\.$/, '');
4552

4653
// Loopback / unspecified.
4754
if (host === 'localhost' || host === '0.0.0.0') {

0 commit comments

Comments
 (0)