companion-loopback-guard-unit.test.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 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 | }); |