diff --git a/apps/api/package.json b/apps/api/package.json index 8a1d96bf..18b648ee 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -39,7 +39,9 @@ "mime-types": "^2.1.35", "csv-parser": "^3.0.0", "csv-writer": "^1.6.0", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "rate-limiter-flexible": "^5.0.3", + "ioredis": "^5.3.2" }, "devDependencies": { "@types/express": "^4.17.21", @@ -60,6 +62,7 @@ "@types/markdown-it": "^13.0.7", "@types/mime-types": "^2.1.4", "@types/multer": "^1.4.11", - "@types/csv-parser": "^3.0.0" + "@types/csv-parser": "^3.0.0", + "@types/ioredis": "^5.0.0" } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 562ba8b5..06698b9c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -20,6 +20,7 @@ import trackingRoutes from './routes/tracking.routes'; import analysisRoutes from './routes/analysis.routes'; import exportRoutes from './routes/export.routes'; import bulkRoutes from './routes/bulk.routes'; +import { legacyRateLimit, requestLogger, rateLimitErrorHandler } from './middleware/rate-limit.middleware'; const app = express(); const PORT = process.env.PORT || 3333; @@ -39,6 +40,9 @@ app.use(helmet({ // Compression middleware app.use(compression()); +// Request logging with header redaction +app.use(requestLogger({ redactionLevel: 'partial' })); + // CORS middleware app.use(cors({ origin: process.env.WEB_URL || 'http://localhost:3000', @@ -95,7 +99,7 @@ app.get('/health', (req, res) => { // ============================================================================ // Original endpoint (deprecated but maintained for backward compatibility) -app.post('/api/track', async (req, res) => { +app.post('/api/track', legacyRateLimit, async (req, res) => { const { url, method = 'GET', userAgent } = req.body; if (!url) { @@ -124,7 +128,7 @@ app.post('/api/track', async (req, res) => { }); // API v1 track endpoint (POST) -app.post('/api/v1/track', apiLimiter, async (req, res) => { +app.post('/api/v1/track', legacyRateLimit, async (req, res) => { const { url, method = 'GET', userAgent } = req.body; if (!url) { @@ -175,7 +179,7 @@ app.post('/api/v1/track', apiLimiter, async (req, res) => { }); // API v1 track endpoint with GET method support (for easy browser/curl usage) -app.get('/api/v1/track', apiLimiter, async (req, res) => { +app.get('/api/v1/track', legacyRateLimit, async (req, res) => { const { url, method = 'GET', userAgent } = req.query; if (!url) { @@ -344,6 +348,9 @@ process.on('SIGINT', () => { process.exit(0); }); +// Rate limiting error handler +app.use(rateLimitErrorHandler); + app.listen(PORT, () => { logger.info(`๐Ÿš€ Redirect Intelligence v2 API Server running on http://localhost:${PORT}`); logger.info(`๐Ÿ“– API Documentation: http://localhost:${PORT}/api/docs`); diff --git a/apps/api/src/middleware/rate-limit.middleware.ts b/apps/api/src/middleware/rate-limit.middleware.ts new file mode 100644 index 00000000..2db92e51 --- /dev/null +++ b/apps/api/src/middleware/rate-limit.middleware.ts @@ -0,0 +1,282 @@ +/** + * Rate Limiting Middleware for Redirect Intelligence v2 + * + * Integrates advanced rate limiting with Express middleware + */ + +import { Request, Response, NextFunction } from 'express'; +import { rateLimitService, RateLimitError, BurstLimitError } from '../services/rate-limit.service'; +import { headerRedactionService } from '../services/header-redaction.service'; +import { logger } from '../lib/logger'; +import { AuthenticatedRequest } from './auth.middleware'; + +export interface RateLimitMiddlewareOptions { + type: 'tracking' | 'bulk' | 'export' | 'legacy'; + keyGenerator?: (req: AuthenticatedRequest) => string; + skipSuccessfulRequests?: boolean; + skipFailedRequests?: boolean; + onLimitReached?: (req: AuthenticatedRequest, res: Response) => void; +} + +/** + * Create rate limiting middleware for specific endpoint types + */ +export function createRateLimitMiddleware(options: RateLimitMiddlewareOptions) { + const { + type, + keyGenerator = defaultKeyGenerator, + skipSuccessfulRequests = false, + skipFailedRequests = false, + onLimitReached, + } = options; + + return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const key = keyGenerator(req); + const userId = req.user?.id; + + // Check rate limits + const rateLimitInfo = await rateLimitService.checkRateLimit(type, key, userId); + + // Set rate limit headers + res.set({ + 'X-RateLimit-Limit': rateLimitInfo.limit.toString(), + 'X-RateLimit-Remaining': rateLimitInfo.remaining.toString(), + 'X-RateLimit-Reset': rateLimitInfo.reset.toISOString(), + 'X-RateLimit-Tier': rateLimitInfo.tier, + }); + + // Check burst limits for authenticated users + if (userId && (type === 'tracking' || type === 'bulk')) { + await rateLimitService.checkBurstLimit(userId); + } + + // Log rate limit usage (with redacted headers) + logger.debug('Rate limit check passed', { + type, + key: headerRedactionService.partiallyRedactValue(key), + userId: userId ? headerRedactionService.partiallyRedactValue(userId) : undefined, + remaining: rateLimitInfo.remaining, + tier: rateLimitInfo.tier, + userAgent: req.get('User-Agent'), + ip: req.ip, + }); + + next(); + } catch (error) { + if (error instanceof RateLimitError) { + // Set rate limit headers even when limit is exceeded + res.set({ + 'X-RateLimit-Limit': '0', + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': error.reset.toISOString(), + 'X-RateLimit-Tier': error.tier, + 'Retry-After': Math.ceil((error.reset.getTime() - Date.now()) / 1000).toString(), + }); + + logger.warn('Rate limit exceeded', { + type, + tier: error.tier, + userId: req.user?.id, + ip: req.ip, + userAgent: req.get('User-Agent'), + }); + + if (onLimitReached) { + onLimitReached(req, res); + return; + } + + return res.status(429).json({ + success: false, + error: 'Rate limit exceeded', + message: `Too many requests for ${error.tier} tier. Please try again later.`, + retryAfter: error.reset.toISOString(), + tier: error.tier, + }); + } + + if (error instanceof BurstLimitError) { + res.set({ + 'X-RateLimit-Type': 'burst', + 'X-RateLimit-Tier': error.tier, + 'Retry-After': '60', // 1 minute for burst limits + }); + + logger.warn('Burst limit exceeded', { + type, + tier: error.tier, + limit: error.limit, + userId: req.user?.id, + ip: req.ip, + }); + + return res.status(429).json({ + success: false, + error: 'Burst limit exceeded', + message: `Too many requests per minute for ${error.tier} tier (limit: ${error.limit}/min).`, + retryAfter: new Date(Date.now() + 60000).toISOString(), + tier: error.tier, + }); + } + + // Other errors + logger.error('Rate limit middleware error:', error); + next(error); + } + }; +} + +/** + * Default key generator function + */ +function defaultKeyGenerator(req: AuthenticatedRequest): string { + // Use user ID for authenticated requests, IP for anonymous + return req.user ? `user:${req.user.id}` : `ip:${req.ip}`; +} + +/** + * Key generator for organization-based limits + */ +export function organizationKeyGenerator(req: AuthenticatedRequest): string { + if (req.user?.memberships?.[0]?.organizationId) { + return `org:${req.user.memberships[0].organizationId}`; + } + return defaultKeyGenerator(req); +} + +/** + * Key generator for project-based limits + */ +export function projectKeyGenerator(req: AuthenticatedRequest): string { + const projectId = req.body?.projectId || req.params?.projectId || req.query?.projectId; + if (projectId) { + return `project:${projectId}`; + } + return defaultKeyGenerator(req); +} + +/** + * Middleware to add rate limit status to response headers + */ +export function addRateLimitStatus(type: 'tracking' | 'bulk' | 'export' | 'legacy') { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const key = defaultKeyGenerator(req); + const userId = req.user?.id; + + const status = await rateLimitService.getRateLimitStatus(type, key, userId); + + res.set({ + 'X-RateLimit-Limit': status.limit.toString(), + 'X-RateLimit-Remaining': status.remaining.toString(), + 'X-RateLimit-Reset': status.reset.toISOString(), + 'X-RateLimit-Tier': status.tier, + }); + + next(); + } catch (error) { + logger.warn('Failed to add rate limit status headers:', error); + next(); // Continue even if we can't add headers + } + }; +} + +/** + * Predefined middleware instances for common use cases + */ + +// Legacy endpoints (maintain existing behavior) +export const legacyRateLimit = createRateLimitMiddleware({ + type: 'legacy', +}); + +// Tracking endpoints +export const trackingRateLimit = createRateLimitMiddleware({ + type: 'tracking', +}); + +// Bulk processing endpoints +export const bulkRateLimit = createRateLimitMiddleware({ + type: 'bulk', +}); + +// Export endpoints +export const exportRateLimit = createRateLimitMiddleware({ + type: 'export', +}); + +// Organization-based tracking limits +export const orgTrackingRateLimit = createRateLimitMiddleware({ + type: 'tracking', + keyGenerator: organizationKeyGenerator, +}); + +// Project-based tracking limits +export const projectTrackingRateLimit = createRateLimitMiddleware({ + type: 'tracking', + keyGenerator: projectKeyGenerator, +}); + +/** + * Middleware to log requests with redacted headers + */ +export function requestLogger(options: { includeBody?: boolean; redactionLevel?: 'full' | 'partial' } = {}) { + const { includeBody = false, redactionLevel = 'full' } = options; + + return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const startTime = Date.now(); + + // Redact request data for logging + const redactedRequest = headerRedactionService.redactLogData({ + method: req.method, + url: req.url, + headers: req.headers, + body: includeBody ? req.body : undefined, + user: req.user ? { + id: req.user.id, + email: headerRedactionService.partiallyRedactValue(req.user.email), + } : undefined, + ip: req.ip, + userAgent: req.get('User-Agent'), + }, { redactionLevel }); + + // Override res.end to log response + const originalEnd = res.end; + res.end = function(chunk?: any, encoding?: any) { + const duration = Date.now() - startTime; + + logger.info('Request completed', { + ...redactedRequest, + statusCode: res.statusCode, + duration, + contentLength: res.get('Content-Length'), + }); + + originalEnd.call(this, chunk, encoding); + }; + + next(); + }; +} + +/** + * Error handler for rate limiting errors + */ +export function rateLimitErrorHandler( + error: Error, + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) { + if (error instanceof RateLimitError || error instanceof BurstLimitError) { + // These should have been handled by the middleware, but just in case + return res.status(429).json({ + success: false, + error: 'Rate limit exceeded', + message: error.message, + }); + } + + next(error); +} diff --git a/apps/api/src/routes/bulk.routes.ts b/apps/api/src/routes/bulk.routes.ts index 5d949a92..b1aa7e83 100644 --- a/apps/api/src/routes/bulk.routes.ts +++ b/apps/api/src/routes/bulk.routes.ts @@ -10,6 +10,7 @@ import path from 'path'; import fs from 'fs/promises'; import { z } from 'zod'; import { requireAuth } from '../middleware/auth.middleware'; +import { bulkRateLimit, addRateLimitStatus } from '../middleware/rate-limit.middleware'; import { BulkProcessorService } from '../services/bulk-processor.service'; import { logger } from '../lib/logger'; @@ -66,7 +67,7 @@ const GetJobsQuerySchema = z.object({ * POST /api/v2/bulk/upload * Upload CSV file and create bulk tracking job */ -router.post('/upload', requireAuth, upload.single('file'), async (req, res) => { +router.post('/upload', requireAuth, bulkRateLimit, upload.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ @@ -123,7 +124,7 @@ router.post('/upload', requireAuth, upload.single('file'), async (req, res) => { * POST /api/v2/bulk/jobs * Create bulk tracking job with URL array */ -router.post('/jobs', requireAuth, async (req, res) => { +router.post('/jobs', requireAuth, bulkRateLimit, async (req, res) => { try { const userId = req.user!.id; const organizationId = req.user!.memberships?.[0]?.organizationId; @@ -172,7 +173,7 @@ router.post('/jobs', requireAuth, async (req, res) => { * GET /api/v2/bulk/jobs * Get user's bulk jobs with pagination */ -router.get('/jobs', requireAuth, async (req, res) => { +router.get('/jobs', requireAuth, addRateLimitStatus('bulk'), async (req, res) => { try { const userId = req.user!.id; const query = GetJobsQuerySchema.parse(req.query); diff --git a/apps/api/src/routes/tracking.routes.ts b/apps/api/src/routes/tracking.routes.ts index 2f54b9f8..6bdf7774 100644 --- a/apps/api/src/routes/tracking.routes.ts +++ b/apps/api/src/routes/tracking.routes.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import rateLimit from 'express-rate-limit'; import { RedirectTrackerService } from '../services/redirect-tracker.service'; import { optionalAuth, requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware'; +import { trackingRateLimit, addRateLimitStatus } from '../middleware/rate-limit.middleware'; import { logger } from '../lib/logger'; const router = express.Router(); @@ -69,8 +70,7 @@ const listChecksSchema = z.object({ */ router.post('/track', optionalAuth, - trackingLimiter, - anonymousTrackingLimiter, + trackingRateLimit, async (req: AuthenticatedRequest, res) => { try { // Validate input diff --git a/apps/api/src/services/header-redaction.service.ts b/apps/api/src/services/header-redaction.service.ts new file mode 100644 index 00000000..77fdec9d --- /dev/null +++ b/apps/api/src/services/header-redaction.service.ts @@ -0,0 +1,439 @@ +/** + * Header Redaction Service for Redirect Intelligence v2 + * + * Sanitizes and redacts sensitive headers for security and privacy + */ + +import { logger } from '../lib/logger'; + +/** + * Headers that should be completely removed from storage/logs + */ +const SENSITIVE_HEADERS = new Set([ + // Authentication headers + 'authorization', + 'x-api-key', + 'x-auth-token', + 'x-access-token', + 'bearer', + 'token', + + // Session headers + 'cookie', + 'set-cookie', + 'session', + 'session-id', + 'sessionid', + 'jsessionid', + 'phpsessid', + + // Personal information + 'x-real-ip', + 'x-forwarded-for', + 'x-client-ip', + 'x-remote-addr', + 'x-user-email', + 'x-user-id', + 'x-username', + + // Security tokens + 'x-csrf-token', + 'x-xsrf-token', + 'x-csrftoken', + 'csrf-token', + 'xsrf-token', + + // Custom application headers that might contain sensitive data + 'x-api-secret', + 'x-private-key', + 'x-secret', + 'x-password', + 'x-token', + 'x-auth', + 'x-authentication', + + // Server internal headers + 'x-forwarded-proto', + 'x-forwarded-host', + 'x-forwarded-server', + 'x-original-forwarded-for', + 'x-cluster-client-ip', + 'cf-connecting-ip', + 'true-client-ip', + + // Application-specific sensitive headers + 'stripe-signature', + 'paypal-auth-version', + 'x-hub-signature', + 'x-github-event', + 'x-slack-signature', +]); + +/** + * Headers that should be partially redacted (show first/last few characters) + */ +const PARTIALLY_REDACTABLE_HEADERS = new Set([ + 'user-agent', + 'referer', + 'origin', + 'x-forwarded-by', + 'via', +]); + +/** + * Headers that are safe to store as-is + */ +const SAFE_HEADERS = new Set([ + 'accept', + 'accept-encoding', + 'accept-language', + 'cache-control', + 'connection', + 'content-type', + 'content-length', + 'content-encoding', + 'date', + 'etag', + 'expires', + 'host', + 'last-modified', + 'location', + 'pragma', + 'server', + 'vary', + 'www-authenticate', + 'x-powered-by', + 'x-frame-options', + 'x-content-type-options', + 'strict-transport-security', + 'content-security-policy', + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + 'retry-after', + 'age', + 'allow', + 'access-control-allow-origin', + 'access-control-allow-methods', + 'access-control-allow-headers', + 'access-control-expose-headers', + 'access-control-max-age', + 'access-control-allow-credentials', +]); + +export interface RedactionOptions { + /** Include headers that are normally filtered out for debugging (admin only) */ + includeDebugHeaders?: boolean; + /** Level of redaction: 'full' removes sensitive headers, 'partial' redacts them */ + redactionLevel?: 'full' | 'partial'; + /** Custom headers to redact */ + customSensitiveHeaders?: string[]; + /** Headers to preserve even if they're normally redacted */ + preserveHeaders?: string[]; +} + +export interface RedactionResult { + headers: Record; + redactedCount: number; + redactedHeaders: string[]; + partiallyRedactedHeaders: string[]; +} + +export class HeaderRedactionService { + + /** + * Redact sensitive headers from a headers object + */ + redactHeaders( + headers: Record, + options: RedactionOptions = {} + ): RedactionResult { + const { + includeDebugHeaders = false, + redactionLevel = 'full', + customSensitiveHeaders = [], + preserveHeaders = [], + } = options; + + const result: RedactionResult = { + headers: {}, + redactedCount: 0, + redactedHeaders: [], + partiallyRedactedHeaders: [], + }; + + // Combine default sensitive headers with custom ones + const allSensitiveHeaders = new Set([ + ...SENSITIVE_HEADERS, + ...customSensitiveHeaders.map(h => h.toLowerCase()), + ]); + + // Headers to preserve + const preserveSet = new Set(preserveHeaders.map(h => h.toLowerCase())); + + for (const [key, value] of Object.entries(headers)) { + const lowerKey = key.toLowerCase(); + const stringValue = Array.isArray(value) ? value.join(', ') : value; + + // Skip empty headers + if (!stringValue || stringValue.trim() === '') { + continue; + } + + // Preserve headers that are explicitly marked for preservation + if (preserveSet.has(lowerKey)) { + result.headers[key] = stringValue; + continue; + } + + // Handle sensitive headers + if (allSensitiveHeaders.has(lowerKey)) { + result.redactedCount++; + result.redactedHeaders.push(key); + + if (redactionLevel === 'partial' || includeDebugHeaders) { + result.headers[key] = this.partiallyRedactValue(stringValue); + } + // If redactionLevel is 'full', we don't include the header at all + continue; + } + + // Handle partially redactable headers + if (PARTIALLY_REDACTABLE_HEADERS.has(lowerKey)) { + result.headers[key] = this.partiallyRedactValue(stringValue); + result.partiallyRedactedHeaders.push(key); + continue; + } + + // Handle safe headers + if (SAFE_HEADERS.has(lowerKey)) { + result.headers[key] = stringValue; + continue; + } + + // For unknown headers, be conservative and redact them unless in debug mode + if (includeDebugHeaders) { + result.headers[key] = this.partiallyRedactValue(stringValue); + result.partiallyRedactedHeaders.push(key); + } else { + result.redactedCount++; + result.redactedHeaders.push(key); + } + } + + logger.debug('Headers redacted', { + originalCount: Object.keys(headers).length, + finalCount: Object.keys(result.headers).length, + redactedCount: result.redactedCount, + redactionLevel, + includeDebugHeaders, + }); + + return result; + } + + /** + * Partially redact a header value (show first and last few characters) + */ + private partiallyRedactValue(value: string): string { + if (value.length <= 8) { + return '*'.repeat(value.length); + } + + const start = value.substring(0, 3); + const end = value.substring(value.length - 3); + const middle = '*'.repeat(Math.min(value.length - 6, 10)); + + return `${start}${middle}${end}`; + } + + /** + * Redact sensitive data from request/response logs + */ + redactLogData(data: any, options: RedactionOptions = {}): any { + if (!data || typeof data !== 'object') { + return data; + } + + const redacted = { ...data }; + + // Redact headers if present + if (redacted.headers) { + const result = this.redactHeaders(redacted.headers, options); + redacted.headers = result.headers; + } + + // Redact request headers + if (redacted.request && redacted.request.headers) { + const result = this.redactHeaders(redacted.request.headers, options); + redacted.request.headers = result.headers; + } + + // Redact response headers + if (redacted.response && redacted.response.headers) { + const result = this.redactHeaders(redacted.response.headers, options); + redacted.response.headers = result.headers; + } + + // Redact common sensitive fields in request/response bodies + if (redacted.body || redacted.data) { + const bodyData = redacted.body || redacted.data; + if (typeof bodyData === 'object') { + redacted.body = this.redactObjectData(bodyData); + redacted.data = this.redactObjectData(bodyData); + } + } + + // Redact URL parameters that might contain sensitive data + if (redacted.url && typeof redacted.url === 'string') { + redacted.url = this.redactSensitiveUrlParams(redacted.url); + } + + if (redacted.originalUrl && typeof redacted.originalUrl === 'string') { + redacted.originalUrl = this.redactSensitiveUrlParams(redacted.originalUrl); + } + + return redacted; + } + + /** + * Redact sensitive fields from object data + */ + private redactObjectData(obj: any): any { + if (!obj || typeof obj !== 'object') { + return obj; + } + + const sensitiveFields = new Set([ + 'password', + 'secret', + 'token', + 'key', + 'authorization', + 'auth', + 'apikey', + 'api_key', + 'access_token', + 'refresh_token', + 'session', + 'cookie', + 'csrf', + 'xsrf', + ]); + + const redacted = Array.isArray(obj) ? [] : {}; + + for (const [key, value] of Object.entries(obj)) { + const lowerKey = key.toLowerCase(); + + if (sensitiveFields.has(lowerKey) || lowerKey.includes('password') || lowerKey.includes('secret')) { + redacted[key] = typeof value === 'string' ? this.partiallyRedactValue(value) : '[REDACTED]'; + } else if (typeof value === 'object' && value !== null) { + redacted[key] = this.redactObjectData(value); + } else { + redacted[key] = value; + } + } + + return redacted; + } + + /** + * Redact sensitive URL parameters + */ + private redactSensitiveUrlParams(url: string): string { + try { + const urlObj = new URL(url); + const sensitiveParams = new Set([ + 'token', + 'key', + 'secret', + 'password', + 'auth', + 'authorization', + 'api_key', + 'apikey', + 'access_token', + 'session', + 'csrf', + 'xsrf', + ]); + + for (const [key, value] of urlObj.searchParams.entries()) { + if (sensitiveParams.has(key.toLowerCase()) || + key.toLowerCase().includes('password') || + key.toLowerCase().includes('secret') || + key.toLowerCase().includes('token')) { + urlObj.searchParams.set(key, this.partiallyRedactValue(value)); + } + } + + return urlObj.toString(); + } catch (error) { + // If URL parsing fails, return original URL + logger.warn('Failed to parse URL for redaction:', { url, error: error.message }); + return url; + } + } + + /** + * Check if a header should be considered sensitive + */ + isSensitiveHeader(headerName: string, customSensitive: string[] = []): boolean { + const lowerName = headerName.toLowerCase(); + return SENSITIVE_HEADERS.has(lowerName) || + customSensitive.map(h => h.toLowerCase()).includes(lowerName); + } + + /** + * Get list of all headers that would be redacted + */ + getSensitiveHeadersList(): string[] { + return Array.from(SENSITIVE_HEADERS).sort(); + } + + /** + * Validate redaction configuration + */ + validateRedactionConfig(options: RedactionOptions): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (options.redactionLevel && !['full', 'partial'].includes(options.redactionLevel)) { + errors.push('redactionLevel must be either "full" or "partial"'); + } + + if (options.customSensitiveHeaders) { + if (!Array.isArray(options.customSensitiveHeaders)) { + errors.push('customSensitiveHeaders must be an array'); + } else { + for (const header of options.customSensitiveHeaders) { + if (typeof header !== 'string') { + errors.push('All customSensitiveHeaders must be strings'); + break; + } + } + } + } + + if (options.preserveHeaders) { + if (!Array.isArray(options.preserveHeaders)) { + errors.push('preserveHeaders must be an array'); + } else { + for (const header of options.preserveHeaders) { + if (typeof header !== 'string') { + errors.push('All preserveHeaders must be strings'); + break; + } + } + } + } + + return { + valid: errors.length === 0, + errors, + }; + } +} + +// Singleton instance +export const headerRedactionService = new HeaderRedactionService(); diff --git a/apps/api/src/services/rate-limit.service.ts b/apps/api/src/services/rate-limit.service.ts new file mode 100644 index 00000000..82e74a9e --- /dev/null +++ b/apps/api/src/services/rate-limit.service.ts @@ -0,0 +1,427 @@ +/** + * Advanced Rate Limiting Service for Redirect Intelligence v2 + * + * Implements user-tier rate limiting with organization-based quotas + * and Redis-backed rate limiting with rate-limiter-flexible + */ + +import { RateLimiterRedis, RateLimiterMemory } from 'rate-limiter-flexible'; +import IORedis from 'ioredis'; +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { logger } from '../lib/logger'; +import { prisma } from '../lib/prisma'; +import { AuthenticatedRequest } from '../middleware/auth.middleware'; + +// Rate limit tiers based on organization plan +export interface RateLimitTier { + name: string; + requestsPerHour: number; + requestsPerMinute: number; + bulkJobsPerDay: number; + maxUrls: number; + exportLimit: number; +} + +export const RATE_LIMIT_TIERS: Record = { + free: { + name: 'Free', + requestsPerHour: 100, + requestsPerMinute: 10, + bulkJobsPerDay: 2, + maxUrls: 50, + exportLimit: 5, + }, + pro: { + name: 'Pro', + requestsPerHour: 1000, + requestsPerMinute: 50, + bulkJobsPerDay: 20, + maxUrls: 1000, + exportLimit: 100, + }, + enterprise: { + name: 'Enterprise', + requestsPerHour: 10000, + requestsPerMinute: 200, + bulkJobsPerDay: 100, + maxUrls: 10000, + exportLimit: 1000, + }, +}; + +export const ANONYMOUS_TIER: RateLimitTier = { + name: 'Anonymous', + requestsPerHour: 50, + requestsPerMinute: 5, + bulkJobsPerDay: 0, + maxUrls: 10, + exportLimit: 0, +}; + +export interface RateLimitInfo { + limit: number; + remaining: number; + reset: Date; + tier: string; +} + +export class RateLimitService { + private redis: IORedis; + private rateLimiters: Map; + + constructor() { + // Initialize Redis connection + this.redis = new IORedis({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + retryDelayOnFailover: 100, + enableReadyCheck: false, + maxRetriesPerRequest: null, + lazyConnect: true, + }); + + this.rateLimiters = new Map(); + this.initializeRateLimiters(); + } + + /** + * Initialize rate limiters for different endpoints and tiers + */ + private initializeRateLimiters(): void { + // Legacy endpoints (preserve existing behavior) + this.rateLimiters.set('legacy', new RateLimiterRedis({ + storeClient: this.redis, + keyPrefix: 'rl_legacy', + points: 100, // requests + duration: 3600, // per hour + blockDuration: 3600, // block for 1 hour + execEvenly: true, + })); + + // Anonymous users + this.rateLimiters.set('anonymous', new RateLimiterRedis({ + storeClient: this.redis, + keyPrefix: 'rl_anon', + points: ANONYMOUS_TIER.requestsPerHour, + duration: 3600, + blockDuration: 3600, + execEvenly: true, + })); + + // Authenticated users by tier + Object.keys(RATE_LIMIT_TIERS).forEach(tier => { + const config = RATE_LIMIT_TIERS[tier]; + + // Hourly limits + this.rateLimiters.set(`user_${tier}_hour`, new RateLimiterRedis({ + storeClient: this.redis, + keyPrefix: `rl_user_${tier}_h`, + points: config.requestsPerHour, + duration: 3600, + blockDuration: 900, // 15 minutes + execEvenly: true, + })); + + // Per-minute limits (burst protection) + this.rateLimiters.set(`user_${tier}_minute`, new RateLimiterRedis({ + storeClient: this.redis, + keyPrefix: `rl_user_${tier}_m`, + points: config.requestsPerMinute, + duration: 60, + blockDuration: 60, + execEvenly: true, + })); + + // Bulk job limits (daily) + this.rateLimiters.set(`bulk_${tier}_day`, new RateLimiterRedis({ + storeClient: this.redis, + keyPrefix: `rl_bulk_${tier}_d`, + points: config.bulkJobsPerDay, + duration: 86400, // 24 hours + blockDuration: 86400, + execEvenly: false, + })); + + // Export limits (daily) + this.rateLimiters.set(`export_${tier}_day`, new RateLimiterRedis({ + storeClient: this.redis, + keyPrefix: `rl_export_${tier}_d`, + points: config.exportLimit, + duration: 86400, + blockDuration: 86400, + execEvenly: false, + })); + }); + } + + /** + * Get user's rate limit tier based on organization plan + */ + async getUserTier(userId?: string): Promise { + if (!userId) { + return ANONYMOUS_TIER; + } + + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + memberships: { + include: { + organization: true, + }, + }, + }, + }); + + if (!user || !user.memberships.length) { + return RATE_LIMIT_TIERS.free; + } + + // Use the highest tier from all organizations + const plans = user.memberships.map(m => m.organization.plan); + if (plans.includes('enterprise')) return RATE_LIMIT_TIERS.enterprise; + if (plans.includes('pro')) return RATE_LIMIT_TIERS.pro; + return RATE_LIMIT_TIERS.free; + } catch (error) { + logger.error('Failed to get user tier:', error); + return RATE_LIMIT_TIERS.free; + } + } + + /** + * Check if request is allowed under rate limits + */ + async checkRateLimit( + type: 'tracking' | 'bulk' | 'export' | 'legacy', + key: string, + userId?: string + ): Promise { + try { + const tier = await this.getUserTier(userId); + let limiterKey: string; + let limit: number; + + if (type === 'legacy') { + limiterKey = 'legacy'; + limit = 100; + } else if (!userId) { + limiterKey = 'anonymous'; + limit = ANONYMOUS_TIER.requestsPerHour; + } else { + const tierName = tier.name.toLowerCase(); + + switch (type) { + case 'tracking': + limiterKey = `user_${tierName}_hour`; + limit = tier.requestsPerHour; + break; + case 'bulk': + limiterKey = `bulk_${tierName}_day`; + limit = tier.bulkJobsPerDay; + break; + case 'export': + limiterKey = `export_${tierName}_day`; + limit = tier.exportLimit; + break; + default: + throw new Error(`Unknown rate limit type: ${type}`); + } + } + + const rateLimiter = this.rateLimiters.get(limiterKey); + if (!rateLimiter) { + throw new Error(`Rate limiter not found: ${limiterKey}`); + } + + const result = await rateLimiter.consume(key, 1); + + return { + limit, + remaining: result.remainingPoints || 0, + reset: new Date(Date.now() + (result.msBeforeNext || 0)), + tier: tier.name, + }; + } catch (error) { + if (error instanceof Error && error.message.includes('Rate limit')) { + // This is a rate limit exceeded error + const tier = await this.getUserTier(userId); + throw new RateLimitError(tier.name, 0, new Date(Date.now() + 3600000)); + } + + logger.error('Rate limit check failed:', error); + throw error; + } + } + + /** + * Check burst protection (per-minute limits for authenticated users) + */ + async checkBurstLimit(userId: string): Promise { + const tier = await this.getUserTier(userId); + if (tier === ANONYMOUS_TIER) return; + + const tierName = tier.name.toLowerCase(); + const limiterKey = `user_${tierName}_minute`; + const rateLimiter = this.rateLimiters.get(limiterKey); + + if (!rateLimiter) { + logger.warn(`Burst rate limiter not found: ${limiterKey}`); + return; + } + + try { + await rateLimiter.consume(userId, 1); + } catch (error) { + throw new BurstLimitError(tier.name, tier.requestsPerMinute); + } + } + + /** + * Get current rate limit status without consuming points + */ + async getRateLimitStatus( + type: 'tracking' | 'bulk' | 'export' | 'legacy', + key: string, + userId?: string + ): Promise { + const tier = await this.getUserTier(userId); + let limiterKey: string; + let limit: number; + + if (type === 'legacy') { + limiterKey = 'legacy'; + limit = 100; + } else if (!userId) { + limiterKey = 'anonymous'; + limit = ANONYMOUS_TIER.requestsPerHour; + } else { + const tierName = tier.name.toLowerCase(); + + switch (type) { + case 'tracking': + limiterKey = `user_${tierName}_hour`; + limit = tier.requestsPerHour; + break; + case 'bulk': + limiterKey = `bulk_${tierName}_day`; + limit = tier.bulkJobsPerDay; + break; + case 'export': + limiterKey = `export_${tierName}_day`; + limit = tier.exportLimit; + break; + default: + throw new Error(`Unknown rate limit type: ${type}`); + } + } + + const rateLimiter = this.rateLimiters.get(limiterKey); + if (!rateLimiter) { + throw new Error(`Rate limiter not found: ${limiterKey}`); + } + + try { + const result = await rateLimiter.get(key); + + return { + limit, + remaining: result ? result.remainingPoints || 0 : limit, + reset: result ? new Date(Date.now() + (result.msBeforeNext || 0)) : new Date(), + tier: tier.name, + }; + } catch (error) { + logger.error('Failed to get rate limit status:', error); + return { + limit, + remaining: limit, + reset: new Date(), + tier: tier.name, + }; + } + } + + /** + * Reset rate limits for a specific key (admin function) + */ + async resetRateLimit(key: string, type?: string): Promise { + try { + if (type) { + const rateLimiter = this.rateLimiters.get(type); + if (rateLimiter) { + await rateLimiter.delete(key); + } + } else { + // Reset all rate limiters for this key + for (const rateLimiter of this.rateLimiters.values()) { + await rateLimiter.delete(key).catch(() => {}); // Ignore errors + } + } + + logger.info(`Rate limit reset for key: ${key}`, { type }); + } catch (error) { + logger.error('Failed to reset rate limit:', error); + throw error; + } + } + + /** + * Get rate limit statistics + */ + async getStatistics(): Promise<{ + totalRequests: number; + activeKeys: number; + tierDistribution: Record; + }> { + try { + // This is a simplified version - in production you'd want more detailed stats + const keys = await this.redis.keys('rl_*'); + + return { + totalRequests: keys.length, // Simplified metric + activeKeys: keys.length, + tierDistribution: { + anonymous: keys.filter(k => k.includes('anon')).length, + free: keys.filter(k => k.includes('free')).length, + pro: keys.filter(k => k.includes('pro')).length, + enterprise: keys.filter(k => k.includes('enterprise')).length, + }, + }; + } catch (error) { + logger.error('Failed to get rate limit statistics:', error); + return { + totalRequests: 0, + activeKeys: 0, + tierDistribution: {}, + }; + } + } +} + +/** + * Custom error classes for rate limiting + */ +export class RateLimitError extends Error { + constructor( + public tier: string, + public remaining: number, + public reset: Date + ) { + super(`Rate limit exceeded for ${tier} tier`); + this.name = 'RateLimitError'; + } +} + +export class BurstLimitError extends Error { + constructor( + public tier: string, + public limit: number + ) { + super(`Burst limit exceeded for ${tier} tier (${limit} requests per minute)`); + this.name = 'BurstLimitError'; + } +} + +// Singleton instance +export const rateLimitService = new RateLimitService(); diff --git a/apps/api/src/services/redirect-tracker.service.ts b/apps/api/src/services/redirect-tracker.service.ts index 63e8412e..80b90f78 100644 --- a/apps/api/src/services/redirect-tracker.service.ts +++ b/apps/api/src/services/redirect-tracker.service.ts @@ -14,6 +14,7 @@ import { CheckStatus, RedirectType } from '@prisma/client'; import { SSLAnalyzerService } from './ssl-analyzer.service'; import { SEOAnalyzerService } from './seo-analyzer.service'; import { SecurityAnalyzerService } from './security-analyzer.service'; +import { headerRedactionService } from './header-redaction.service'; // Input validation schemas const trackRequestSchema = z.object({ @@ -272,7 +273,13 @@ export class RedirectTrackerService { // Extract response details const statusCode = response.status; const contentType = response.headers['content-type']; - const responseHeaders = response.headers as Record; + + // Redact sensitive headers before storing + const redactionResult = headerRedactionService.redactHeaders( + response.headers as Record, + { redactionLevel: 'partial' } + ); + const responseHeaders = redactionResult.headers; // Determine redirect type and next URL let redirectType: RedirectType; @@ -332,7 +339,9 @@ export class RedirectTrackerService { redirectType: RedirectType.OTHER, latencyMs, reason: `Error: ${error.message}`, - responseHeaders: error.response?.headers || {}, + responseHeaders: error.response?.headers ? + headerRedactionService.redactHeaders(error.response.headers, { redactionLevel: 'partial' }).headers : + {}, statusCode: error.response?.status, }); diff --git a/test-phase-7.js b/test-phase-7.js new file mode 100644 index 00000000..b0fafdc6 --- /dev/null +++ b/test-phase-7.js @@ -0,0 +1,455 @@ +/** + * Test script for Phase 7: Advanced Rate Limiting + Header Redaction + * Tests the new rate limiting system and header redaction functionality + */ + +const http = require('http'); + +const BASE_URL = 'http://localhost:3333'; + +// Helper function to make HTTP requests +function makeRequest(options, data = null) { + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => body += chunk); + res.on('end', () => { + try { + const result = { + statusCode: res.statusCode, + headers: res.headers, + body: res.headers['content-type'] && res.headers['content-type'].includes('application/json') + ? JSON.parse(body) + : body + }; + resolve(result); + } catch (error) { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: body + }); + } + }); + }); + + req.on('error', reject); + + if (data) { + if (typeof data === 'string') { + req.write(data); + } else { + req.write(JSON.stringify(data)); + } + } + + req.end(); + }); +} + +// Helper function to sleep +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function runTests() { + console.log('๐Ÿงช Starting Phase 7: Advanced Rate Limiting + Header Redaction Tests\n'); + + let authToken = null; + + // Test 1: User Registration/Login + console.log('1๏ธโƒฃ Testing user authentication...'); + try { + const loginResult = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/v1/auth/login', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, { + email: 'rate-test@example.com', + password: 'ratetest123' + }); + + if (loginResult.statusCode === 200 && loginResult.body.success) { + authToken = loginResult.body.data.token; + console.log('โœ… User login successful'); + } else { + // Try to register if login fails + const registerResult = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/v1/auth/register', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, { + email: 'rate-test@example.com', + name: 'Rate Test User', + password: 'ratetest123', + organizationName: 'Rate Test Org' + }); + + if (registerResult.statusCode === 201 && registerResult.body.success) { + authToken = registerResult.body.data.token; + console.log('โœ… User registration successful'); + } else { + console.log('โŒ Authentication failed:', registerResult.body); + return; + } + } + } catch (error) { + console.log('โŒ Authentication error:', error.message); + return; + } + + // Test 2: Rate Limit Headers + console.log('\n2๏ธโƒฃ Testing rate limit headers...'); + try { + const trackResult = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/v2/track', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + }, { + url: 'https://example.com' + }); + + console.log('Status Code:', trackResult.statusCode); + console.log('Rate Limit Headers:'); + console.log(' X-RateLimit-Limit:', trackResult.headers['x-ratelimit-limit']); + console.log(' X-RateLimit-Remaining:', trackResult.headers['x-ratelimit-remaining']); + console.log(' X-RateLimit-Reset:', trackResult.headers['x-ratelimit-reset']); + console.log(' X-RateLimit-Tier:', trackResult.headers['x-ratelimit-tier']); + + if (trackResult.headers['x-ratelimit-limit']) { + console.log('โœ… Rate limit headers present'); + } else { + console.log('โš ๏ธ Rate limit headers missing'); + } + } catch (error) { + console.log('โŒ Rate limit headers test error:', error.message); + } + + // Test 3: Anonymous vs Authenticated Rate Limits + console.log('\n3๏ธโƒฃ Testing anonymous vs authenticated rate limits...'); + try { + // Anonymous request + const anonResult = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/v2/track', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, { + url: 'https://httpbin.org/get' + }); + + console.log('Anonymous Request:'); + console.log(' Limit:', anonResult.headers['x-ratelimit-limit']); + console.log(' Tier:', anonResult.headers['x-ratelimit-tier']); + + // Authenticated request + const authResult = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/v2/track', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + }, { + url: 'https://httpbin.org/get' + }); + + console.log('Authenticated Request:'); + console.log(' Limit:', authResult.headers['x-ratelimit-limit']); + console.log(' Tier:', authResult.headers['x-ratelimit-tier']); + + const anonLimit = parseInt(anonResult.headers['x-ratelimit-limit'] || '0'); + const authLimit = parseInt(authResult.headers['x-ratelimit-limit'] || '0'); + + if (authLimit > anonLimit) { + console.log('โœ… Authenticated users have higher rate limits'); + } else { + console.log('โš ๏ธ Rate limit difference not detected'); + } + } catch (error) { + console.log('โŒ Rate limit comparison error:', error.message); + } + + // Test 4: Legacy Endpoint Rate Limiting + console.log('\n4๏ธโƒฃ Testing legacy endpoint rate limiting...'); + try { + const legacyResult = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/track', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, { + url: 'https://example.com' + }); + + console.log('Legacy endpoint status:', legacyResult.statusCode); + console.log('Legacy rate limit:', legacyResult.headers['x-ratelimit-limit']); + + if (legacyResult.headers['x-ratelimit-limit']) { + console.log('โœ… Legacy endpoints have rate limiting'); + } else { + console.log('โš ๏ธ Legacy endpoints missing rate limiting'); + } + } catch (error) { + console.log('โŒ Legacy rate limiting test error:', error.message); + } + + // Test 5: Burst Protection + console.log('\n5๏ธโƒฃ Testing burst protection...'); + try { + console.log('Making rapid authenticated requests...'); + const requests = []; + + for (let i = 0; i < 15; i++) { + requests.push( + makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/v2/track', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + }, { + url: `https://httpbin.org/delay/0`, + timeout: 5000 + }) + ); + } + + const results = await Promise.allSettled(requests); + const statusCodes = results.map(r => r.status === 'fulfilled' ? r.value.statusCode : 'error'); + const rateLimited = statusCodes.filter(code => code === 429).length; + const successful = statusCodes.filter(code => code === 200 || code === 201).length; + + console.log(`Results: ${successful} successful, ${rateLimited} rate limited`); + + if (rateLimited > 0) { + console.log('โœ… Burst protection is working'); + } else { + console.log('โš ๏ธ Burst protection may not be active (or limits are very high)'); + } + } catch (error) { + console.log('โŒ Burst protection test error:', error.message); + } + + // Test 6: Header Redaction (indirect test via response logs) + console.log('\n6๏ธโƒฃ Testing header redaction...'); + try { + const sensitiveHeadersResult = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/v2/track', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + 'X-Secret-Key': 'secret123', + 'Cookie': 'session=abc123', + 'X-API-Key': 'api_key_12345', + 'User-Agent': 'RedirectIntelligence Test Client v1.0', + }, + }, { + url: 'https://httpbin.org/headers' + }); + + console.log('Request with sensitive headers sent'); + console.log('Status:', sensitiveHeadersResult.statusCode); + + // Check if the request was processed (indicating headers were handled properly) + if (sensitiveHeadersResult.statusCode === 200 || sensitiveHeadersResult.statusCode === 201) { + console.log('โœ… Request with sensitive headers processed successfully'); + console.log(' (Header redaction occurs server-side in logs/storage)'); + } else { + console.log('โš ๏ธ Request with sensitive headers failed'); + } + } catch (error) { + console.log('โŒ Header redaction test error:', error.message); + } + + // Test 7: Bulk Rate Limiting + console.log('\n7๏ธโƒฃ Testing bulk endpoint rate limiting...'); + try { + const bulkResult = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/v2/bulk/jobs', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + }, { + urls: [ + { url: 'https://example.com', label: 'Test 1' } + ] + }); + + console.log('Bulk endpoint status:', bulkResult.statusCode); + console.log('Bulk rate limit tier:', bulkResult.headers['x-ratelimit-tier']); + console.log('Bulk rate limit:', bulkResult.headers['x-ratelimit-limit']); + + if (bulkResult.headers['x-ratelimit-tier']) { + console.log('โœ… Bulk endpoints have tier-based rate limiting'); + } else { + console.log('โš ๏ธ Bulk rate limiting not detected'); + } + } catch (error) { + console.log('โŒ Bulk rate limiting test error:', error.message); + } + + // Test 8: Rate Limit Exceeded Response + console.log('\n8๏ธโƒฃ Testing rate limit exceeded response...'); + try { + console.log('Making requests to approach rate limit...'); + let lastResponse = null; + + // Make many requests quickly to trigger rate limiting + for (let i = 0; i < 60; i++) { + try { + const response = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/track', // Use legacy endpoint for predictable low limits + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, { + url: 'https://httpbin.org/get' + }); + + lastResponse = response; + + if (response.statusCode === 429) { + console.log('โœ… Rate limit exceeded (429) response received'); + console.log('Response body:', response.body); + console.log('Retry-After header:', response.headers['retry-after']); + console.log('X-RateLimit headers:', { + limit: response.headers['x-ratelimit-limit'], + remaining: response.headers['x-ratelimit-remaining'], + reset: response.headers['x-ratelimit-reset'], + }); + break; + } + + // Small delay to avoid overwhelming the server + await sleep(50); + } catch (error) { + console.log('Request error (expected for rate limiting):', error.message); + } + } + + if (!lastResponse || lastResponse.statusCode !== 429) { + console.log('โš ๏ธ Rate limit not triggered (limits may be too high for testing)'); + } + } catch (error) { + console.log('โŒ Rate limit exceeded test error:', error.message); + } + + // Test 9: Different User Agent Handling + console.log('\n9๏ธโƒฃ Testing different user agent handling...'); + try { + const userAgents = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'curl/7.68.0', + 'RedirectIntelligence/2.0 TestClient', + ]; + + for (const ua of userAgents) { + const result = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/api/v2/track', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + 'User-Agent': ua, + }, + }, { + url: 'https://httpbin.org/user-agent' + }); + + console.log(`User-Agent: ${ua.substring(0, 30)}... -> Status: ${result.statusCode}`); + } + + console.log('โœ… User-Agent handling test completed'); + } catch (error) { + console.log('โŒ User-Agent test error:', error.message); + } + + // Test 10: Health Check (should not be rate limited) + console.log('\n๐Ÿ”Ÿ Testing health check endpoint...'); + try { + const healthResult = await makeRequest({ + hostname: 'localhost', + port: 3333, + path: '/health', + method: 'GET', + }); + + console.log('Health check status:', healthResult.statusCode); + console.log('Health check rate limited:', !!healthResult.headers['x-ratelimit-limit']); + + if (healthResult.statusCode === 200 && !healthResult.headers['x-ratelimit-limit']) { + console.log('โœ… Health check endpoint is not rate limited'); + } else { + console.log('โš ๏ธ Health check endpoint may have rate limiting'); + } + } catch (error) { + console.log('โŒ Health check test error:', error.message); + } + + console.log('\n๐ŸŽ‰ Phase 7 testing completed!'); + console.log('\nKey features tested:'); + console.log('โœ“ Advanced rate limiting with user tiers'); + console.log('โœ“ Rate limit headers in responses'); + console.log('โœ“ Anonymous vs authenticated rate limits'); + console.log('โœ“ Legacy endpoint rate limiting'); + console.log('โœ“ Burst protection for rapid requests'); + console.log('โœ“ Header redaction (server-side)'); + console.log('โœ“ Bulk endpoint tier-based limiting'); + console.log('โœ“ Rate limit exceeded responses'); + console.log('โœ“ User-Agent handling'); + console.log('โœ“ Health check endpoint exclusion'); +} + +// Error handling +process.on('uncaughtException', (error) => { + console.log('\n๐Ÿ’ฅ Uncaught Exception:', error.message); + process.exit(1); +}); + +process.on('unhandledRejection', (reason) => { + console.log('\n๐Ÿ’ฅ Unhandled Rejection:', reason); + process.exit(1); +}); + +// Run tests +runTests().catch(error => { + console.log('\n๐Ÿ’ฅ Test execution failed:', error.message); + process.exit(1); +});