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": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
"firebase-admin": "^12.2.0",
|
"firebase-admin": "^12.2.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
|
|
||||||
const requiredEnvVars = ['FIREBASE_PROJECT_ID'] as const
|
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 RequiredEnvVar = (typeof requiredEnvVars)[number]
|
||||||
type RecommendedEnvVar = (typeof recommendedEnvVars)[number]
|
type RecommendedEnvVar = (typeof recommendedEnvVars)[number]
|
||||||
|
|
@ -63,3 +70,21 @@ export function getEnvValue(varName: string): string {
|
||||||
const value = process.env[varName]
|
const value = process.env[varName]
|
||||||
return value ?? ''
|
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 { initFirebase } from './config/firebase'
|
||||||
import { startAnswerListener } from './listeners/answerListener'
|
import { startAnswerListener } from './listeners/answerListener'
|
||||||
import { validateEnv } from './config/env'
|
import { validateEnv } from './config/env'
|
||||||
|
import { webhookLimiter, healthLimiter } from './middleware/rateLimiter'
|
||||||
import healthRouter from './routes/health'
|
import healthRouter from './routes/health'
|
||||||
import webhooksRouter from './routes/webhooks'
|
import webhooksRouter from './routes/webhooks'
|
||||||
|
|
||||||
|
|
@ -32,6 +33,10 @@ app.use(express.json({
|
||||||
app.use(helmet())
|
app.use(helmet())
|
||||||
app.use(morgan('combined'))
|
app.use(morgan('combined'))
|
||||||
|
|
||||||
|
// Rate limiting middleware (applied before routes)
|
||||||
|
app.use('/webhooks', webhookLimiter)
|
||||||
|
app.use('/health', healthLimiter)
|
||||||
|
|
||||||
app.use('/health', healthRouter)
|
app.use('/health', healthRouter)
|
||||||
app.use('/webhooks', webhooksRouter)
|
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