From 048282648f0d5dae496a8eb17ed96ea3354724f0 Mon Sep 17 00:00:00 2001 From: otobongdev Date: Mon, 5 Oct 2026 13:00:06 +0000 Subject: [PATCH] fix(webhooks): gate test-only /deliver/:developerId route behind NODE_ENV === 'test' --- ARCHITECTURE.md | 2 +- docs/webhooks.md | 18 +++++++-------- examples/webhook-receiver.ts | 42 ++++++++++++++++++++++++++++++++++ src/routes/webhooks.ts | 11 ++++++--- src/webhooks/webhook.routes.ts | 11 ++++++--- 5 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 examples/webhook-receiver.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 39507967..2d0cbad9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,7 +70,7 @@ available through one entrypoint and absent from the other. Rows marked | POST | /api/webhooks/:developerId/rotate-secret | src/routes/webhooks.ts | route-specific | webhook management limiter | | DELETE | /api/webhooks/:developerId | src/routes/webhooks.ts | route-specific | webhook management limiter | | PATCH | /api/webhooks/:developerId/retry-policy | src/routes/webhooks.ts | route-specific | webhook management limiter | -| POST | /api/webhooks/deliver/:developerId | src/routes/webhooks.ts | route-specific | webhook management limiter | +| POST | /api/webhooks/deliver/:developerId | src/routes/webhooks.ts (registered only when `NODE_ENV === 'test'`; 404 in production) | route-specific | webhook management limiter | | GET | /api/health/db/ | src/routes/health.ts | route-specific | none | | GET | /api/health/health/ | src/routes/health.ts | route-specific | none | | GET | /api/plans/ | src/routes/plans.ts | route-specific | none | diff --git a/docs/webhooks.md b/docs/webhooks.md index 1ab20acf..45a14f14 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -288,12 +288,12 @@ idempotency for replay defense, and treat X-Callora-Timestamp as advisory metadata only. A future change will be versioned and documented here before receivers are required to adopt it. -Inbound deliver route (platform-internal) -POST /api/webhooks/deliver/:developerId uses a separate scheme -(X-Callora-Signature-256 over .. with nonce -replay checks in src/webhooks/webhook.signature.ts). That path authenticates -traffic into Callora. Third-party endpoints that receive Callora events -must implement the outbound contract above, not the inbound one. +Inbound deliver example (test-only) +POST /api/webhooks/deliver/:developerId is a test fixture and is only +registered when NODE_ENV === 'test'; it returns 404 in production builds. +For a runnable receiver that verifies Callora deliveries, see +examples/webhook-receiver.ts. Third-party endpoints that receive Callora +events must implement the outbound contract above, not the inbound scheme. Retry Policy Failed deliveries (non-2xx, timeout, DNS failure) are retried with exponential backoff: @@ -333,9 +333,9 @@ PATCH /api/webhooks/:developerId/retry-policy Update retry policy DELETE /api/webhooks/:developerId Remove webhook Rate Limiting The webhook management endpoints (POST /, GET /:developerId, DELETE /:developerId) are -protected by an IP-based rate limiter. The signed inbound delivery route -(POST /deliver/:developerId) is not rate-limited here because it is -protected independently by HMAC signature verification. +protected by an IP-based rate limiter. The signed inbound delivery example +(POST /deliver/:developerId) is test-only and not registered in production, +so it is not rate-limited here. Env variable Default (fallback) Description WEBHOOK_RATE_LIMIT_WINDOW_MS REST_RATE_LIMIT_WINDOW_MS (60 000) Window length in milliseconds diff --git a/examples/webhook-receiver.ts b/examples/webhook-receiver.ts new file mode 100644 index 00000000..fac4eb3b --- /dev/null +++ b/examples/webhook-receiver.ts @@ -0,0 +1,42 @@ +/** + * Example: a receiver endpoint that verifies inbound Callora webhook + * deliveries using the same HMAC-SHA256 scheme as src/webhooks/webhook.signature.ts. + * + * The platform no longer ships a production route for this — see + * docs/webhooks.md. Run locally with: + * + * npx tsx examples/webhook-receiver.ts + */ +import express, { Request, Response, NextFunction } from 'express'; +import { + captureRawBody, + verifyWebhookSignature, + parseCapturedJson, +} from '../src/webhooks/webhook.signature.js'; + +const app = express(); +const PORT = Number(process.env.PORT ?? 4100); + +// In a real deployment, look the secret up from your subscription record +// instead of a hard-coded constant. +const WEBHOOK_SECRETS = (process.env.WEBHOOK_SECRETS ?? 'example-secret').split(','); + +app.post( + '/webhooks', + captureRawBody, + (req: Request & { webhookSecrets?: string[] }, _res: Response, next: NextFunction) => { + req.webhookSecrets = WEBHOOK_SECRETS; + next(); + }, + verifyWebhookSignature, + parseCapturedJson, + (req: Request, res: Response) => { + const event = (req.body as { event?: string })?.event ?? 'unknown'; + console.log('[webhook-receiver] verified delivery:', event); + res.status(200).json({ message: 'Webhook delivery accepted.', event }); + }, +); + +app.listen(PORT, () => { + console.log(`webhook-receiver listening on http://localhost:${PORT}/webhooks`); +}); diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 582abfd4..6ea11b02 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -417,8 +417,12 @@ router.patch('/:developerId/retry-policy', webhookMgmtRateLimit, express.json(), } }); -router.post( - '/deliver/:developerId', +// POST /api/webhooks/deliver/:developerId is a test fixture only. +// It must never be reachable in production: it acts as a signature oracle +// and reflects request bodies. Register it only when NODE_ENV === 'test'. +if (process.env.NODE_ENV === 'test') { + router.post( + '/deliver/:developerId', captureRawBody, (req: Request & { webhookSecrets?: string[] }, res: Response, next) => { const config = WebhookStore.get(req.params.developerId); @@ -437,7 +441,8 @@ router.post( (req: Request, res: Response) => { return res.status(200).json({ message: 'Webhook delivery accepted.', body: req.body }); } -); + ); +} export function createWebhooksRouter(): Router { return router; diff --git a/src/webhooks/webhook.routes.ts b/src/webhooks/webhook.routes.ts index 8da38df5..f6f84684 100644 --- a/src/webhooks/webhook.routes.ts +++ b/src/webhooks/webhook.routes.ts @@ -234,8 +234,12 @@ router.patch('/:developerId/retry-policy', webhookMgmtRateLimit, express.json(), * 3. verifyWebhookSignature — enforces HMAC + replay-window check * 4. express.json() — parses the verified body for the handler */ -router.post( - '/deliver/:developerId', +// Test fixture only: never mounted in production. In non-test environments +// this route is not registered, so requests fall through to a 404 and no +// request body is ever echoed back. +if (process.env.NODE_ENV === 'test') { + router.post( + '/deliver/:developerId', validate({ params: webhookDeveloperParamsSchema }), captureRawBody, // Attach the stored secret so verifyWebhookSignature can read it @@ -258,6 +262,7 @@ router.post( // Payload has been verified — safe to process return res.status(200).json({ message: 'Webhook delivery accepted.', body: req.body }); } -); + ); +} export default router;