Skip to content
Merged
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
31 changes: 28 additions & 3 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '尝试次数过多,请稍后重试' }),
});

Expand All @@ -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;
Expand Down
99 changes: 99 additions & 0 deletions test/rate-limit.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading