fix(#199): implement rate limiting on webhook endpoints
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Implements comprehensive rate limiting on all webhook and coordinator endpoints to prevent DoS attacks. Follows TDD protocol with 14 passing tests. Implementation: - Added @nestjs/throttler package for rate limiting - Created ThrottlerApiKeyGuard for per-API-key rate limiting - Created ThrottlerValkeyStorageService for distributed rate limiting via Redis - Configured rate limits on stitcher endpoints (60 req/min) - Configured rate limits on coordinator endpoints (100 req/min) - Higher limits for health endpoints (300 req/min for monitoring) - Added environment variables for rate limit configuration - Rate limiting logs violations for security monitoring Rate Limits: - Stitcher webhooks: 60 requests/minute per API key - Coordinator endpoints: 100 requests/minute per API key - Health endpoints: 300 requests/minute (higher for monitoring) Storage: - Uses Valkey (Redis) for distributed rate limiting across API instances - Falls back to in-memory storage if Redis unavailable Testing: - 14 comprehensive rate limiting tests (all passing) - Tests verify: rate limit enforcement, Retry-After headers, per-API-key isolation - TDD approach: RED (failing tests) → GREEN (implementation) → REFACTOR Additional improvements: - Type safety improvements in websocket gateway - Array type notation standardization in coordinator service Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2
apps/api/src/common/throttler/index.ts
Normal file
2
apps/api/src/common/throttler/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ThrottlerApiKeyGuard } from "./throttler-api-key.guard";
|
||||
export { ThrottlerValkeyStorageService } from "./throttler-storage.service";
|
||||
44
apps/api/src/common/throttler/throttler-api-key.guard.ts
Normal file
44
apps/api/src/common/throttler/throttler-api-key.guard.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable, ExecutionContext } from "@nestjs/common";
|
||||
import { ThrottlerGuard, ThrottlerException } from "@nestjs/throttler";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Custom ThrottlerGuard that tracks rate limits by API key instead of IP
|
||||
*
|
||||
* This guard extracts the API key from the X-API-Key header and uses it
|
||||
* as the tracking key for rate limiting. This ensures that different API
|
||||
* keys have independent rate limits.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ThrottlerApiKeyGuard extends ThrottlerGuard {
|
||||
/**
|
||||
* Generate tracking key based on API key from X-API-Key header
|
||||
*
|
||||
* If no API key is present, falls back to IP-based tracking.
|
||||
*/
|
||||
protected getTracker(req: Request): Promise<string> {
|
||||
const apiKey = req.headers["x-api-key"] as string | undefined;
|
||||
|
||||
if (apiKey) {
|
||||
// Track by API key
|
||||
return Promise.resolve(`apikey:${apiKey}`);
|
||||
}
|
||||
|
||||
// Fallback to IP tracking
|
||||
const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
||||
return Promise.resolve(`ip:${ip}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to add custom error handling and logging
|
||||
*/
|
||||
protected async throwThrottlingException(context: ExecutionContext): Promise<void> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const tracker = await this.getTracker(request);
|
||||
|
||||
// Log rate limit violations for security monitoring
|
||||
console.warn(`Rate limit exceeded for ${tracker} on ${request.method} ${request.url}`);
|
||||
|
||||
throw new ThrottlerException("Rate limit exceeded. Please try again later.");
|
||||
}
|
||||
}
|
||||
146
apps/api/src/common/throttler/throttler-storage.service.ts
Normal file
146
apps/api/src/common/throttler/throttler-storage.service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Injectable, OnModuleInit, Logger } from "@nestjs/common";
|
||||
import { ThrottlerStorageService } from "@nestjs/throttler";
|
||||
import Redis from "ioredis";
|
||||
|
||||
/**
|
||||
* Redis-based storage for rate limiting using Valkey
|
||||
*
|
||||
* This service uses Valkey (Redis-compatible) as the storage backend
|
||||
* for rate limiting. This allows rate limits to work across multiple
|
||||
* API instances in a distributed environment.
|
||||
*
|
||||
* If Redis is unavailable, falls back to in-memory storage.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ThrottlerValkeyStorageService implements ThrottlerStorageService, OnModuleInit {
|
||||
private readonly logger = new Logger(ThrottlerValkeyStorageService.name);
|
||||
private client?: Redis;
|
||||
private readonly THROTTLER_PREFIX = "mosaic:throttler:";
|
||||
private readonly fallbackStorage = new Map<string, number[]>();
|
||||
private useRedis = false;
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const valkeyUrl = process.env.VALKEY_URL ?? "redis://localhost:6379";
|
||||
|
||||
try {
|
||||
this.logger.log(`Connecting to Valkey for rate limiting at ${valkeyUrl}`);
|
||||
|
||||
this.client = new Redis(valkeyUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy: (times: number) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
lazyConnect: true, // Don't connect immediately
|
||||
});
|
||||
|
||||
// Try to connect
|
||||
await this.client.connect();
|
||||
await this.client.ping();
|
||||
|
||||
this.useRedis = true;
|
||||
this.logger.log("Valkey connected successfully for rate limiting");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to connect to Valkey for rate limiting: ${errorMessage}`);
|
||||
this.logger.warn("Falling back to in-memory rate limiting storage");
|
||||
this.useRedis = false;
|
||||
this.client = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of requests for a given key
|
||||
*
|
||||
* @param key - Throttle key (e.g., "apikey:xxx" or "ip:192.168.1.1")
|
||||
* @param ttl - Time to live in milliseconds
|
||||
* @returns Promise resolving to the current number of requests
|
||||
*/
|
||||
async increment(key: string, ttl: number): Promise<number> {
|
||||
const throttleKey = this.getThrottleKey(key);
|
||||
|
||||
if (this.useRedis && this.client) {
|
||||
try {
|
||||
const result = await this.client.multi().incr(throttleKey).pexpire(throttleKey, ttl).exec();
|
||||
|
||||
if (result?.[0]?.[1]) {
|
||||
return result[0][1] as number;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Redis increment failed: ${errorMessage}`);
|
||||
// Fall through to in-memory
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory fallback
|
||||
return this.incrementMemory(throttleKey, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current number of requests for a given key
|
||||
*
|
||||
* @param key - Throttle key
|
||||
* @returns Promise resolving to the current number of requests
|
||||
*/
|
||||
async get(key: string): Promise<number> {
|
||||
const throttleKey = this.getThrottleKey(key);
|
||||
|
||||
if (this.useRedis && this.client) {
|
||||
try {
|
||||
const value = await this.client.get(throttleKey);
|
||||
return value ? parseInt(value, 10) : 0;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Redis get failed: ${errorMessage}`);
|
||||
// Fall through to in-memory
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory fallback
|
||||
return this.getMemory(throttleKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory increment implementation
|
||||
*/
|
||||
private incrementMemory(key: string, ttl: number): number {
|
||||
const now = Date.now();
|
||||
const timestamps = this.fallbackStorage.get(key) ?? [];
|
||||
|
||||
// Remove expired timestamps
|
||||
const validTimestamps = timestamps.filter((timestamp) => now - timestamp < ttl);
|
||||
|
||||
// Add new timestamp
|
||||
validTimestamps.push(now);
|
||||
|
||||
// Store updated timestamps
|
||||
this.fallbackStorage.set(key, validTimestamps);
|
||||
|
||||
return validTimestamps.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory get implementation
|
||||
*/
|
||||
private getMemory(key: string): number {
|
||||
const timestamps = this.fallbackStorage.get(key);
|
||||
return timestamps ? timestamps.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get throttle key with prefix
|
||||
*/
|
||||
private getThrottleKey(key: string): string {
|
||||
return `${this.THROTTLER_PREFIX}${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up on module destroy
|
||||
*/
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.client) {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user