security: add express-rate-limit — webhook 10/min, health 30/min, default 60/min, configurable via env, localhost skip

This commit is contained in:
null 2026-06-16 21:56:32 -05:00
parent ae1087b0aa
commit b6e7a3e9cf
5 changed files with 3676 additions and 1 deletions

3585
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"express": "^4.19.2",
"express-rate-limit": "^7.5.0",
"firebase-admin": "^12.2.0",
"helmet": "^7.1.0",
"morgan": "^1.10.0",

View File

@ -1,7 +1,14 @@
import 'dotenv/config'
const requiredEnvVars = ['FIREBASE_PROJECT_ID'] as const
const recommendedEnvVars = ['REVENUECAT_WEBHOOK_SECRET', 'REVENUECAT_SIGNING_KEY', 'REVENUECAT_PREMIUM_PRODUCT_IDS'] as const
const recommendedEnvVars = [
'REVENUECAT_WEBHOOK_SECRET',
'REVENUECAT_SIGNING_KEY',
'REVENUECAT_PREMIUM_PRODUCT_IDS',
'RATE_LIMIT_WEBHOOK',
'RATE_LIMIT_HEALTH',
'RATE_LIMIT_DEFAULT',
] as const
type RequiredEnvVar = (typeof requiredEnvVars)[number]
type RecommendedEnvVar = (typeof recommendedEnvVars)[number]
@ -63,3 +70,21 @@ export function getEnvValue(varName: string): string {
const value = process.env[varName]
return value ?? ''
}
/**
* Retrieves a rate limit value from env var, with default fallback.
* Warns if env var is missing and uses default.
*/
export function getRateLimit(varName: string, defaultValue: number): number {
const value = process.env[varName]
if (!value) {
console.warn(`[env] ${varName} not set, using default: ${defaultValue} requests/min`)
return defaultValue
}
const parsed = parseInt(value, 10)
if (isNaN(parsed) || parsed <= 0) {
console.warn(`[env] ${varName} has invalid value '${value}', using default: ${defaultValue} requests/min`)
return defaultValue
}
return parsed
}

View File

@ -5,6 +5,7 @@ import morgan from 'morgan'
import { initFirebase } from './config/firebase'
import { startAnswerListener } from './listeners/answerListener'
import { validateEnv } from './config/env'
import { webhookLimiter, healthLimiter } from './middleware/rateLimiter'
import healthRouter from './routes/health'
import webhooksRouter from './routes/webhooks'
@ -32,6 +33,10 @@ app.use(express.json({
app.use(helmet())
app.use(morgan('combined'))
// Rate limiting middleware (applied before routes)
app.use('/webhooks', webhookLimiter)
app.use('/health', healthLimiter)
app.use('/health', healthRouter)
app.use('/webhooks', webhooksRouter)

View File

@ -0,0 +1,59 @@
import rateLimit from 'express-rate-limit'
import { getRateLimit } from '../config/env'
/**
* Skip rate limiting for successful requests from localhost (development)
*/
const skipLocalhost = (req: any): boolean => {
return req.socket?.remoteAddress?.includes('127.0.0.1') ||
req.socket?.remoteAddress?.includes('::1') ||
req.ip?.includes('127.0.0.1') ||
req.ip?.includes('::1')
}
// Get rate limit values from env vars (with defaults)
const WEBHOOK_LIMIT = getRateLimit('RATE_LIMIT_WEBHOOK', 10)
const HEALTH_LIMIT = getRateLimit('RATE_LIMIT_HEALTH', 30)
const DEFAULT_LIMIT = getRateLimit('RATE_LIMIT_DEFAULT', 60)
/**
* Webhook limiter: 10 requests per minute per IP (configurable via RATE_LIMIT_WEBHOOK)
* RevenueCat retries are spaced minutes apart, so this is generous
*/
export const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: WEBHOOK_LIMIT,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
keyGenerator: (req) => req.ip || req.socket?.remoteAddress || 'unknown',
skip: (req) => skipLocalhost(req),
})
/**
* Health limiter: 30 requests per minute per IP (configurable via RATE_LIMIT_HEALTH)
* Allows frequent health checks without abuse
*/
export const healthLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: HEALTH_LIMIT,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
keyGenerator: (req) => req.ip || req.socket?.remoteAddress || 'unknown',
skip: (req) => skipLocalhost(req),
})
/**
* Default/API limiter: 60 requests per minute per IP (configurable via RATE_LIMIT_DEFAULT)
* For future authenticated routes
*/
export const defaultLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: DEFAULT_LIMIT,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
keyGenerator: (req) => req.ip || req.socket?.remoteAddress || 'unknown',
skip: (req) => skipLocalhost(req),
})