companion-loopback-guard-e2e.test.mjs
143 lines 5.6 KB
Raw
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