-
Notifications
You must be signed in to change notification settings - Fork 202
security(webhook): add HMAC signature verification for Sendblue webhooks #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
1ed57c4
d80f530
810e86a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| }, | ||
| }), | ||
| ); | ||
|
Comment on lines
+58
to
+66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new The fix is to add the |
||
|
|
||
| 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" }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| 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"; | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| 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" }); | ||
| }; | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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) { | ||
|
Comment on lines
+341
to
+343
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The three added lines open a new The fix is to replace the old handler wholesale: add |
||
| res.json({ ok: true, skipped: true }); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.