Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions app/backend/src/claims/claims.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,11 +744,11 @@ export class ClaimsService {
// Note: Since tokenAddress is not a direct field, we filter by checking metadata
// This is a simplified approach - in production, tokenAddress should be a direct field
if (query.tokenAddress) {
// Check if either claim or campaign metadata contains the token address
// Check if campaign metadata contains the token address
where.OR = [
{
campaign: {
metadata: { path: ['tokenAddress'] as any, equals: query.tokenAddress },
metadata: { path: ['tokenAddress'], equals: query.tokenAddress },
},
},
];
Expand Down
3 changes: 3 additions & 0 deletions app/backend/src/common/guards/adaptive-rate-limit.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@
}

private getStrategy(request: Request): keyof typeof this.limits {
const path = (request as any).path ?? (request as any).url ?? '';

Check warning on line 68 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .url on an `any` value

Check warning on line 68 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .path on an `any` value

Check warning on line 68 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an `any` value
if (path.includes('/search')) return 'search';

Check warning on line 69 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .includes on an `any` value

Check warning on line 69 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe call of an `any` typed value

const user = (request as any).user;

Check warning on line 71 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .user on an `any` value

Check warning on line 71 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an `any` value
if (user) {
if (user.authType === 'apiKey' || user.authType === 'envApiKey') {

Check warning on line 73 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .authType on an `any` value

Check warning on line 73 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe member access .authType on an `any` value
return 'apiKey';
}
return 'auth';
Expand All @@ -80,6 +80,9 @@
}

private getIdentifier(request: Request): string {
const orgId = (request as any).org;

Check warning on line 83 in app/backend/src/common/guards/adaptive-rate-limit.guard.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unsafe assignment of an `any` value
if (orgId) return `org:${orgId}`;

const user = (request as any).user;
if (user?.id) return user.id as string;
if (user?.apiKeyId) return user.apiKeyId as string;
Expand Down
3 changes: 3 additions & 0 deletions app/backend/src/common/guards/api-key.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export class ApiKeyGuard implements CanActivate {
apiKeyId: record.id,
authType: 'apiKey',
};
if (record.orgId) {
(request as any).org = record.orgId;
}
return true;
}

Expand Down
60 changes: 49 additions & 11 deletions app/backend/src/common/security/security.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
import { ConfigService } from '@nestjs/config';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import helmet, { HelmetOptions } from 'helmet';
import { RedisService } from '@liaoliaots/nestjs-redis';

import { CspReportController } from './csp-report.controller';
import { LoggerModule } from '../../logger/logger.module';
import { PrismaClient } from '@prisma/client';
import { createHash } from 'node:crypto';

const prisma = new PrismaClient();

const DEFAULT_ALLOWED_ORIGINS = [
'http://localhost:3000',
Expand Down Expand Up @@ -212,7 +211,7 @@
}

// Apply rate limiting for verification endpoints always,
// otherwise only apply to unauthenticated requests (no Authorization header)
// otherwise only apply to unauthenticated requests (no Authorization header or x-api-key)
const path = req.path ?? req.originalUrl ?? req.url ?? '';
const normalizedPath = path.split('?')[0];
const isVerificationPath = /^\/(api\/)?(v\d+\/)?verification(\/|$)/i.test(
Expand All @@ -221,7 +220,9 @@

const hasAuthHeader = !!(
(req.headers &&
(req.headers.authorization || req.headers.Authorization)) ||
(req.headers.authorization ||
req.headers.Authorization ||
req.headers['x-api-key'])) ||
req.user
);

Expand All @@ -231,13 +232,50 @@
return;
}

let orgId = (req as any).org;
if (!orgId) {
const apiKeyHeader = req.headers ? req.headers['x-api-key'] : undefined;
const apiKey =
typeof apiKeyHeader === 'string'
? apiKeyHeader
: Array.isArray(apiKeyHeader)
? apiKeyHeader[0]
: undefined;
if (apiKey) {
try {
const apiKeyHash = createHash('sha256').update(apiKey).digest('hex');

Check failure

Code scanning / CodeQL

Use of password hash with insufficient computational effort High

Password from
an access to x-api-key
is hashed insecurely.
Password from
an access to apiKeyHeader
is hashed insecurely.
Password from
an access to apiKeyHeader
is hashed insecurely.
Password from
an access to apiKey
is hashed insecurely.
const record = await prisma.apiKey.findFirst({
where: {
revokedAt: null,
OR: [{ keyHash: apiKeyHash }, { key: apiKey }],
},
});
if (record && record.orgId) {
orgId = record.orgId;
(req as any).org = orgId;
}
} catch {
// ignore database errors during rate limiting lookup
}
}
}

const now = Date.now();
cleanupExpiredEntries(now);

const forwardedIp =
Array.isArray(req.ips) && req.ips.length > 0 ? req.ips[0] : undefined;
const key = `ratelimit:global:${
const ipKey =
(typeof forwardedIp === 'string' ? forwardedIp : undefined) ??
(typeof req.ip === 'string' ? req.ip : undefined) ??
'unknown'
}`;
'unknown';

const key = orgId ? `org:${orgId}` : ipKey;
let entry = store.get(key);
if (!entry || entry.resetTimeMs <= now) {
entry = { count: 0, resetTimeMs: now + windowMs };
store.set(key, entry);
}

const now = Date.now();
const minTimestamp = now - windowMs;
Expand Down Expand Up @@ -312,9 +350,9 @@
* CSRF is currently mitigated by design due to our stateless, token-based authentication
* mechanism (`x-api-key` header). Since browsers do not automatically attach custom headers
* on cross-origin requests, CSRF attacks are inherently prevented.
*
*
* WARNING:
* If cookie-based session management or any browser-managed credentials are introduced
* If cookie-based session management or any browser-managed credentials are introduced
* in the future, CSRF protection middleware MUST be implemented.
*/
@Module({
Expand Down
173 changes: 125 additions & 48 deletions app/backend/test/security.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Logger, INestApplication, VersioningType } from '@nestjs/common';

Check failure on line 1 in app/backend/test/security.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / build-and-test

'Logger' is defined but never used. Allowed unused vars must match /^_/u
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import request from 'supertest';
import crypto from 'node:crypto';
import { PrismaService } from '../src/prisma/prisma.service';
import { AppModule } from '../src/app.module';
import {
buildCorsOptions,
Expand Down Expand Up @@ -241,57 +243,132 @@
}
});

it('should rate limit 100 hits in 1 s => 80+ return 429, and include correct headers', async () => {
// Create a specific application instance configured for 20 req/s
process.env.RATE_LIMIT_LIMIT = '20';
process.env.RATE_LIMIT_WINDOW_MS = '1000';

const appInstance = await createTestApp({ enableDocs: false });
const redisService = appInstance.get(RedisService);
const testMockRedis = new RedisMock();
jest.spyOn(redisService, 'getOrThrow').mockReturnValue(testMockRedis as any);

const server = appInstance.getHttpServer();
const results: any[] = [];

for (let i = 0; i < 100; i += 1) {
results.push(request(server).get('/api/v1/'));
}

const responses = await Promise.all(results);
const count429 = responses.filter(r => r.status === 429).length;

expect(count429).toBeGreaterThanOrEqual(80);

const rateLimitedResponse = responses.find(r => r.status === 429);
expect(rateLimitedResponse).toBeDefined();
expect(rateLimitedResponse.headers['ratelimit-limit']).toBe('20');
expect(rateLimitedResponse.headers['ratelimit-remaining']).toBeDefined();
expect(rateLimitedResponse.headers['ratelimit-reset']).toBeDefined();
expect(rateLimitedResponse.headers['retry-after']).toBeDefined();

await appInstance.close();
});

it('should fail open with a WARN log, not 500, when Redis is down', async () => {
const appInstance = await createTestApp({ enableDocs: false });
const redisService = appInstance.get(RedisService);

jest.spyOn(redisService, 'getOrThrow').mockImplementation(() => {
throw new Error('Redis connection down');
describe('Organization-based Rate Limiting', () => {
let orgRateLimitApp: INestApplication;
let prisma: any;

const hashApiKey = (key: string) => {
return crypto.createHash('sha256').update(key).digest('hex');
};

const cleanupDb = async () => {
try {
await prisma.apiKey.deleteMany({
where: {
orgId: { in: ['org-a', 'org-b'] },
},
});
await prisma.organization.deleteMany({
where: {
id: { in: ['org-a', 'org-b'] },
},
});
} catch {
// ignore cleanup errors
}
};

afterEach(async () => {
if (orgRateLimitApp) {
await orgRateLimitApp.close();
}
});

const warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});

const server = appInstance.getHttpServer();
const response = await request(server).get('/api/v1/');

expect(response.status).not.toBe(500);
expect(response.status).not.toBe(429);
expect(warnSpy).toHaveBeenCalled();
it('should simulate 200 requests under org A and 200 under org B in parallel without tripping 429', async () => {
process.env.API_RATE_LIMIT = '250';
process.env.THROTTLE_TTL = '60000';
orgRateLimitApp = await createTestApp({ enableDocs: false });
prisma = orgRateLimitApp.get(PrismaService);

await cleanupDb();

await prisma.organization.create({ data: { id: 'org-a', name: 'Org A' } });
await prisma.organization.create({ data: { id: 'org-b', name: 'Org B' } });

await prisma.apiKey.create({
data: {
id: 'key-a',
keyHash: hashApiKey('key-a-secret'),
role: 'operator',
orgId: 'org-a',
},
});
await prisma.apiKey.create({
data: {
id: 'key-b',
keyHash: hashApiKey('key-b-secret'),
role: 'operator',
orgId: 'org-b',
},
});

const server = orgRateLimitApp.getHttpServer();

// Run 200 requests for org A and 200 for org B in parallel
const promisesA = Array.from({ length: 200 }).map(() =>
request(server)
.post('/api/v1/verification')
.set('x-api-key', 'key-a-secret')
.send({}),
);
const promisesB = Array.from({ length: 200 }).map(() =>
request(server)
.post('/api/v1/verification')
.set('x-api-key', 'key-b-secret')
.send({}),
);

const responsesA = await Promise.all(promisesA);
const responsesB = await Promise.all(promisesB);

for (const res of responsesA) {
expect(res.status).not.toBe(429);
}
for (const res of responsesB) {
expect(res.status).not.toBe(429);
}

await cleanupDb();
});

warnSpy.mockRestore();
await appInstance.close();
it('should trip 429 on the 51st request for a single org when limit is 50', async () => {
process.env.API_RATE_LIMIT = '50';
process.env.THROTTLE_TTL = '60000';
orgRateLimitApp = await createTestApp({ enableDocs: false });
prisma = orgRateLimitApp.get(PrismaService);

await cleanupDb();

await prisma.organization.create({ data: { id: 'org-a', name: 'Org A' } });
await prisma.apiKey.create({
data: {
id: 'key-a',
keyHash: hashApiKey('key-a-secret'),
role: 'operator',
orgId: 'org-a',
},
});

const server = orgRateLimitApp.getHttpServer();

// 50 requests should succeed or at least not 429
for (let i = 0; i < 50; i++) {
const res = await request(server)
.post('/api/v1/verification')
.set('x-api-key', 'key-a-secret')
.send({});
expect(res.status).not.toBe(429);
}

// 51st request should trip 429
const limitedRes = await request(server)
.post('/api/v1/verification')
.set('x-api-key', 'key-a-secret')
.send({});
expect(limitedRes.status).toBe(429);

await cleanupDb();
});
});
});

Expand Down
28 changes: 28 additions & 0 deletions docs/security/rate-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Rate Limiting

ChainForge enforces rate limits to ensure API service availability, prevent abuse, and provide fair resource distribution.

## Granularity and Keys

Rate limiting key selection varies based on request authentication status:

1. **Organization-based Rate Limiting**:
- If the request is authenticated with an API key containing an organization ID (`orgId`) set by `ApiKeyGuard` (making `request.org` present), the rate limiter buckets request counts by:
`org:<orgId>`
- This ensures that different organizations do not share rate limit quotas, preventing one organization's usage or DDoS attacks from blocking another.

2. **IP-based Rate Limiting (Fallback)**:
- If the request is unauthenticated or has no associated organization ID, the rate limiter falls back to keying by the caller's IP address:
`req.ips[0]` or `req.ip` or `anonymous`/`unknown`

## Guards and Middleware

Rate limiting is implemented at two levels:

- **Express Middleware (`createRateLimiter`)**:
- Registered globally to rate limit unauthenticated and verification requests.
- Dynamically retrieves `orgId` from the database if an `x-api-key` is supplied to ensure organization-specific limit thresholds are respected.

- **NestJS Guard (`AdaptiveRateLimitGuard`)**:
- Global NestJS guard that provides adaptive rate limiting using Redis sliding windows.
- Intercepts requests after `ApiKeyGuard` has run, extracting `(request as any).org` to determine the rate limiting key.
Loading