companion-loopback-guard-integration.test.mjs file-level

at sha256:3 · 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 /**
2 * Tier 2 — INTEGRATION: verifyLoopbackRequest composed with the rate-state lifecycle.
3 *
4 * Exercises the guard the way a future Phase 5 listener will: decide → (conditionally) record →
5 * carry the new rate state to the next request. Verifies the EVALUATION ORDER holds across
6 * combined inputs (e.g. a bad-host request is rejected for host even when its token is also
7 * wrong) and that the caller-side record contract bounds brute-force without enabling a
8 * budget-exhaustion DoS.
9 *
10 * Reference: docs/COMPANION-APP-PHASE-2-LOOPBACK-SECURITY.md (guard contract, evaluation order).
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 LOOPBACK_GUARD_REASONS,
20 } from '../lib/companion-loopback-guard.mjs';
21
22 const TOKEN = 'tok-' + 'c'.repeat(40);
23 const PORT = '49215';
24 const ALLOWED_HOSTS = [`127.0.0.1:${PORT}`, `localhost:${PORT}`];
25
26 function req(overrides = {}, rateState) {
27 return {
28 method: 'POST',
29 headers: { Host: `127.0.0.1:${PORT}`, Origin: `http://127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'same-origin' },
30 token: TOKEN,
31 expectedToken: TOKEN,
32 allowedHosts: ALLOWED_HOSTS,
33 now: 0,
34 rateState,
35 ...overrides,
36 };
37 }
38
39 /** Simulate a listener loop: decide, then record iff the verdict counts. Returns {verdict, state}. */
40 function step(request) {
41 const verdict = verifyLoopbackRequest(request);
42 let state = request.rateState;
43 if (shouldCountTowardRateLimit(verdict)) {
44 state = recordLoopbackRequest(state, request.now);
45 }
46 return { verdict, state };
47 }
48
49 describe('Integration — evaluation order under combined faults', () => {
50 it('bad host wins over bad token (host checked before token)', () => {
51 const v = verifyLoopbackRequest(req({
52 headers: { Host: 'attacker.example:443' },
53 token: 'wrong',
54 }, createLoopbackRateState()));
55 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.HOST_NOT_ALLOWED);
56 });
57
58 it('cross-site wins over bad token (origin checked before token)', () => {
59 const v = verifyLoopbackRequest(req({
60 headers: { Host: `127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'cross-site' },
61 token: 'wrong',
62 }, createLoopbackRateState()));
63 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.CROSS_SITE_FORBIDDEN);
64 });
65
66 it('rate limit wins over a valid token (rate checked before token)', () => {
67 const rateState = { windowMs: 60_000, maxRequests: 1, timestamps: [0] };
68 const v = verifyLoopbackRequest(req({ now: 1 }, rateState));
69 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.RATE_LIMITED);
70 });
71
72 it('rate limit wins over an INVALID token too (so brute-force is bounded by 429, not 401)', () => {
73 const rateState = { windowMs: 60_000, maxRequests: 1, timestamps: [0] };
74 const v = verifyLoopbackRequest(req({ now: 1, token: 'wrong-guess' }, rateState));
75 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.RATE_LIMITED);
76 });
77
78 it('method wins over everything (checked first)', () => {
79 const v = verifyLoopbackRequest(req({
80 method: 'DELETE',
81 headers: { Host: 'attacker.example' },
82 token: 'wrong',
83 }, undefined));
84 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.METHOD_NOT_ALLOWED);
85 });
86 });
87
88 describe('Integration — rate-state lifecycle across a request sequence', () => {
89 it('admits up to maxRequests, then trips 429, then recovers after the window slides', () => {
90 let state = createLoopbackRateState({ windowMs: 1000, maxRequests: 3 });
91 // 3 admitted within the window.
92 for (let i = 0; i < 3; i++) {
93 const r = step(req({ now: 100 + i }, state));
94 assert.equal(r.verdict.allow, true, `request ${i} should be admitted`);
95 state = r.state;
96 }
97 // 4th within the window → 429.
98 const fourth = step(req({ now: 110 }, state));
99 assert.equal(fourth.verdict.status, 429);
100 assert.equal(fourth.verdict.reason, LOOPBACK_GUARD_REASONS.RATE_LIMITED);
101 state = fourth.state;
102 // After the window slides past all three, a new request is admitted again.
103 const later = step(req({ now: 2000 }, state));
104 assert.equal(later.verdict.allow, true);
105 });
106 });
107
108 describe('Integration — brute-force bounding (failed auth consumes budget)', () => {
109 it('token-guessing reaches 429 once the window fills, not an unbounded stream of 401s', () => {
110 let state = createLoopbackRateState({ windowMs: 60_000, maxRequests: 5 });
111 let saw429 = false;
112 for (let i = 0; i < 50; i++) {
113 const r = step(req({ now: 1000 + i, token: `guess-${i}` }, state));
114 state = r.state;
115 if (r.verdict.status === 429) { saw429 = true; break; }
116 assert.equal(r.verdict.status, 401, 'pre-429 guesses are 401');
117 }
118 assert.equal(saw429, true, 'brute-force must hit a 429 ceiling');
119 });
120 });
121
122 describe('Integration — budget-exhaustion DoS is prevented', () => {
123 it('cross-origin/bad-host probes do NOT consume the rate budget', () => {
124 let state = createLoopbackRateState({ windowMs: 60_000, maxRequests: 3 });
125 // 100 cross-site probes — each 403, none recorded.
126 for (let i = 0; i < 100; i++) {
127 const r = step(req({
128 now: 1000 + i,
129 headers: { Host: `127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'cross-site' },
130 }, state));
131 assert.equal(r.verdict.reason, LOOPBACK_GUARD_REASONS.CROSS_SITE_FORBIDDEN);
132 state = r.state;
133 }
134 assert.equal(state.timestamps.length, 0, 'no probe should have consumed budget');
135 // The legitimate client is still fully served.
136 const legit = step(req({ now: 1200 }, state));
137 assert.equal(legit.verdict.allow, true);
138 });
139 });