companion-loopback-guard.mjs
451 lines 20.6 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Companion loopback endpoint REQUEST-GUARD — pure decision core.
3 *
4 * Phase 2 of the Companion App build plan (feat/companion-app).
5 * See docs/COMPANION-APP-PHASE-2-LOOPBACK-SECURITY.md for the accepted design,
6 * the adversarial threat model, and the contract Phase 5 must honour to bind a socket.
7 *
8 * WHAT THIS MODULE IS
9 * The companion app (a future tray helper) will expose a local AI-inference endpoint on
10 * 127.0.0.1:<ephemeral-port>. That listening socket is the single most security-critical
11 * surface in the whole companion design: every web page in the user's browser can issue
12 * requests to http://127.0.0.1:<port>, and DNS-rebinding can make a remote origin appear
13 * to target loopback. Binding to 127.0.0.1 is necessary but NOT sufficient (gate §4).
14 *
15 * This module is the *request-decision core* for that endpoint: given a single request's
16 * method, headers, presented token, and the current rate-limit state, it returns a verdict
17 * ({ allow, status, reason }) enforcing gate §4 controls 1, 2, 3, 5, 6, and 8 at the
18 * request-decision level. It binds NO socket and performs NO I/O (the gate's
19 * "DOES NOT approve" list still forbids opening a new local HTTP listener; the bind is
20 * deferred to Phase 5 behind an explicit gate — see the design doc §"What Phase 5 must do").
21 *
22 * DESIGN CONSTRAINTS (read before modifying — these are security invariants, not style):
23 * - PURE. No I/O, no process.env reads, no network, no logging, no clock reads. Every input
24 * (including `now`) is passed explicitly so the guard is deterministic and testable, and so
25 * it can be composed at any layer without environment coupling.
26 * - FAIL-CLOSED. Anything missing, malformed, ambiguous, or unrecognised → DENY. There is no
27 * fail-open branch anywhere in this module.
28 * - NO AMBIENT AUTHORITY (gate §4.6). The guard's sole output is an inference-admission
29 * verdict. It never returns, references, or has access to the vault, the canister client,
30 * or the stored JWT. The narrow return shape is the structural enforcement of that.
31 * - NO SECRET IN OUTPUT (gate §4.8). Reason codes are fixed constants. The presented token,
32 * the expected token, a JWT, and note bodies are NEVER copied into a reason string, a
33 * returned value, or a thrown error.
34 * - NOTE BODY IS DATA, NEVER CONTROL (gate §4.7 / brief §8.3). The guard does not accept,
35 * read, or branch on any request body. A prompt-injection payload in a note body therefore
36 * cannot influence the admission decision — it is structurally outside this function.
37 *
38 * Hard constraint from docs/COMPANION-APP-MODEL-ROUTING-AND-ENRICHMENT-ARCHITECTURE.md §3:
39 * The cloud gateway NEVER proxies local inference. This endpoint is loopback-only; the only
40 * browser origin permitted to call it is its OWN loopback origin (same-origin). A remote
41 * origin — including the hosted Knowtation web app — is cross-origin and is rejected here.
42 * Permitting a remote origin to drive the companion would be a deliberate allowlist extension
43 * decided at the Phase 5 bind gate, not a default of this guard.
44 */
45
46 import crypto from 'node:crypto';
47
48 /**
49 * HTTP methods the loopback inference endpoint accepts.
50 *
51 * GET — health/status probe (no model work, no body).
52 * POST — inference request (body carried as DATA only; the guard never reads it).
53 *
54 * Everything else — including OPTIONS — is rejected. The endpoint serves only same-origin
55 * (loopback) callers and non-browser local processes, so there is no legitimate CORS preflight
56 * to honour; accepting OPTIONS would only widen the surface.
57 * @type {ReadonlySet<string>}
58 */
59 export const ALLOWED_METHODS = new Set(['GET', 'POST']);
60
61 /**
62 * Hostnames recognised as loopback. The request `Host` must resolve to one of these AND appear
63 * in the caller-supplied `allowedHosts` allowlist — belt-and-suspenders so a caller that
64 * misconfigures `allowedHosts` with a non-loopback literal still cannot open a non-loopback path
65 * (gate §4.5: loopback bind only).
66 * @type {ReadonlySet<string>}
67 */
68 export const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1']);
69
70 /**
71 * `Sec-Fetch-Site` values that may proceed. A browser attaches `Sec-Fetch-Site` automatically
72 * and a page cannot forge or strip it (it is a Forbidden header). Only a same-origin fetch from
73 * the loopback origin itself ('same-origin') or a top-level user navigation ('none') is allowed.
74 * 'same-site' and 'cross-site' are rejected: loopback has no registrable-domain siblings, so any
75 * 'same-site'/'cross-site' signal is an out-of-context (cross-origin) caller.
76 * @type {ReadonlySet<string>}
77 */
78 const ALLOWED_SEC_FETCH_SITE = new Set(['same-origin', 'none']);
79
80 /**
81 * Fixed reason codes. These are the ONLY strings the guard ever returns as a reason. None of
82 * them is derived from request input, so no secret or attacker-controlled value can leak through
83 * the reason channel (gate §4.8).
84 * @readonly
85 */
86 export const LOOPBACK_GUARD_REASONS = Object.freeze({
87 OK: 'ok',
88 MALFORMED_REQUEST: 'malformed_request',
89 METHOD_NOT_ALLOWED: 'method_not_allowed',
90 HOST_NOT_ALLOWED: 'host_not_allowed',
91 CROSS_SITE_FORBIDDEN: 'cross_site_forbidden',
92 RATE_STATE_UNAVAILABLE: 'rate_state_unavailable',
93 RATE_LIMITED: 'rate_limited',
94 MISSING_TOKEN: 'missing_token',
95 INVALID_TOKEN: 'invalid_token',
96 });
97
98 /**
99 * @typedef {Object} LoopbackRateState
100 * @property {number} windowMs Sliding-window size in milliseconds (> 0).
101 * @property {number} maxRequests Max requests permitted within the window (integer > 0).
102 * @property {number[]} timestamps Epoch-ms timestamps of prior requests that reached the
103 * rate stage. Maintained by the caller via recordLoopbackRequest.
104 */
105
106 /**
107 * @typedef {Object} LoopbackVerdict
108 * @property {boolean} allow true only for an admitted request.
109 * @property {200|401|403|429} status HTTP status the listener (Phase 5) should return.
110 * @property {string} reason A LOOPBACK_GUARD_REASONS value — never a secret.
111 */
112
113 /**
114 * Build a fresh, valid rate-limit state.
115 *
116 * @param {{ windowMs?: number, maxRequests?: number }} [opts]
117 * windowMs defaults to 60_000 (1 minute); maxRequests defaults to 60.
118 * @returns {LoopbackRateState}
119 */
120 export function createLoopbackRateState({ windowMs = 60_000, maxRequests = 60 } = {}) {
121 if (!Number.isFinite(windowMs) || windowMs <= 0) {
122 throw new TypeError('windowMs must be a positive, finite number');
123 }
124 if (!Number.isInteger(maxRequests) || maxRequests <= 0) {
125 throw new TypeError('maxRequests must be a positive integer');
126 }
127 return { windowMs, maxRequests, timestamps: [] };
128 }
129
130 /**
131 * Constant-time string equality.
132 *
133 * Both inputs are hashed to a fixed 32-byte digest before comparison, so:
134 * - timingSafeEqual always receives equal-length buffers (it throws on length mismatch, which
135 * would otherwise be an early-exit length oracle),
136 * - the comparison time does not depend on where the first differing byte is, and
137 * - inputs of different lengths simply produce different digests (no length leak in timing).
138 *
139 * Non-string or empty inputs return false WITHOUT performing a compare — absence of a value is
140 * not a content-timing oracle (there is no secret content to compare against yet).
141 *
142 * @param {unknown} a
143 * @param {unknown} b
144 * @returns {boolean}
145 */
146 export function constantTimeStringEqual(a, b) {
147 if (typeof a !== 'string' || typeof b !== 'string') return false;
148 if (a.length === 0 || b.length === 0) return false;
149 const da = crypto.createHash('sha256').update(a, 'utf8').digest();
150 const db = crypto.createHash('sha256').update(b, 'utf8').digest();
151 return crypto.timingSafeEqual(da, db);
152 }
153
154 /**
155 * Case-insensitive header lookup over a plain headers object.
156 * Returns the trimmed string value, or undefined if absent / not a scalar string.
157 * If a header appears as an array (Node can deliver duplicates as arrays), it is treated as
158 * ambiguous and returns undefined → fail-closed at the call site.
159 *
160 * @param {Record<string, unknown>} headers
161 * @param {string} name
162 * @returns {string | undefined}
163 */
164 function getHeader(headers, name) {
165 const target = name.toLowerCase();
166 for (const key of Object.keys(headers)) {
167 if (key.toLowerCase() === target) {
168 const value = headers[key];
169 if (typeof value === 'string') return value.trim();
170 return undefined; // arrays / non-string → ambiguous → fail-closed
171 }
172 }
173 return undefined;
174 }
175
176 /**
177 * Parse a `Host` header into { hostname, port }. Handles IPv6 bracket form `[::1]:port`.
178 * Returns null on anything malformed.
179 *
180 * @param {string} host
181 * @returns {{ hostname: string, port: string } | null}
182 */
183 export function parseHostHeader(host) {
184 if (typeof host !== 'string' || host.length === 0) return null;
185 const trimmed = host.trim();
186 // IPv6: [::1]:port or [::1]
187 if (trimmed.startsWith('[')) {
188 const close = trimmed.indexOf(']');
189 if (close === -1) return null;
190 const hostname = trimmed.slice(1, close);
191 const rest = trimmed.slice(close + 1);
192 if (rest === '') return { hostname, port: '' };
193 if (rest.startsWith(':')) return { hostname, port: rest.slice(1) };
194 return null;
195 }
196 // IPv4 / hostname: split on the LAST colon so we don't mis-split bare IPv6 (which must use
197 // bracket form here anyway).
198 const idx = trimmed.lastIndexOf(':');
199 if (idx === -1) return { hostname: trimmed, port: '' };
200 // A colon with more colons before it (bare IPv6 without brackets) is malformed for our purposes.
201 if (trimmed.indexOf(':') !== idx) return null;
202 return { hostname: trimmed.slice(0, idx), port: trimmed.slice(idx + 1) };
203 }
204
205 /**
206 * True when the host string names a loopback hostname (ignoring the port).
207 * @param {string} host
208 * @returns {boolean}
209 */
210 export function isLoopbackHost(host) {
211 const parsed = parseHostHeader(host);
212 if (!parsed) return false;
213 return LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase());
214 }
215
216 /**
217 * Evaluate the sliding-window rate limit WITHOUT mutating state.
218 * Returns whether the CURRENT request may proceed given prior requests in the window.
219 *
220 * Fail-closed: a missing or malformed rateState denies (we cannot prove the rate is bounded).
221 *
222 * @param {LoopbackRateState | undefined} rateState
223 * @param {number} now Epoch-ms for the current request.
224 * @returns {{ ok: true } | { ok: false, reason: string }}
225 */
226 export function evaluateRateLimit(rateState, now) {
227 if (!rateState || typeof rateState !== 'object') {
228 return { ok: false, reason: LOOPBACK_GUARD_REASONS.RATE_STATE_UNAVAILABLE };
229 }
230 const { windowMs, maxRequests, timestamps } = /** @type {LoopbackRateState} */ (rateState);
231 if (!Number.isFinite(windowMs) || windowMs <= 0) {
232 return { ok: false, reason: LOOPBACK_GUARD_REASONS.RATE_STATE_UNAVAILABLE };
233 }
234 if (!Number.isInteger(maxRequests) || maxRequests <= 0) {
235 return { ok: false, reason: LOOPBACK_GUARD_REASONS.RATE_STATE_UNAVAILABLE };
236 }
237 if (!Array.isArray(timestamps)) {
238 return { ok: false, reason: LOOPBACK_GUARD_REASONS.RATE_STATE_UNAVAILABLE };
239 }
240 const windowStart = now - windowMs;
241 let countInWindow = 0;
242 for (const t of timestamps) {
243 if (typeof t === 'number' && Number.isFinite(t) && t > windowStart) countInWindow += 1;
244 }
245 if (countInWindow >= maxRequests) {
246 return { ok: false, reason: LOOPBACK_GUARD_REASONS.RATE_LIMITED };
247 }
248 return { ok: true };
249 }
250
251 /**
252 * Record that a request reached the rate stage, returning a NEW rate state (pure; the input is
253 * not mutated). Old timestamps outside the window are pruned so the array cannot grow without
254 * bound.
255 *
256 * Caller contract: invoke this for every request that passed the network-identity gate
257 * (method + host + origin/sec-fetch) — i.e. every request that reached the rate/token stage,
258 * regardless of whether the token then matched. This is what bounds token brute-force: failed-auth
259 * attempts still consume the window budget. Requests rejected EARLIER (bad method/host/cross-site)
260 * must NOT be recorded, so cross-origin/rebinding probes cannot exhaust the budget and deny the
261 * legitimate client (a budget-exhaustion DoS). See shouldCountTowardRateLimit().
262 *
263 * @param {LoopbackRateState} rateState
264 * @param {number} now Epoch-ms for the current request.
265 * @returns {LoopbackRateState}
266 */
267 export function recordLoopbackRequest(rateState, now) {
268 if (!rateState || typeof rateState !== 'object') {
269 throw new TypeError('recordLoopbackRequest: rateState must be an object');
270 }
271 const { windowMs, maxRequests, timestamps } = /** @type {LoopbackRateState} */ (rateState);
272 if (!Number.isFinite(windowMs) || windowMs <= 0 || !Number.isInteger(maxRequests) || maxRequests <= 0) {
273 throw new TypeError('recordLoopbackRequest: rateState is malformed');
274 }
275 if (!Number.isFinite(now)) {
276 throw new TypeError('recordLoopbackRequest: now must be a finite number');
277 }
278 const windowStart = now - windowMs;
279 const source = Array.isArray(timestamps) ? timestamps : [];
280 const pruned = source.filter((t) => typeof t === 'number' && Number.isFinite(t) && t > windowStart);
281 pruned.push(now);
282 return { windowMs, maxRequests, timestamps: pruned };
283 }
284
285 /**
286 * Reasons whose request reached the TOKEN stage and therefore consumed a rate-window slot:
287 * an admitted request (OK) and a request that passed the network-identity + rate gates but failed
288 * authentication (MISSING_TOKEN / INVALID_TOKEN). These are the requests the caller must record.
289 *
290 * Deliberately EXCLUDED:
291 * - pre-rate rejections (malformed/method/host/cross-site): recording them would let a
292 * cross-origin or DNS-rebinding flood exhaust the shared budget and deny the legitimate
293 * client (a budget-exhaustion DoS).
294 * - rate rejections (rate_limited / rate_state_unavailable): the request did NOT get a slot;
295 * recording it would re-feed the window so it never drains under a sustained flood and would
296 * let the timestamps array grow past maxRequests. Counting only slot-consuming requests keeps
297 * the array bounded by maxRequests while still counting failed-auth attempts (so token
298 * brute-force remains bounded).
299 * @type {ReadonlySet<string>}
300 */
301 const SLOT_CONSUMING_REASONS = new Set([
302 LOOPBACK_GUARD_REASONS.OK,
303 LOOPBACK_GUARD_REASONS.MISSING_TOKEN,
304 LOOPBACK_GUARD_REASONS.INVALID_TOKEN,
305 ]);
306
307 /**
308 * Whether a given verdict consumed a rate-window slot and must be recorded by the caller.
309 * True only for verdicts that reached the token stage (OK / missing / invalid token); see
310 * SLOT_CONSUMING_REASONS for why pre-rate and rate rejections are excluded.
311 *
312 * @param {LoopbackVerdict} verdict
313 * @returns {boolean}
314 */
315 export function shouldCountTowardRateLimit(verdict) {
316 if (!verdict || typeof verdict !== 'object') return false;
317 return SLOT_CONSUMING_REASONS.has(verdict.reason);
318 }
319
320 /** Internal helper to build a deny verdict with a fixed reason. */
321 function deny(status, reason) {
322 return { allow: false, status, reason };
323 }
324
325 /**
326 * Decide whether a single loopback request may proceed to model inference.
327 *
328 * Enforces gate §4 controls at the request-decision level:
329 * §4.1 bearer token on every request (constant-time compare) → 401
330 * §4.2 strict Host allowlist (primary DNS-rebinding defense) → 403
331 * §4.3 strict Origin / Sec-Fetch-Site (no cross-site, no wildcard) → 403
332 * §4.5 loopback-only (Host must resolve to a loopback hostname) → 403
333 * §4.6 no ambient authority (verdict is the ONLY output) → (structural)
334 * §4.8 rate limiting (bounded request rate) → 429
335 * §4.7 untrusted input — note body is never read here → (structural)
336 *
337 * EVALUATION ORDER is itself a security decision and must not be reordered casually:
338 * 1. Structural validity — malformed input fails closed (403).
339 * 2. Method allowlist — only GET/POST (403).
340 * 3. Host allowlist + loopback — DNS-rebinding defense (403). Cheap; rejects the bulk of
341 * browser-based abuse before any budget is touched.
342 * 4. Origin / Sec-Fetch-Site — cross-site rejection (403).
343 * 5. Rate limit — BEFORE the token check (429). Placing it before the token
344 * check is what bounds token brute-force: once the window is
345 * full, even token-guessing requests get 429 rather than an
346 * unbounded stream of 401s. It is placed AFTER host/origin so a
347 * cross-origin/rebinding flood (already 403'd) cannot consume the
348 * shared budget and deny the legitimate client.
349 * 6. Token — presence then constant-time match (401).
350 * 7. Allow — 200.
351 *
352 * The function NEVER throws: any unexpected internal error is caught and converted to a
353 * fail-closed 403 with a fixed reason, so no exception can carry input data outward.
354 *
355 * @param {{
356 * method?: unknown,
357 * headers?: unknown,
358 * token?: unknown,
359 * expectedToken?: unknown,
360 * allowedHosts?: unknown,
361 * now?: unknown,
362 * rateState?: LoopbackRateState,
363 * }} params
364 * @returns {LoopbackVerdict}
365 */
366 export function verifyLoopbackRequest(params) {
367 try {
368 const { method, headers, token, expectedToken, allowedHosts, now, rateState } = params ?? {};
369
370 // 1. Structural validity — fail closed on anything malformed.
371 if (
372 typeof method !== 'string' ||
373 method.length === 0 ||
374 headers === null ||
375 typeof headers !== 'object' ||
376 Array.isArray(headers) ||
377 typeof now !== 'number' ||
378 !Number.isFinite(now)
379 ) {
380 return deny(403, LOOPBACK_GUARD_REASONS.MALFORMED_REQUEST);
381 }
382 const headerBag = /** @type {Record<string, unknown>} */ (headers);
383
384 // 2. Method allowlist.
385 if (!ALLOWED_METHODS.has(method.toUpperCase())) {
386 return deny(403, LOOPBACK_GUARD_REASONS.METHOD_NOT_ALLOWED);
387 }
388
389 // 3. Host allowlist + loopback enforcement (primary DNS-rebinding defense, §4.2/§4.5).
390 if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) {
391 return deny(403, LOOPBACK_GUARD_REASONS.HOST_NOT_ALLOWED); // cannot validate → deny
392 }
393 const hostHeader = getHeader(headerBag, 'host');
394 if (hostHeader === undefined || hostHeader.length === 0) {
395 return deny(403, LOOPBACK_GUARD_REASONS.HOST_NOT_ALLOWED);
396 }
397 const hostMatchesAllowlist = allowedHosts.some(
398 (h) => typeof h === 'string' && h.toLowerCase() === hostHeader.toLowerCase(),
399 );
400 if (!hostMatchesAllowlist || !isLoopbackHost(hostHeader)) {
401 return deny(403, LOOPBACK_GUARD_REASONS.HOST_NOT_ALLOWED);
402 }
403
404 // 4. Origin / Sec-Fetch-Site cross-site rejection (§4.3).
405 // The only browser origins permitted are the loopback origins derived from allowedHosts
406 // (same-origin). A present Origin must be one of them; a present Sec-Fetch-Site must be
407 // same-origin/none. A non-browser local process sends neither and is allowed past this
408 // gate (it still needs a valid token, and loopback bind keeps remote clients out).
409 const secFetchSite = getHeader(headerBag, 'sec-fetch-site');
410 if (secFetchSite !== undefined && !ALLOWED_SEC_FETCH_SITE.has(secFetchSite.toLowerCase())) {
411 return deny(403, LOOPBACK_GUARD_REASONS.CROSS_SITE_FORBIDDEN);
412 }
413 const origin = getHeader(headerBag, 'origin');
414 if (origin !== undefined && origin.length > 0) {
415 const allowedOrigins = new Set();
416 for (const h of allowedHosts) {
417 if (typeof h === 'string') {
418 allowedOrigins.add(`http://${h.toLowerCase()}`);
419 allowedOrigins.add(`https://${h.toLowerCase()}`);
420 }
421 }
422 if (!allowedOrigins.has(origin.toLowerCase())) {
423 return deny(403, LOOPBACK_GUARD_REASONS.CROSS_SITE_FORBIDDEN);
424 }
425 }
426
427 // 5. Rate limit — BEFORE token (bounds brute-force), AFTER host/origin (no budget DoS).
428 const rate = evaluateRateLimit(rateState, now);
429 if (!rate.ok) {
430 return deny(429, rate.reason);
431 }
432
433 // 6. Token — presence, then constant-time match (§4.1).
434 if (typeof token !== 'string' || token.length === 0) {
435 return deny(401, LOOPBACK_GUARD_REASONS.MISSING_TOKEN);
436 }
437 if (typeof expectedToken !== 'string' || expectedToken.length === 0) {
438 // No credential is configured → nothing can authenticate. Fail closed.
439 return deny(401, LOOPBACK_GUARD_REASONS.INVALID_TOKEN);
440 }
441 if (!constantTimeStringEqual(token, expectedToken)) {
442 return deny(401, LOOPBACK_GUARD_REASONS.INVALID_TOKEN);
443 }
444
445 // 7. Admitted.
446 return { allow: true, status: 200, reason: LOOPBACK_GUARD_REASONS.OK };
447 } catch {
448 // Defense in depth: never let an unexpected error escape with input data attached.
449 return deny(403, LOOPBACK_GUARD_REASONS.MALFORMED_REQUEST);
450 }
451 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago