Skip to content

Commit 2cf733f

Browse files
Merge pull request #854 from 3m1n3nc3/fix/error-report-rate-limit
fix(security): rate limit /api/errors/report to prevent log-flooding DoS
2 parents 13e07aa + 3b4272c commit 2cf733f

3 files changed

Lines changed: 101 additions & 2 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { NextRequest } from 'next/server';
3+
import { POST } from '../route';
4+
import { RATE_LIMIT_TIERS } from '@/lib/ratelimit';
5+
6+
// Silence the logger so error reports don't pollute test output.
7+
vi.mock('@/lib/logging', () => ({
8+
createLogger: () => ({
9+
error: vi.fn(),
10+
warn: vi.fn(),
11+
info: vi.fn(),
12+
debug: vi.fn(),
13+
}),
14+
}));
15+
16+
const LIMIT = RATE_LIMIT_TIERS.REPORTING.limit;
17+
18+
let ipCounter = 0;
19+
/** Fresh IP per call keeps each test isolated from the shared in-memory store. */
20+
function makeRequest(body: unknown, ip = `203.0.113.${ipCounter++}`): NextRequest {
21+
return new NextRequest('https://example.com/api/errors/report', {
22+
method: 'POST',
23+
headers: {
24+
'content-type': 'application/json',
25+
'x-forwarded-for': ip,
26+
},
27+
body: JSON.stringify(body),
28+
});
29+
}
30+
31+
const sampleReport = {
32+
id: 'rep_1',
33+
sessionId: 'sess_1',
34+
userId: 'user_1',
35+
url: 'https://example.com/page',
36+
environment: 'production',
37+
errorData: { message: 'Something broke', type: 'TypeError' },
38+
};
39+
40+
describe('POST /api/errors/report rate limiting', () => {
41+
beforeEach(() => {
42+
ipCounter = 0;
43+
});
44+
45+
it('accepts legitimate error reports within the limit', async () => {
46+
const res = await POST(makeRequest(sampleReport));
47+
expect(res.status).toBe(200);
48+
await expect(res.json()).resolves.toEqual({ ok: true });
49+
});
50+
51+
it('returns 429 once the per-IP limit is exceeded', async () => {
52+
const ip = '198.51.100.1';
53+
54+
// Exhaust the allowed quota for this IP.
55+
for (let i = 0; i < LIMIT; i++) {
56+
const ok = await POST(makeRequest(sampleReport, ip));
57+
expect(ok.status).toBe(200);
58+
}
59+
60+
// The next request from the same IP is rate limited.
61+
const blocked = await POST(makeRequest(sampleReport, ip));
62+
expect(blocked.status).toBe(429);
63+
});
64+
65+
it('includes a Retry-After header on the 429 response', async () => {
66+
const ip = '198.51.100.2';
67+
68+
for (let i = 0; i < LIMIT; i++) {
69+
await POST(makeRequest(sampleReport, ip));
70+
}
71+
const blocked = await POST(makeRequest(sampleReport, ip));
72+
73+
expect(blocked.status).toBe(429);
74+
const retryAfter = blocked.headers.get('Retry-After');
75+
expect(retryAfter).not.toBeNull();
76+
expect(Number(retryAfter)).toBeGreaterThan(0);
77+
});
78+
79+
it('does not let one IP exhaust another IP quota', async () => {
80+
const noisy = '198.51.100.3';
81+
for (let i = 0; i < LIMIT; i++) {
82+
await POST(makeRequest(sampleReport, noisy));
83+
}
84+
expect((await POST(makeRequest(sampleReport, noisy))).status).toBe(429);
85+
86+
// A different IP is unaffected.
87+
const other = await POST(makeRequest(sampleReport, '198.51.100.4'));
88+
expect(other.status).toBe(200);
89+
});
90+
});

src/app/api/errors/report/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { createLogger } from '@/lib/logging';
3+
import { withRateLimit } from '@/lib/ratelimit';
34

45
const logger = createLogger('errors.report');
56

@@ -11,6 +12,11 @@ class ClientError extends Error {
1112
}
1213

1314
export async function POST(request: NextRequest): Promise<NextResponse> {
15+
// Rate limit per IP — this endpoint is called by client-side JS and is
16+
// otherwise open to log-flooding DoS. Use the lower REPORTING tier (10/min).
17+
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'REPORTING');
18+
if (rateLimitResponse) return rateLimitResponse;
19+
1420
try {
1521
const report = await request.json();
1622

@@ -30,9 +36,9 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
3036
error: clientError,
3137
});
3238

33-
return NextResponse.json({ ok: true }, { status: 200 });
39+
return addHeaders(NextResponse.json({ ok: true }, { status: 200 }));
3440
} catch (err) {
3541
logger.warn('Failed to process error report', { error: err });
36-
return NextResponse.json({ ok: false }, { status: 400 });
42+
return addHeaders(NextResponse.json({ ok: false }, { status: 400 }));
3743
}
3844
}

src/lib/ratelimit.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ export const RATE_LIMIT_TIERS = {
9090
AUTH: { limit: 5, windowMs: 60_000 },
9191
WRITE: { limit: 30, windowMs: 60_000 },
9292
READ: { limit: 60, windowMs: 60_000 },
93+
// Lower tier for unauthenticated, client-driven endpoints (e.g. error
94+
// reporting) that are prone to log-flooding abuse.
95+
REPORTING: { limit: 10, windowMs: 60_000 },
9396
} as const;
9497

9598
export type RateLimitTier = keyof typeof RATE_LIMIT_TIERS;

0 commit comments

Comments
 (0)