From f45f8dd1140af268426b1554b3fa5cd14297d6cb Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 21:37:57 -0500 Subject: [PATCH] security: fail-closed webhook auth, constant-time secret comparison, centralized env validation --- server/src/config/env.ts | 37 +++++++++++++++++++++++++++++++++++ server/src/index.ts | 10 ++++++++++ server/src/routes/webhooks.ts | 24 +++++++++++++++++------ 3 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 server/src/config/env.ts diff --git a/server/src/config/env.ts b/server/src/config/env.ts new file mode 100644 index 00000000..7618ec57 --- /dev/null +++ b/server/src/config/env.ts @@ -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 +} diff --git a/server/src/index.ts b/server/src/index.ts index 9bcdb851..543c4a06 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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() diff --git a/server/src/routes/webhooks.ts b/server/src/routes/webhooks.ts index 67f249ec..d0489eff 100644 --- a/server/src/routes/webhooks.ts +++ b/server/src/routes/webhooks.ts @@ -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) => {