security: fail-closed webhook auth, constant-time secret comparison, centralized env validation
This commit is contained in:
parent
e8274370d1
commit
f45f8dd114
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue