security: add express-rate-limit — webhook 10/min, health 30/min, default 60/min, configurable via env, localhost skip
This commit is contained in:
parent
ae1087b0aa
commit
b6e7a3e9cf
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
Loading…
Reference in New Issue