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

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:9 feat(calendar): hosted bridge/gateway route parity and timeline noteRec… · aaronrene · Jun 19, 2026
1 /**
2 * Tier 1 — UNIT: lib/companion-loopback-guard.mjs
3 *
4 * Smallest behavioural contracts of the pure helpers and verifyLoopbackRequest in total
5 * isolation — no network, no env, no socket. Each control (method, host, origin, rate, token)
6 * is exercised on its own with everything else valid, so a failure points at one control.
7 *
8 * Reference: docs/COMPANION-APP-PHASE-2-LOOPBACK-SECURITY.md (guard contract),
9 * docs/COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md §4 (the 8 loopback controls).
10 */
11 import { describe, it } from 'node:test';
12 import assert from 'node:assert/strict';
13 import {
14 verifyLoopbackRequest,
15 createLoopbackRateState,
16 recordLoopbackRequest,
17 evaluateRateLimit,
18 constantTimeStringEqual,
19 parseHostHeader,
20 isLoopbackHost,
21 shouldCountTowardRateLimit,
22 ALLOWED_METHODS,
23 LOOPBACK_HOSTNAMES,
24 LOOPBACK_GUARD_REASONS,
25 } from '../lib/companion-loopback-guard.mjs';
26
27 const TOKEN = 'a'.repeat(43); // ~256-bit base64url-ish length
28 const PORT = '51847';
29 const ALLOWED_HOSTS = [`127.0.0.1:${PORT}`, `localhost:${PORT}`];
30
31 /** A request that should be admitted, with one override slot. */
32 function goodRequest(overrides = {}) {
33 return {
34 method: 'POST',
35 headers: {
36 Host: `127.0.0.1:${PORT}`,
37 Origin: `http://127.0.0.1:${PORT}`,
38 'Sec-Fetch-Site': 'same-origin',
39 },
40 token: TOKEN,
41 expectedToken: TOKEN,
42 allowedHosts: ALLOWED_HOSTS,
43 now: 1_000_000,
44 rateState: createLoopbackRateState({ windowMs: 60_000, maxRequests: 60 }),
45 ...overrides,
46 };
47 }
48
49 describe('Unit — happy path', () => {
50 it('admits a well-formed same-origin POST with valid token', () => {
51 const v = verifyLoopbackRequest(goodRequest());
52 assert.deepEqual(v, { allow: true, status: 200, reason: 'ok' });
53 });
54
55 it('admits a GET health probe', () => {
56 const v = verifyLoopbackRequest(goodRequest({ method: 'GET' }));
57 assert.equal(v.allow, true);
58 assert.equal(v.status, 200);
59 });
60
61 it('admits a non-browser local client (no Origin, no Sec-Fetch-Site)', () => {
62 const v = verifyLoopbackRequest(
63 goodRequest({ headers: { Host: `127.0.0.1:${PORT}` } }),
64 );
65 assert.equal(v.allow, true);
66 });
67 });
68
69 describe('Unit — method allowlist', () => {
70 it('ALLOWED_METHODS is exactly GET and POST', () => {
71 assert.deepEqual([...ALLOWED_METHODS].sort(), ['GET', 'POST']);
72 });
73
74 for (const method of ['OPTIONS', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'TRACE']) {
75 it(`rejects ${method} with 403 method_not_allowed`, () => {
76 const v = verifyLoopbackRequest(goodRequest({ method }));
77 assert.equal(v.allow, false);
78 assert.equal(v.status, 403);
79 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.METHOD_NOT_ALLOWED);
80 });
81 }
82
83 it('method is matched case-insensitively (post → POST)', () => {
84 const v = verifyLoopbackRequest(goodRequest({ method: 'post' }));
85 assert.equal(v.allow, true);
86 });
87 });
88
89 describe('Unit — host allowlist + loopback', () => {
90 it('rejects a missing Host header', () => {
91 const v = verifyLoopbackRequest(goodRequest({ headers: { Origin: `http://127.0.0.1:${PORT}` } }));
92 assert.equal(v.status, 403);
93 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.HOST_NOT_ALLOWED);
94 });
95
96 it('rejects a Host not in the allowlist', () => {
97 const v = verifyLoopbackRequest(goodRequest({ headers: { Host: `127.0.0.1:9999` } }));
98 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.HOST_NOT_ALLOWED);
99 });
100
101 it('rejects when allowedHosts is empty (cannot validate)', () => {
102 const v = verifyLoopbackRequest(goodRequest({ allowedHosts: [] }));
103 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.HOST_NOT_ALLOWED);
104 });
105
106 it('accepts localhost:<port> form', () => {
107 const v = verifyLoopbackRequest(
108 goodRequest({ headers: { Host: `localhost:${PORT}`, 'Sec-Fetch-Site': 'same-origin', Origin: `http://localhost:${PORT}` } }),
109 );
110 assert.equal(v.allow, true);
111 });
112 });
113
114 describe('Unit — origin / sec-fetch-site', () => {
115 it('rejects Sec-Fetch-Site: cross-site', () => {
116 const v = verifyLoopbackRequest(goodRequest({
117 headers: { Host: `127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'cross-site' },
118 }));
119 assert.equal(v.status, 403);
120 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.CROSS_SITE_FORBIDDEN);
121 });
122
123 it('rejects Sec-Fetch-Site: same-site (loopback has no same-site siblings)', () => {
124 const v = verifyLoopbackRequest(goodRequest({
125 headers: { Host: `127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'same-site' },
126 }));
127 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.CROSS_SITE_FORBIDDEN);
128 });
129
130 it('rejects an unrecognised Sec-Fetch-Site value (fail-closed)', () => {
131 const v = verifyLoopbackRequest(goodRequest({
132 headers: { Host: `127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'totally-made-up' },
133 }));
134 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.CROSS_SITE_FORBIDDEN);
135 });
136
137 it('rejects a foreign Origin even when Host is loopback', () => {
138 const v = verifyLoopbackRequest(goodRequest({
139 headers: { Host: `127.0.0.1:${PORT}`, Origin: 'https://evil.example' },
140 }));
141 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.CROSS_SITE_FORBIDDEN);
142 });
143
144 it('accepts Sec-Fetch-Site: none (top-level navigation)', () => {
145 const v = verifyLoopbackRequest(goodRequest({
146 headers: { Host: `127.0.0.1:${PORT}`, 'Sec-Fetch-Site': 'none' },
147 }));
148 assert.equal(v.allow, true);
149 });
150 });
151
152 describe('Unit — token', () => {
153 it('rejects a missing token with 401 missing_token', () => {
154 const v = verifyLoopbackRequest(goodRequest({ token: undefined }));
155 assert.equal(v.status, 401);
156 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.MISSING_TOKEN);
157 });
158
159 it('rejects an empty-string token with 401 missing_token', () => {
160 const v = verifyLoopbackRequest(goodRequest({ token: '' }));
161 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.MISSING_TOKEN);
162 });
163
164 it('rejects a wrong token with 401 invalid_token', () => {
165 const v = verifyLoopbackRequest(goodRequest({ token: 'b'.repeat(43) }));
166 assert.equal(v.status, 401);
167 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.INVALID_TOKEN);
168 });
169
170 it('rejects when no expectedToken is configured (fail-closed)', () => {
171 const v = verifyLoopbackRequest(goodRequest({ expectedToken: undefined }));
172 assert.equal(v.status, 401);
173 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.INVALID_TOKEN);
174 });
175 });
176
177 describe('Unit — rate limit', () => {
178 it('rejects with 429 rate_limited when the window is full', () => {
179 const rateState = { windowMs: 60_000, maxRequests: 2, timestamps: [999_999, 1_000_000] };
180 const v = verifyLoopbackRequest(goodRequest({ rateState, now: 1_000_001 }));
181 assert.equal(v.status, 429);
182 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.RATE_LIMITED);
183 });
184
185 it('rejects with 429 rate_state_unavailable when rateState is missing', () => {
186 const v = verifyLoopbackRequest(goodRequest({ rateState: undefined }));
187 assert.equal(v.status, 429);
188 assert.equal(v.reason, LOOPBACK_GUARD_REASONS.RATE_STATE_UNAVAILABLE);
189 });
190 });
191
192 describe('Unit — constantTimeStringEqual', () => {
193 it('true for equal strings', () => {
194 assert.equal(constantTimeStringEqual('abc123', 'abc123'), true);
195 });
196 it('false for different strings of equal length', () => {
197 assert.equal(constantTimeStringEqual('abc123', 'abc124'), false);
198 });
199 it('false for different lengths (no throw)', () => {
200 assert.equal(constantTimeStringEqual('abc', 'abcdef'), false);
201 });
202 it('false for non-string / empty inputs', () => {
203 assert.equal(constantTimeStringEqual('', 'x'), false);
204 assert.equal(constantTimeStringEqual(undefined, 'x'), false);
205 assert.equal(constantTimeStringEqual('x', null), false);
206 assert.equal(constantTimeStringEqual(123, 123), false);
207 });
208 });
209
210 describe('Unit — parseHostHeader / isLoopbackHost', () => {
211 it('parses ipv4:port', () => {
212 assert.deepEqual(parseHostHeader('127.0.0.1:8080'), { hostname: '127.0.0.1', port: '8080' });
213 });
214 it('parses hostname:port', () => {
215 assert.deepEqual(parseHostHeader('localhost:8080'), { hostname: 'localhost', port: '8080' });
216 });
217 it('parses ipv6 bracket form', () => {
218 assert.deepEqual(parseHostHeader('[::1]:8080'), { hostname: '::1', port: '8080' });
219 });
220 it('returns null for bare ipv6 without brackets', () => {
221 assert.equal(parseHostHeader('::1:8080'), null);
222 });
223 it('isLoopbackHost recognises the loopback set', () => {
224 assert.equal(isLoopbackHost('127.0.0.1:1'), true);
225 assert.equal(isLoopbackHost('localhost:1'), true);
226 assert.equal(isLoopbackHost('[::1]:1'), true);
227 assert.equal(isLoopbackHost('10.0.0.5:1'), false);
228 assert.equal(isLoopbackHost('attacker.example:1'), false);
229 });
230 it('LOOPBACK_HOSTNAMES is the documented set', () => {
231 assert.deepEqual([...LOOPBACK_HOSTNAMES].sort(), ['127.0.0.1', '::1', 'localhost']);
232 });
233 });
234
235 describe('Unit — evaluateRateLimit / recordLoopbackRequest', () => {
236 it('evaluateRateLimit ok under limit', () => {
237 assert.deepEqual(evaluateRateLimit({ windowMs: 1000, maxRequests: 3, timestamps: [] }, 0), { ok: true });
238 });
239 it('evaluateRateLimit prunes out-of-window timestamps', () => {
240 const r = evaluateRateLimit({ windowMs: 1000, maxRequests: 2, timestamps: [0, 1, 2] }, 5000);
241 assert.deepEqual(r, { ok: true }); // all three timestamps are outside the 1s window at now=5000
242 });
243 it('recordLoopbackRequest appends now and prunes, returning new state', () => {
244 const s0 = createLoopbackRateState({ windowMs: 1000, maxRequests: 5 });
245 const s1 = recordLoopbackRequest(s0, 100);
246 assert.deepEqual(s0.timestamps, [], 'input not mutated');
247 assert.deepEqual(s1.timestamps, [100]);
248 const s2 = recordLoopbackRequest(s1, 2000);
249 assert.deepEqual(s2.timestamps, [2000], 'stale 100 pruned at now=2000');
250 });
251 it('shouldCountTowardRateLimit counts only slot-consuming (token-stage) verdicts', () => {
252 // Pre-rate rejections never consume budget (no DoS via cross-origin/rebinding floods).
253 assert.equal(shouldCountTowardRateLimit({ reason: LOOPBACK_GUARD_REASONS.MALFORMED_REQUEST }), false);
254 assert.equal(shouldCountTowardRateLimit({ reason: LOOPBACK_GUARD_REASONS.METHOD_NOT_ALLOWED }), false);
255 assert.equal(shouldCountTowardRateLimit({ reason: LOOPBACK_GUARD_REASONS.HOST_NOT_ALLOWED }), false);
256 assert.equal(shouldCountTowardRateLimit({ reason: LOOPBACK_GUARD_REASONS.CROSS_SITE_FORBIDDEN }), false);
257 // Rate rejections did not get a slot → not recorded (keeps the array bounded by maxRequests).
258 assert.equal(shouldCountTowardRateLimit({ reason: LOOPBACK_GUARD_REASONS.RATE_LIMITED }), false);
259 assert.equal(shouldCountTowardRateLimit({ reason: LOOPBACK_GUARD_REASONS.RATE_STATE_UNAVAILABLE }), false);
260 // Token-stage verdicts consume a slot (failed auth must count so brute-force is bounded).
261 assert.equal(shouldCountTowardRateLimit({ reason: LOOPBACK_GUARD_REASONS.MISSING_TOKEN }), true);
262 assert.equal(shouldCountTowardRateLimit({ reason: LOOPBACK_GUARD_REASONS.INVALID_TOKEN }), true);
263 assert.equal(shouldCountTowardRateLimit({ reason: LOOPBACK_GUARD_REASONS.OK }), true);
264 });
265 });