companion-loopback-guard.mjs
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