From 30d31ca031fcb7966374689a95e4de3d658c2297 Mon Sep 17 00:00:00 2001 From: alloevil <12680612+alloevil@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:53:44 -0700 Subject: [PATCH] security: rate-limit every route with express-rate-limit (CodeQL js/missing-rate-limiting) Fixes the 11 open js/missing-rate-limiting alerts on master (#64-#74: backup.js:20, library.js:35/56/90/114/176/293, sessions.js:180/227, watch.js:196, server.js:67) and #78 on #75. Every route handler touches the filesystem and none had a limiter. lib/rate-limit.js: express-rate-limit 8.7.1, 600 requests / 60 s per client IP, draft-8 RateLimit headers, JSON 429 body. Mounted with app.use() in server.js before the static files, /api/version and every route module, so it covers API, static and the SPA fallback. Not disabled in tests: each test file starts its own server and no test comes near 600 requests a minute. New test (test/api.test.js) asserts the RateLimit-Policy q=600 / w=60 on /api/version, / and the SPA fallback, so the test count is 349: claims.json test-count and docs/ROADMAP.md updated to 349 (the number follows the code, the gate is unchanged). Local (Node 22.23.3): npm ci + build OK, no pure.js/diagnostics.cjs drift, biome exit 0, npm test 349/349, verify-claims 16 ok / 0 error / 3 manual. --- claims.json | 12 ++++----- docs/ROADMAP.md | 2 +- lib/rate-limit.js | 21 +++++++++++++++ package-lock.json | 66 +++++++++++++++++++++++++++++++++++++---------- package.json | 3 ++- server.js | 4 +++ test/api.test.js | 12 +++++++++ 7 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 lib/rate-limit.js diff --git a/claims.json b/claims.json index a6f2f71..6e93080 100644 --- a/claims.json +++ b/claims.json @@ -106,16 +106,16 @@ }, { "id": "test-count", - "claim": "348 tests pass on Node's built-in test runner, the count docs/ROADMAP.md records for `npm test`.", - "value": "348", - "metric": "passing node:test cases (# tests 348 / # pass 348 / # fail 0)", - "method": "npm test → node --test test/*.test.js, run in the claims job after npm ci, and the TAP summary is asserted. The roadmap sentence ('348 tests on Node's built-in runner (`npm test`, 2026-09-27)') is verified by the run, not read back from the prose.", + "claim": "349 tests pass on Node's built-in test runner, the count docs/ROADMAP.md records for `npm test`.", + "value": "349", + "metric": "passing node:test cases (# tests 349 / # pass 349 / # fail 0)", + "method": "npm test → node --test test/*.test.js, run in the claims job after npm ci, and the TAP summary is asserted. The roadmap sentence ('349 tests on Node's built-in runner (`npm test`, 2026-10-17)') is verified by the run, not read back from the prose.", "repro": "npm test 2>&1 | grep -E '^# (tests|pass|fail)'", "evidence": "docs/ROADMAP.md", - "as_of": "2026-09-27", + "as_of": "2026-10-17", "check": { "cmd": "npm test 2>&1 | grep -E '^# (tests|pass|fail)'", - "expect": { "contains": ["# tests 348", "# pass 348", "# fail 0"] }, + "expect": { "contains": ["# tests 349", "# pass 349", "# fail 0"] }, "timeout": 120 } }, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1ff44cf..f96b93d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,7 +8,7 @@ - **Session browser** with tool-call inspection, trace/waterfall view, spawn tracking and message timeline - **Prompt tooling** — extraction (noise filtered), template clustering with outcome attribution, Claude-powered rewrites, and a prompt library that installs entries as native slash commands - **Global search** across all platforms, insights dashboard, incremental session backup -- **React + Vite frontend** served by an Express backend; 348 tests on Node's built-in runner (`npm test`, 2026-09-27), CI on Node 22 +- **React + Vite frontend** served by an Express backend; 349 tests on Node's built-in runner (`npm test`, 2026-10-17), CI on Node 22 - **Evidence-backed failure events and local review** with full-result invalidation, evidence navigation and narrow-screen session layout ## Current priorities diff --git a/lib/rate-limit.js b/lib/rate-limit.js new file mode 100644 index 0000000..bc549f4 --- /dev/null +++ b/lib/rate-limit.js @@ -0,0 +1,21 @@ +// Per-IP request limiter for every route (API, static files, SPA fallback). +// +// AgentXRay is a local dashboard bound to 127.0.0.1 by default, but every route +// reads from disk (session logs, the library repo, backups, the built UI), and +// HOST=0.0.0.0 exposes it on the LAN. The limit is deliberately generous so the UI +// (initial load, polling, SSE reconnects) and the test suite never come close: +// 600 requests per minute per client IP. Overflow gets 429 with RateLimit headers. +const { rateLimit } = require('express-rate-limit'); + +const WINDOW_MS = 60 * 1000; +const LIMIT = 600; + +const limiter = rateLimit({ + windowMs: WINDOW_MS, + limit: LIMIT, + standardHeaders: 'draft-8', + legacyHeaders: false, + message: { error: 'Too many requests, slow down.' }, +}); + +module.exports = { limiter, WINDOW_MS, LIMIT }; diff --git a/package-lock.json b/package-lock.json index 4b23968..2f79d9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.24.0", "license": "MIT", "dependencies": { - "express": "^4.21.2" + "express": "^4.21.2", + "express-rate-limit": "^8.7.1" }, "bin": { "agent-xray": "bin/agentxray.js", @@ -91,9 +92,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -111,9 +109,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -131,9 +126,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -151,9 +143,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -462,6 +451,48 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -618,6 +649,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index bb243d1..e5dc687 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ }, "license": "MIT", "dependencies": { - "express": "^4.21.2" + "express": "^4.21.2", + "express-rate-limit": "^8.7.1" }, "engines": { "node": ">=22.13" diff --git a/server.js b/server.js index a5a3d0f..93f7232 100644 --- a/server.js +++ b/server.js @@ -12,6 +12,7 @@ const mountBackupRoutes = require('./lib/routes/backup'); const mountWatchRoutes = require('./lib/routes/watch'); const mountLlmRoutes = require('./lib/routes/llm'); const { csrfGuard } = require('./lib/csrf'); +const { limiter } = require('./lib/rate-limit'); const app = express(); const PORT = process.env.PORT || 3800; @@ -23,6 +24,9 @@ const DIST_DIR = path.join(__dirname, 'frontend', 'dist'); // Serve the built React UI when present; the legacy vanilla UI stays at /legacy. const HAS_DIST = fs.existsSync(path.join(DIST_DIR, 'index.html')); +// Rate-limit every route before anything touches the filesystem (lib/rate-limit.js). +app.use(limiter); + // Frontend staleness detection: the SPA polls this and prompts a reload // when the serving process (and thus possibly the code) has changed. const SERVER_BOOT_ID = `${Date.now().toString(36)}-${process.pid}`; diff --git a/test/api.test.js b/test/api.test.js index 31f456d..13037a7 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -38,6 +38,18 @@ describe('AgentXRay API', () => { assert.ok(body.bootId.length > 0); }); + it('every route is rate-limited: 600 requests per minute per IP (lib/rate-limit.js)', async () => { + for (const route of ['/api/version', '/', '/no/such/page']) { + const res = await fetch(`${srv.base}${route}`); + assert.equal(res.status, 200, route); + const policy = res.headers.get('ratelimit-policy') || ''; + assert.match(policy, /q=600/, `${route} policy: ${policy}`); + assert.match(policy, /w=60/, `${route} policy: ${policy}`); + assert.ok(res.headers.get('ratelimit'), `${route} has a RateLimit header`); + await res.arrayBuffer(); + } + }); + describe('sessions: codex', () => { it('lists sessions newest-first with metadata', async () => { const sessions = await getJson(srv.base, '/api/codex/sessions');