Some checks failed
Quality gates / quality (pull_request) Failing after 2m39s
Closes the PR 63 hostile-review blockers: 1. Immutable pinned Python/Pillow runtime (TIMMY_PYTHON absolute, --verify-pin), deployment/runtime re-encode smoke gate in build_release + deploy_staging. 2. Header-only width/height/total-pixel/bomb rejection before full decode; proves 6000x6000 and 12000x12000 stay resource bounded (RSS + address-space caps). 3. Fail-fast decoder concurrency ceiling; tests count actual spawned children. 4. Rate-limiter key cardinality hard-bounded under 20k+ unexpired identities, trusted-loopback-proxy identity, no spoofable forwarded headers, fixed-window boundary burst smoothed by two-window sliding count. 5. build_release explicitly syntax/gates every new JS module + Python re-encoder + production-runtime smoke; CI runs reencode-image test and the runtime pin smoke. 6. Robust polyglot contract via canonical container parsing; rejects uppercase/mixed script, appended HTML, ZIP local/EOCD and archive tails, data after canonical JPEG/PNG/WebP end; no naive compressed-byte scans (false-positive controls pass). 7. Canonical base64 data-URL grammar with byte-exact round trip; rejects missing/excess padding, whitespace/CRLF, malformed and noncanonical encodings; exact MIME policy. 8. Missing Pillow / runtime-unavailable maps to sanitized 503 + manual fallback. 9. Inbound body-read timeout and stop-on-oversize; preserves sanitized 413, base path, provider suppression, and temp cleanup; socket torn down on rejection. Audited prior partial edits: reused the sound source modules, re-wired new tests into the unit/syntax gates, fixed a non-canonical readJson that destroyed the socket before delivering 413/408, and hardened test flakiness (port reuse, EPIPE, startup races).
136 lines
6.3 KiB
JavaScript
136 lines
6.3 KiB
JavaScript
// Bounded rate state and explicit client identity.
|
|
//
|
|
// Two separate defects are covered here:
|
|
// 1. The limiter's map must be hard-bounded. Only evicting *expired* entries
|
|
// means 20k+ unexpired keys grow state without limit.
|
|
// 2. Behind a reverse proxy, req.socket.remoteAddress is the proxy's loopback
|
|
// address, so every user shares one quota. The policy must be explicit:
|
|
// either trust a configured loopback proxy's forwarded client address, or
|
|
// state truthfully that the limit is global — never silently trust a
|
|
// spoofable header from an untrusted peer.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { createRateLimiter } from '../src/rate-limiter.js';
|
|
import { resolveClientIdentity } from '../src/client-identity.js';
|
|
|
|
test('rate state is hard-bounded under a flood of unexpired distinct keys', () => {
|
|
const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 10, maxKeys: 1024 });
|
|
const now = Date.now();
|
|
for (let i = 0; i < 20_000; i += 1) {
|
|
limiter.take(`key-${i}`, now); // all within the same, unexpired window
|
|
}
|
|
const size = limiter.size();
|
|
assert.ok(size <= 1024,
|
|
`limiter retained ${size} unexpired keys; state must be hard-bounded, not merely expiry-swept`);
|
|
});
|
|
|
|
test('a flood of distinct keys never lets a repeat offender escape its own limit', () => {
|
|
const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 3, maxKeys: 64 });
|
|
const now = Date.now();
|
|
// The offender opens its window first.
|
|
for (let i = 0; i < 3; i += 1) assert.equal(limiter.take('offender', now).allowed, true);
|
|
assert.equal(limiter.take('offender', now).allowed, false, 'offender must be blocked');
|
|
// Now flood far past the cap to try to evict the offender's counter.
|
|
for (let i = 0; i < 5_000; i += 1) limiter.take(`flood-${i}`, now);
|
|
const afterFlood = limiter.take('offender', now);
|
|
assert.equal(afterFlood.allowed, false,
|
|
'eviction must not reset an active offender: that would make the limit bypassable by flooding');
|
|
});
|
|
|
|
test('eviction prefers expired entries before evicting live ones', () => {
|
|
const limiter = createRateLimiter({ windowMs: 1_000, maxRequests: 5, maxKeys: 8 });
|
|
const t0 = 1_000_000;
|
|
for (let i = 0; i < 8; i += 1) limiter.take(`old-${i}`, t0);
|
|
// Well after those windows expired, new keys must reuse the reclaimed space.
|
|
for (let i = 0; i < 8; i += 1) limiter.take(`new-${i}`, t0 + 5_000);
|
|
assert.ok(limiter.size() <= 8);
|
|
assert.equal(limiter.take('new-0', t0 + 5_000).allowed, true);
|
|
});
|
|
|
|
test('the fixed-window boundary cannot be used to double the burst', () => {
|
|
const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 10 });
|
|
const t0 = 1_000_000;
|
|
let allowed = 0;
|
|
// Open the window, then spend the whole budget at the very end of it.
|
|
if (limiter.take('same', t0).allowed) allowed += 1;
|
|
for (let i = 0; i < 9; i += 1) if (limiter.take('same', t0 + 59_999).allowed) allowed += 1;
|
|
assert.equal(allowed, 10, 'the nominal budget must be usable');
|
|
// Immediately across the boundary a naive fixed window grants a fresh budget,
|
|
// allowing 2x the nominal rate within milliseconds of real time.
|
|
let acrossBoundary = 0;
|
|
for (let i = 0; i < 10; i += 1) if (limiter.take('same', t0 + 60_000).allowed) acrossBoundary += 1;
|
|
assert.ok(acrossBoundary < 10,
|
|
`boundary straddle allowed a full extra budget (${acrossBoundary}), doubling the effective rate`);
|
|
const total = allowed + acrossBoundary;
|
|
assert.ok(total <= 14,
|
|
`${total} requests accepted across a ~1ms boundary straddle against a 10-per-60s budget`);
|
|
});
|
|
|
|
test('sustained load stays near the nominal rate rather than bursting to 2x each boundary', () => {
|
|
const limiter = createRateLimiter({ windowMs: 1_000, maxRequests: 5 });
|
|
let accepted = 0;
|
|
// Ten seconds of continuous pressure at 50 requests/second.
|
|
for (let ms = 0; ms < 10_000; ms += 20) {
|
|
if (limiter.take('steady', 500_000 + ms).allowed) accepted += 1;
|
|
}
|
|
// 10s at 5/s is 50; allow a small margin but not a 2x boundary doubling.
|
|
assert.ok(accepted <= 60, `sustained acceptance ${accepted} exceeded the nominal 5/s budget envelope`);
|
|
assert.ok(accepted >= 40, `sustained acceptance ${accepted} is unusably far below the nominal budget`);
|
|
});
|
|
|
|
test('client identity behind an untrusted peer never trusts forwarded headers', () => {
|
|
const identity = resolveClientIdentity({
|
|
remoteAddress: '203.0.113.7',
|
|
headers: { 'x-forwarded-for': '198.51.100.9', 'x-real-ip': '198.51.100.10' },
|
|
trustedProxies: [],
|
|
});
|
|
assert.equal(identity.key, '203.0.113.7', 'the peer address is the only trustworthy identity here');
|
|
assert.equal(identity.scope, 'peer');
|
|
assert.equal(identity.trustedProxy, false);
|
|
});
|
|
|
|
test('a configured trusted loopback proxy contributes the forwarded client address', () => {
|
|
const identity = resolveClientIdentity({
|
|
remoteAddress: '127.0.0.1',
|
|
headers: { 'x-forwarded-for': '198.51.100.9, 10.0.0.5' },
|
|
trustedProxies: ['127.0.0.1'],
|
|
});
|
|
assert.equal(identity.key, '198.51.100.9', 'the left-most forwarded address is the client');
|
|
assert.equal(identity.scope, 'forwarded');
|
|
assert.equal(identity.trustedProxy, true);
|
|
});
|
|
|
|
test('a trusted proxy that forwards nothing usable degrades to a truthful global policy', () => {
|
|
const identity = resolveClientIdentity({
|
|
remoteAddress: '127.0.0.1',
|
|
headers: {},
|
|
trustedProxies: ['127.0.0.1'],
|
|
});
|
|
assert.equal(identity.scope, 'global',
|
|
'without a usable forwarded address the quota is shared; the policy must say so');
|
|
assert.equal(identity.key, 'global');
|
|
});
|
|
|
|
test('forwarded addresses are validated, not echoed', () => {
|
|
for (const spoof of ['not-an-ip', '', ' ', '999.999.999.999', '<script>', '127.0.0.1; rm -rf /']) {
|
|
const identity = resolveClientIdentity({
|
|
remoteAddress: '127.0.0.1',
|
|
headers: { 'x-forwarded-for': spoof },
|
|
trustedProxies: ['127.0.0.1'],
|
|
});
|
|
assert.equal(identity.scope, 'global', `must not accept ${JSON.stringify(spoof)} as an identity`);
|
|
assert.equal(identity.key, 'global');
|
|
}
|
|
});
|
|
|
|
test('identity keys never contain payload data and stay short', () => {
|
|
const identity = resolveClientIdentity({
|
|
remoteAddress: '2001:db8::1',
|
|
headers: {},
|
|
trustedProxies: [],
|
|
});
|
|
assert.ok(identity.key.length <= 64);
|
|
assert.doesNotMatch(identity.key, /[A-Za-z0-9+/]{40,}/);
|
|
});
|