Introduction
In a Next.js application handling name registry claims, we discovered a critical rate limiting vulnerability in app/api/claim/route.js. The /api/claim endpoint processes POST requests to claim usernames, consuming a shared GitHub API token (REGISTRY_TOKEN) for each attempt. However, unlike its sibling endpoint /api/records, this route had no per-user rate limiting—only a basic busy response that reacted to GitHub's own rate limiting.
This meant an attacker with valid session credentials could send hundreds of rapid POST requests, exhausting the shared API quota and potentially claiming multiple names before atomic checks could prevent it. The vulnerability was particularly dangerous because the endpoint was publicly accessible and the application explicitly acknowledged in comments that other endpoints needed rate limiting for the same reason—but this protection was never added to /api/claim.
The Vulnerability Explained
Let's examine the vulnerable code in app/api/claim/route.js before the fix:
import { sessionFromRequest } from '../../../lib/session.js';
import { evaluateClaim } from '../../../lib/claim.js';
import { putRecord } from '../../../lib/registry.js';
import { getOwnerIndex, putOwnerIndex } from '../../../lib/owners.js';
const TOKEN = () => process.env.REGISTRY_TOKEN;
const BUSY_RESPONSE = () =>
Response.json({ error: 'busy', retryInMs: 4000 }, { status: 503, headers: { 'Retry-After': '4' } });
export async function POST(request) {
const session = await sessionFromRequest(request);
const { name } = await request.json();
let ownerIndex = null;
if (session?.login) {
try {
ownerIndex = await getOwnerIndex(session.login, { token: TOKEN() });
} catch {
return BUSY_RESPONSE();
}
// ... claim processing continues
}
}
The problem is on line 17: after extracting the session, the code immediately calls getOwnerIndex(session.login, { token: TOKEN() }) without any rate limiting check. Every request from the same user consumes the shared REGISTRY_TOKEN quota.
The only protection was the BUSY_RESPONSE() catch block, which returned a 503 error if GitHub itself rate-limited the token. But this is reactive, not proactive—by the time GitHub throttles you, an attacker has already sent dozens or hundreds of requests, exhausting your quota and potentially disrupting service for legitimate users.
How Could This Be Exploited?
An attacker with a valid GitHub session could:
- Script rapid POST requests to
/api/claimwith different or identical name payloads - Exhaust the shared
REGISTRY_TOKENquota across all users, causing the entire service to become unavailable - Attempt to claim multiple names in rapid succession before atomic checks on the GitHub side could prevent duplicates
- Trigger denial of service for legitimate users who would receive 503 errors
Here's what an attack script might look like:
// Attacker script
const session_cookie = "session=valid_github_token";
for (let i = 0; i < 1000; i++) {
fetch('https://victim-app.com/api/claim', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': session_cookie
},
body: JSON.stringify({ name: `claimed-name-${i}` })
});
}
Without per-user rate limiting, all 1000 requests would be processed sequentially, each consuming GitHub API quota. The application would only stop when GitHub's own rate limiting kicked in—far too late.
Real-World Impact
For this specific name registry application:
- Service disruption: Legitimate users couldn't claim names because the quota was exhausted
- Unfair resource allocation: Attackers could claim multiple desirable names before rate limits applied
- Operational costs: Increased GitHub API usage, potential need for higher-tier API access
- Inconsistent security posture: The
/api/recordsendpoint had proper rate limiting (as noted in the fix comments), but/api/claimdidn't, creating a security gap
The Fix
The security patch adds per-user rate limiting to the /api/claim endpoint, matching the protection already present on /api/records. Here's the before-and-after comparison:
Before (Vulnerable):
export async function POST(request) {
const session = await sessionFromRequest(request);
const { name } = await request.json();
let ownerIndex = null;
if (session?.login) {
try {
ownerIndex = await getOwnerIndex(session.login, { token: TOKEN() });
} catch {
return BUSY_RESPONSE();
}
After (Fixed):
import { createRateLimiter } from '../../../lib/throttle.js';
const CLAIM_WINDOW_MS = 10 * 60 * 1000;
const CLAIM_MAX = 12;
const takeClaim = createRateLimiter({ windowMs: CLAIM_WINDOW_MS, max: CLAIM_MAX });
export async function POST(request) {
const session = await sessionFromRequest(request);
const { name } = await request.json();
let ownerIndex = null;
if (session?.login) {
const budget = takeClaim(session.login.toLowerCase());
if (!budget.ok) {
const seconds = Math.ceil(budget.retryAfterMs / 1000);
return Response.json(
{ error: 'rate_limited', retryInMs: budget.retryAfterMs },
{ status: 429, headers: { 'Retry-After': String(seconds) } },
);
}
try {
ownerIndex = await getOwnerIndex(session.login, { token: TOKEN() });
} catch {
return BUSY_RESPONSE();
Key Changes:
- Import rate limiter (line 5): Added
createRateLimiterfrom the existing throttle library - Configure limits (lines 7-9): Set a 10-minute window with maximum 12 claims per user—matching the
/api/recordsendpoint's rationale - Check budget before processing (lines 18-25): Call
takeClaim(session.login.toLowerCase())and return HTTP 429 if the user has exceeded their quota - Proper HTTP response: Return
status: 429(Too Many Requests) withRetry-Afterheader indicating when the user can retry
The fix also includes an explanatory comment:
// Same rationale as /api/records: claiming spends the same shared
// REGISTRY_TOKEN quota (an owner-index read plus a record write per
// attempt), so a looped or leaned-on button must be capped per account here
// too, not just on the edit endpoint.
This comment is crucial—it documents why rate limiting is necessary, referencing the shared resource (REGISTRY_TOKEN) and the specific operations that consume quota.
Frontend Update
The fix also updates app/claim-form.jsx to handle the new rate limiting response:
ineligible_repos: 'Your GitHub account needs at least one pu
While the diff is truncated, this likely adds a user-friendly message for the rate_limited error case, ensuring users understand why their request was rejected.
Prevention & Best Practices
1. Apply Rate Limiting to All State-Changing Endpoints
Any endpoint that:
- Consumes external API quota
- Modifies database state
- Sends emails or notifications
- Performs expensive computations
...should have per-user or per-IP rate limiting. Don't assume that downstream rate limiting (like GitHub's) is sufficient protection.
2. Use Consistent Rate Limiting Policies
In this codebase, /api/records already had rate limiting, but /api/claim didn't. Maintain a consistent security posture:
// Define rate limiting policies centrally
const API_RATE_LIMITS = {
claim: { windowMs: 10 * 60 * 1000, max: 12 },
records: { windowMs: 10 * 60 * 1000, max: 12 },
search: { windowMs: 1 * 60 * 1000, max: 60 },
};
3. Implement Defense in Depth
Combine multiple rate limiting strategies:
- Per-user limits: Based on session or API key
- Per-IP limits: For anonymous endpoints
- Global limits: Protect against distributed attacks
- Adaptive throttling: Increase restrictions during attack patterns
4. Return Proper HTTP Status Codes
Always use HTTP 429 (Too Many Requests) for rate limiting, not 503 (Service Unavailable) or 403 (Forbidden). Include the Retry-After header to inform clients when they can retry:
return Response.json(
{ error: 'rate_limited', retryInMs: budget.retryAfterMs },
{ status: 429, headers: { 'Retry-After': String(seconds) } },
);
5. Use Established Libraries
Don't roll your own rate limiting from scratch. Use proven libraries:
- Node.js: express-rate-limit, rate-limiter-flexible
- Python: flask-limiter, slowapi
- Go: golang.org/x/time/rate
- Ruby: rack-attack
6. Monitor and Alert
Track rate limiting metrics:
- Number of requests blocked per endpoint
- Users hitting limits repeatedly
- Sudden spikes in rate limit violations
Set up alerts for abnormal patterns that might indicate an attack.
7. Document Rate Limits
Make rate limits visible to API consumers:
- Document limits in API documentation
- Include current usage in response headers (X-RateLimit-Remaining)
- Provide clear error messages explaining the limit
Key Takeaways
- The
/api/claimendpoint consumed shared GitHub API quota but had no per-user throttling, allowing unlimited rapid requests from authenticated users - Rate limiting must be proactive, not reactive—waiting for GitHub's rate limiting to kick in meant attackers could exhaust quota before protection applied
- The
createRateLimiter()function with 12 requests per 10-minute window now prevents individual users from monopolizing the sharedREGISTRY_TOKEN - Consistency matters: The
/api/recordsendpoint already had identical rate limiting, but the vulnerability existed because/api/claimwas overlooked - Proper HTTP semantics improve security: Returning 429 with
Retry-Afterheaders allows legitimate clients to back off gracefully while blocking abuse
How Orbis AppSec Detected This
- Source: Authenticated POST requests to
/api/claimendpoint with session cookies - Sink:
getOwnerIndex(session.login, { token: TOKEN() })call inapp/api/claim/route.js:25consuming shared GitHub API token without rate limiting - Missing control: No per-user throttling before the
getOwnerIndexcall, despite the endpoint consuming the same sharedREGISTRY_TOKENquota as the protected/api/recordsendpoint - CWE: CWE-770 (Allocation of Resources Without Limits or Throttling)
- Fix: Added
createRateLimiter()with 10-minute window and 12-request maximum per user session, returning HTTP 429 when exceeded
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This rate limiting vulnerability demonstrates why security controls must be applied consistently across all endpoints that consume shared resources. While the application developers had correctly implemented rate limiting on /api/records, they overlooked the /api/claim endpoint, creating an exploitable gap.
The fix is straightforward—adding session-based throttling with clear limits and proper HTTP responses—but the lesson is broader: security is not just about protecting individual functions, but ensuring consistent protection across your entire API surface. When you implement a security control in one place, audit similar endpoints to ensure they have equivalent protection.
By adding the createRateLimiter() check with a 10-minute window and 12-request maximum, the application now protects the shared GitHub API quota from abuse while still allowing legitimate users to claim names at a reasonable rate. The inclusion of explanatory comments and proper error handling makes the code maintainable and the security boundary clear to future developers.