Skip to content

Commit 959ee32

Browse files
fix: add principal api rate limits
1 parent 1ba46c7 commit 959ee32

3 files changed

Lines changed: 89 additions & 12 deletions

File tree

backend/src/index.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { auditRoutes } from './routes/audit';
1515
import enterpriseIntegrationRoutes, { oidcPublicRouter } from './routes/enterprise/integration';
1616
import enterpriseComplianceRoutes from './routes/enterprise/compliance';
1717
import { authMiddleware } from './middleware/auth';
18-
import { createRateLimiter } from './middleware/rateLimit';
18+
import { createRateLimiter, extractPrincipalRateLimitIdentifier } from './middleware/rateLimit';
1919
import {
2020
checkedProductionSafetyControls,
2121
collectProductionSafetyViolations,
@@ -175,6 +175,18 @@ const apiRouteLimiter = createRateLimiter({
175175
keyPrefix: 'rl:api-route',
176176
});
177177

178+
function createAuthenticatedPrincipalLimiter(
179+
keyPrefix: string,
180+
maxRequests: number,
181+
) {
182+
return createRateLimiter({
183+
windowMs: 60_000,
184+
maxRequests,
185+
keyPrefix,
186+
keyExtractor: extractPrincipalRateLimitIdentifier,
187+
});
188+
}
189+
178190
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
179191

180192
function resolveRequestId(value: unknown): string {
@@ -339,18 +351,24 @@ app.use('/api', globalLimiter);
339351
// ---------------------------------------------------------------------------
340352
// API routes
341353
// ---------------------------------------------------------------------------
354+
const credentialPrincipalLimiter = createAuthenticatedPrincipalLimiter('rl:principal:credentials', 30);
355+
const verificationPrincipalLimiter = createAuthenticatedPrincipalLimiter('rl:principal:verification', 60);
356+
const governancePrincipalLimiter = createAuthenticatedPrincipalLimiter('rl:principal:governance', 30);
357+
const auditPrincipalLimiter = createAuthenticatedPrincipalLimiter('rl:principal:audit', 30);
358+
342359
app.use('/api/v1/identity', apiRouteLimiter, identityRoutes);
343-
app.use('/api/v1/credentials', apiRouteLimiter, authMiddleware, credentialRoutes);
344-
app.use('/api/v1/verification', apiRouteLimiter, authMiddleware, verificationRoutes);
345-
app.use('/api/v1/governance', apiRouteLimiter, authMiddleware, governanceRoutes);
346-
app.use('/api/v1/audit', apiRouteLimiter, authMiddleware, auditRoutes);
360+
app.use('/api/v1/credentials', apiRouteLimiter, authMiddleware, credentialPrincipalLimiter, credentialRoutes);
361+
app.use('/api/v1/verification', apiRouteLimiter, authMiddleware, verificationPrincipalLimiter, verificationRoutes);
362+
app.use('/api/v1/governance', apiRouteLimiter, authMiddleware, governancePrincipalLimiter, governanceRoutes);
363+
app.use('/api/v1/audit', apiRouteLimiter, authMiddleware, auditPrincipalLimiter, auditRoutes);
347364

348365
// Enterprise routes — mounted behind auth + stricter rate limit
349366
const enterpriseLimiter = createRateLimiter({
350367
windowMs: 60_000,
351368
maxRequests: 30,
352369
keyPrefix: 'rl:enterprise',
353370
});
371+
const enterprisePrincipalLimiter = createAuthenticatedPrincipalLimiter('rl:principal:enterprise', 30);
354372

355373
// OIDC public routes — discovery, JWKS, and token endpoints MUST be accessible
356374
// without a bearer token per OpenID Connect Discovery §4 and OAuth 2.0 §3.2.
@@ -363,8 +381,8 @@ const oidcPublicLimiter = createRateLimiter({
363381
app.use('/api/v1/enterprise', oidcPublicLimiter, oidcPublicRouter);
364382

365383
// Auth-gated enterprise routes (registration, authorize, userinfo, webhooks, etc.)
366-
app.use('/api/v1/enterprise', enterpriseLimiter, authMiddleware, enterpriseIntegrationRoutes);
367-
app.use('/api/v1/enterprise/compliance', enterpriseLimiter, authMiddleware, enterpriseComplianceRoutes);
384+
app.use('/api/v1/enterprise', enterpriseLimiter, authMiddleware, enterprisePrincipalLimiter, enterpriseIntegrationRoutes);
385+
app.use('/api/v1/enterprise/compliance', enterpriseLimiter, authMiddleware, enterprisePrincipalLimiter, enterpriseComplianceRoutes);
368386

369387
const { aiComplianceRoutes } = require('./routes/ai/compliance') as typeof import('./routes/ai/compliance');
370388
const { aiAgentIdentityRoutes } = require('./routes/ai/agent-identity') as typeof import('./routes/ai/agent-identity');

backend/src/middleware/rateLimit.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,9 +284,13 @@ function handleRateLimitStoreFailure(
284284
export function createDIDRateLimiter(config: Omit<RateLimitConfig, 'keyExtractor'>) {
285285
return createRateLimiter({
286286
...config,
287-
keyExtractor: (req: Request) => {
288-
const authReq = req as Request & { identity?: { did: string } };
289-
return authReq.identity?.did ?? extractClientIP(req);
290-
},
287+
keyExtractor: extractPrincipalRateLimitIdentifier,
291288
});
292289
}
290+
291+
export function extractPrincipalRateLimitIdentifier(req: Request): string {
292+
const authReq = req as Request & { identity?: { id?: string; did?: string } };
293+
if (authReq.identity?.did) return authReq.identity.did;
294+
if (authReq.identity?.id) return `identity:${authReq.identity.id}`;
295+
return extractClientIP(req);
296+
}

backend/test/rate-limit.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ jest.mock('../src/index', () => ({
1414
},
1515
}));
1616

17-
import { createRateLimiter } from '../src/middleware/rateLimit';
17+
import {
18+
createRateLimiter,
19+
extractPrincipalRateLimitIdentifier,
20+
} from '../src/middleware/rateLimit';
1821

1922
const ORIGINAL_ENV = { ...process.env };
2023

@@ -176,6 +179,58 @@ describe('createRateLimiter', () => {
176179
);
177180
});
178181

182+
it('keys authenticated principal limits by DID instead of source IP', async () => {
183+
mockEval.mockResolvedValue(1);
184+
const limiter = createRateLimiter({
185+
windowMs: 60_000,
186+
maxRequests: 2,
187+
keyPrefix: 'rl:test',
188+
keyExtractor: extractPrincipalRateLimitIdentifier,
189+
});
190+
const { req, next } = createMockHttp();
191+
req.ip = '198.51.100.23';
192+
req.identity = { id: 'identity-1', did: 'did:aethelred:alice' };
193+
194+
await limiter(req, createMockHttp().res, next);
195+
196+
expect(mockEval).toHaveBeenCalledWith(
197+
expect.stringContaining('ZREMRANGEBYSCORE'),
198+
1,
199+
'rl:test:did:aethelred:alice',
200+
expect.any(String),
201+
expect.any(String),
202+
expect.any(String),
203+
expect.any(String),
204+
expect.any(String),
205+
);
206+
});
207+
208+
it('falls back to IP for principal limits before authentication', async () => {
209+
mockEval.mockResolvedValue(1);
210+
const limiter = createRateLimiter({
211+
windowMs: 60_000,
212+
maxRequests: 2,
213+
keyPrefix: 'rl:test',
214+
keyExtractor: extractPrincipalRateLimitIdentifier,
215+
});
216+
const { req, next } = createMockHttp();
217+
req.ip = '198.51.100.23';
218+
req.socket.remoteAddress = '198.51.100.23';
219+
220+
await limiter(req, createMockHttp().res, next);
221+
222+
expect(mockEval).toHaveBeenCalledWith(
223+
expect.stringContaining('ZREMRANGEBYSCORE'),
224+
1,
225+
'rl:test:198.51.100.23',
226+
expect.any(String),
227+
expect.any(String),
228+
expect.any(String),
229+
expect.any(String),
230+
expect.any(String),
231+
);
232+
});
233+
179234
it('fails open outside production when Redis returns an invalid count', async () => {
180235
mockEval.mockResolvedValue(null);
181236
const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 2, keyPrefix: 'rl:test' });

0 commit comments

Comments
 (0)