Files
hackerspace.sh/__tests__/door-logic.test.ts
T
Moheeb ZaraandClaude Opus 4.8 a3e9b97292 fix(door): block hex IPv4-mapped IPv6 in the SSRF filter (M3)
isBlockedDoorIp blocked the dotted IPv4-mapped form (::ffff:169.254.169.254)
but the hex form ::ffff:a9fe:a9fe (=169.254.169.254 IMDS) / ::ffff:7f00:1
(=127.0.0.1) fell through the IPv6 branch and returned false. Decode the hex
IPv4-mapped form to dotted IPv4 and run the existing blocked-range checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-01 21:31:34 -07:00

207 lines
8.9 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
last4,
maskCardUid,
isAlwaysBlockedHost,
isBlockedDoorIp,
normalizeHost,
validateDoorTarget,
encodeHeatSyncGrant,
encodeHeatSyncRevoke,
encodeHeatSyncControl,
applyTemplate,
redactDoorSecrets,
} from '@/lib/door-logic'
describe('last4', () => {
it('returns the last 4 chars, or the whole short value', () => {
expect(last4('0123456789')).toBe('6789')
expect(last4('ABCD')).toBe('ABCD')
expect(last4('AB')).toBe('AB')
})
})
describe('maskCardUid', () => {
it('reveals only the last 4 characters', () => {
expect(maskCardUid('0123456789')).toBe('••••••6789')
expect(maskCardUid('AABBCCDD')).toBe('••••CCDD')
})
it('reveals nothing for short UIDs', () => {
expect(maskCardUid('ABCD')).toBe('••••')
expect(maskCardUid('AB')).toBe('••')
expect(maskCardUid('')).toBe('••••')
})
})
describe('isBlockedDoorIp (resolved-IP DNS-rebind guard)', () => {
it('blocks loopback / unspecified / link-local / metadata (v4)', () => {
for (const ip of ['127.0.0.1', '127.1.2.3', '0.0.0.0', '0.1.2.3', '169.254.1.2', '169.254.169.254']) {
expect(isBlockedDoorIp(ip)).toBe(true)
}
})
it('blocks v6 loopback/unspecified/link-local/AWS-IMDS + IPv4-mapped', () => {
for (const ip of ['::1', '::', 'fe80::1', 'fd00:ec2::254', '::ffff:127.0.0.1', '::ffff:169.254.169.254', '[::1]']) {
expect(isBlockedDoorIp(ip)).toBe(true)
}
})
it('blocks the HEX IPv4-mapped form (::ffff:HHHH:HHHH) too', () => {
// ::ffff:a9fe:a9fe == 169.254.169.254 (IMDS); ::ffff:7f00:1 == 127.0.0.1
for (const ip of ['::ffff:a9fe:a9fe', '::ffff:7f00:1', '::ffff:7f00:0001', '::ffff:a9fe:0102', '[::ffff:7f00:1]']) {
expect(isBlockedDoorIp(ip)).toBe(true)
}
})
it('ALLOWS non-blocked ranges in HEX IPv4-mapped form', () => {
// ::ffff:c0a8:0109 == 192.168.1.9 ; ::ffff:cb00:7107 == 203.0.113.7
for (const ip of ['::ffff:c0a8:0109', '::ffff:cb00:7107']) {
expect(isBlockedDoorIp(ip)).toBe(false)
}
})
it('ALLOWS RFC1918 / LAN / ULA / public (door controllers live there)', () => {
for (const ip of ['10.0.0.5', '172.16.4.4', '192.168.1.50', '203.0.113.7', 'fd12:3456::1', '2001:db8::1', '::ffff:192.168.1.9']) {
expect(isBlockedDoorIp(ip)).toBe(false)
}
})
it('fails closed on unrecognizable / malformed input', () => {
for (const ip of ['', 'not-an-ip', '999.1.1.1', '1.2.3', 'door.local']) {
expect(isBlockedDoorIp(ip)).toBe(true)
}
})
})
describe('isAlwaysBlockedHost', () => {
it('blocks cloud metadata and link-local', () => {
expect(isAlwaysBlockedHost('169.254.169.254')).toBe(true)
expect(isAlwaysBlockedHost('169.254.1.2')).toBe(true)
expect(isAlwaysBlockedHost('metadata.google.internal')).toBe(true)
expect(isAlwaysBlockedHost('fe80::1')).toBe(true)
expect(isAlwaysBlockedHost('0.0.0.0')).toBe(true)
})
it('allows ordinary LAN hosts', () => {
expect(isAlwaysBlockedHost('192.168.1.50')).toBe(false)
expect(isAlwaysBlockedHost('10.0.0.5')).toBe(false)
expect(isAlwaysBlockedHost('door.local')).toBe(false)
})
})
describe('validateDoorTarget', () => {
it('allows a LAN target that matches the pin', () => {
const r = validateDoorTarget('http://192.168.1.50/?o1&e=1234', '192.168.1.50')
expect(r.ok).toBe(true)
})
it('rejects a host that does not match the pin (anti-redirect/SSRF)', () => {
const r = validateDoorTarget('http://evil.example.com/x', '192.168.1.50')
expect(r.ok).toBe(false)
})
it('rejects metadata even if someone pins it', () => {
const r = validateDoorTarget('http://169.254.169.254/latest/meta-data/', '169.254.169.254')
expect(r.ok).toBe(false)
})
it('rejects non-http protocols', () => {
expect(validateDoorTarget('file:///etc/passwd', 'x').ok).toBe(false)
expect(validateDoorTarget('gopher://192.168.1.50', '192.168.1.50').ok).toBe(false)
})
it('pin may be entered with scheme/port/path and still matches host', () => {
const r = validateDoorTarget('http://192.168.1.50:8080/?9&e=1234', 'http://192.168.1.50:8080/door')
expect(r.ok).toBe(true)
})
})
describe('encodeHeatSyncGrant', () => {
it('zero-pads slot/perm to 3 and tag to 8 (firmware byte offsets)', () => {
const r = encodeHeatSyncGrant({ slot: 7, permissionMask: 1, tagHex: 'abcd', password: '1234' })
expect(r).toEqual({ ok: true, query: '?m007&p001&t0000abcd&e=1234' })
})
it('rejects out-of-range slot/perm and bad tag', () => {
expect(encodeHeatSyncGrant({ slot: 999, permissionMask: 1, tagHex: 'ab', password: '1' }).ok).toBe(false)
expect(encodeHeatSyncGrant({ slot: 1, permissionMask: 500, tagHex: 'ab', password: '1' }).ok).toBe(false)
expect(encodeHeatSyncGrant({ slot: 1, permissionMask: 1, tagHex: 'xyz', password: '1' }).ok).toBe(false)
expect(encodeHeatSyncGrant({ slot: 1, permissionMask: 1, tagHex: 'ab', password: '' }).ok).toBe(false)
})
})
describe('encodeHeatSyncRevoke / control', () => {
it('revoke zero-pads the slot and appends auth', () => {
expect(encodeHeatSyncRevoke(12, 'pass')).toEqual({ ok: true, query: '?r012&e=pass' })
})
it('control maps verbs and appends auth', () => {
expect(encodeHeatSyncControl('open1', 'pw')).toEqual({ ok: true, query: '?o1&e=pw' })
expect(encodeHeatSyncControl('status', 'pw')).toEqual({ ok: true, query: '?9&e=pw' })
expect(encodeHeatSyncControl('nope', 'pw').ok).toBe(false)
})
})
describe('applyTemplate', () => {
it('substitutes and URL-encodes known vars, leaves unknowns', () => {
expect(applyTemplate('?card={tag}&slot={slot}', { tag: 'a b', slot: 5 })).toBe('?card=a%20b&slot=5')
expect(applyTemplate('?x={missing}', {})).toBe('?x={missing}')
})
})
describe('redactDoorSecrets', () => {
it('scrubs the literal password and e= query value', () => {
expect(redactDoorSecrets('GET http://x/?m007&t00ab&e=1234 -> 200', '1234'))
.toBe('GET http://x/?m007&t00ab&e=<redacted> -> 200')
})
it('scrubs auth-ish params even without the password known', () => {
expect(redactDoorSecrets('?pw=hunter2&token=abc')).toBe('?pw=<redacted>&token=<redacted>')
})
it('leaves unrelated text intact', () => {
expect(redactDoorSecrets('status 200 ok', null)).toBe('status 200 ok')
})
it('redacts a regex-special password literally (split/join, not regex)', () => {
expect(redactDoorSecrets('controller said a.*b ok', 'a.*b')).toBe('controller said <redacted> ok')
})
it('redacts an e= value with no trailing delimiter (end of string)', () => {
expect(redactDoorSecrets('GET /?o1&e=1234')).toBe('GET /?o1&e=<redacted>')
})
})
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
const r = validateDoorTarget('http://[email protected]/?o1', '192.168.1.50')
expect(r.ok).toBe(false)
})
it('matches the pin case-insensitively for hostnames', () => {
expect(validateDoorTarget('http://Door.Local/?9', 'door.local').ok).toBe(true)
})
it('a trailing-dot FQDN does not equal the bare pin (fails closed)', () => {
expect(validateDoorTarget('http://door.local./?9', 'door.local').ok).toBe(false)
})
})