Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
15 changes: 15 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
}),
);
Comment on lines +58 to +66
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New global express.json placed before Composio's express.raw, breaking Composio HMAC

The new express.json middleware (lines 58-66) is mounted before the Composio-specific express.raw at line 77. Express body-parsers set req._body = true on first parse and skip on all subsequent parsers, so the stream is fully consumed at line 58. When the Composio route handler runs, Buffer.isBuffer(req.body) at composio-routes.ts:190 returns false (the body is now a JS object, not a Buffer), rawBody becomes "", and every Composio webhook request fails HMAC verification with a rejected payload.

The fix is to add the verify hook to the existing express.json call at line 78 (after the Composio raw handler) rather than inserting a second, globally-ordered one before it.


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
Expand Down
139 changes: 139 additions & 0 deletions server/sendblue.ts
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";
Expand All @@ -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";
}
Comment thread
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;

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, ""))
Expand Down Expand Up @@ -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) {
Comment on lines +341 to +343
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Incomplete route handler replacement — syntax error, file cannot compile

The three added lines open a new router.post handler and an if block that are never closed. The original handler body (lines 344–418) is syntactically inside that if block; after its }); on line 418 the if block and outer async handler remain open, and the single } on line 421 that closes createSendblueRouter is the only remaining closing brace — leaving two unmatched { tokens. TypeScript will reject this file entirely.

The fix is to replace the old handler wholesale: add sendblueWebhookAuth as the second argument of the existing router.post, rather than prepending a new, incomplete one.

router.post("/webhook", async (req, res) => {
const { content, from_number, is_outbound, message_handle, media_url, media_urls } =
req.body ?? {};
Expand Down