harden(door): correct IPv6 pin handling via pure normalizeHost

The SSRF pin parser did pinnedHost.split(':')[0], which mangled any
IPv6 pin (e.g. '::1' -> '') so IPv6 controllers could never be pinned
(failed closed). New pure, exhaustively-tested normalizeHost strips
scheme/userinfo/port/path and unwraps [ipv6]/[ipv6]:port/bare-ipv6,
applied to both the request host and the pin so they compare
apples-to-apples. All prior behavior preserved (IPv4, host:port,
userinfo-spoof reject, trailing-dot reject, metadata always blocked);
IPv4-mapped-IPv6 vs IPv4 pin still fails closed. 29 door-logic tests
(was 21); suite 454.
This commit is contained in:
Moheeb Zara
2026-05-16 22:39:25 -07:00
parent 2dc0af5178
commit 1e1b93e8c4
2 changed files with 64 additions and 3 deletions
+36
View File
@@ -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:[email protected]/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