diff --git a/__tests__/door-logic.test.ts b/__tests__/door-logic.test.ts index 318e5cb..ed44556 100644 --- a/__tests__/door-logic.test.ts +++ b/__tests__/door-logic.test.ts @@ -3,6 +3,7 @@ import { last4, maskCardUid, isAlwaysBlockedHost, + normalizeHost, validateDoorTarget, encodeHeatSyncGrant, encodeHeatSyncRevoke, @@ -119,6 +120,41 @@ describe('redactDoorSecrets', () => { }) }) +describe('normalizeHost', () => { + it('strips scheme, userinfo, port, path', () => { + expect(normalizeHost('192.168.1.50')).toBe('192.168.1.50') + expect(normalizeHost('192.168.1.50:8080')).toBe('192.168.1.50') + expect(normalizeHost('http://192.168.1.50:8080/door?x=1')).toBe('192.168.1.50') + expect(normalizeHost('https://user:pass@door.local/path')).toBe('door.local') + expect(normalizeHost(' Door.Local ')).toBe('door.local') + }) + it('normalizes every IPv6 spelling to the inner address', () => { + expect(normalizeHost('[::1]')).toBe('::1') + expect(normalizeHost('[::1]:8080')).toBe('::1') + expect(normalizeHost('http://[2001:db8::1]/x')).toBe('2001:db8::1') + expect(normalizeHost('::1')).toBe('::1') + expect(normalizeHost('fe80::1')).toBe('fe80::1') + }) + it('does not strip a trailing dot (distinct host, fails closed)', () => { + expect(normalizeHost('door.local.')).toBe('door.local.') + }) +}) + +describe('validateDoorTarget — IPv6 pins', () => { + it('matches an IPv6 pin across bracket/scheme/port spellings', () => { + expect(validateDoorTarget('http://[::1]/?9&e=1', '::1').ok).toBe(true) + expect(validateDoorTarget('http://[::1]:8080/?9&e=1', '[::1]:8080').ok).toBe(true) + expect(validateDoorTarget('http://[2001:db8::1]/?9', 'http://[2001:db8::1]/door').ok).toBe(true) + }) + it('still blocks IPv6 metadata / link-local even if pinned', () => { + expect(validateDoorTarget('http://[fd00:ec2::254]/latest', 'fd00:ec2::254').ok).toBe(false) + expect(validateDoorTarget('http://[fe80::1]/x', 'fe80::1').ok).toBe(false) + }) + it('an IPv4-mapped IPv6 request does not satisfy an IPv4 pin (fails closed)', () => { + expect(validateDoorTarget('http://[::ffff:192.168.1.50]/?o1', '192.168.1.50').ok).toBe(false) + }) +}) + describe('validateDoorTarget — SSRF host-spoof edges', () => { it('rejects a userinfo-spoofed host (real host is what after @)', () => { // new URL(...).hostname is "evil.com", not the pinned LAN IP -> reject diff --git a/lib/door-logic.ts b/lib/door-logic.ts index fd5ba77..ee19900 100644 --- a/lib/door-logic.ts +++ b/lib/door-logic.ts @@ -69,6 +69,30 @@ export function isAlwaysBlockedHost(host: string): boolean { return false } +// Normalize a host for pin comparison. Accepts either a bare hostname/IP or +// what an admin pasted (scheme, userinfo, port, path, brackets) and reduces +// it to just the host identity, so the pinned host and the request host +// compare apples-to-apples. IPv6 is handled: `[::1]`, `[::1]:8080` and +// `http://[2001:db8::1]/` all normalize to the inner address; a bare IPv6 +// (`fe80::1`) is kept whole (no port concept). Plain `host:port` drops the +// port. This only strips transport decoration; it never rewrites the host +// identity, so it cannot widen what the SSRF guard allows (mismatches still +// fail closed, and `isAlwaysBlockedHost` runs independently). +export function normalizeHost(input: string): string { + let h = input.trim().toLowerCase() + h = h.replace(/^[a-z][a-z0-9+.-]*:\/\//, '') // scheme:// + h = h.replace(/^[^/@]*@/, '') // userinfo@ + h = h.split(/[/?#]/)[0] // path/query/fragment + if (h.startsWith('[')) { + const end = h.indexOf(']') + return end === -1 ? h.slice(1) : h.slice(1, end) // [ipv6](:port)? -> ipv6 + } + const colons = (h.match(/:/g) ?? []).length + if (colons >= 2) return h // bare IPv6, no port concept + if (colons === 1) return h.split(':')[0] // host:port -> host + return h +} + export type DoorTarget = | { ok: true; url: string; host: string } | { ok: false; reason: string } @@ -87,10 +111,11 @@ export function validateDoorTarget(rawUrl: string, pinnedHost: string): DoorTarg if (u.protocol !== 'http:' && u.protocol !== 'https:') { return { ok: false, reason: 'Only http and https are allowed.' } } - const host = u.hostname.toLowerCase() - const pin = pinnedHost.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0].split(':')[0] + const rawHost = u.hostname.toLowerCase() + const host = normalizeHost(rawHost) + const pin = normalizeHost(pinnedHost) if (!pin) return { ok: false, reason: 'This connection has no pinned host.' } - if (isAlwaysBlockedHost(host)) { + if (isAlwaysBlockedHost(rawHost) || isAlwaysBlockedHost(host)) { return { ok: false, reason: 'That host is blocked (link-local / metadata).' } } if (host !== pin) {