Skip to content

Commit 63d9885

Browse files
eshaanagkali
authored andcommitted
fix(api): rate limit stats refresh bypass
1 parent c8fb1e5 commit 63d9885

2 files changed

Lines changed: 125 additions & 4 deletions

File tree

app/api/stats/route.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ vi.mock('../../../lib/github', () => ({
77

88
import { fetchGitHubContributions } from '../../../lib/github';
99
import type { ContributionCalendar } from '../../../types';
10+
import { quotaMonitor } from '@/services/github/quota-monitor';
11+
import { refreshPolicy } from '@/services/github/refresh-policy';
12+
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';
1013

1114
// Calendar with a known, predictable streak so assertions are deterministic.
1215
// Last day (2024-06-16) has commits; "today" in tests is set to that date.
@@ -27,17 +30,25 @@ const mockCalendar: ContributionCalendar = {
2730
],
2831
};
2932

30-
function makeRequest(params: Record<string, string> = {}): Request {
33+
function makeRequest(
34+
params: Record<string, string> = {},
35+
headers: Record<string, string> = {}
36+
): Request {
3137
const url = new URL('http://localhost/api/stats');
3238
for (const [key, value] of Object.entries(params)) {
3339
url.searchParams.set(key, value);
3440
}
35-
return new Request(url.toString());
41+
return new Request(url.toString(), {
42+
headers: new Headers(headers),
43+
});
3644
}
3745

3846
describe('GET /api/stats', () => {
3947
beforeEach(() => {
4048
vi.clearAllMocks();
49+
quotaMonitor.reset();
50+
refreshPolicy.reset();
51+
refreshRateLimiter.reset();
4152
vi.mocked(fetchGitHubContributions).mockResolvedValue({
4253
calendar: mockCalendar,
4354
repoContributions: [],
@@ -110,6 +121,7 @@ describe('GET /api/stats', () => {
110121
expect(response.headers.get('Cache-Control')).toBe('no-store, no-cache, must-revalidate');
111122
expect(response.headers.get('Pragma')).toBe('no-cache');
112123
expect(response.headers.get('Expires')).toBe('0');
124+
expect(response.headers.get('X-Refresh-Status')).toBe('Fresh');
113125
});
114126

115127
it('still returns valid stats data when refresh=true', async () => {
@@ -143,6 +155,47 @@ describe('GET /api/stats', () => {
143155
expect(fetchGitHubContributions).toHaveBeenCalledWith('testuser', { bypassCache: false });
144156
});
145157

158+
it('serves cached stats instead of bypassing cache during refresh cooldown', async () => {
159+
await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
160+
161+
const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
162+
163+
expect(response.status).toBe(200);
164+
expect(fetchGitHubContributions).toHaveBeenLastCalledWith('testuser', {
165+
bypassCache: false,
166+
});
167+
expect(response.headers.get('X-Refresh-Status')).toBe('Cooldown-Served-Cached');
168+
expect(response.headers.get('Cache-Control')).toBe(
169+
'public, s-maxage=3600, stale-while-revalidate=86400'
170+
);
171+
});
172+
173+
it('returns 429 when stats refresh exceeds the client refresh rate limit', async () => {
174+
refreshRateLimiter.setLimit(1);
175+
176+
await GET(makeRequest({ user: 'testuser', refresh: 'true' }, { 'x-real-ip': '203.0.113.9' }));
177+
178+
const response = await GET(
179+
makeRequest({ user: 'octocat', refresh: 'true' }, { 'x-real-ip': '203.0.113.9' })
180+
);
181+
182+
expect(response.status).toBe(429);
183+
const body = await response.json();
184+
expect(body.error).toContain('Refresh rate limit exceeded');
185+
expect(response.headers.get('X-RateLimit-Limit')).toBe('1');
186+
});
187+
188+
it('blocks stats refresh when the shared GitHub quota is low', async () => {
189+
quotaMonitor.setQuota(5000, 400, Date.now() + 60_000);
190+
191+
const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
192+
193+
expect(response.status).toBe(429);
194+
const body = await response.json();
195+
expect(body.error).toContain('quota is low');
196+
expect(fetchGitHubContributions).not.toHaveBeenCalled();
197+
});
198+
146199
it('accepts a valid IANA timezone without error', async () => {
147200
const response = await GET(makeRequest({ user: 'testuser', tz: 'America/New_York' }));
148201
expect(response.status).toBe(200);

app/api/stats/route.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ import { NextResponse } from 'next/server';
33
import { fetchGitHubContributions } from '@/lib/github';
44
import { calculateStreak } from '@/lib/calculate';
55
import { statsParamsSchema } from '@/lib/validations';
6+
import { getClientIp } from '@/utils/getClientIp';
7+
import { quotaMonitor } from '@/services/github/quota-monitor';
8+
import { refreshPolicy } from '@/services/github/refresh-policy';
9+
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';
10+
11+
function logSecurityEvent(event: string, details: Record<string, unknown>) {
12+
console.warn(
13+
JSON.stringify({
14+
timestamp: new Date().toISOString(),
15+
type: 'SECURITY_EVENT',
16+
event,
17+
...details,
18+
})
19+
);
20+
}
621

722
/**
823
* GET /api/stats?user=<username>[&refresh=true][&tz=<IANA timezone>]
@@ -20,6 +35,7 @@ import { statsParamsSchema } from '@/lib/validations';
2035
*/
2136
export async function GET(request: Request) {
2237
const { searchParams } = new URL(request.url);
38+
const ip = getClientIp(request);
2339

2440
const parseResult = statsParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));
2541

@@ -47,6 +63,54 @@ export async function GET(request: Request) {
4763

4864
const { user, refresh, tz } = parseResult.data;
4965

66+
if (refresh && quotaMonitor.isQuotaLow()) {
67+
logSecurityEvent('LOW_QUOTA_STATS_REFRESH_BLOCKED', {
68+
user,
69+
ip,
70+
remainingQuota: quotaMonitor.getQuota().remaining,
71+
});
72+
return NextResponse.json(
73+
{ error: 'GitHub API quota is low. Stats refresh temporarily disabled.' },
74+
{ status: 429 }
75+
);
76+
}
77+
78+
if (refresh) {
79+
const rateLimitCheck = refreshRateLimiter.checkLimit(ip);
80+
if (!rateLimitCheck.success) {
81+
logSecurityEvent('STATS_REFRESH_RATE_LIMIT_EXCEEDED', {
82+
user,
83+
ip,
84+
limit: rateLimitCheck.limit,
85+
});
86+
return NextResponse.json(
87+
{ error: 'Refresh rate limit exceeded. Please try again later.' },
88+
{
89+
status: 429,
90+
headers: {
91+
'X-RateLimit-Limit': rateLimitCheck.limit.toString(),
92+
'X-RateLimit-Remaining': rateLimitCheck.remaining.toString(),
93+
'X-RateLimit-Reset': rateLimitCheck.reset.toString(),
94+
},
95+
}
96+
);
97+
}
98+
}
99+
100+
let shouldBypassCache = refresh;
101+
if (refresh) {
102+
if (!refreshPolicy.isRefreshAllowed(user)) {
103+
logSecurityEvent('STATS_REFRESH_COOLDOWN_VIOLATION', {
104+
user,
105+
ip,
106+
remainingMs: refreshPolicy.getRemainingCooldown(user),
107+
});
108+
shouldBypassCache = false;
109+
} else {
110+
refreshPolicy.recordRefresh(user);
111+
}
112+
}
113+
50114
// Validate the optional IANA timezone early so callers get a clear 400
51115
// rather than a silent fallback or a 500.
52116
let timezone = 'UTC';
@@ -59,19 +123,23 @@ export async function GET(request: Request) {
59123
}
60124

61125
try {
62-
const userData = await fetchGitHubContributions(user, { bypassCache: refresh });
126+
const userData = await fetchGitHubContributions(user, { bypassCache: shouldBypassCache });
63127
const calendar = userData.calendar;
64128
const stats = calculateStreak(calendar, timezone);
65129
const headers = new Headers({
66130
// Cache until next UTC midnight; clients can bust with ?refresh=true
67131
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
68132
});
69133

70-
if (refresh) {
134+
if (shouldBypassCache) {
71135
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate');
72136
headers.set('Pragma', 'no-cache');
73137
headers.set('Expires', '0');
74138
}
139+
headers.set(
140+
'X-Refresh-Status',
141+
shouldBypassCache ? 'Fresh' : refresh ? 'Cooldown-Served-Cached' : 'Cached'
142+
);
75143

76144
return NextResponse.json(
77145
{

0 commit comments

Comments
 (0)