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