Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 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
3 changes: 3 additions & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,13 @@ const Headers = {
ContentSecurityPolicy: 'content-security-policy',
ContentSecurityPolicyReportOnly: 'content-security-policy-report-only',
EnableDebug: 'x-clerk-debug',
CfConnectingIp: 'cf-connecting-ip',
ForwardedFor: 'x-forwarded-for',
ForwardedHost: 'x-forwarded-host',
ForwardedPort: 'x-forwarded-port',
ForwardedProto: 'x-forwarded-proto',
Host: 'host',
RealIp: 'x-real-ip',
Location: 'location',
Nonce: 'x-nonce',
Origin: 'origin',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { checkOAuthTokenRateLimit, resetOAuthTokenRateLimiter } from '../oauthTokenRateLimiter';

afterEach(() => {
resetOAuthTokenRateLimiter();
vi.useRealTimers();
});

describe('checkOAuthTokenRateLimit', () => {
it('allows the first request from an IP', () => {
expect(checkOAuthTokenRateLimit('1.2.3.4')).toBe(true);
});

it('allows up to MAX_BURST requests in a burst', () => {
const ip = '10.0.0.1';
for (let i = 0; i < 20; i++) {
expect(checkOAuthTokenRateLimit(ip), `request ${i + 1} should be allowed`).toBe(true);
}
});

it('blocks requests that exceed MAX_BURST', () => {
const ip = '10.0.0.2';
for (let i = 0; i < 20; i++) {
checkOAuthTokenRateLimit(ip);
}
expect(checkOAuthTokenRateLimit(ip)).toBe(false);
});

it('allows requests again after tokens refill', () => {
vi.useFakeTimers();
const ip = '10.0.0.3';
for (let i = 0; i < 20; i++) {
checkOAuthTokenRateLimit(ip);
}
expect(checkOAuthTokenRateLimit(ip)).toBe(false);

// Advance 2 seconds: at 10 tokens/s, 20 new tokens should be available
vi.advanceTimersByTime(2000);
expect(checkOAuthTokenRateLimit(ip)).toBe(true);
});

it('tracks different IPs independently', () => {
const ipA = '192.168.1.1';
const ipB = '192.168.1.2';
for (let i = 0; i < 20; i++) {
checkOAuthTokenRateLimit(ipA);
}
expect(checkOAuthTokenRateLimit(ipA)).toBe(false);
expect(checkOAuthTokenRateLimit(ipB)).toBe(true);
});

it('treats the unknown sentinel as a single IP', () => {
for (let i = 0; i < 20; i++) {
checkOAuthTokenRateLimit('unknown');
}
expect(checkOAuthTokenRateLimit('unknown')).toBe(false);
});

it('evicts the oldest bucket and allows a new IP when MAX_BUCKETS is reached', () => {
// Fill up to MAX_BUCKETS (10 000) unique IPs
for (let i = 0; i < 10_000; i++) {
checkOAuthTokenRateLimit(`10.${Math.floor(i / 65536)}.${Math.floor((i % 65536) / 256)}.${i % 256}`);
}
// The 10 001st IP triggers eviction of the oldest entry; the new IP gets a fresh bucket
const freshIp = '172.16.0.1';
expect(checkOAuthTokenRateLimit(freshIp)).toBe(true);
});

it('allows a previously blocked IP after resetOAuthTokenRateLimiter', () => {
const ip = '5.5.5.5';
for (let i = 0; i < 21; i++) {
checkOAuthTokenRateLimit(ip);
}
expect(checkOAuthTokenRateLimit(ip)).toBe(false);
resetOAuthTokenRateLimiter();
expect(checkOAuthTokenRateLimit(ip)).toBe(true);
});
});
190 changes: 190 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { http, HttpResponse } from 'msw';

Check failure on line 1 in packages/backend/src/tokens/__tests__/request.test.ts

View workflow job for this annotation

GitHub Actions / Static analysis

Run autofix to sort these imports!
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';

import { MachineTokenVerificationErrorCode, TokenVerificationErrorReason } from '../../errors';
import { checkOAuthTokenRateLimit, resetOAuthTokenRateLimiter } from '../oauthTokenRateLimiter';
import {
mockExpiredJwt,
mockInvalidSignatureJwt,
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand Down Expand Up @@ -1762,6 +1764,194 @@
});
});

describe('Rate limiting', () => {
afterEach(() => {
resetOAuthTokenRateLimiter();
});

const exhaustBucket = (ip: string) => {
for (let i = 0; i < 20; i++) {
checkOAuthTokenRateLimit(ip);
}
};

test('blocks machine token request when IP exceeds burst limit', async () => {
server.use(
http.post(mockMachineAuthResponses.oauth_token.endpoint, () =>
HttpResponse.json(mockVerificationResults.oauth_token),
),
);
const ip = '203.0.113.1';
exhaustBucket(ip);
const rateLimited = await authenticateRequest(
mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}`, 'cf-connecting-ip': ip }),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(rateLimited).toBeMachineUnauthenticated({
tokenType: 'oauth_token',
reason: AuthErrorReason.OAuthTokenRateLimit,
message: '',
});
});

test('prefers cf-connecting-ip over x-forwarded-for for rate limit key', async () => {
server.use(
http.post(mockMachineAuthResponses.oauth_token.endpoint, () =>
HttpResponse.json(mockVerificationResults.oauth_token),
),
);
const cfIp = '10.0.0.1';
exhaustBucket(cfIp);
// cf-connecting-ip is exhausted; x-forwarded-for is a different IP and has full bucket
const rateLimited = await authenticateRequest(
mockRequest({
authorization: `Bearer ${mockTokens.oauth_token}`,
'cf-connecting-ip': cfIp,
'x-forwarded-for': '99.99.99.99',
}),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(rateLimited).toBeMachineUnauthenticated({
tokenType: 'oauth_token',
reason: AuthErrorReason.OAuthTokenRateLimit,
message: '',
});
// x-forwarded-for IP is untouched: a request using only that header must be allowed
const allowed = await authenticateRequest(
mockRequest({
authorization: `Bearer ${mockTokens.oauth_token}`,
'x-forwarded-for': '99.99.99.99',
}),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(allowed).toBeMachineAuthenticated();
});

test('falls back to x-real-ip when cf-connecting-ip is absent', async () => {
server.use(
http.post(mockMachineAuthResponses.oauth_token.endpoint, () =>
HttpResponse.json(mockVerificationResults.oauth_token),
),
);
const realIp = '192.168.10.20';
exhaustBucket(realIp);
const rateLimited = await authenticateRequest(
mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}`, 'x-real-ip': realIp }),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(rateLimited).toBeMachineUnauthenticated({
tokenType: 'oauth_token',
reason: AuthErrorReason.OAuthTokenRateLimit,
message: '',
});
});

test('falls back to first value in x-forwarded-for when higher-priority headers are absent', async () => {
server.use(
http.post(mockMachineAuthResponses.oauth_token.endpoint, () =>
HttpResponse.json(mockVerificationResults.oauth_token),
),
);
const ip = '172.16.5.5';
exhaustBucket(ip);
const rateLimited = await authenticateRequest(
mockRequest({
authorization: `Bearer ${mockTokens.oauth_token}`,
'x-forwarded-for': `${ip}, 10.0.0.2`,
}),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(rateLimited).toBeMachineUnauthenticated({
tokenType: 'oauth_token',
reason: AuthErrorReason.OAuthTokenRateLimit,
message: '',
});
});

test('session token path is not rate-limited', async () => {
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
// Exhaust the 'unknown' sentinel bucket (no IP headers on mockRequestWithHeaderAuth)
exhaustBucket('unknown');
const result = await authenticateRequest(mockRequestWithHeaderAuth(), mockOptions());
expect(result).toBeSignedIn();
});

test('OAuth JWT tokens bypass the rate limiter', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(mockJwtPayload.iat * 1000));
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
const ip = '203.0.113.2';
exhaustBucket(ip);
const { data: oauthJwt } = await signJwt(
{
iss: 'https://clerk.oauth.example.test',
sub: 'user_2vYVtestTESTtestTESTtestTESTtest',
client_id: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc',
scope: 'read:foo',
exp: mockJwtPayload.iat + 300,
iat: mockJwtPayload.iat,
nbf: mockJwtPayload.iat - 10,
},
signingJwks,
{ algorithm: 'RS256', header: { typ: 'at+jwt', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' } },
);
const result = await authenticateRequest(
mockRequest({ authorization: `Bearer ${oauthJwt}`, 'cf-connecting-ip': ip }),
mockOptions({ acceptsToken: 'oauth_token' }),
);
expect(result.reason).not.toBe(AuthErrorReason.OAuthTokenRateLimit);
vi.useRealTimers();
});

test('M2M JWT tokens bypass the rate limiter', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(mockJwtPayload.iat * 1000));
server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)));
const ip = '203.0.113.3';
exhaustBucket(ip);
const { data: m2mJwt } = await signJwt(mockM2MJwtPayload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});
const result = await authenticateRequest(
mockRequest({ authorization: `Bearer ${m2mJwt}`, 'cf-connecting-ip': ip }),
mockOptions({ acceptsToken: 'm2m_token' }),
);
expect(result.reason).not.toBe(AuthErrorReason.OAuthTokenRateLimit);
vi.useRealTimers();
});

test('opaque api_key tokens bypass the rate limiter', async () => {
server.use(
http.post(mockMachineAuthResponses.api_key.endpoint, () =>
HttpResponse.json(mockVerificationResults.api_key),
),
);
const ip = '203.0.113.4';
exhaustBucket(ip);
const result = await authenticateRequest(
mockRequest({ authorization: `Bearer ${mockTokens.api_key}`, 'cf-connecting-ip': ip }),
mockOptions({ acceptsToken: 'api_key' }),
);
expect(result.reason).not.toBe(AuthErrorReason.OAuthTokenRateLimit);
});

test('opaque m2m tokens bypass the rate limiter', async () => {
server.use(
http.post(mockMachineAuthResponses.m2m_token.endpoint, () =>
HttpResponse.json(mockVerificationResults.m2m_token),
),
);
const ip = '203.0.113.5';
exhaustBucket(ip);
const result = await authenticateRequest(
mockRequest({ authorization: `Bearer ${mockTokens.m2m_token}`, 'cf-connecting-ip': ip }),
mockOptions({ acceptsToken: 'm2m_token' }),
);
expect(result.reason).not.toBe(AuthErrorReason.OAuthTokenRateLimit);
});
});

describe('Token Location Validation', () => {
test.each(tokenTypes)('returns unauthenticated state when %s is in cookie instead of header', async tokenType => {
const mockToken = mockTokens[tokenType];
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/tokens/authStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const AuthErrorReason = {
SessionTokenWithoutClientUAT: 'session-token-but-no-client-uat',
ActiveOrganizationMismatch: 'active-organization-mismatch',
TokenTypeMismatch: 'token-type-mismatch',
OAuthTokenRateLimit: 'oauth-token-rate-limit',
UnexpectedError: 'unexpected-error',
} as const;

Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/tokens/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export function isMachineTokenByPrefix(token: string): boolean {
return MACHINE_TOKEN_PREFIXES.some(prefix => token.startsWith(prefix));
}

export function isOAuthTokenByPrefix(token: string): boolean {
return token.startsWith(OAUTH_TOKEN_PREFIX);
}

/**
* Checks if a token is a machine token by looking at its prefix or if it's an OAuth/M2M JWT.
*
Expand Down
32 changes: 32 additions & 0 deletions packages/backend/src/tokens/oauthTokenRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const MAX_BURST = 20;
const REFILL_RATE = 10; // tokens per second
const MAX_BUCKETS = 10_000;

type Bucket = { tokens: number; lastRefill: number };
const buckets = new Map<string, Bucket>();

export function checkOAuthTokenRateLimit(ip: string): boolean {
if (buckets.size >= MAX_BUCKETS) {
// Evict the oldest entry rather than clearing all buckets to prevent an attacker
// from neutralizing rate limits by forcing key churn across many distinct IPs.
const oldest = buckets.keys().next().value;
if (oldest !== undefined) {
buckets.delete(oldest);
}
}
const now = Date.now();
const existing = buckets.get(ip);
const bucket: Bucket = existing ?? { tokens: MAX_BURST, lastRefill: now };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Eviction is triggered for existing IPs at capacity, which weakens throttling.

At Line 9, eviction runs before checking whether ip already has a bucket. When buckets.size === MAX_BUCKETS, even requests from existing IPs evict other clients’ buckets, causing unintended rate-limit resets and reducing protection under load.

Suggested fix
 export function checkMachineTokenRateLimit(ip: string): boolean {
-  if (buckets.size >= MAX_BUCKETS) {
+  const existing = buckets.get(ip);
+  if (existing === undefined && buckets.size >= MAX_BUCKETS) {
     // Evict the oldest entry rather than clearing all buckets to prevent an attacker
     // from neutralizing rate limits by forcing key churn across many distinct IPs.
     const oldest = buckets.keys().next().value;
     if (oldest !== undefined) {
       buckets.delete(oldest);
     }
   }
   const now = Date.now();
-  const existing = buckets.get(ip);
   const bucket: Bucket = existing ?? { tokens: MAX_BURST, lastRefill: now };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/tokens/machineTokenRateLimiter.ts` around lines 9 - 19,
The eviction runs before checking whether the incoming ip already has a bucket,
causing eviction even for requests from existing IPs; change the logic in the
token bucket handling so you first fetch existing = buckets.get(ip) and only
perform the MAX_BUCKETS eviction when existing is undefined (i.e., about to
insert a new bucket). Specifically: get existing (buckets.get(ip)) before the
eviction check, and if existing is falsy and buckets.size >= MAX_BUCKETS then
evict the oldest key; then create bucket = existing ?? { tokens: MAX_BURST,
lastRefill: now } as before. Reference: buckets, MAX_BUCKETS, ip, existing,
Bucket, tokens, lastRefill.

const elapsed = (now - bucket.lastRefill) / 1000;
const refilled = Math.min(MAX_BURST, bucket.tokens + elapsed * REFILL_RATE);
if (refilled < 1) {
buckets.set(ip, { tokens: refilled, lastRefill: now });
return false;
}
buckets.set(ip, { tokens: refilled - 1, lastRefill: now });
return true;
}

export function resetOAuthTokenRateLimiter(): void {
buckets.clear();
}
Loading
Loading