security: fail-closed webhook auth, constant-time secret comparison, centralized env validation

This commit is contained in:
null 2026-06-16 21:37:57 -05:00
parent e8274370d1
commit f45f8dd114
3 changed files with 65 additions and 6 deletions

37
server/src/config/env.ts Normal file
View File

@ -0,0 +1,37 @@
import 'dotenv/config'
const requiredEnvVars = ['REVENUECAT_WEBHOOK_SECRET', 'FIREBASE_PROJECT_ID'] as const
type RequiredEnvVar = (typeof requiredEnvVars)[number]
/**
* Validates that all required environment variables are set.
* Throws an error with details if any are missing.
*/
export function validateEnv(): void {
const missing: RequiredEnvVar[] = []
for (const varName of requiredEnvVars) {
const value = process.env[varName]
if (!value || value.trim() === '') {
missing.push(varName)
}
}
if (missing.length > 0) {
const message = missing.map(v => ` - ${v}`).join('\n')
throw new Error(`Missing required environment variables:\n${message}`)
}
}
/**
* Safely retrieves a required environment variable.
* Throws if not set (after validateEnv has been called).
*/
export function getEnv(varName: RequiredEnvVar): string {
const value = process.env[varName]
if (!value) {
throw new Error(`Environment variable ${varName} is not set`)
}
return value
}

View File

@ -4,9 +4,19 @@ import helmet from 'helmet'
import morgan from 'morgan'
import { initFirebase } from './config/firebase'
import { startAnswerListener } from './listeners/answerListener'
import { validateEnv } from './config/env'
import healthRouter from './routes/health'
import webhooksRouter from './routes/webhooks'
// Validate required env vars before starting
try {
validateEnv()
} catch (err) {
console.error('[startup] Env validation failed:')
console.error(err)
process.exit(1)
}
initFirebase()
const app = express()

View File

@ -1,17 +1,29 @@
import { Router, Request, Response } from 'express'
import { syncEntitlement } from '../services/entitlement'
import { RevenueCatEvent } from '../types'
import { getEnv } from '../config/env'
import * as crypto from 'crypto'
const router = Router()
/**
* Verifies RevenueCat webhook signature using constant-time comparison.
* Fails closed: returns false if secret is missing or signature doesn't match.
*/
function verifyRevenueCatSecret(req: Request): boolean {
const secret = process.env.REVENUECAT_WEBHOOK_SECRET
if (!secret) {
console.warn('[webhook] REVENUECAT_WEBHOOK_SECRET not set — skipping auth check')
return true
}
const secret = getEnv('REVENUECAT_WEBHOOK_SECRET')
const auth = req.headers['authorization'] ?? ''
return auth === secret
// Constant-time comparison to prevent timing attacks
const authBuffer = Buffer.from(auth)
const secretBuffer = Buffer.from(secret)
if (authBuffer.length !== secretBuffer.length) {
return false
}
return crypto.timingSafeEqual(authBuffer, secretBuffer)
}
router.post('/revenuecat', async (req: Request, res: Response) => {