diff --git a/backend/src/api/controllers/webhooks.ts b/backend/src/api/controllers/webhooks.ts index 7457a469..ba13fe8f 100644 --- a/backend/src/api/controllers/webhooks.ts +++ b/backend/src/api/controllers/webhooks.ts @@ -1,6 +1,8 @@ import type { Request, Response, NextFunction } from "express"; import { query } from "../../db/index.js"; -import { validateWebhookUrl } from "../../services/notifications.js"; +import { validateWebhookUrl, NotificationService } from "../../services/notifications.js"; + +const notificationService = new NotificationService(); interface WebhookRow { id: number; @@ -8,10 +10,18 @@ interface WebhookRow { events: string[]; active: boolean; created_at: Date; + consecutive_failures: number; } function formatWebhook(w: WebhookRow) { - return { id: w.id, url: w.url, events: w.events, active: w.active, createdAt: w.created_at }; + return { + id: w.id, + url: w.url, + events: w.events, + active: w.active, + createdAt: w.created_at, + consecutiveFailures: w.consecutive_failures ?? 0, + }; } export async function createWebhook(req: Request, res: Response, next: NextFunction) { @@ -28,7 +38,7 @@ export async function createWebhook(req: Request, res: Response, next: NextFunct const rows = await query( `INSERT INTO webhooks (url, events, secret) VALUES ($1, $2, $3) - RETURNING id, url, events, active, created_at`, + RETURNING id, url, events, active, created_at, consecutive_failures`, [url, events, secret ?? null], ); @@ -41,7 +51,7 @@ export async function createWebhook(req: Request, res: Response, next: NextFunct export async function listWebhooks(_req: Request, res: Response, next: NextFunction) { try { const rows = await query( - "SELECT id, url, events, active, created_at FROM webhooks WHERE active = TRUE ORDER BY created_at DESC", + "SELECT id, url, events, active, created_at, consecutive_failures FROM webhooks WHERE active = TRUE ORDER BY created_at DESC", ); res.json(rows.map(formatWebhook)); @@ -69,3 +79,60 @@ export async function deleteWebhook(req: Request, res: Response, next: NextFunct next(err); } } + +/** + * POST /api/v1/admin/webhooks/:id/test + * Sends a test ping to the webhook URL and returns delivery metadata. + * Issue #666. + */ +export async function testWebhook(req: Request, res: Response, next: NextFunction) { + try { + const id = parseInt(req.params["id"] as string, 10); + if (isNaN(id)) { + res.status(400).json({ error: "InvalidId", message: "Webhook ID must be a positive integer" }); + return; + } + + const rows = await query( + "SELECT id, url, events, active, created_at, consecutive_failures, secret FROM webhooks WHERE id = $1", + [id], + ); + + if (rows.length === 0) { + res.status(404).json({ error: "NotFound", message: "Webhook not found" }); + return; + } + + const webhook = rows[0] as WebhookRow & { secret: string | null }; + const result = await notificationService.testDeliver(webhook); + + res.json(result); + } catch (err) { + next(err); + } +} + +/** + * POST /api/v1/webhooks/verify-signature + * Verifies an HMAC-SHA256 webhook signature. + * Issue #664. + */ +export async function verifyWebhookSignature(req: Request, res: Response, next: NextFunction) { + try { + const { payload, signature, secret } = req.body as { + payload: string; + signature: string; + secret: string; + }; + + if (typeof payload !== "string" || typeof signature !== "string" || typeof secret !== "string") { + res.status(400).json({ error: "BadRequest", message: "payload, signature, and secret are required strings" }); + return; + } + + const valid = notificationService.verifySignature(payload, signature, secret); + res.json({ valid }); + } catch (err) { + next(err); + } +} diff --git a/backend/src/api/routes/webhooks.ts b/backend/src/api/routes/webhooks.ts index 20600474..9f305d0e 100644 --- a/backend/src/api/routes/webhooks.ts +++ b/backend/src/api/routes/webhooks.ts @@ -1,6 +1,12 @@ import { Router } from "express"; import { z } from "zod"; -import { createWebhook, listWebhooks, deleteWebhook } from "../controllers/webhooks.js"; +import { + createWebhook, + listWebhooks, + deleteWebhook, + testWebhook, + verifyWebhookSignature, +} from "../controllers/webhooks.js"; import { requireApiKey } from "../middleware/auth.js"; import { validateBody, validateParams } from "../middleware/validate.js"; @@ -32,6 +38,13 @@ const webhookParamsSchema = z.object({ id: z.string().regex(/^\d+$/, "ID must be a positive integer"), }); +/** Schema for POST /webhooks/verify-signature (#664) */ +const verifySignatureSchema = z.object({ + payload: z.string(), + signature: z.string(), + secret: z.string(), +}); + export const webhooksRouter = Router(); webhooksRouter.use(requireApiKey()); @@ -39,3 +52,13 @@ webhooksRouter.use(requireApiKey()); webhooksRouter.post("/", validateBody(createWebhookSchema), createWebhook); webhooksRouter.get("/", listWebhooks); webhooksRouter.delete("/:id", validateParams(webhookParamsSchema), deleteWebhook); + +/** POST /webhooks/verify-signature — verify HMAC signature (#664) */ +webhooksRouter.post( + "/verify-signature", + validateBody(verifySignatureSchema), + verifyWebhookSignature, +); + +/** POST /admin/webhooks/:id/test — send test ping (#666) */ +webhooksRouter.post("/:id/test", validateParams(webhookParamsSchema), testWebhook); diff --git a/backend/src/db/migrations/023_webhook_consecutive_failures.sql b/backend/src/db/migrations/023_webhook_consecutive_failures.sql new file mode 100644 index 00000000..625eef0e --- /dev/null +++ b/backend/src/db/migrations/023_webhook_consecutive_failures.sql @@ -0,0 +1,5 @@ +-- Migration 023: Add consecutive_failures tracking to webhooks table +-- Required for auto-deactivation after sustained delivery failures (issue #667) + +ALTER TABLE webhooks + ADD COLUMN IF NOT EXISTS consecutive_failures INT NOT NULL DEFAULT 0; diff --git a/backend/src/services/notifications-webhook.test.ts b/backend/src/services/notifications-webhook.test.ts new file mode 100644 index 00000000..11f4daba --- /dev/null +++ b/backend/src/services/notifications-webhook.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NotificationService } from "./notifications.js"; + +// Mock the db query function +vi.mock("../db/index.js", () => ({ + query: vi.fn(), +})); + +// Mock logger +vi.mock("../logger.js", () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); + +import { query } from "../db/index.js"; + +const mockQuery = query as ReturnType; + +describe("NotificationService.verifySignature (#664)", () => { + const svc = new NotificationService(); + + it("returns true for a correctly signed payload", () => { + const secret = "my-secret"; + const payload = '{"event":"test"}'; + // Compute expected signature manually + const { createHmac } = require("crypto"); + const expected = `sha256=${createHmac("sha256", secret).update(payload).digest("hex")}`; + expect(svc.verifySignature(payload, expected, secret)).toBe(true); + }); + + it("returns false for a tampered payload", () => { + const secret = "my-secret"; + const originalPayload = '{"event":"test"}'; + const tamperedPayload = '{"event":"tampered"}'; + const { createHmac } = require("crypto"); + const sig = `sha256=${createHmac("sha256", secret).update(originalPayload).digest("hex")}`; + expect(svc.verifySignature(tamperedPayload, sig, secret)).toBe(false); + }); + + it("returns false for a wrong secret", () => { + const payload = '{"event":"test"}'; + const { createHmac } = require("crypto"); + const sig = `sha256=${createHmac("sha256", "correct-secret").update(payload).digest("hex")}`; + expect(svc.verifySignature(payload, sig, "wrong-secret")).toBe(false); + }); + + it("returns false when signature length differs", () => { + expect(svc.verifySignature("payload", "short", "secret")).toBe(false); + }); +}); + +describe("NotificationService.notify — consecutive_failures tracking (#667)", () => { + let svc: NotificationService; + + beforeEach(() => { + svc = new NotificationService(); + mockQuery.mockReset(); + vi.stubGlobal("fetch", vi.fn()); + }); + + it("increments consecutive_failures on delivery failure", async () => { + const webhookRow = { + id: 1, + url: "https://example.com/hook", + events: ["deposit"], + secret: null, + consecutive_failures: 0, + }; + + // SELECT webhooks returns one webhook + mockQuery.mockResolvedValueOnce([webhookRow]); + + // fetch returns non-2xx + (fetch as any).mockResolvedValueOnce({ ok: false, status: 500 } as Response); + + // Mock validateWebhookUrl to succeed (no DNS calls in unit test) + const notifMod = await import("./notifications.js"); + vi.spyOn(notifMod, "validateWebhookUrl").mockResolvedValue(undefined); + + // UPDATE consecutive_failures + mockQuery.mockResolvedValueOnce([]); + // INSERT webhook_deliveries + mockQuery.mockResolvedValueOnce([]); + + await svc.notify("deposit", { amount: 100 }); + + // The second query call should be the UPDATE for consecutive_failures = 1 + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("consecutive_failures"), + [1, 1], + ); + }); + + it("resets consecutive_failures to 0 on success", async () => { + const webhookRow = { + id: 2, + url: "https://example.com/hook", + events: ["deposit"], + secret: null, + consecutive_failures: 5, + }; + + mockQuery.mockResolvedValueOnce([webhookRow]); + + // fetch succeeds + (fetch as any).mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + const notifMod = await import("./notifications.js"); + vi.spyOn(notifMod, "validateWebhookUrl").mockResolvedValue(undefined); + + // UPDATE consecutive_failures = 0 + mockQuery.mockResolvedValueOnce([]); + + await svc.notify("deposit", { amount: 50 }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("consecutive_failures = 0"), + [2], + ); + }); + + it("auto-deactivates webhook after 10 consecutive failures", async () => { + const webhookRow = { + id: 3, + url: "https://example.com/hook", + events: ["deposit"], + secret: null, + consecutive_failures: 9, // one more will reach 10 + }; + + mockQuery.mockResolvedValueOnce([webhookRow]); + + (fetch as any).mockResolvedValueOnce({ ok: false, status: 500 } as Response); + + const notifMod = await import("./notifications.js"); + vi.spyOn(notifMod, "validateWebhookUrl").mockResolvedValue(undefined); + + // UPDATE with active = FALSE + mockQuery.mockResolvedValueOnce([]); + // INSERT webhook_deliveries + mockQuery.mockResolvedValueOnce([]); + + await svc.notify("deposit", {}); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("active = FALSE"), + [10, 3], + ); + }); +}); diff --git a/backend/src/services/notifications.ts b/backend/src/services/notifications.ts index b7cd6e5c..4fc6a9d4 100644 --- a/backend/src/services/notifications.ts +++ b/backend/src/services/notifications.ts @@ -10,6 +10,9 @@ const BLOCKED_HOSTNAMES = new Set([ "100.100.100.200", ]); +/** Number of consecutive delivery failures that trigger auto-deactivation (#667). */ +const MAX_CONSECUTIVE_FAILURES = 10; + function isPrivateIp(ip: string): boolean { const v4 = [ /^127\./, @@ -55,12 +58,13 @@ interface WebhookRow { url: string; events: string[]; secret: string | null; + consecutive_failures: number; } export class NotificationService { async notify(event: string, data: Record): Promise { const webhooks = await query( - "SELECT id, url, events, secret FROM webhooks WHERE active = TRUE AND $1 = ANY(events)", + "SELECT id, url, events, secret, consecutive_failures FROM webhooks WHERE active = TRUE AND $1 = ANY(events)", [event], ); @@ -73,13 +77,46 @@ export class NotificationService { ); for (let i = 0; i < webhooks.length; i++) { + const webhook = webhooks[i]; const result = results[i]; - if (result.status === "rejected" || (result.status === "fulfilled" && !result.value)) { + const failed = + result.status === "rejected" || (result.status === "fulfilled" && !result.value); + + if (failed) { + const newFailures = (webhook.consecutive_failures ?? 0) + 1; + if (newFailures >= MAX_CONSECUTIVE_FAILURES) { + // Auto-deactivate after threshold reached (#667) + await query( + `UPDATE webhooks SET consecutive_failures = $1, active = FALSE WHERE id = $2`, + [newFailures, webhook.id], + ); + logger.warn( + { webhookId: webhook.id, consecutiveFailures: newFailures }, + "Webhook auto-deactivated after reaching consecutive failure threshold", + ); + } else { + await query( + `UPDATE webhooks SET consecutive_failures = $1 WHERE id = $2`, + [newFailures, webhook.id], + ); + } + await query( `INSERT INTO webhook_deliveries (webhook_id, payload, attempt, next_retry_at, last_error) VALUES ($1, $2, 1, NOW() + INTERVAL '5 seconds', $3)`, - [webhooks[i].id, payload, result.status === "rejected" ? String(result.reason) : "non-2xx response"], + [ + webhook.id, + payload, + result.status === "rejected" + ? String((result as PromiseRejectedResult).reason) + : "non-2xx response", + ], ); + } else { + // Successful delivery — reset consecutive_failures counter + if ((webhook.consecutive_failures ?? 0) > 0) { + await query(`UPDATE webhooks SET consecutive_failures = 0 WHERE id = $1`, [webhook.id]); + } } } } @@ -108,7 +145,7 @@ export class NotificationService { for (const row of dueRows) { try { const webhookRows = await query( - "SELECT id, url, events, secret FROM webhooks WHERE id = $1", + "SELECT id, url, events, secret, consecutive_failures FROM webhooks WHERE id = $1", [row.webhook_id], ); if (webhookRows.length === 0) continue; @@ -120,6 +157,10 @@ export class NotificationService { "UPDATE webhook_deliveries SET delivered_at = NOW() WHERE id = $1", [row.id], ); + // Reset consecutive_failures on successful re-delivery + if ((webhook.consecutive_failures ?? 0) > 0) { + await query(`UPDATE webhooks SET consecutive_failures = 0 WHERE id = $1`, [webhook.id]); + } } else { const nextAttempt = row.attempt + 1; const delaySeconds = Math.min(Math.pow(2, row.attempt) * 5, 3600); @@ -143,6 +184,56 @@ export class NotificationService { } } + /** + * Send a test ping to a webhook endpoint (#666). + * Returns delivery result metadata: delivered, statusCode, durationMs. + */ + async testDeliver( + webhook: WebhookRow, + ): Promise<{ delivered: boolean; statusCode: number | null; durationMs: number }> { + const payload = JSON.stringify({ + event: "test", + timestamp: new Date().toISOString(), + contractId: null, + }); + + const headers: Record = { "Content-Type": "application/json" }; + if (webhook.secret) { + const signature = createHmac("sha256", webhook.secret).update(payload).digest("hex"); + headers["X-StellarYield-Signature"] = `sha256=${signature}`; + } + + const start = Date.now(); + try { + const response = await fetch(webhook.url, { + method: "POST", + headers, + body: payload, + signal: AbortSignal.timeout(5000), + redirect: "manual", + }); + const durationMs = Date.now() - start; + return { delivered: response.ok, statusCode: response.status, durationMs }; + } catch { + const durationMs = Date.now() - start; + return { delivered: false, statusCode: null, durationMs }; + } + } + + /** + * Verify an HMAC-SHA256 webhook signature (#664). + * Computes sha256=HMAC(payload, secret) and performs constant-time comparison. + */ + verifySignature(payload: string, signature: string, secret: string): boolean { + const expected = `sha256=${createHmac("sha256", secret).update(payload).digest("hex")}`; + if (expected.length !== signature.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i); + } + return diff === 0; + } + /** * Deliver a webhook payload. Returns true on success, false on failure. * Throws on network/SSRF errors.