UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

281 lines (280 loc) • 15.8 kB
/** * hosting/doorGuard — what a request has to be before any handler sees it. * * const guard = doorGuard({ name: 'auth', allowedHosts: ['bot.corp.example'] }); * const refusal = guard.check(req); // an IncomingMessage works as-is * if (refusal) return answer(refusal.status, refusal.message, refusal.code); * * ── The threat, in one paragraph ──────────────────────────────────────────── * A door with no verifier, reachable only inside a company network, looks safe * because the network is the fence. It is not: every person inside the fence * runs a browser, and every web page they open can make that browser send * requests to the door FROM INSIDE. Three ways, all live before this file: * * 1. **A forged request (CSRF).** A page on any site can make the browser * POST a JSON-shaped body as `text/plain` (or as a form, or with no * content type at all) without asking the server first — a CORS "simple" * request. The door parsed it as JSON and ran a turn: model budget spent, * tools called, a conversation stored. The attacker cannot read the reply; * they do not need to. * 2. **DNS rebinding.** A page on an attacker's domain re-points its OWN name * at the door's address. The browser now treats the attacker's page as * same-origin with the door, so it may send anything and READ the answer. * 3. **A cross-site WebSocket.** A WebSocket is not bound by the same-origin * policy at all; any page can open one, and only the server can say no. * * ── The rules, one owner ──────────────────────────────────────────────────── * `check()` judges a request that CHANGES something — every method but GET, * HEAD and OPTIONS, plus every WebSocket upgrade (a GET that opens a channel): * * - **Host** (with `allowedHosts` set, or defaulted to the loopback names on * a loopback bind; judged on EVERY method): a name this door was not * configured for is refused, 421. First, because under rebinding the other * two checks pass — the Host header, which no page can set, is the one * fact that still names the attacker. `X-Forwarded-Host` is never read for * it: a rebinding page is same-origin and may set that header itself. * - **Origin**: a browser request from a page this door does not allow is * refused, 403 — including `Origin: null`. A request WITHOUT an Origin is a * script or a server (browsers always send one on these requests) and is * not judged by it; that is what keeps every non-browser client working. * - **Fetch metadata**, as defence in depth: a request the browser itself * marked `Sec-Fetch-Site: cross-site` is refused (403) unless its Origin is * one `allowedOrigins` LISTS — so a proxy that strips `Origin` but forwards * `Sec-Fetch-*` still cannot let a forged request through. For a WebSocket * handshake these two are the whole defence: the browser API cannot set a * header, so there is no preflight to force. * - **Content type** (not for an upgrade — it has no body): anything but * `application/json` is refused, 415. A browser must ask first (a * preflight, which this host never approves) before it sends that type * across origins, so requiring it makes a forged request one the browser * refuses to send. It is the same property a required custom header buys * (RFC 10017 §6.1.3.3.2), without breaking every JSON client that already * sends the header and no other. * * `checkSessionId()` is the fourth bound, and it is not about browsers: a * session id is caller data that rides every stored row, log line and trace * span of a conversation, so a door refuses one over * {@link MAX_SESSION_ID_LENGTH} or carrying a control character. * * Where it runs: `httpHost` calls `check()` on every request to its request * door and on every conversation upgrade, and `checkSessionId()` on the id each * dialect reads. An application's OWN routes beside the door (a sign-in route * on `onUnhandled`, say) are the application's, and arrive untouched — so this * is exported for them to call, and the rule keeps one spelling. * * Pure: no socket, no clock, no I/O. The design note is * `docs/design/2026-09-door-hardening.md`. */ import { HostNotAllowedError, InvalidSessionIdError, OriginNotAllowedError, UnsupportedMediaTypeError } from './errors.js'; /** * The longest session id any door carries, in UTF-16 code units — JavaScript's * own `string.length`. * * 400 is the width the reference SQL session store gives its `session_id` * column (`NVARCHAR(400)`, which counts the same units), so no id this door * admits is one a store refuses AFTER the model was paid for. The id formats in * use fit with room to spare — a UUID is 36 — and there is deliberately no * knob: a correlation handle wider than this is not one anybody needs. */ export declare const MAX_SESSION_ID_LENGTH = 400; /** * The cross-site rules a host enforces at its doors. Every field is optional, * and the defaults are the safe ones — see each field for what it costs. */ export interface CrossSiteOptions { /** * The page origins allowed to drive this door from a browser — * `['https://app.corp.example']`: scheme, host and port, nothing after. * * **Unset (the default):** a browser request is served only from a page on * this door's own name — its Origin must match the request's `Host`, or an * `X-Forwarded-Host` a proxy added. That is every app served from the same * host as its door, unchanged; an app on ANOTHER origin must be listed. * * **Know what the default does not do:** it does not stop DNS rebinding. * There, the attacker's page and the request's Host both carry the * attacker's name, so they match. {@link CrossSiteOptions.allowedHosts} is * the defence for that. * * A listed origin may be on another SITE by design, so a request from one * passes even when the browser marks it `Sec-Fetch-Site: cross-site`. Any * other request marked that way is refused — the catch for a proxy that * strips `Origin`. A reverse proxy must forward `Origin` and `Sec-Fetch-*` * unchanged. * * `'any'` turns both browser checks off — for a door no browser page can * reach with a user's ambient authority. `'null'` and `'*'` are refused at * construction: the first is every sandboxed frame on every site, the second * would read as a pattern it is not. */ readonly allowedOrigins?: readonly string[] | 'any'; /** * The names this door answers for — `['bot.corp.example']`. A request whose * `Host` names anything else is refused with 421, which is what stops DNS * rebinding. Matched case-insensitively; an entry without a port matches any * port, an entry with one matches only it; IPv6 in brackets (`'[::1]'`). * * **Unset on a LOOPBACK bind** (`127.0.0.1`, `::1`, `localhost` — a host * binding its own socket there): `localhost`, `127.0.0.1` and `[::1]`, any * port, silently. Such a socket has no other name, so this refuses nobody * and closes rebinding on a developer's own machine. * * **Unset on any other bind:** every Host is served, and the host prints * ONE warning at boot naming this option. A deliberate staged default: * refusing at boot would stop existing deployments starting. * * `'any'` serves every Host on purpose and prints nothing — for a door whose * port no browser can reach (a platform's front door in front of it). An * empty list is refused at construction: it could never serve anything. * * Health probes are not judged: a load balancer or kubelet sends the * target's ADDRESS as Host, and a probe that failed on it would restart a * healthy server. */ readonly allowedHosts?: readonly string[] | 'any'; /** * Refuse a request that changes something unless it says * `content-type: application/json` (415). Default `true`. * * `false` is the named escape hatch for a NON-BROWSER integration that * cannot send the header. It removes the half of the defence that does not * depend on Origin — so set it only on a door no browser can reach, and * keep `allowedOrigins` and `allowedHosts` doing their part. */ readonly requireJsonContentType?: boolean; } /** {@link doorGuard}'s options: the rules, plus who is refusing. */ export interface DoorGuardOptions extends CrossSiteOptions { /** Who refuses — every refusal names it, as every host refusal does. */ readonly name: string; /** * The address this door's socket is bound to, when the door bound it * itself. A loopback address with `allowedHosts` unset makes the loopback * names the list (see {@link loopbackAllowedHosts}), and a refusal then * says the list was defaulted and how to set one. Absent: nothing is * inferred. */ readonly bindHost?: string; } /** * The parts of a request the guard reads. A `node:http` `IncomingMessage` is * one as it stands; so is a plain `{ method, headers }`. Header names are * matched case-insensitively. */ export interface DoorRequest { /** The HTTP method. Absent is judged as a request that changes something. */ readonly method?: string; readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>; } /** Why a request may not enter — one of three, each carrying its own `status`. */ export type CrossSiteRefusal = UnsupportedMediaTypeError | OriginNotAllowedError | HostNotAllowedError; /** A guard, built once, asked per request. */ export interface DoorGuard { /** The refusal this request earns at the door, or `undefined` to let it in. */ check(request: DoorRequest): CrossSiteRefusal | undefined; /** * `true` when `allowedHosts` was left unset — the door answers any Host and * the host says so once at boot. `false` for a list or for `'any'`. */ readonly allowedHostsUnset: boolean; /** * `true` when both browser rules are off (`allowedOrigins: 'any'` and * `requireJsonContentType: false`) — the host says so once at boot. */ readonly browserRulesOff: boolean; } /** * Build a guard. Configuration a door could only ever misread is refused HERE, * at construction, by name — never discovered as a mystery 403 in production. */ export declare function doorGuard(options: DoorGuardOptions): DoorGuard; /** * The session-id bound — `undefined` for an id a door may carry (or none). * * A session id is one to {@link MAX_SESSION_ID_LENGTH} characters of VISIBLE * ASCII (0x21–0x7E) — the rule MCP gives its own session ids. Why ASCII and * not "Unicode minus the dangerous parts": * * - **One id, one byte string, from every field.** Node reads a header value * as latin1 and a JSON body as UTF-8, so `café` in `x-session-id` and `café` * in the body were two different stored conversations. With ASCII only, * the two readings cannot differ. * - **Nothing a log, a span or a store can reinterpret.** Controls, line and * paragraph separators (U+2028/9), bidi overrides (U+202E), zero-width * characters and lone surrogates (which any UTF-8 store rewrites to U+FFFD, * merging two ids) are all outside it — no list of exceptions to keep up. * - Every id format in use already fits: UUIDs, platform runtime ids, `conv_…`. * * The empty string is refused too: every caller that sent `""` shared ONE * conversation. The length is judged first, so an over-long id costs one * comparison — after a body read that `maxBodyBytes` bounds. */ export declare function checkSessionId(sessionId: string | undefined, hostName: string): InvalidSessionIdError | undefined; /** * The names a LOOPBACK-bound door answers to when `allowedHosts` was not set — * `localhost`, `127.0.0.1`, `[::1]` (and the bound address itself when it is * another `127.x`), any port: the socket already fixes the port, so a name is * all a rebinding page can vary. `undefined` for any other bind. * * It closes DNS rebinding on the classic victim, a developer's own machine, * and the MCP SDK's `createMcpExpressApp` does the same for a localhost bind. * What it refuses that is legitimate, stated rather than discovered: * * - **A reverse proxy on the same machine that forwards the PUBLIC name** in * `Host` (nginx `proxy_set_header Host $host`) — the common case, and the * recommended "bind 127.0.0.1 behind nginx" setup. The proxy's requests * arrive on loopback carrying `bot.corp.example`, which the socket cannot * tell from a rebinding page. List that name in `allowedHosts`; the 421 * says so, naming this default (`HostNotAllowedError` · `rule`). * - A hosts-file alias for a loopback address — the same fix. * * Both fail loudly and in the safe direction, and the fix is the option such * a deployment should set anyway. * * @internal — `doorGuard` asks it with the address a door bound * (`DoorGuardOptions.bindHost`). */ export declare function loopbackAllowedHosts(bindHost: string | undefined): readonly string[] | undefined; /** * `localhost`, any `127.x` literal, and `::1` or `::ffff:127.x` in ANY IPv6 * spelling (bracketed or not, `0:0:0:0:0:0:0:1`, leading zeros) — IPv6 is * canonicalised by the WHATWG URL parser before it is compared. */ export declare function isLoopbackBind(bindHost: string | undefined): boolean; /** * The cross-site rules for a door a PLATFORM fronts — the hosted-runtime * adapters' defaults, with whatever the deployment set kept as given. * * Exempt by default, and why, once for every such adapter: the platform's own * front door is the only way to the container's port, and the credential it * demands is one no web page holds (a signed request, a bearer the caller * attaches) — so a forged browser request cannot authenticate and no browser * can reach the port to rebind it. The browser rules would protect nothing * there, and which headers the platform forwards (its content type, Origin, * Host) is not a fact this library has verified; enforcing them could refuse * the platform's own forwarding. Any rule the deployment sets is enforced * exactly as on every other door. The session-id bound is NOT exempt: it is * not about browsers. * * @internal — the adapters' shared default, not a public setting. */ export declare function platformFrontedDoor(options: CrossSiteOptions, bindHost: string | undefined): CrossSiteOptions; /** * Said once per host name per process, at boot, when a door serves with the * browser rules OFF (`allowedOrigins: 'any'` and `requireJsonContentType: * false` — the hosted-runtime adapters' default on a non-loopback bind). Those * rules are off because a platform's front door is supposed to be the only way * in; a self-hoster on `0.0.0.0` needs to hear that it is not. * * @internal — the words have one owner; `httpHost` decides when. */ export declare function warnBrowserRulesOff(hostName: string): void; /** * The staged default, said out loud: a host serving with `allowedHosts` unset * prints this ONCE per host name per process, at boot. A name is how every * refusal identifies a host, so two hosts sharing one are one door to anyone * reading the log — and once is what keeps a warning from becoming noise. * * @internal — the words have one owner; `httpHost` decides when. */ export declare function warnAllowedHostsUnset(hostName: string): void;