From 1ed57c47b1af5fb4997b019ccf107742d5647df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B6=F0=9D=99=BB=20*=20=F0=9D=9A=83=F0=9D=99=B2?= =?UTF-8?q?=F0=9D=99=B7=F0=9D=99=B4=F0=9D=9A=82?= Date: Tue, 28 Apr 2026 02:03:42 -0700 Subject: [PATCH 1/2] security(webhook): add HMAC signature verification for Sendblue webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies HMAC-SHA256 signatures on POST /sendblue/webhook before any payload processing. Accepts signature in x-sendblue-signature, signature, or x-webhook-signature headers (hex or base64). Falls back to a raw shared-secret check on x-webhook-secret header / ?secret= query param for upstreams that cannot sign. All comparisons use crypto.timingSafeEqual to avoid timing oracles. When SENDBLUE_SIGNING_SECRET is unset, verification is disabled and a WARNING is logged at startup so existing local-dev users keep working. When set, missing/invalid signatures get a generic 401 and the failure is logged with the source IP only — never the payload body, never the expected signature. express.json now stashes the raw request body buffer on req.rawBody via its verify hook so HMAC can be computed against the unparsed bytes. --- .env.example | 9 +++ server/index.ts | 16 +++++- server/sendblue.ts | 137 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 0e4cc708..933ce900 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,15 @@ VITE_CONVEX_URL= SENDBLUE_API_KEY= SENDBLUE_API_SECRET= SENDBLUE_FROM_NUMBER= +# --- Security (NEW) --- +# HMAC-SHA256 secret used to verify Sendblue webhook signatures. When set, +# the /sendblue/webhook endpoint rejects requests with missing or invalid +# signatures (401). When unset, signature verification is DISABLED and a +# warning is logged at startup. Generate: `openssl rand -base64 32`. +# Configure the same value in the Sendblue dashboard webhook settings, or +# pass it as the raw secret in the `x-webhook-secret` header / `?secret=` +# query parameter for upstreams that cannot sign. +SENDBLUE_SIGNING_SECRET= # ---- Claude model ---- # Uses your Claude Code subscription automatically — no separate API key needed. diff --git a/server/index.ts b/server/index.ts index 2f3444ba..ffc646c6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,7 +23,21 @@ async function main() { const app = express(); app.use(cors()); - app.use(express.json({ limit: "2mb" })); + app.use( + express.json({ + limit: "2mb", + verify: (req, _res, buf) => { + // Stash raw body bytes for HMAC verification on signed webhook routes. + (req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf); + }, + }), + ); + + if (!process.env.SENDBLUE_SIGNING_SECRET) { + console.warn( + "[security] SENDBLUE_SIGNING_SECRET is not set — Sendblue webhook signature verification is DISABLED. Forged webhooks will be accepted. Set this env var in .env.local for production.", + ); + } app.get("/health", (_req, res) => { res.json({ ok: true, service: "boop-agent" }); diff --git a/server/sendblue.ts b/server/sendblue.ts index ec6ddf3c..cf4d616f 100644 --- a/server/sendblue.ts +++ b/server/sendblue.ts @@ -1,4 +1,5 @@ import express from "express"; +import { createHmac, timingSafeEqual } from "node:crypto"; import { api } from "../convex/_generated/api.js"; import { convex } from "./convex-client.js"; import { handleUserMessage } from "./interaction-agent.js"; @@ -7,6 +8,140 @@ import { broadcast } from "./broadcast.js"; const API_BASE = "https://api.sendblue.com/api"; const MAX_CHUNK = 2900; +// NOTE: As of 2026-04, Sendblue echoes the raw secret in `sb-signing-secret` +// rather than sending an HMAC digest. The HMAC verification path below exists +// for forward-compatibility if Sendblue (or a replacement provider) adds +// proper HMAC-SHA256 payload signing in the future. +const SIGNATURE_HEADERS = [ + "x-sendblue-signature", + "signature", + "x-webhook-signature", +]; + +// Shared-secret carriers. Sendblue currently puts the raw signing secret in +// `sb-signing-secret`; the generic alternate covers proxied upstreams that +// rename the header. Use only on trusted transport (TLS). +const SHARED_SECRET_HEADERS = [ + "sb-signing-secret", + "x-webhook-secret", +]; + +function bufferEquals(a: Buffer, b: Buffer): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +function decodeSignature(value: string): Buffer | null { + const trimmed = value.trim().replace(/^sha256=/i, ""); + if (/^[0-9a-f]+$/i.test(trimmed) && trimmed.length % 2 === 0) { + return Buffer.from(trimmed, "hex"); + } + if (/^[A-Za-z0-9+/=_-]+$/.test(trimmed)) { + const normalized = trimmed.replace(/-/g, "+").replace(/_/g, "/"); + try { + return Buffer.from(normalized, "base64"); + } catch { + return null; + } + } + return null; +} + +function verifyHmac(rawBody: Buffer, secret: string, signatureHeader: string): boolean { + const expected = createHmac("sha256", secret).update(rawBody).digest(); + const provided = decodeSignature(signatureHeader); + if (!provided) return false; + return bufferEquals(expected, provided); +} + +function verifySharedSecret(provided: string, secret: string): boolean { + // HMAC both sides so the comparison is always fixed-length (32 bytes) and + // does not leak the real secret's length via an early length-mismatch + // return path. + const a = createHmac("sha256", "webhook-verify").update(provided).digest(); + const b = createHmac("sha256", "webhook-verify").update(secret).digest(); + return timingSafeEqual(a, b); +} + +function clientIp(req: express.Request): string { + const fwd = req.headers["x-forwarded-for"]; + if (typeof fwd === "string" && fwd.length > 0) return fwd.split(",")[0]!.trim(); + if (Array.isArray(fwd) && fwd[0]) return fwd[0]; + return req.ip ?? req.socket.remoteAddress ?? "unknown"; +} + +function verifyWebhookRequest( + req: express.Request, +): express.RequestHandler | true { + const secret = process.env.SENDBLUE_SIGNING_SECRET; + if (!secret) { + // Graceful degradation — startup already warned. Allow through. + return true; + } + + const rawBody = (req as express.Request & { rawBody?: Buffer }).rawBody; + if (!rawBody) { + // No raw body captured — refuse rather than silently bypassing HMAC. + return (_req, res) => { + res.status(400).json({ error: "bad request" }); + }; + } + + for (const name of SIGNATURE_HEADERS) { + const header = req.headers[name]; + const value = Array.isArray(header) ? header[0] : header; + if (typeof value === "string" && value.length > 0) { + if (verifyHmac(rawBody, secret, value)) return true; + const ip = clientIp(req); + console.warn( + `[security] sendblue webhook signature verification FAILED (header=${name}, ip=${ip})`, + ); + return (_req, res) => { + res.status(401).json({ error: "unauthorized" }); + }; + } + } + + // Shared-secret path. Sendblue's actual delivery uses `sb-signing-secret`; + // we also accept a generic alternate for proxied upstreams. Header-only — + // query parameters leak into access logs, proxy logs, and ngrok inspection. + for (const name of SHARED_SECRET_HEADERS) { + const header = req.headers[name]; + const value = Array.isArray(header) ? header[0] : header; + if (typeof value === "string" && value.length > 0) { + if (verifySharedSecret(value, secret)) return true; + const ip = clientIp(req); + console.warn( + `[security] sendblue webhook shared-secret verification FAILED (header=${name}, ip=${ip})`, + ); + return (_req, res) => { + res.status(401).json({ error: "unauthorized" }); + }; + } + } + + const ip = clientIp(req); + console.warn( + `[security] sendblue webhook missing signature (ip=${ip})`, + ); + return (_req, res) => { + res.status(401).json({ error: "unauthorized" }); + }; +} + +function sendblueWebhookAuth( + req: express.Request, + res: express.Response, + next: express.NextFunction, +): void { + const result = verifyWebhookRequest(req); + if (result === true) { + next(); + return; + } + result(req, res, next); +} + function stripMarkdown(text: string): string { return text .replace(/```[\s\S]*?```/g, (m) => m.replace(/```\w*\n?|```/g, "")) @@ -122,7 +257,7 @@ export function startTypingLoop(toNumber: string): () => void { export function createSendblueRouter(): express.Router { const router = express.Router(); - router.post("/webhook", async (req, res) => { + router.post("/webhook", sendblueWebhookAuth, async (req, res) => { const { content, from_number, is_outbound, message_handle } = req.body ?? {}; if (is_outbound || !content || !from_number) { res.json({ ok: true, skipped: true }); From d80f5303e41b4713915641118f4df0fe01622c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B6=F0=9D=99=BB=20*=20=F0=9D=9A=83=F0=9D=99=B2?= =?UTF-8?q?=F0=9D=99=B7=F0=9D=99=B4=F0=9D=9A=82?= Date: Tue, 28 Apr 2026 02:36:46 -0700 Subject: [PATCH 2/2] security(webhook): fix four review findings on signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: drop stale ?secret= reference in .env.example. The query parameter fallback was already removed from the code path; the comment lingered. P2: clientIp no longer trusts X-Forwarded-For. The previous implementation read the header unconditionally, letting any attacker spoof the source IP in security logs. Now uses req.ip — Express resolves that via its trust proxy setting, so without explicit configuration X-Forwarded-For is ignored and the socket address is used. P2: move the rawBody guard inside the SIGNATURE_HEADERS loop. The check previously ran before the shared-secret path, so a request with a valid sb-signing-secret header but a content-type that express.json skips (e.g. text/plain) got a 400 instead of being verified via the shared secret. The HMAC path is the only one that needs raw bytes. P2: drop dead try/catch in decodeSignature. Buffer.from(str, "base64") silently ignores invalid characters and never throws. Replaced with an explicit length check requiring exactly 32 bytes (SHA-256 digest length) on both hex and base64 branches. --- .env.example | 3 +-- server/sendblue.ts | 31 ++++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 933ce900..fddf0d55 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,7 @@ SENDBLUE_FROM_NUMBER= # signatures (401). When unset, signature verification is DISABLED and a # warning is logged at startup. Generate: `openssl rand -base64 32`. # Configure the same value in the Sendblue dashboard webhook settings, or -# pass it as the raw secret in the `x-webhook-secret` header / `?secret=` -# query parameter for upstreams that cannot sign. +# pass it as the raw secret in the `x-webhook-secret` header for upstreams. SENDBLUE_SIGNING_SECRET= # ---- Claude model ---- diff --git a/server/sendblue.ts b/server/sendblue.ts index cf4d616f..a8eae060 100644 --- a/server/sendblue.ts +++ b/server/sendblue.ts @@ -33,17 +33,18 @@ function bufferEquals(a: Buffer, b: Buffer): boolean { function decodeSignature(value: string): Buffer | null { const trimmed = value.trim().replace(/^sha256=/i, ""); - if (/^[0-9a-f]+$/i.test(trimmed) && trimmed.length % 2 === 0) { + // Hex-encoded: 64 hex chars = 32 bytes + if (/^[0-9a-f]+$/i.test(trimmed) && trimmed.length === 64) { return Buffer.from(trimmed, "hex"); } + // Base64-encoded: decode and verify exactly 32 bytes if (/^[A-Za-z0-9+/=_-]+$/.test(trimmed)) { const normalized = trimmed.replace(/-/g, "+").replace(/_/g, "/"); - try { - return Buffer.from(normalized, "base64"); - } catch { - return null; - } + const decoded = Buffer.from(normalized, "base64"); + if (decoded.length === 32) return decoded; + return null; } + return null; } @@ -64,9 +65,9 @@ function verifySharedSecret(provided: string, secret: string): boolean { } function clientIp(req: express.Request): string { - const fwd = req.headers["x-forwarded-for"]; - if (typeof fwd === "string" && fwd.length > 0) return fwd.split(",")[0]!.trim(); - if (Array.isArray(fwd) && fwd[0]) return fwd[0]; + // Use req.ip which respects Express's `trust proxy` setting. + // When trust proxy is not configured, req.ip returns the socket address + // and ignores X-Forwarded-For, preventing log spoofing. return req.ip ?? req.socket.remoteAddress ?? "unknown"; } @@ -80,17 +81,17 @@ function verifyWebhookRequest( } const rawBody = (req as express.Request & { rawBody?: Buffer }).rawBody; - if (!rawBody) { - // No raw body captured — refuse rather than silently bypassing HMAC. - return (_req, res) => { - res.status(400).json({ error: "bad request" }); - }; - } for (const name of SIGNATURE_HEADERS) { const header = req.headers[name]; const value = Array.isArray(header) ? header[0] : header; if (typeof value === "string" && value.length > 0) { + if (!rawBody) { + // HMAC header is present but no raw body to verify against — refuse. + return (_req, res) => { + res.status(400).json({ error: "bad request" }); + }; + } if (verifyHmac(rawBody, secret, value)) return true; const ip = clientIp(req); console.warn(