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
TypeScript
/**
* 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;