diff --git a/.env.example b/.env.example index ef2bae2b..5a8c3091 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,14 @@ 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 for upstreams. +SENDBLUE_SIGNING_SECRET= # ---- Agent runtime ---- # claude = Claude Agent SDK / Claude Code path. codex = local `codex app-server` diff --git a/server/index.ts b/server/index.ts index 6311f0c2..4f9c5480 100644 --- a/server/index.ts +++ b/server/index.ts @@ -55,6 +55,21 @@ async function main() { const app = express(); app.use(cors()); + 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.", + ); + } // Composio webhook receiver must read raw bytes for HMAC verification, so // its body parser is mounted BEFORE the global express.json. Without this // ordering the JSON parser consumes the stream first and the raw buffer diff --git a/server/sendblue.ts b/server/sendblue.ts index 7469bde5..0b901aae 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"; @@ -8,6 +9,141 @@ import { validateImageHeader, MAX_IMAGE_BYTES, type ImageMediaType } from "./ima 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, ""); + // 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, "/"); + const decoded = Buffer.from(normalized, "base64"); + if (decoded.length === 32) return decoded; + 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 { + // 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"; +} + +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; + + 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( + `[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, "")) @@ -202,6 +338,9 @@ export async function ingestSendblueImage( export function createSendblueRouter(): express.Router { const router = express.Router(); + router.post("/webhook", sendblueWebhookAuth, async (req, res) => { + const { content, from_number, is_outbound, message_handle } = req.body ?? {}; + if (is_outbound || !content || !from_number) { router.post("/webhook", async (req, res) => { const { content, from_number, is_outbound, message_handle, media_url, media_urls } = req.body ?? {};