Door epic phases 4-5: inbound access-log ingest + universal API-call buttons
Phase 4 (migration 053): pull entry/denied events into door_access_log and match each card to a member, via two transports sharing one ingest core. Poll: a CRON_SECRET-guarded /api/cron/door-ingest reads each opted-in native-HeatSync connection's ?z log through the existing hardened executor and parses it (firmware-characterized, best-effort ring buffer). Webhook: /api/door/inbound/[connection] accepts pushed event JSON authenticated by a per-connection bearer secret from the vault. Both dedupe on (connection_id, dedupe_key) so re-polls and retries are no-ops. Adds an inbound config panel to /door/manage. Phase 5 (migration 054): admins define api_buttons that fire a configured HTTP call (full verbs + headers + body) when an authorized member presses them, through the same hardened egress (now shared between callDoor and the new callApi). Each button carries a required_permission (default the new apicall.invoke code); members see only buttons they may press. Builder at /door/buttons with a door-template preset; members press from /doors. Every press is recorded in api_call_log with secrets redacted. Both subsystems are inert until an admin enables inbound on a connection or creates a button. 624 unit + 50 integration tests; migrations applied top-to-bottom in schema.sql; types and docs updated.
This commit is contained in:
+115
-30
@@ -1,12 +1,12 @@
|
||||
// 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.
|
||||
// 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 }
|
||||
@@ -14,39 +14,57 @@ export type DoorCallResult =
|
||||
|
||||
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
|
||||
|
||||
export async function callDoor(opts: {
|
||||
// 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
|
||||
password?: string | null
|
||||
authParam?: string | null
|
||||
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.
|
||||
// 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 controller host.' }
|
||||
return { ok: false, reason: 'Could not resolve the target host.' }
|
||||
}
|
||||
if (resolved.length === 0) {
|
||||
return { ok: false, reason: 'The controller host did not resolve.' }
|
||||
return { ok: false, reason: 'The target host did not resolve.' }
|
||||
}
|
||||
for (const a of resolved) {
|
||||
if (isBlockedDoorIp(a.address)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'The controller host resolves to a blocked address (loopback / link-local / metadata).',
|
||||
reason: 'The target host resolves to a blocked address (loopback / link-local / metadata).',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,22 +75,22 @@ export async function callDoor(opts: {
|
||||
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: 'GET',
|
||||
// Never chase a redirect: a 3xx could bounce us off the pinned host
|
||||
// (and would re-resolve, escaping the IP pin).
|
||||
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,
|
||||
// Host preserved best-effort so a vhosting controller still routes;
|
||||
// single-purpose door firmware ignores it. The UA may drop a forbidden
|
||||
// Host header — harmless for these devices.
|
||||
headers: { accept: 'text/plain, text/html', host: target.host },
|
||||
headers,
|
||||
body: opts.body ?? undefined,
|
||||
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).',
|
||||
reason: 'The target returned a redirect, which is refused (anti-SSRF).',
|
||||
status: res.status || undefined,
|
||||
}
|
||||
}
|
||||
@@ -81,7 +99,7 @@ export async function callDoor(opts: {
|
||||
const reader = res.body?.getReader()
|
||||
if (reader) {
|
||||
const dec = new TextDecoder()
|
||||
while (received.length < MAX_BODY) {
|
||||
while (received.length < opts.maxBody) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
received += dec.decode(value, { stream: true })
|
||||
@@ -92,12 +110,79 @@ export async function callDoor(opts: {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const snippet = redactDoorSecrets(received.slice(0, SNIPPET), opts.password, opts.authParam)
|
||||
const snippet = redactDoorSecrets(received.slice(0, opts.snippetLen), opts.redactSecret, opts.redactAuthParam)
|
||||
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) }
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user