Files
hackerspace.sh/lib/door/executor.ts
T
Moheeb Zara 66cb84bc57 fix(door): never return raw controller reason to the client (D10)
Every door action bubbled the executor's reason/e.message to the
client; redaction was password-literal + a fixed param-name regex, so a
generic-adapter connection with a custom auth param + verbose upstream
error could surface the secret client-side. Client returns are now a
generic 'controller call failed' message; the redacted detail still
goes to door_access_log for operators. redactDoorSecrets + callDoor
gained an optional authParam (scrub the connection's actual auth param
value regardless of name); threading conn.auth_param through the
door-call selects is a deferred defense-in-depth follow-up (client
leak is fully closed; native HeatSync e= is already regex-covered).
2026-05-18 18:44:54 -07:00

70 lines
2.3 KiB
TypeScript

// Server-only. The single hardened egress point for door-controller HTTP.
// Every door call funnels through here so the SSRF guard, no-redirect rule,
// timeout, response cap and secret redaction cannot be bypassed. The secret
// is already embedded in `url` by the caller (per the verified firmware,
// auth is a query param); it is never logged -- only a redacted snippet is
// ever returned.
import { validateDoorTarget, redactDoorSecrets } from '@/lib/door-logic'
export type DoorCallResult =
| { ok: true; status: number; snippet: string }
| { ok: false; reason: string; status?: number }
const MAX_BODY = 4096
const SNIPPET = 500
export async function callDoor(opts: {
url: string
pinnedHost: string
password?: string | null
authParam?: string | null
timeoutMs?: number
}): Promise<DoorCallResult> {
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
if (!guard.ok) return { ok: false, reason: guard.reason }
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
try {
const res = await fetch(guard.url, {
method: 'GET',
// Never chase a redirect: a 3xx could bounce us off the pinned host.
redirect: 'manual',
signal: controller.signal,
headers: { accept: 'text/plain, text/html' },
cache: 'no-store',
})
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
return {
ok: false,
reason: 'The controller returned a redirect, which is refused (anti-SSRF).',
status: res.status || undefined,
}
}
let received = ''
const reader = res.body?.getReader()
if (reader) {
const dec = new TextDecoder()
while (received.length < MAX_BODY) {
const { done, value } = await reader.read()
if (done) break
received += dec.decode(value, { stream: true })
}
try {
await reader.cancel()
} catch {
/* ignore */
}
}
const snippet = redactDoorSecrets(received.slice(0, SNIPPET), opts.password, opts.authParam)
return { ok: res.ok, status: res.status, snippet }
} catch (e) {
const msg = e instanceof Error ? e.message : 'request failed'
return { ok: false, reason: redactDoorSecrets(msg, opts.password, opts.authParam) }
} finally {
clearTimeout(timer)
}
}