COMPANION-APP-PHASE-2-LOOPBACK-SECURITY.md file-level

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
1 # Companion App β€” Phase 2: Loopback Endpoint Security Core
2
3 **Status:** accepted design + implementation (pure request-guard; **no socket bound**).
4 **Branch:** `feat/companion-app` (Muse-canonical; not a docs-only PR to `main`).
5 **Phase table ref:** Gate Β§12, Phase 2 β€” 🧠 Thinking. "DNS-rebinding and cross-origin abuse are
6 adversarial; the defense must be argued against an attacker model, not pattern-matched."
7 **Depends on:** Phase 0 Decision Record (gate Β§13, D1–D3 accepted) and Phase 1 adapter seam
8 ([`COMPANION-APP-PHASE-1-ADAPTER-SEAM.md`](COMPANION-APP-PHASE-1-ADAPTER-SEAM.md)).
9 **Upstream:** [`COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md`](COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md)
10 Β§4 (the 8 loopback controls), Β§10 (7-tier test obligations);
11 [`COMPANION-APP-MODEL-ROUTING-AND-ENRICHMENT-ARCHITECTURE.md`](COMPANION-APP-MODEL-ROUTING-AND-ENRICHMENT-ARCHITECTURE.md)
12 Β§3 (client-side constraint), Β§8.1 (localhost security), Β§8.3 (prompt injection).
13
14 ---
15
16 ## Simple summary
17
18 The companion app runs a tiny AI server **on your own laptop** so your private notes can be
19 processed locally and never leave the device. The most dangerous moment in that whole feature is
20 when that little server **opens its door** to the network: every web page open in your browser can
21 knock on `http://127.0.0.1:<port>`, and a trick called **DNS-rebinding** can make a stranger's
22 website *look* like it's coming from your own machine.
23
24 This phase builds the **bouncer** that stands at the door and decides, for every single knock,
25 whether to let it in. The bouncer checks: *do you carry the right one-time pass (token)? are you
26 actually knocking on the loopback door and not a disguised one (Host)? are you a page from this same
27 local app and not some random website (Origin)? have there been too many knocks too fast
28 (rate-limit)?* If anything is missing or even slightly off, the answer is **no** β€” the bouncer
29 fails safe.
30
31 Crucially, we built and exhaustively tested the **bouncer by itself, before installing the actual
32 door.** The door (the real listening socket) is deliberately **not** opened in this phase β€” that is
33 the single most security-critical action and it stays behind a separate explicit approval (Phase 5).
34 The bouncer is a pure function: same inputs always give the same answer, it touches no files, no
35 network, no settings, so we can prove it is incorruptible.
36
37 ## Technical summary
38
39 Phase 2 delivers **`lib/companion-loopback-guard.mjs`** β€” a pure, I/O-free request-decision core
40 (`verifyLoopbackRequest`) enforcing gate Β§4 controls **1, 2, 3, 5, 6, 8** at the request-decision
41 level, plus the rate-state helpers (`createLoopbackRateState`, `recordLoopbackRequest`,
42 `evaluateRateLimit`, `shouldCountTowardRateLimit`) and a constant-time comparator
43 (`constantTimeStringEqual`). It binds **no socket** and reads **no environment**, mirroring how
44 Phase 1 shipped pure decision logic.
45
46 This scope is deliberate and gate-compliant. The gate's
47 ["DOES NOT approve (no code)"](COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md) list forbids
48 *"opening any new local HTTP listener / loopback model endpoint in any repo,"* and Β§13.2 restates
49 that this prohibition is **unchanged** by Phase 0 acceptance. Building the request-guard as a pure,
50 fully-tested function β€” and deferring the actual `server.listen()` bind to Phase 5 behind an
51 explicit gate β€” keeps the most security-critical surface closed while letting the adversarial
52 decision logic be proven now. Β§4's controls 4 (non-predictable port) and 5 (loopback bind) are
53 **binding-time** properties; this doc specifies exactly what Phase 5 must do to satisfy them
54 (see [Β§6](#6-what-phase-5-must-do-to-bind-the-socket-safely)).
55
56 The guard is **fail-closed everywhere**: any missing, malformed, ambiguous, or unrecognised input
57 denies. It never throws (a catch-all converts internal errors to a fixed-reason 403), never logs,
58 and never copies a token, JWT, or note body into a reason string, a return value, or an error β€”
59 satisfying gate Β§4.8 "never log token, JWT, or note bodies."
60
61 ---
62
63 ## 1. Adversarial threat model
64
65 The loopback endpoint is the GitHub-analogue of a service bound to `127.0.0.1`: reachable by
66 anything already running on the machine. We model four attacker capabilities and, for each, the
67 **exact control** that stops it. The defense is argued against the attacker, not pattern-matched.
68
69 ### Attacker A β€” malicious web page in the user's browser (cross-origin)
70
71 **Capability.** The user visits `https://evil.example`. That page's JavaScript can issue `fetch()`
72 / `XMLHttpRequest` to `http://127.0.0.1:<port>` (the browser will connect to loopback). The attacker
73 controls the request method, the URL path, and most request headers β€” but **cannot** set the
74 Forbidden headers `Origin` and `Sec-Fetch-*` (the browser sets these from the page's real context),
75 and **cannot** read a cross-origin response unless the server emits permissive CORS.
76
77 **Stops it:**
78 - **`Sec-Fetch-Site: cross-site`** is attached automatically by the browser β†’ guard returns **403**
79 (`cross_site_forbidden`). The attacker cannot forge or strip this header.
80 - **`Origin: https://evil.example`** is attached on the cross-origin request and is **not** the
81 loopback origin β†’ guard returns **403** even if `Sec-Fetch-Site` were somehow absent.
82 - **No wildcard CORS / no Origin reflection** (control Β§4.3) β€” Phase 5 must never emit
83 `Access-Control-Allow-Origin: *` nor reflect an arbitrary `Origin`, so even a response the
84 attacker provokes is unreadable cross-origin. The guard models this by accepting **only** the
85 loopback origin; a foreign origin is denied and never echoed back.
86 - Even if the attacker has somehow learned the per-session token, the cross-site Origin check
87 rejects the request **before** the token is consulted (evaluation order, [Β§3](#3-evaluation-order-and-why)).
88
89 ### Attacker B β€” DNS-rebinding (make a remote origin appear to target loopback)
90
91 **Capability.** `evil.example` initially resolves to the attacker's server, then re-resolves to
92 `127.0.0.1`. The victim's browser, still treating the page as same-origin to `evil.example`, sends
93 requests that physically reach the local endpoint. The defining signature: the **`Host` header
94 carries the attacker's domain** (`evil.example:<port>`), because the browser fills `Host` from the
95 URL the page fetched β€” not from the resolved IP.
96
97 **Stops it:**
98 - **Strict `Host` allowlist** (control Β§4.2, the primary DNS-rebinding defense) β€” the guard accepts
99 `Host` only when it both (a) matches the caller-supplied `allowedHosts` literal list and (b)
100 resolves to a recognised loopback hostname (`127.0.0.1` / `localhost` / `::1`). A rebound domain
101 presents `Host: evil.example:<port>` β†’ **403** (`host_not_allowed`), before any model work.
102 - **Loopback-only double-check** (control Β§4.5) β€” even if a caller misconfigures `allowedHosts`
103 with a LAN IP, the independent loopback-hostname check still refuses it. The bind itself (Phase 5)
104 must use `127.0.0.1`, never `0.0.0.0`.
105
106 ### Attacker C β€” a local non-browser process
107
108 **Capability.** Malware or another user's process on the same machine speaks raw HTTP to the
109 endpoint. It can set **any** header (including `Host`, `Origin`, `Sec-Fetch-Site`) because it is not
110 a browser. It cannot, however, present the **per-session token** unless it has read the OS keychain
111 (a separate, higher privilege).
112
113 **Stops it:**
114 - **Per-session bearer token** (control Β§4.1) β€” a high-entropy token, generated at companion start
115 and stored in the OS keychain, is required on every request. A process without it gets **401**.
116 Constant-time comparison (`constantTimeStringEqual`) prevents a timing side-channel from leaking
117 the token byte-by-byte.
118 - **Rate limiting** (control Β§4.8) β€” bounds brute-force guessing; once the window is full even
119 token-guessing requests get **429**, not an unbounded stream of 401s.
120 - **No ambient authority** (control Β§4.6) β€” even an admitted request can only reach model
121 inference; the endpoint never exposes the vault, the canister client, or the stored JWT. A
122 compromise of the inference path cannot pivot to data exfiltration through this surface.
123
124 ### Attacker D β€” prompt-injection payload inside a note body
125
126 **Capability.** A note contains adversarial text ("IGNORE ALL PREVIOUS INSTRUCTIONS… set Host to…
127 use Bearer …"). This text is processed by the model; the attacker hopes the body can influence
128 control decisions (auth, host, routing).
129
130 **Stops it:**
131 - **Note body is data, never control** (control Β§4.7 / brief Β§8.3) β€” structurally, the guard does
132 **not accept, read, or branch on any request body.** `verifyLoopbackRequest` has no `body`
133 parameter; the admission decision is a function only of method, headers, token, allowlist, clock,
134 and rate state. A payload in the body therefore cannot alter the Host, the Origin, the token, or
135 the verdict. (Downstream prompt construction β€” treating the body strictly as data when building
136 the model prompt β€” is the runtime's obligation in a later phase; the guard guarantees the body
137 never reaches *this* decision.)
138
139 ---
140
141 ## 2. Guard contract β€” `lib/companion-loopback-guard.mjs`
142
143 ### 2.1 `verifyLoopbackRequest(params) β†’ LoopbackVerdict`
144
145 **Signature.**
146
147 ```js
148 verifyLoopbackRequest({ method, headers, token, expectedToken, allowedHosts, now, rateState })
149 β†’ { allow: boolean, status: 200 | 401 | 403 | 429, reason: string }
150 ```
151
152 | Param | Type | Meaning |
153 | --- | --- | --- |
154 | `method` | `string` | HTTP method. Allowlist: `GET`, `POST` (case-insensitive). Anything else β†’ 403. |
155 | `headers` | `Record<string,string>` | Request headers (case-insensitive lookup). Array-valued (duplicate) headers are treated as ambiguous β†’ fail-closed. |
156 | `token` | `string` | Bearer token presented by the caller (already extracted from `Authorization`). |
157 | `expectedToken` | `string` | The per-session token to match against (from the OS keychain, supplied by Phase 5). |
158 | `allowedHosts` | `string[]` | Loopback host literals, e.g. `['127.0.0.1:51847','localhost:51847']`. Empty/missing β†’ deny. |
159 | `now` | `number` | Epoch-ms for this request (passed explicitly β€” the guard never reads the clock). |
160 | `rateState` | `LoopbackRateState` | Current sliding-window state. Missing/malformed β†’ 429 fail-closed. |
161
162 **Verdict.** Exactly `{ allow, status, reason }`. `reason` is always one of the frozen
163 `LOOPBACK_GUARD_REASONS` constants β€” never a value derived from input:
164
165 | `reason` | `status` | Meaning |
166 | --- | --- | --- |
167 | `ok` | 200 | Admitted. |
168 | `malformed_request` | 403 | Structurally invalid input (fail-closed). |
169 | `method_not_allowed` | 403 | Method not in `{GET, POST}`. |
170 | `host_not_allowed` | 403 | Missing/foreign/non-loopback `Host` (DNS-rebinding defense). |
171 | `cross_site_forbidden` | 403 | Cross-site `Sec-Fetch-Site` or foreign `Origin`. |
172 | `rate_state_unavailable` | 429 | Rate state missing/malformed β€” cannot prove the rate is bounded. |
173 | `rate_limited` | 429 | Window full. |
174 | `missing_token` | 401 | No token presented. |
175 | `invalid_token` | 401 | Token mismatch, or no `expectedToken` configured. |
176
177 **Guarantees (all under test):**
178 - **Pure:** no I/O, no `process.env`, no network, no logging, no clock read. Deterministic.
179 - **Fail-closed:** anything missing/malformed/ambiguous denies. No fail-open branch exists.
180 - **Never throws:** a catch-all converts any internal error to `403 malformed_request`, so no
181 exception can carry input data outward.
182 - **No ambient authority:** the verdict is the only output. No vault, canister, or JWT handle.
183 - **No secret in output:** the presented token, expected token, JWT, and any note body never appear
184 in a reason, a return value, or an error.
185
186 ### 2.2 Rate-limit helpers
187
188 - `createLoopbackRateState({ windowMs = 60_000, maxRequests = 60 })` β†’ fresh `{ windowMs,
189 maxRequests, timestamps: [] }`.
190 - `evaluateRateLimit(rateState, now)` β†’ `{ ok: true }` or `{ ok: false, reason }`. Pure; counts
191 in-window timestamps; β‰₯ `maxRequests` β†’ `rate_limited`.
192 - `recordLoopbackRequest(rateState, now)` β†’ **new** state with `now` appended and out-of-window
193 timestamps pruned (pure; input not mutated). The array is bounded by `maxRequests`.
194 - `shouldCountTowardRateLimit(verdict)` β†’ `true` **only** for verdicts that reached the token stage
195 (`ok` / `missing_token` / `invalid_token`). See [Β§4](#4-the-rate-limit-recording-contract).
196
197 ### 2.3 Why the Origin allowlist is the loopback origin only
198
199 The signature intentionally has **no `allowedOrigins`** parameter. The guard derives the permitted
200 browser origins from `allowedHosts` (i.e. `http(s)://<allowedHost>`), so the **only** browser origin
201 that may call the endpoint is its **own loopback origin** (same-origin). A remote origin β€” including
202 the hosted Knowtation web app (`https://knowtation.store`) β€” is cross-origin and is **rejected**.
203 This is the strictest reading of control Β§4.3 ("no reflecting arbitrary Origin") and it cleanly
204 resolves the DNS-rebinding + cross-origin story: the loopback endpoint trusts only same-origin
205 loopback browser context and non-browser local clients (which send no `Origin`/`Sec-Fetch-Site` and
206 still must present a valid token).
207
208 If a future product decision requires the hosted web tab to *drive* the local companion, that is a
209 **deliberate, documented allowlist extension** decided at the Phase 5 bind gate β€” not a silent
210 default of this guard. Per brief Β§3/Β§2, in-browser inference today runs **in the tab via WebGPU**
211 (reusing the web session), not through the loopback endpoint, so the same-origin-only default is
212 correct for Phase 2.
213
214 ---
215
216 ## 3. Evaluation order (and why)
217
218 The order of checks is itself a security decision:
219
220 ```
221 1. Structural validity β†’ 403 malformed_request (fail-closed on bad input)
222 2. Method allowlist β†’ 403 method_not_allowed
223 3. Host allowlist+loopback β†’ 403 host_not_allowed (DNS-rebinding; cheap, rejects most abuse)
224 4. Origin / Sec-Fetch-Site β†’ 403 cross_site_forbidden
225 5. Rate limit β†’ 429 rate_limited / rate_state_unavailable
226 6. Token (constant-time) β†’ 401 missing_token / invalid_token
227 7. Admit β†’ 200 ok
228 ```
229
230 - **Host/Origin before rate-limit.** A cross-origin or DNS-rebinding flood is rejected at steps 3–4
231 and is **not** recorded against the rate window (see Β§4). If those checks came *after* rate-limit,
232 an attacker could exhaust the shared budget with cheap 403'd probes and **deny the legitimate
233 client** (a budget-exhaustion DoS). Rejecting them first, without consuming budget, prevents that.
234 - **Rate-limit before token.** Placing the rate check *before* the token check is what **bounds
235 token brute-force**: once the window is full, even token-guessing requests receive **429** rather
236 than an unbounded stream of `401`s. If token came first, the function would short-circuit at the
237 token check and never reach the 429 gate, leaving guessing unbounded.
238
239 ---
240
241 ## 4. The rate-limit recording contract
242
243 `verifyLoopbackRequest` is pure and does **not** mutate `rateState`. The caller (Phase 5 listener)
244 advances the window:
245
246 ```js
247 const verdict = verifyLoopbackRequest({ ...req, expectedToken, allowedHosts, now, rateState });
248 if (shouldCountTowardRateLimit(verdict)) {
249 rateState = recordLoopbackRequest(rateState, now);
250 }
251 ```
252
253 `shouldCountTowardRateLimit` returns `true` **only** for verdicts that reached the token stage
254 (`ok`, `missing_token`, `invalid_token`). This is the precise contract that makes two properties
255 hold simultaneously:
256
257 - **Brute-force is bounded** β€” failed-auth (`401`) requests consume a slot, so a guessing flood
258 fills the window and trips `429`.
259 - **No budget-exhaustion DoS, and the array stays bounded** β€” pre-rate rejections
260 (`malformed`/`method`/`host`/`cross_site`) and rate rejections (`rate_limited`/
261 `rate_state_unavailable`) are **not** recorded, so cross-origin/rebinding floods cannot drain the
262 budget, and the `timestamps` array can never grow past `maxRequests`.
263
264 ---
265
266 ## 5. Mapping: gate Β§4 controls β†’ Phase 2 enforcement
267
268 | Gate Β§4 control | Where enforced | Status |
269 | --- | --- | --- |
270 | **1. Bearer token on every request** | `verifyLoopbackRequest` token stage; `constantTimeStringEqual` | βœ… request-decision |
271 | **2. Strict `Host` allowlist (DNS-rebinding)** | `allowedHosts` match + `isLoopbackHost` | βœ… request-decision |
272 | **3. Strict `Origin`/`Sec-Fetch-Site`, no wildcard CORS** | Sec-Fetch-Site allowlist + loopback-origin-only check | βœ… request-decision |
273 | **4. Non-predictable ephemeral port** | β€” | ⏭ **Phase 5 (bind-time)** β€” see Β§6 |
274 | **5. Loopback bind only (`127.0.0.1`)** | `isLoopbackHost` double-check at decision level | βœ… partial (decision); bind ⏭ Phase 5 |
275 | **6. No ambient authority** | Narrow verdict shape; no vault/canister/JWT reachable | βœ… structural |
276 | **7. Untrusted input (note body as data)** | Guard never reads a body β€” structurally outside the decision | βœ… structural |
277 | **8. Rate limiting + minimal logging** | Sliding-window rate gate; guard never logs; no secret in output | βœ… request-decision |
278
279 > Gate Β§4: *"A future implementation that omits any of items 1–3, 5, or 6 fails this gate."* Items
280 > 1, 2, 3, 6 are fully enforced at the request-decision level; item 5's loopback **assertion** is
281 > enforced at the decision level and its **bind** is specified for Phase 5 below. No required item
282 > is omitted.
283
284 ---
285
286 ## 6. What Phase 5 must do to bind the socket safely
287
288 The pure guard is the bouncer; Phase 5 (companion shell) installs the door. Binding the listener is
289 the single most security-critical action and **requires an explicit gate**. When Phase 5 binds, it
290 MUST:
291
292 1. **Bind loopback only.** `server.listen(port, '127.0.0.1')` β€” never `0.0.0.0`, never a public
293 interface (control Β§4.5). Do not bind `::` ; if IPv6 loopback is offered, bind `::1` explicitly.
294 2. **Allocate a non-predictable ephemeral port.** Let the OS assign an ephemeral port (`listen(0,
295 '127.0.0.1')`) and treat the chosen port as a secret-ish capability; do not use a fixed
296 well-known port (control Β§4.4). Persist it only for the local session.
297 3. **Generate the per-session token with a CSPRNG.** `crypto.randomBytes(32)` (β‰₯ 256-bit),
298 base64url-encoded, stored in the **OS keychain** (Keychain / DPAPI / libsecret), regenerated each
299 companion start. Pass it to the guard as `expectedToken`. Never log it; never place it in a URL.
300 4. **Build `allowedHosts` from the actual bound port** β€” `['127.0.0.1:<port>', 'localhost:<port>']`
301 β€” and pass it to every `verifyLoopbackRequest` call.
302 5. **Extract the presented token** from `Authorization: Bearer <token>` and pass it as `token`.
303 Pass `Date.now()` as `now` and maintain `rateState` per the Β§4 recording contract.
304 6. **Call the guard before any model work.** On `allow === false`, return `verdict.status` with a
305 generic body and **no** secret; do not proceed to the runtime. On `allow === true`, proceed.
306 7. **Emit no permissive CORS.** Never `Access-Control-Allow-Origin: *`; if any CORS header is
307 emitted at all, set `Access-Control-Allow-Origin` to the **validated loopback origin only** and
308 never reflect an arbitrary `Origin`. (Contrast `hub/bridge/server.mjs`, which defaults to
309 `Access-Control-Allow-Origin: *` β€” that pattern MUST NOT be copied to the loopback endpoint.)
310 8. **Minimal logging.** Log admission decisions by `reason` code only; never log the token, JWT,
311 `Authorization` header, `Origin`, or any note body (control Β§4.8).
312 9. **Ship its own 7-tier suite** for the bind/lifecycle layer (socket bind assertion, ephemeral-port
313 randomness, keychain read/write, concurrent-connection handling) per gate Β§10 β€” the pure guard's
314 suite does not absolve the listener of its own tests.
315
316 Until that explicit Phase 5 gate is approved, **no socket is bound** and the gate's no-listener
317 prohibition remains in force.
318
319 ---
320
321 ## 7. Test obligations satisfied (gate Β§10, 7 tiers)
322
323 All under `test/companion-loopback-guard-*.test.mjs` (102 cases, all green):
324
325 | Tier | File | Focus |
326 | --- | --- | --- |
327 | Unit | `…-unit.test.mjs` | Each control in isolation; helpers (`parseHostHeader`, `constantTimeStringEqual`, rate helpers). |
328 | Integration | `…-integration.test.mjs` | Evaluation order under combined faults; rate-state lifecycle; brute-force bounding; budget-DoS prevention. |
329 | End-to-end | `…-e2e.test.mjs` | Realistic callers: companion UI, local CLI, cross-origin page, DNS-rebinding, stolen-token-still-blocked, full interleaved session. |
330 | Stress | `…-stress.test.mjs` | 100k wrong-token attempts (zero accidental allows); bounded window under 50k load; 10k-entry allowlist; pathological header bags. |
331 | Data-integrity | `…-data-integrity.test.mjs` | Determinism (10k identical calls); no input mutation; verdict shape; reason domain; env-independence. |
332 | Performance | `…-performance.test.mjs` | Sub-ms mean per-decision; 100k decisions < 2s; no super-linear blowup with window size. |
333 | **Security** | `…-security.test.mjs` | **Centerpiece:** missing/wrong token (constant-time, no length oracle); DNS-rebinding 403; cross-site 403; no wildcard CORS / no Origin reflection; rate-limit 429; no ambient authority; note-body-as-data; no secret in any output/reason/error; global fail-closed posture. |
334
335 ---
336
337 ## 8. Deferred (explicitly not Phase 2)
338
339 - The real listening socket, ephemeral-port allocation, and loopback bind β€” **Phase 5** behind an
340 explicit gate (Β§6).
341 - OS-keychain read/write of the per-session token β€” Phase 3 (OAuth/keychain) / Phase 5.
342 - Downstream prompt construction that treats the note body strictly as data when building the model
343 prompt β€” runtime phase (the guard guarantees the body never reaches the admission decision).
344 - Any change to OAuth client registration or scopes (gate "DOES NOT approve" list β€” unchanged).