Deferred deeper-audit P1. validateDoorTarget only string-checked the host; a bare fetch did its own DNS resolution, so a hostname could resolve to 127.0.0.1/169.254.169.254 between check and connect. The executor now resolves once (dns.lookup all), rejects if ANY resolved address is blocked (isBlockedDoorIp: loopback/unspecified/link-local/ metadata incl IPv4-mapped IPv6; RFC1918/LAN/ULA allowed since door controllers live there), then connects to the validated IP literal so fetch performs no second resolution (TOCTOU closed). Original Host kept best-effort; redirect:'manual' retained. Dependency-free (no undici seam). 33 door-logic tests; suite 532.
262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
// Pure, dependency-free logic for the Door epic. No Supabase/React/Next.
|
|
// Unit-tested directly. The card UID is a credential, so the canonical
|
|
// masking lives here and is reused everywhere a non-manager could see it.
|
|
|
|
export function last4(uid: string): string {
|
|
return uid.length <= 4 ? uid : uid.slice(-4)
|
|
}
|
|
|
|
// "••••AB12" — never reveals more than the last 4 characters. For a UID of 4
|
|
// or fewer characters nothing is revealed.
|
|
export function maskCardUid(uid: string): string {
|
|
if (uid.length <= 4) return '•'.repeat(uid.length || 4)
|
|
return '•'.repeat(uid.length - 4) + uid.slice(-4)
|
|
}
|
|
|
|
// ─── Known door controllers (shown in the UI, with source links) ────────────
|
|
// The app is cloud-hosted, so a connection's target may be a publicly
|
|
// reachable controller/proxy OR a VPN-reachable LAN device. The adapter is
|
|
// not assumed to be on a private network; the SSRF guard pins the exact host
|
|
// (public or private) and always blocks metadata/link-local + redirects.
|
|
|
|
export type KnownController = {
|
|
id: string
|
|
label: string
|
|
adapter: 'native_heatsync' | 'generic_http'
|
|
repos: { label: string; url: string }[]
|
|
note: string
|
|
}
|
|
|
|
export const KNOWN_DOOR_CONTROLLERS: KnownController[] = [
|
|
{
|
|
id: 'heatsync',
|
|
label: 'HeatSync / 23b Open Source Access Control (native)',
|
|
adapter: 'native_heatsync',
|
|
repos: [
|
|
{ label: 'heatsynclabs/Open-Source-Access-Control-Web-Interface', url: 'https://github.com/heatsynclabs/Open-Source-Access-Control-Web-Interface' },
|
|
{ label: 'zyphlar/Open_Access_Control_Ethernet (23b lineage)', url: 'https://github.com/zyphlar/Open_Access_Control_Ethernet' },
|
|
],
|
|
note: 'Arduino HTTP query-string controller. Built-in verb encoding (fixed-width, password as a query param). Point base_url at the controller or a proxy that reaches it.',
|
|
},
|
|
{
|
|
id: 'generic',
|
|
label: 'Other / generic HTTP controller',
|
|
adapter: 'generic_http',
|
|
repos: [],
|
|
note: 'Any controller you can drive over HTTP. Configure a per-verb URL/query template with {slot}, {tag}, {perm}, {door}, {pw} placeholders.',
|
|
},
|
|
]
|
|
|
|
export function controllerForAdapter(adapter: string): KnownController | undefined {
|
|
return KNOWN_DOOR_CONTROLLERS.find(c => c.adapter === adapter)
|
|
}
|
|
|
|
// ─── SSRF guard (the dangerous part) ─────────────────────────────────────────
|
|
// A door connection deliberately targets a LAN device, so private ranges are
|
|
// allowed — but ONLY the exact host the admin pinned, and never the cloud
|
|
// metadata / link-local range. The executor additionally forbids redirects
|
|
// and caps response size/time; this pure function decides if a target URL is
|
|
// allowed to be called at all.
|
|
|
|
// Always-blocked, regardless of pin: IMDS / link-local and unspecified.
|
|
// Block policy applied to a RESOLVED numeric IP (defeats DNS-rebind: the
|
|
// executor resolves once, checks every address here, then connects to the
|
|
// validated IP literal so fetch performs no second resolution). RFC1918/LAN
|
|
// (10/8, 172.16/12, 192.168/16) and ULA/GUA IPv6 are intentionally ALLOWED
|
|
// (door controllers live there); only loopback, unspecified, link-local and
|
|
// cloud-metadata are blocked. Unrecognizable input fails closed.
|
|
export function isBlockedDoorIp(ip: string): boolean {
|
|
let h = ip.trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
const mapped = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
|
|
if (mapped) h = mapped[1]
|
|
if (h.includes(':')) {
|
|
if (h === '::1' || h === '::' || h === '0:0:0:0:0:0:0:0') return true
|
|
if (h === 'fd00:ec2::254') return true // AWS IPv6 IMDS
|
|
if (h.startsWith('fe80:') || h.startsWith('fe80::')) return true // link-local
|
|
return false // other IPv6 (ULA/GUA/LAN) allowed
|
|
}
|
|
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
if (!m) return true // not a clean IPv4 -> fail closed
|
|
const o = m.slice(1).map(Number)
|
|
if (o.some(n => n > 255)) return true
|
|
if (o[0] === 127) return true // loopback 127.0.0.0/8
|
|
if (o[0] === 0) return true // 0.0.0.0/8 (incl. unspecified)
|
|
if (o[0] === 169 && o[1] === 254) return true // link-local + 169.254.169.254 metadata
|
|
return false // RFC1918 / LAN / public allowed
|
|
}
|
|
|
|
export function isAlwaysBlockedHost(host: string): boolean {
|
|
const h = host.toLowerCase().replace(/^\[|\]$/g, '')
|
|
if (h === '169.254.169.254' || h === 'metadata' || h === 'metadata.google.internal') return true
|
|
if (h.startsWith('169.254.')) return true // IPv4 link-local (includes IMDS)
|
|
if (h === '0.0.0.0' || h === '::' || h === '0:0:0:0:0:0:0:0') return true
|
|
if (h === 'fd00:ec2::254') return true // AWS IPv6 IMDS
|
|
if (h.startsWith('fe80:')) return true // IPv6 link-local
|
|
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 }
|
|
|
|
// Validate a fully-formed request URL against the connection's pinned host.
|
|
// pinnedHost is exactly what the admin entered (hostname or IP, optional
|
|
// :port is compared on the host only). The request host must equal the pin
|
|
// (case-insensitive) and must not be an always-blocked address.
|
|
export function validateDoorTarget(rawUrl: string, pinnedHost: string): DoorTarget {
|
|
let u: URL
|
|
try {
|
|
u = new URL(rawUrl)
|
|
} catch {
|
|
return { ok: false, reason: 'The request URL is not a valid URL.' }
|
|
}
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
return { ok: false, reason: 'Only http and https are allowed.' }
|
|
}
|
|
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(rawHost) || isAlwaysBlockedHost(host)) {
|
|
return { ok: false, reason: 'That host is blocked (link-local / metadata).' }
|
|
}
|
|
if (host !== pin) {
|
|
return { ok: false, reason: `Request host "${host}" does not match the pinned host "${pin}".` }
|
|
}
|
|
return { ok: true, url: u.toString(), host }
|
|
}
|
|
|
|
// ─── HeatSync native adapter encoders ────────────────────────────────────────
|
|
// Firmware (zyphlar/Open_Access_Control_Ethernet.ino, re-verified 2026-05-17)
|
|
// parses FIXED-WIDTH ZERO-PADDED query params by byte offset: user slot = 3
|
|
// digits, perm mask = 3 digits, tag = 8 hex chars, password = the shared
|
|
// secret. Auth is satisfied when "?e=" or "&e=" is present, so every
|
|
// privileged verb appends "&e=<pw>". Getting the widths wrong addresses the
|
|
// WRONG slot — so these are pure and unit-tested.
|
|
|
|
function pad(n: number, width: number): string {
|
|
return String(n).padStart(width, '0')
|
|
}
|
|
|
|
export type EncodeResult = { ok: true; query: string } | { ok: false; reason: string }
|
|
|
|
function checkSlot(slot: number): string | null {
|
|
if (!Number.isInteger(slot) || slot < 0 || slot > 200) return 'Card slot must be an integer 0-200.'
|
|
return null
|
|
}
|
|
|
|
export function encodeHeatSyncGrant(input: {
|
|
slot: number
|
|
permissionMask: number
|
|
tagHex: string
|
|
password: string
|
|
}): EncodeResult {
|
|
const slotErr = checkSlot(input.slot)
|
|
if (slotErr) return { ok: false, reason: slotErr }
|
|
if (!Number.isInteger(input.permissionMask) || input.permissionMask < 0 || input.permissionMask > 255) {
|
|
return { ok: false, reason: 'Permission mask must be an integer 0-255.' }
|
|
}
|
|
if (!/^[0-9a-fA-F]{1,8}$/.test(input.tagHex)) {
|
|
return { ok: false, reason: 'Tag must be 1-8 hexadecimal characters.' }
|
|
}
|
|
if (input.password.length < 1 || input.password.length > 16) {
|
|
return { ok: false, reason: 'Door password is missing or implausible.' }
|
|
}
|
|
const tag = input.tagHex.toLowerCase().padStart(8, '0')
|
|
return {
|
|
ok: true,
|
|
query: `?m${pad(input.slot, 3)}&p${pad(input.permissionMask, 3)}&t${tag}&e=${input.password}`,
|
|
}
|
|
}
|
|
|
|
export function encodeHeatSyncRevoke(slot: number, password: string): EncodeResult {
|
|
const slotErr = checkSlot(slot)
|
|
if (slotErr) return { ok: false, reason: slotErr }
|
|
if (!password) return { ok: false, reason: 'Door password is missing.' }
|
|
return { ok: true, query: `?r${pad(slot, 3)}&e=${password}` }
|
|
}
|
|
|
|
// verb: 'open1' | 'open2' | 'unlock' | 'lock' | 'arm' | 'disarm' | 'status' | 'log'
|
|
export function encodeHeatSyncControl(verb: string, password: string): EncodeResult {
|
|
if (!password) return { ok: false, reason: 'Door password is missing.' }
|
|
const map: Record<string, string> = {
|
|
open1: '?o1',
|
|
open2: '?o2',
|
|
unlock: '?u',
|
|
lock: '?l',
|
|
disarm: '?1',
|
|
arm: '?2',
|
|
status: '?9',
|
|
log: '?z',
|
|
}
|
|
const base = map[verb]
|
|
if (!base) return { ok: false, reason: `Unknown door verb "${verb}".` }
|
|
return { ok: true, query: `${base}&e=${password}` }
|
|
}
|
|
|
|
// ─── Generic adapter template substitution ───────────────────────────────────
|
|
// A generic connection stores a path/query template per verb with {slot},
|
|
// {tag}, {perm}, {door}, {pw} placeholders. Substitution is literal and the
|
|
// values are URL-encoded; unknown placeholders are left untouched so a
|
|
// misconfig fails loudly rather than silently calling something unintended.
|
|
|
|
export function applyTemplate(
|
|
template: string,
|
|
vars: Record<string, string | number>,
|
|
): string {
|
|
return template.replace(/\{(\w+)\}/g, (m, key) =>
|
|
key in vars ? encodeURIComponent(String(vars[key])) : m,
|
|
)
|
|
}
|
|
|
|
// Before anything (URL, response snippet) is written to the audit log, scrub
|
|
// the shared door password: any literal occurrence, and any e=/&e= query
|
|
// value (the firmware auth param), become "<redacted>". The password is a
|
|
// credential and must never land in door_access_log.
|
|
export function redactDoorSecrets(
|
|
text: string,
|
|
password?: string | null,
|
|
authParam?: string | null,
|
|
): string {
|
|
let out = text
|
|
if (password && password.length > 0) {
|
|
out = out.split(password).join('<redacted>')
|
|
}
|
|
// The connection's actual auth query param (generic adapters may name it
|
|
// anything, not just e=/pw=). Scrub its value regardless of the name so a
|
|
// verbose upstream error echoing the URL cannot leak the secret even when
|
|
// `password` is empty/unknown.
|
|
if (authParam && /^\w+$/.test(authParam)) {
|
|
out = out.replace(
|
|
new RegExp(`([?&]${authParam}=)[^&\\s"']*`, 'gi'),
|
|
'$1<redacted>',
|
|
)
|
|
}
|
|
out = out.replace(/([?&](?:e|pw|password|api[_-]?key|token)=)[^&\s"']*/gi, '$1<redacted>')
|
|
return out
|
|
}
|