Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ See also [`docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.
| GET | `/api/auth/user` | current user profile (no tokens) |
| GET | `/api/auth/status` | `{ authenticated, user }` |
| POST | `/api/auth/logout` | end session |
| POST | `/api/scan` | run a live Puppeteer + axe-core scan |
| POST | `/api/scan` | run a live Puppeteer + axe-core scan (rate limited: 10 / 15 min per IP, 30 per signed-in user; 429 `RATE_LIMITED`) |
| GET | `/api/scan-results?url=` | re-run a scan for a URL (used by deep links) |
| GET | `/api/scans` | list saved scans from the user's store |
| GET | `/api/scans/:id` | load one saved report from the user's store |
Expand Down
6 changes: 5 additions & 1 deletion backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const mockScanResults = require('./data/mockScanResults');
const ScanRunner = require('./services/scanRunner');
const AuthService = require('./services/authService');
const StorageService = require('./services/storageService');
const makeScanRateLimit = require('./middleware/scanRateLimit');

const scanRunner = new ScanRunner();

Expand Down Expand Up @@ -59,7 +60,10 @@ function buildApp(overrides = {}) {
storageService,
});

mountRoutes(app, { scanController, authService, storageService });
// Built per app so each instance (and each test app) starts with fresh counts.
const scanRateLimit = overrides.scanRateLimit || makeScanRateLimit();

mountRoutes(app, { scanController, authService, storageService, scanRateLimit });

return app;
}
Expand Down
55 changes: 55 additions & 0 deletions backend/middleware/scanRateLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Rate limiting for the live-scan routes (POST /api/scan and
* GET /api/scan-results). Every scan launches a headless Chromium,
* so request volume is a cost and abuse surface, not just a fairness concern.
*
* Anonymous requests are limited per IP (IPv6 grouped by subnet, so rotating
* addresses within one allocation doesn't reset the count). Signed-in requests
* are limited per user instead — people sharing an office or campus IP each
* get their own budget.
*
* ponytail: in-memory store, so on Vercel each warm instance counts on its own
* and a cold start resets it. Move to a shared store (e.g. Redis/Upstash via
* express-rate-limit's `store` option) if abuse gets past per-instance limits.
*/
const { rateLimit } = require('express-rate-limit');

const WINDOW_MS = 15 * 60 * 1000;
const ANON_LIMIT = 10;
const USER_LIMIT = 30;

const tooMany = (who) => ({
error: `Too many scans ${who}. Try again in a few minutes.`,
code: 'RATE_LIMITED',
});

/**
* @param {{ windowMs?: number, anonLimit?: number, userLimit?: number }} [options]
* @returns {import('express').RequestHandler[]}
*/
function makeScanRateLimit({
windowMs = WINDOW_MS,
anonLimit = ANON_LIMIT,
userLimit = USER_LIMIT,
} = {}) {
const common = { windowMs, standardHeaders: 'draft-8', legacyHeaders: false };

const perIp = rateLimit({
...common,
limit: anonLimit,
skip: (req) => Boolean(req.user),
message: tooMany('from this network'),
});

const perUser = rateLimit({
...common,
limit: userLimit,
skip: (req) => !req.user,
keyGenerator: (req) => `user:${req.user.id}`,
message: tooMany('from your account'),
});

return [perIp, perUser];
}

module.exports = makeScanRateLimit;
20 changes: 20 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-rate-limit": "^8.7.1",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
"puppeteer-core": "^24.43.1"
Expand Down
5 changes: 3 additions & 2 deletions backend/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ const makeAuthRouter = require('./auth');
* scanController: import('../controllers/scanController'),
* authService: import('../services/authService'),
* storageService: import('../services/storageService'),
* scanRateLimit?: import('express').RequestHandler[],
* }} deps
*/
function mountRoutes(app, { scanController, authService, storageService }) {
function mountRoutes(app, { scanController, authService, storageService, scanRateLimit }) {
// Session + passport once for all API routes (scan needs req.user too).
app.use(...authService.middleware());

app.use('/api/auth', makeAuthRouter({ authService, storageService }));
app.use('/api', makeScanRouter(scanController));
app.use('/api', makeScanRouter(scanController, { scanRateLimit }));
app.use('/api/problems', makeProblemsRouter(scanController));
}

Expand Down
12 changes: 7 additions & 5 deletions backend/routes/scan.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Scan-related routes:
* - POST /api/scan
* - GET /api/scan-results
* - POST /api/scan (rate limited per IP / per user)
* - GET /api/scan-results (same limiter — it also runs a live scan)
* - GET /api/scans
* - GET /api/scans/:id
* - DELETE /api/scans/:id
Expand All @@ -14,12 +14,14 @@ const { Router } = require('express');

/**
* @param {ScanController} controller
* @param {{ scanRateLimit?: import('express').RequestHandler[] }} [options]
* @returns {import('express').Router}
*/
function makeScanRouter(controller) {
function makeScanRouter(controller, { scanRateLimit = [] } = {}) {
const router = Router();
router.post('/scan', controller.postScan);
router.get('/scan-results', controller.getScanResults);
router.post('/scan', ...scanRateLimit, controller.postScan);
// Same middleware instances, so both routes draw on one shared budget.
router.get('/scan-results', ...scanRateLimit, controller.getScanResults);
router.get('/scans', controller.getSavedScans);
router.get('/scans/:id', controller.getSavedScan);
router.delete('/scans/:id', controller.deleteSavedScan);
Expand Down
10 changes: 10 additions & 0 deletions backend/tests/dependencies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,13 @@ test('@octokit/rest ships a CommonJS entry', () => {
'import() before upgrading.',
);
});

test('express-rate-limit ships a CommonJS entry', () => {
// Same Vercel constraint as @octokit/rest above: an ESM-only release would
// pass locally and 500 POST /api/scan in production.
const pkg = readInstalledPackageJson('express-rate-limit');
assert.ok(
Boolean(pkg.main) || Boolean(pkg.exports?.['.']?.require),
`express-rate-limit@${pkg.version} is ESM-only, which Vercel cannot require().`,
);
});
79 changes: 79 additions & 0 deletions backend/tests/scanRateLimit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Rate limiting on the live-scan routes — per IP when anonymous, per user
* when signed in.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const express = require('express');
const request = require('supertest');

require('./helpers/testEnv');
const { buildApp } = require('../app');
const mockScanResults = require('../data/mockScanResults');
const makeScanRateLimit = require('../middleware/scanRateLimit');

test('POST /api/scan returns 429 once an IP is over the limit, without scanning', async () => {
let scans = 0;
const app = buildApp({
scanRunner: { run: async () => { scans += 1; return mockScanResults; } },
scanRateLimit: makeScanRateLimit({ anonLimit: 2 }),
});
const scan = () => request(app).post('/api/scan').send({ url: 'https://example.com' });

assert.equal((await scan()).status, 200);
assert.equal((await scan()).status, 200);
const blocked = await scan();

assert.equal(blocked.status, 429);
assert.equal(blocked.body.code, 'RATE_LIMITED');
assert.match(blocked.body.error, /too many scans/i);
assert.ok(blocked.headers['retry-after']);
assert.equal(scans, 2);
});

test('GET /api/scan-results shares the same budget as POST /api/scan', async () => {
let scans = 0;
const run = async () => { scans += 1; return mockScanResults; };
const app = buildApp({
scanRunner: { run, getResults: run },
scanRateLimit: makeScanRateLimit({ anonLimit: 2 }),
});

assert.equal((await request(app).post('/api/scan').send({ url: 'https://example.com' })).status, 200);
assert.equal((await request(app).get('/api/scan-results').query({ url: 'https://example.com' })).status, 200);
const blocked = await request(app).get('/api/scan-results').query({ url: 'https://example.com' });

assert.equal(blocked.status, 429);
assert.equal(blocked.body.code, 'RATE_LIMITED');
assert.equal(scans, 2);
});

test('signed-in users are limited per user, not by the shared IP', async () => {
const app = express();
// Stand-in for passport: the header plays the role of the session user.
app.use((req, _res, next) => {
const id = req.get('x-test-user');
if (id) req.user = { id };
next();
});
app.post('/scan', ...makeScanRateLimit({ anonLimit: 1, userLimit: 2 }), (_req, res) => {
res.json({ ok: true });
});
const scan = (user) => {
const req = request(app).post('/scan');
return user ? req.set('x-test-user', user) : req;
};

assert.equal((await scan('alice')).status, 200);
assert.equal((await scan('alice')).status, 200);
assert.equal((await scan('alice')).status, 429);

// Same IP, different account: its own budget.
assert.equal((await scan('bob')).status, 200);

// Signed-in traffic didn't use up the anonymous budget for this IP.
assert.equal((await scan()).status, 200);
const anonBlocked = await scan();
assert.equal(anonBlocked.status, 429);
assert.match(anonBlocked.body.error, /from this network/);
});
2 changes: 1 addition & 1 deletion docs/plans/axecore-integration-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ tests.
### Phase 4 — Productionization

- [ ] Caching of scan results by URL (with TTL)
- [ ] Rate limiting per IP
- [x] Rate limiting per IP (#128)
- [ ] Background job queue (e.g. BullMQ) for slow sites
- [ ] Persistence (Postgres) for scan history
- [ ] Authentication (JWT) for scan endpoints
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/project-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ Goal: something we could put a domain in front of.

- [ ] Cache scan results by URL (TTL, in-memory first, swap later) so
anonymous re-scans of the same URL are cheap.
- [ ] Per-IP rate limiting on `/api/scan`; per-user rate limiting on
- [x] Per-IP rate limiting on `/api/scan`; per-user rate limiting on
authenticated scans.
- [ ] Background job queue (e.g. BullMQ) for slow sites; revisit
sync vs async API decision.
Expand Down
Loading