Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 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 { checkMachineTokenRateLimit, resetMachineTokenRateLimiter } from '../machineTokenRateLimiter';

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

describe('checkMachineTokenRateLimit', () => {
it('allows the first request from an IP', () => {
expect(checkMachineTokenRateLimit('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(checkMachineTokenRateLimit(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++) {
checkMachineTokenRateLimit(ip);
}
expect(checkMachineTokenRateLimit(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++) {
checkMachineTokenRateLimit(ip);
}
expect(checkMachineTokenRateLimit(ip)).toBe(false);

// Advance 2 seconds: at 10 tokens/s, 20 new tokens should be available
vi.advanceTimersByTime(2000);
expect(checkMachineTokenRateLimit(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++) {
checkMachineTokenRateLimit(ipA);
}
expect(checkMachineTokenRateLimit(ipA)).toBe(false);
expect(checkMachineTokenRateLimit(ipB)).toBe(true);
});

it('treats the unknown sentinel as a single IP', () => {
for (let i = 0; i < 20; i++) {
checkMachineTokenRateLimit('unknown');
}
expect(checkMachineTokenRateLimit('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++) {
checkMachineTokenRateLimit(`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(checkMachineTokenRateLimit(freshIp)).toBe(true);
});

it('allows a previously blocked IP after resetMachineTokenRateLimiter', () => {
const ip = '5.5.5.5';
for (let i = 0; i < 21; i++) {
checkMachineTokenRateLimit(ip);
}
expect(checkMachineTokenRateLimit(ip)).toBe(false);
resetMachineTokenRateLimiter();
expect(checkMachineTokenRateLimit(ip)).toBe(true);
});
});
160 changes: 160 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 { checkMachineTokenRateLimit, resetMachineTokenRateLimiter } from '../machineTokenRateLimiter';
import {
mockExpiredJwt,
mockInvalidSignatureJwt,
mockJwks,
mockJwt,
mockJwtPayload,
mockM2MJwtPayload,
signingJwks,
} from '../../fixtures';
import {
Expand Down Expand Up @@ -1762,6 +1764,164 @@
});
});

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

const exhaustBucket = (ip: string) => {
for (let i = 0; i < 20; i++) {
checkMachineTokenRateLimit(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.MachineTokenRateLimit,
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.MachineTokenRateLimit,
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.MachineTokenRateLimit,
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.MachineTokenRateLimit,
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.MachineTokenRateLimit);
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.MachineTokenRateLimit);
vi.useRealTimers();
});
});

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',
MachineTokenRateLimit: 'machine-token-rate-limit',
UnexpectedError: 'unexpected-error',
} as const;

Expand Down
32 changes: 32 additions & 0 deletions packages/backend/src/tokens/machineTokenRateLimiter.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 checkMachineTokenRateLimit(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 };
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 resetMachineTokenRateLimiter(): void {
buckets.clear();
}
46 changes: 45 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,39 @@ import { AuthErrorReason, handshake, signedIn, signedOut, signedOutInvalidToken
import { createClerkRequest } from './clerkRequest';
import { getCookieName, getCookieValue } from './cookie';
import { HandshakeService } from './handshake';
import { getMachineTokenType, isMachineJwt, isMachineToken, isTokenTypeAccepted } from './machine';
import {
getMachineTokenType,
isMachineJwt,
isMachineToken,
isMachineTokenByPrefix,
isTokenTypeAccepted,
} from './machine';
import { checkMachineTokenRateLimit } from './machineTokenRateLimiter';
import { OrganizationMatcher } from './organizationMatcher';
import type { MachineTokenType, SessionTokenType } from './tokenTypes';
import { TokenType } from './tokenTypes';
import type { AuthenticateRequestOptions } from './types';
import { verifyMachineAuthToken, verifyToken } from './verify';

// NOTE: IP headers like x-forwarded-for can be spoofed by clients not behind a trusted proxy.
// cf-connecting-ip (set by Cloudflare) and x-real-ip (set by Nginx) are more reliable when present.
// The rate limiter is defense-in-depth against BAPI quota exhaustion, not a security boundary.
function extractCallerIp(request: Request): string {
const cfConnectingIp = request.headers.get(constants.Headers.CfConnectingIp);
if (cfConnectingIp) {
return cfConnectingIp;
}
const xRealIp = request.headers.get(constants.Headers.RealIp);
if (xRealIp) {
return xRealIp;
}
const xForwardedFor = request.headers.get(constants.Headers.ForwardedFor);
if (xForwardedFor) {
return xForwardedFor.split(',')[0]?.trim() ?? 'unknown';
}
return 'unknown';
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

export const RefreshTokenErrorReason = {
NonEligibleNoCookie: 'non-eligible-no-refresh-cookie',
NonEligibleNonGet: 'non-eligible-non-get',
Expand Down Expand Up @@ -795,6 +821,15 @@ export const authenticateRequest: AuthenticateRequest = (async (
return mismatchState;
}

if (isMachineTokenByPrefix(tokenInHeader) && !checkMachineTokenRateLimit(extractCallerIp(request))) {
return signedOut({
tokenType: parsedTokenType,
authenticateContext,
reason: AuthErrorReason.MachineTokenRateLimit,
message: '',
});
}

const { data, tokenType, errors } = await verifyMachineAuthToken(tokenInHeader, authenticateContext);
if (errors) {
return handleMachineError(tokenType, errors[0]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -822,6 +857,15 @@ export const authenticateRequest: AuthenticateRequest = (async (
return mismatchState;
}

if (isMachineTokenByPrefix(tokenInHeader) && !checkMachineTokenRateLimit(extractCallerIp(request))) {
return signedOut({
tokenType: parsedTokenType,
authenticateContext,
reason: AuthErrorReason.MachineTokenRateLimit,
message: '',
});
}

const { data, tokenType, errors } = await verifyMachineAuthToken(tokenInHeader, authenticateContext);
if (errors) {
return handleMachineError(tokenType, errors[0]);
Expand Down
Loading