UNPKG

agentfootprint

Version:

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

509 lines • 25.8 kB
"use strict"; /** * 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`. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.warnAllowedHostsUnset = exports.warnBrowserRulesOff = exports.platformFrontedDoor = exports.isLoopbackBind = exports.loopbackAllowedHosts = exports.checkSessionId = exports.doorGuard = exports.MAX_SESSION_ID_LENGTH = void 0; const errors_js_1 = require("./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. */ exports.MAX_SESSION_ID_LENGTH = 400; /** Methods that change nothing, and so are not judged — the ruling's GET/HEAD/OPTIONS. */ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); /** * 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. */ function doorGuard(options) { const { name } = options; const origins = readAllowedOrigins(name, options.allowedOrigins); const defaulted = options.allowedHosts === undefined ? loopbackAllowedHosts(options.bindHost) : undefined; const hosts = readAllowedHosts(name, options.allowedHosts ?? defaulted); const hostRule = defaulted === undefined ? 'listed' : 'loopback-default'; const requireJson = readRequireJson(name, options.requireJsonContentType); return { allowedHostsUnset: options.allowedHosts === undefined && defaulted === undefined, browserRulesOff: origins === 'any' && !requireJson, check(request) { const { headers } = request; if (hosts !== 'any' && !hostAllowed(hosts, headerOf(headers, 'host'))) { return new errors_js_1.HostNotAllowedError(name, hostRule); } const method = (request.method ?? '').toUpperCase(); const carriesBody = !SAFE_METHODS.has(method); const upgrade = isWebSocketUpgrade(headers); if (!carriesBody && !upgrade) return undefined; const browser = browserVerdict(origins, headers, upgrade); if (browser !== undefined) return new errors_js_1.OriginNotAllowedError(name, browser); if (requireJson && carriesBody && !isJson(headerOf(headers, 'content-type'))) { return new errors_js_1.UnsupportedMediaTypeError(name); } return undefined; }, }; } exports.doorGuard = doorGuard; /** * What the BROWSER said about where this request came from, judged against the * origin rule — or `undefined` when it may pass. * * Two facts, in order. `Origin` first: present and not allowed is a refusal. * Then Fetch metadata, as defence in depth: a request the browser itself * marked `Sec-Fetch-Site: cross-site` is refused unless its Origin is one * `allowedOrigins` LISTS — which catches the request whose `Origin` a proxy * stripped while `Sec-Fetch-Site` rode through. Neither header can be set by a * page, and a request carrying neither is not a browser's. * * One more, for a WebSocket upgrade only: a browser ALWAYS sends `Origin` on a * handshake, and with it `Sec-Fetch-Site`, so a handshake carrying * `Sec-Fetch-Site` but no `Origin` is a browser's with its Origin stripped — * refused whatever the site says, because a handshake has no content type and * no preflight to fall back on. `Sec-Fetch-Site` and not any `Sec-Fetch-*`: * Node's own `WebSocket` client sends `Sec-Fetch-Mode: websocket` and no * Origin, and it is not a browser page. */ function browserVerdict(rule, headers, upgrade) { if (rule === 'any') return undefined; const origin = headerOf(headers, 'origin'); const listed = rule !== 'same-host'; if (origin !== undefined) { if (!originAllowed(rule, origin, headers)) return listed ? 'listed' : 'same-host'; // An origin the deployment LISTED may be on another site by design. if (listed) return undefined; } else if (upgrade && headerOf(headers, 'sec-fetch-site') !== undefined) { return 'fetch-metadata'; } const site = headerOf(headers, 'sec-fetch-site'); return site?.trim().toLowerCase() === 'cross-site' ? 'fetch-metadata' : undefined; } /** Visible ASCII, `!` (0x21) to `~` (0x7E) — nothing else, from any field. */ const VISIBLE_ASCII = /^[\x21-\x7e]+$/; /** * 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. */ function checkSessionId(sessionId, hostName) { if (sessionId === undefined) return undefined; if (sessionId.length === 0) { return new errors_js_1.InvalidSessionIdError('empty', hostName, 0, exports.MAX_SESSION_ID_LENGTH); } if (sessionId.length > exports.MAX_SESSION_ID_LENGTH) { return new errors_js_1.InvalidSessionIdError('too-long', hostName, sessionId.length, exports.MAX_SESSION_ID_LENGTH); } if (!VISIBLE_ASCII.test(sessionId)) { return new errors_js_1.InvalidSessionIdError('invalid-character', hostName, sessionId.length, exports.MAX_SESSION_ID_LENGTH); } return undefined; } exports.checkSessionId = checkSessionId; /** * 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`). */ function loopbackAllowedHosts(bindHost) { if (!isLoopbackBind(bindHost)) return undefined; const names = ['localhost', '127.0.0.1', '[::1]']; const literal = (bindHost ?? '').trim().toLowerCase(); if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(literal) && literal !== '127.0.0.1') { names.push(literal); } return names; } exports.loopbackAllowedHosts = loopbackAllowedHosts; /** * `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. */ function isLoopbackBind(bindHost) { if (bindHost === undefined) return false; const host = bindHost .trim() .toLowerCase() .replace(/^\[|\]$/g, ''); if (host === 'localhost' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; if (!host.includes(':')) return false; const canonical = parseUrl(`http://[${host}]`)?.hostname; // `::ffff:127.x.y.z` canonicalises to `[::ffff:7fxx:xxxx]`. return canonical === '[::1]' || /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(canonical ?? ''); } exports.isLoopbackBind = isLoopbackBind; /** * 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. */ function platformFrontedDoor(options, bindHost) { // A LOOPBACK bind is by definition not behind the platform's front door — it // is a laptop or a self-hosted box, the classic victim — so it keeps every // default a plain `httpHost` has (and the loopback names for `allowedHosts`). if (isLoopbackBind(bindHost)) { return { ...(options.allowedOrigins !== undefined && { allowedOrigins: options.allowedOrigins }), ...(options.allowedHosts !== undefined && { allowedHosts: options.allowedHosts }), ...(options.requireJsonContentType !== undefined && { requireJsonContentType: options.requireJsonContentType, }), }; } return { allowedOrigins: options.allowedOrigins ?? 'any', allowedHosts: options.allowedHosts ?? 'any', requireJsonContentType: options.requireJsonContentType ?? false, }; } exports.platformFrontedDoor = platformFrontedDoor; const warnedHostNames = new Set(); const warnedRulesOff = new Set(); /** * 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. */ function warnBrowserRulesOff(hostName) { if (warnedRulesOff.has(hostName)) return; warnedRulesOff.add(hostName); // eslint-disable-next-line no-console console.warn(`[hosting] the '${hostName}' host serves with the browser rules OFF (allowedOrigins: ` + `'any', requireJsonContentType: false): any web page a user opens can make their ` + `browser send it requests. That is right only when the one way to this port is a ` + `platform front door that demands its own credential. Anywhere else — a ` + `laptop, a self-hosted VM — set allowedOrigins, allowedHosts and ` + `requireJsonContentType: true, or bind to 127.0.0.1.`); } exports.warnBrowserRulesOff = warnBrowserRulesOff; /** * 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. */ function warnAllowedHostsUnset(hostName) { if (warnedHostNames.has(hostName)) return; warnedHostNames.add(hostName); // eslint-disable-next-line no-console console.warn(`[hosting] the '${hostName}' host answers a request for ANY Host name, because ` + `allowedHosts is not set. A web page on another domain can re-point its own name at ` + `this server's address (DNS rebinding) and then read this door's answers as if it were ` + `the app itself — the Origin check cannot tell, because the page's Origin and the ` + `request's Host both carry the attacker's name. Set allowedHosts: ['<the name people ` + `use to reach it>'] to refuse every other name (421), or allowedHosts: 'any' to state ` + `that no browser can reach this port. Serving continues: in this release an unset ` + `list is a warning, not a refusal, so existing deployments keep starting.`); } exports.warnAllowedHostsUnset = warnAllowedHostsUnset; function readAllowedOrigins(name, value) { if (value === undefined) return 'same-host'; if (value === 'any') return 'any'; if (!Array.isArray(value)) { throw new TypeError(`[hosting] ${name}: allowedOrigins must be a list of origins ` + `(['https://app.example']) or 'any'; received ${describe(value)}.`); } const origins = new Set(); for (const entry of value) origins.add(readOriginEntry(name, entry)); return origins; } function readOriginEntry(name, entry) { const text = typeof entry === 'string' ? entry.trim() : ''; if (text === '*') { throw new TypeError(`[hosting] ${name}: allowedOrigins cannot contain '*'. To turn the Origin check off, ` + `pass allowedOrigins: 'any' — a word, so nobody reads it as a wildcard pattern.`); } if (text.toLowerCase() === 'null') { throw new TypeError(`[hosting] ${name}: allowedOrigins cannot contain 'null'. It is the origin of every ` + `sandboxed frame and data: page on every site, so allowing it would allow them all.`); } const url = parseUrl(text); if (url === undefined || url.origin === 'null' || !bareAuthority(url)) { throw new TypeError(`[hosting] ${name}: allowedOrigins entry ${JSON.stringify(entry)} is not an origin. ` + `An origin is scheme://host[:port] with nothing after it — 'https://app.example'.`); } return url.origin; } function readAllowedHosts(name, value) { if (value === undefined || value === 'any') return 'any'; if (!Array.isArray(value)) { throw new TypeError(`[hosting] ${name}: allowedHosts must be a list of host names ` + `(['app.example']) or 'any'; received ${describe(value)}.`); } if (value.length === 0) { throw new TypeError(`[hosting] ${name}: allowedHosts is an empty list, so every request would be refused — ` + `nothing could ever be served. List the names this door is reached by, or pass 'any'.`); } return value.map((entry) => readHostEntry(name, entry)); } function readHostEntry(name, entry) { const parsed = typeof entry === 'string' ? parseAuthority(entry) : undefined; if (parsed === undefined) { throw new TypeError(`[hosting] ${name}: allowedHosts entry ${JSON.stringify(entry)} is not a host name. ` + `Expected a name with an optional port — 'app.example', 'app.example:8443', or an ` + `IPv6 address in brackets, '[::1]'.`); } return parsed; } function readRequireJson(name, value) { if (value === undefined) return true; if (typeof value !== 'boolean') { throw new TypeError(`[hosting] ${name}: requireJsonContentType must be true or false; received ` + `${describe(value)}.`); } return value; } // ─── Judging a request (per request) ───────────────────────────────── function hostAllowed(entries, host) { const seen = host === undefined ? undefined : parseAuthority(host); if (seen === undefined) return false; return entries.some((entry) => entry.hostname === seen.hostname && (entry.port === undefined || entry.port === seen.port)); } function originAllowed(rule, origin, headers) { const url = parseUrl(origin.trim()); if (url === undefined || url.origin === 'null') return false; if (rule !== 'same-host') return rule.has(url.origin); // The page's own name, as the browser addressed the door: the Host header, // or what a proxy in front recorded before rewriting it. A cross-site page // cannot set X-Forwarded-Host without a preflight this host never approves. // // A Host with no port is read under the scheme the client actually used // when a proxy said so (`X-Forwarded-Proto`), and under the Origin's scheme // otherwise — so `http://name` (port 80) and `https://name` (port 443) are // two origins, as they are to a browser. const own = authorityKey(url); const scheme = forwardedProto(headers) ?? url.protocol; const candidates = [ headerOf(headers, 'host'), ...(headerOf(headers, 'x-forwarded-host')?.split(',') ?? []), ]; return candidates.some((candidate) => { const parsed = candidate === undefined ? undefined : parseUrl(`${scheme}//${candidate.trim()}`); return parsed !== undefined && bareAuthority(parsed) && authorityKey(parsed) === own; }); } /** The first `X-Forwarded-Proto` entry, when it is `http` or `https`. */ function forwardedProto(headers) { const first = headerOf(headers, 'x-forwarded-proto')?.split(',')[0]?.trim().toLowerCase(); return first === 'http' || first === 'https' ? `${first}:` : undefined; } /** The media type's essence is exactly `application/json` — never a `+json` cousin. */ function isJson(contentType) { if (contentType === undefined) return false; const essence = contentType.split(';', 1)[0] ?? ''; return essence.trim().toLowerCase() === 'application/json'; } function isWebSocketUpgrade(headers) { const upgrade = headerOf(headers, 'upgrade'); if (upgrade === undefined) return false; return upgrade .toLowerCase() .split(',') .some((token) => token.trim() === 'websocket'); } // ─── Small parsers ─────────────────────────────────────────────────── /** One header's value, case-insensitively; repeated values joined as node joins them. */ function headerOf(headers, name) { const direct = headers[name]; if (direct !== undefined) return joined(direct); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === name && value !== undefined) return joined(value); } return undefined; } function joined(value) { return typeof value === 'string' ? value : value.join(', '); } function parseUrl(text) { try { return new URL(text); } catch { return undefined; } } /** A URL that is nothing but scheme and authority — no credentials, path, query or fragment. */ function bareAuthority(url) { return (url.username === '' && url.password === '' && url.pathname === '/' && url.search === '' && url.hash === ''); } /** A `host[:port]` value as a {@link HostEntry}, or `undefined` when it is anything more. */ function parseAuthority(value) { const url = parseUrl(`http://${value.trim()}`); if (url === undefined || !bareAuthority(url) || url.hostname === '') return undefined; const hostname = withoutTrailingDot(url.hostname); return url.port === '' ? { hostname } : { hostname, port: url.port }; } /** Host and EFFECTIVE port as one comparable string — a default port spelled out. */ function authorityKey(url) { const hostname = withoutTrailingDot(url.hostname); const port = url.port !== '' ? url.port : DEFAULT_PORT[url.protocol] ?? ''; return `${hostname}:${port}`; } const DEFAULT_PORT = { 'http:': '80', 'https:': '443', 'ws:': '80', 'wss:': '443', }; function withoutTrailingDot(hostname) { return hostname.endsWith('.') ? hostname.slice(0, -1) : hostname; } function describe(value) { if (typeof value === 'string') return `the string ${JSON.stringify(value)}`; return Array.isArray(value) ? 'an array' : typeof value; } //# sourceMappingURL=doorGuard.js.map