companion-loopback-guard-e2e.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Tier 3 — END-TO-END: realistic caller scenarios end to end. |
| 3 | * |
| 4 | * Models the decision flow a Phase 5 listener would run for representative real-world callers, |
| 5 | * WITHOUT binding a socket (the bind is out of scope per the gate). Each scenario asserts the |
| 6 | * full verdict, demonstrating the guard behaves correctly for the legitimate companion UI, a |
| 7 | * legitimate non-browser local client, and the headline attacks (cross-origin page, |
| 8 | * DNS-rebinding, token theft attempt). |
| 9 | * |
| 10 | * Reference: docs/COMPANION-APP-PHASE-2-LOOPBACK-SECURITY.md (threat-model → control mapping). |
| 11 | */ |
| 12 | import { describe, it } from 'node:test'; |
| 13 | import assert from 'node:assert/strict'; |
| 14 | import { |
| 15 | verifyLoopbackRequest, |
| 16 | createLoopbackRateState, |
| 17 | recordLoopbackRequest, |
| 18 | shouldCountTowardRateLimit, |
| 19 | } from '../lib/companion-loopback-guard.mjs'; |
| 20 | |
| 21 | const PORT = '52310'; |
| 22 | const SESSION_TOKEN = 'kc_' + 'd9f3a1b2'.repeat(8); // high-entropy per-session token (stand-in) |
| 23 | const ALLOWED_HOSTS = [`127.0.0.1:${PORT}`, `localhost:${PORT}`]; |
| 24 | const LOOPBACK_ORIGIN = `http://127.0.0.1:${PORT}`; |
| 25 | |
| 26 | function listenerDecide(request, state) { |
| 27 | const verdict = verifyLoopbackRequest({ ...request, allowedHosts: ALLOWED_HOSTS, expectedToken: SESSION_TOKEN, rateState: state }); |
| 28 | const nextState = shouldCountTowardRateLimit(verdict) ? recordLoopbackRequest(state, request.now) : state; |
| 29 | return { verdict, nextState }; |
| 30 | } |
| 31 | |
| 32 | describe('E2E — legitimate companion UI (same-origin browser tab)', () => { |
| 33 | it('a same-origin POST with the session token is admitted', () => { |
| 34 | const state = createLoopbackRateState(); |
| 35 | const { verdict } = listenerDecide({ |
| 36 | method: 'POST', |
| 37 | headers: { |
| 38 | Host: `127.0.0.1:${PORT}`, |
| 39 | Origin: LOOPBACK_ORIGIN, |
| 40 | 'Sec-Fetch-Site': 'same-origin', |
| 41 | 'Content-Type': 'application/json', |
| 42 | Authorization: `Bearer ${SESSION_TOKEN}`, |
| 43 | }, |
| 44 | token: SESSION_TOKEN, |
| 45 | now: 1, |
| 46 | }, state); |
| 47 | assert.deepEqual(verdict, { allow: true, status: 200, reason: 'ok' }); |
| 48 | }); |
| 49 | }); |
| 50 | |
| 51 | describe('E2E — legitimate non-browser local client (CLI / companion backend)', () => { |
| 52 | it('a request with no Origin and no Sec-Fetch-Site but a valid token is admitted', () => { |
| 53 | const state = createLoopbackRateState(); |
| 54 | const { verdict } = listenerDecide({ |
| 55 | method: 'POST', |
| 56 | headers: { Host: `localhost:${PORT}`, Authorization: `Bearer ${SESSION_TOKEN}` }, |
| 57 | token: SESSION_TOKEN, |
| 58 | now: 1, |
| 59 | }, state); |
| 60 | assert.equal(verdict.allow, true); |
| 61 | }); |
| 62 | }); |
| 63 | |
| 64 | describe('E2E — malicious cross-origin web page', () => { |
| 65 | it('a fetch from https://evil.example to the loopback is rejected (cross-site)', () => { |
| 66 | const state = createLoopbackRateState(); |
| 67 | const { verdict, nextState } = listenerDecide({ |
| 68 | method: 'POST', |
| 69 | headers: { |
| 70 | Host: `127.0.0.1:${PORT}`, |
| 71 | Origin: 'https://evil.example', |
| 72 | 'Sec-Fetch-Site': 'cross-site', |
| 73 | }, |
| 74 | token: '', // attacker has no token |
| 75 | now: 1, |
| 76 | }, state); |
| 77 | assert.equal(verdict.allow, false); |
| 78 | assert.equal(verdict.status, 403); |
| 79 | assert.equal(nextState.timestamps.length, 0, 'cross-site probe consumes no budget'); |
| 80 | }); |
| 81 | }); |
| 82 | |
| 83 | describe('E2E — DNS-rebinding attack', () => { |
| 84 | it('a rebound domain (Host: attacker-rebind.example) is rejected before any model work', () => { |
| 85 | const state = createLoopbackRateState(); |
| 86 | const { verdict } = listenerDecide({ |
| 87 | method: 'POST', |
| 88 | // Browser connected to 127.0.0.1 via rebinding, but the URL/Host is the attacker domain. |
| 89 | headers: { Host: 'attacker-rebind.example:' + PORT, 'Sec-Fetch-Site': 'same-origin' }, |
| 90 | token: SESSION_TOKEN, // even if the attacker somehow learned the token, host check stops it |
| 91 | now: 1, |
| 92 | }, state); |
| 93 | assert.equal(verdict.allow, false); |
| 94 | assert.equal(verdict.status, 403); |
| 95 | assert.equal(verdict.reason, 'host_not_allowed'); |
| 96 | }); |
| 97 | }); |
| 98 | |
| 99 | describe('E2E — stolen/guessed token still blocked by network identity', () => { |
| 100 | it('a cross-site page that somehow holds the token is STILL rejected (origin defense)', () => { |
| 101 | const state = createLoopbackRateState(); |
| 102 | const { verdict } = listenerDecide({ |
| 103 | method: 'POST', |
| 104 | headers: { Host: `127.0.0.1:${PORT}`, Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' }, |
| 105 | token: SESSION_TOKEN, |
| 106 | now: 1, |
| 107 | }, state); |
| 108 | assert.equal(verdict.allow, false); |
| 109 | assert.equal(verdict.status, 403); |
| 110 | }); |
| 111 | }); |
| 112 | |
| 113 | describe('E2E — full session: warm-up, steady use, attack interleaved', () => { |
| 114 | it('legitimate traffic flows while interleaved attacks are all rejected and unbilled', () => { |
| 115 | let state = createLoopbackRateState({ windowMs: 10_000, maxRequests: 100 }); |
| 116 | let admitted = 0; |
| 117 | let rejected = 0; |
| 118 | for (let i = 0; i < 60; i++) { |
| 119 | const now = 1000 + i * 10; |
| 120 | // legit request |
| 121 | const legit = listenerDecide({ |
| 122 | method: i % 5 === 0 ? 'GET' : 'POST', |
| 123 | headers: { Host: `127.0.0.1:${PORT}`, Origin: LOOPBACK_ORIGIN, 'Sec-Fetch-Site': 'same-origin' }, |
| 124 | token: SESSION_TOKEN, |
| 125 | now, |
| 126 | }, state); |
| 127 | state = legit.nextState; |
| 128 | if (legit.verdict.allow) admitted++; |
| 129 | // interleaved attack (does not touch budget) |
| 130 | const attack = listenerDecide({ |
| 131 | method: 'POST', |
| 132 | headers: { Host: `127.0.0.1:${PORT}`, Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' }, |
| 133 | token: 'stolen?', |
| 134 | now: now + 1, |
| 135 | }, state); |
| 136 | state = attack.nextState; |
| 137 | if (!attack.verdict.allow) rejected++; |
| 138 | } |
| 139 | assert.equal(admitted, 60, 'all 60 legit requests admitted'); |
| 140 | assert.equal(rejected, 60, 'all 60 attacks rejected'); |
| 141 | assert.equal(state.timestamps.length, 60, 'only legit requests consumed budget'); |
| 142 | }); |
| 143 | }); |
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