Files
hackerspace.sh/lib/door/executor.ts
T
Moheeb ZaraandClaude Opus 4.8 b1edb5c460 types: clear remaining tsc errors across 41 files (76→14, behavior-preserving)
Per-file type-only fixes surfaced after the Supabase client was correctly
typed. All fixes preserve runtime behavior; no any/ts-ignore/ts-expect-error.

- lib/supabase/client.ts: annotate the browser client as SupabaseClient<Database>
  (same ssr/js drift as server.ts; fixes it centrally for all callers).
- Supabase embedded-select joins whose FK metadata is missing from the
  generated types are bridged with `as unknown as <target>` + a TODO to remove
  after regenerating types/database.ts.
- setState optimistic-update variance and raw <select> enum values cast to the
  precise element/union type; .insert payload fields (Json, enums) given the
  precise column type via satisfies/field casts.
- Added narrowing guards where a value was legitimately nullable (comms channel,
  public form page) without changing the intended path.

The 14 remaining errors are confined to the four files fixed in Phase B (real
bugs + the tautological actions.test rewrite). 639 tests still green.

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

189 lines
6.8 KiB
TypeScript

// Server-only. The single hardened egress point for both the door controller
// HTTP (callDoor) and the universal API-call buttons (callApi). Every outbound
// call funnels through `egress` so the SSRF guard, no-redirect rule, timeout,
// response cap and secret redaction cannot be bypassed. Secrets are never
// logged -- only a redacted snippet is ever returned.
import dns from 'node:dns/promises'
import { validateDoorTarget, redactDoorSecrets, isBlockedDoorIp } from '@/lib/door-logic'
import { buildApiRequest, type ApiAuthMode } from '@/lib/api-call-logic'
export type DoorCallResult =
| { ok: true; status: number; snippet: string }
| { ok: false; reason: string; status?: number }
const MAX_BODY = 4096
const SNIPPET = 500
// Log-ingest polls (HeatSync ?z) need the whole ring-buffer dump, which is
// larger than a status snippet. Capped so a hostile/garbage response still
// cannot exhaust memory.
const MAX_BODY_INGEST = 32768
// API-button responses: enough for a useful audit detail, not unbounded.
const MAX_BODY_API = 8192
const SNIPPET_API = 1000
// The shared hardened egress. Validates the target against the connection's
// pinned host, resolves once and connects to the validated IP literal (so
// fetch performs no second resolution -> closes the DNS-rebind TOCTOU),
// refuses redirects, caps time and body, and redacts secrets from anything
// returned. `headers.host` is always forced to the original pinned host, so a
// caller-supplied header map can never repoint routing or override it.
async function egress(opts: {
method: string
url: string
pinnedHost: string
headers?: Record<string, string>
body?: string | null
redactSecret?: string | null
redactAuthParam?: string | null
timeoutMs?: number
maxBody: number
snippetLen: number
}): Promise<DoorCallResult> {
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
if (!guard.ok) return { ok: false, reason: guard.reason }
// Defeat DNS-rebinding: validateDoorTarget only string-checked the host, and
// a bare fetch would do its OWN resolution (a hostname could resolve to
// 127.0.0.1 / 169.254.169.254 between check and connect). Resolve once here,
// reject if ANY resolved address is blocked, then connect to the validated
// IP literal so fetch performs no second resolution. Plaintext HTTP to LAN
// devices that don't vhost, so connecting by IP (with a best-effort original
// Host header) is correct; no TLS/SNI concern.
const target = new URL(guard.url)
let resolved: { address: string; family: number }[]
try {
resolved = await dns.lookup(target.hostname, { all: true, verbatim: true })
} catch {
return { ok: false, reason: 'Could not resolve the target host.' }
}
if (resolved.length === 0) {
return { ok: false, reason: 'The target host did not resolve.' }
}
for (const a of resolved) {
if (isBlockedDoorIp(a.address)) {
return {
ok: false,
reason: 'The target host resolves to a blocked address (loopback / link-local / metadata).',
}
}
}
const pinned = new URL(guard.url)
const isV6 = resolved[0].family === 6
pinned.hostname = isV6 ? `[${resolved[0].address}]` : resolved[0].address
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
try {
// Host forced last so a custom header map cannot override the pinned host.
const headers: Record<string, string> = { ...(opts.headers ?? {}), host: target.host }
const res = await fetch(pinned.toString(), {
method: opts.method,
// Never chase a redirect: a 3xx could bounce us off the pinned host (and
// would re-resolve, escaping the IP pin).
redirect: 'manual',
signal: controller.signal,
headers,
body: opts.body ?? undefined,
cache: 'no-store',
})
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
return {
ok: false,
reason: 'The target 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 < opts.maxBody) {
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, opts.snippetLen), opts.redactSecret, opts.redactAuthParam)
return { ok: res.ok, status: res.status, snippet } as DoorCallResult
} catch (e) {
const msg = e instanceof Error ? e.message : 'request failed'
return { ok: false, reason: redactDoorSecrets(msg, opts.redactSecret, opts.redactAuthParam) }
} finally {
clearTimeout(timer)
}
}
// Door controller call. GET only; the secret (if any) is already embedded in
// `url` by the caller (per the verified firmware, auth is a query param).
// `fullBody` returns the whole capped body (the inbound-log poll); otherwise a
// 500-char snippet. Behavior is unchanged from the original single-function
// executor.
export async function callDoor(opts: {
url: string
pinnedHost: string
password?: string | null
authParam?: string | null
timeoutMs?: number
fullBody?: boolean
}): Promise<DoorCallResult> {
return egress({
method: 'GET',
url: opts.url,
pinnedHost: opts.pinnedHost,
headers: { accept: 'text/plain, text/html' },
redactSecret: opts.password,
redactAuthParam: opts.authParam,
timeoutMs: opts.timeoutMs,
maxBody: opts.fullBody ? MAX_BODY_INGEST : MAX_BODY,
snippetLen: opts.fullBody ? MAX_BODY_INGEST : SNIPPET,
})
}
// Universal API-call button. Full verbs + custom headers + optional body, with
// the secret injected server-side per auth_mode (query param / custom header /
// bearer) and never returned. Same SSRF guard, host pin, no-redirect, caps and
// redaction as the door path.
export async function callApi(opts: {
method: string
baseUrl: string
urlTemplate?: string | null
pinnedHost: string
headers?: Record<string, string> | null
body?: string | null
authMode: ApiAuthMode
authParam?: string | null
secret?: string | null
timeoutMs?: number
}): Promise<DoorCallResult> {
const req = buildApiRequest({
baseUrl: opts.baseUrl,
urlTemplate: opts.urlTemplate,
method: opts.method,
headers: { accept: '*/*', ...(opts.headers ?? {}) },
body: opts.body,
authMode: opts.authMode,
authParam: opts.authParam,
secret: opts.secret,
})
return egress({
method: req.method,
url: req.url,
pinnedHost: opts.pinnedHost,
headers: req.headers,
body: req.body,
redactSecret: opts.secret,
redactAuthParam: opts.authParam,
timeoutMs: opts.timeoutMs,
maxBody: MAX_BODY_API,
snippetLen: SNIPPET_API,
})
}