diff --git a/server.js b/server.js index 7c08573..3836f1e 100644 --- a/server.js +++ b/server.js @@ -84,11 +84,33 @@ const apiLimiter = rateLimit({ legacyHeaders: false, handler: (req, res) => res.status(429).json({ error: '请求过于频繁,请稍后重试' }), }); -const authLimiter = rateLimit({ +// Per-IP bucket — default keyGenerator (IP only). Independent of any value +// the client puts in the body, so it can't be bypassed by rotating account. +const authIpLimiter = rateLimit({ windowMs: 15 * 60 * 1000, - limit: 20, + limit: 5, standardHeaders: 'draft-8', legacyHeaders: false, + skipSuccessfulRequests: true, + handler: (req, res) => res.status(429).json({ error: '尝试次数过多,请稍后重试' }), +}); + +function authAccountKey(req) { + return String((req.body && (req.body.email || req.body.username)) || '').trim().toLowerCase(); +} + +// Per-account bucket — independent of source IP, so spreading attempts +// across many IPs against one account is still capped. Needs req.body, so +// it must be mounted after the body parsers. Requests with no usable +// account value skip this limiter; authIpLimiter above still applies to them. +const authAccountLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 5, + standardHeaders: 'draft-8', + legacyHeaders: false, + skipSuccessfulRequests: true, + skip: (req) => !authAccountKey(req), + keyGenerator: authAccountKey, handler: (req, res) => res.status(429).json({ error: '尝试次数过多,请稍后重试' }), }); @@ -102,11 +124,14 @@ const mailLimiter = rateLimit({ handler: (req, res) => res.status(429).json({ error: '请求过于频繁,请稍后再试' }), }); +const AUTH_PATHS = ['/api/auth/login', '/api/auth/register', '/api/auth/reset-password']; + app.use('/api', apiLimiter); -app.use(['/api/auth/login', '/api/auth/register', '/api/auth/reset-password'], authLimiter); +app.use(AUTH_PATHS, authIpLimiter); app.use(['/api/auth/send-code', '/api/auth/forgot-password'], mailLimiter); app.use(express.json({ limit: '100kb' })); app.use(express.urlencoded({ extended: true, limit: '100kb' })); +app.use(AUTH_PATHS, authAccountLimiter); const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; diff --git a/test/rate-limit.test.js b/test/rate-limit.test.js new file mode 100644 index 0000000..f4e056d --- /dev/null +++ b/test/rate-limit.test.js @@ -0,0 +1,99 @@ +process.env.TRUST_PROXY = 'true'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const request = require('supertest'); +const bcrypt = require('bcryptjs'); +const { app, pool } = require('../server'); + +test.after(async () => { + await pool.end(); +}); + +let ipCounter = 0; +function nextIp() { + ipCounter += 1; + return `10.0.0.${ipCounter}`; +} + +test('repeated failed attempts for one account trip the account limiter even from different IPs', async () => { + const email = 'account-limit-test@example.test'; + for (let i = 0; i < 5; i += 1) { + const res = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', nextIp()) + .send({ email, password: 'wrong-password' }); + assert.notEqual(res.status, 429); + } + const blocked = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', nextIp()) + .send({ email, password: 'wrong-password' }); + assert.equal(blocked.status, 429); + assert.equal(blocked.body.error, '尝试次数过多,请稍后重试'); +}); + +test('repeated failed attempts across many accounts from one IP trip the IP limiter', async () => { + const ip = nextIp(); + for (let i = 0; i < 5; i += 1) { + const res = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', ip) + .send({ email: `rotating-${i}@example.test`, password: 'wrong-password' }); + assert.notEqual(res.status, 429); + } + const blocked = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', ip) + .send({ email: 'rotating-final@example.test', password: 'wrong-password' }); + assert.equal(blocked.status, 429); + assert.equal(blocked.body.error, '尝试次数过多,请稍后重试'); +}); + +test('successful logins are not counted against the auth limiters', async (t) => { + const email = 'success-test@example.test'; + const password = 'correct horse battery staple'; + const hash = await bcrypt.hash(password, 4); + + t.mock.method(pool, 'query', async () => ({ + rows: [{ + id: 1, + email, + password_hash: hash, + username: 'tester', + avatar_url: '', + bio: '', + role: 'user', + signature: '', + }], + })); + + const ip = nextIp(); + for (let i = 0; i < 8; i += 1) { + const res = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', ip) + .send({ email, password }); + assert.equal(res.status, 200); + } +}); + +test('missing body fields fall back to the IP-only bucket without crashing', async () => { + const ip = nextIp(); + const res = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', ip) + .send({}); + assert.equal(res.status, 400); +}); + +test('malformed JSON bodies are rejected without crashing the server', async () => { + const ip = nextIp(); + const res = await request(app) + .post('/api/auth/login') + .set('X-Forwarded-For', ip) + .set('Content-Type', 'application/json') + .send('{not valid json'); + assert.ok(res.status >= 400); + assert.ok(res.body.error); +});