diff --git a/packages/connect-protocol/src/index.test.ts b/packages/connect-protocol/src/index.test.ts index 743edfba12e..55f46278b4e 100644 --- a/packages/connect-protocol/src/index.test.ts +++ b/packages/connect-protocol/src/index.test.ts @@ -1,4 +1,4 @@ -import { generateKeyPairSync, randomBytes } from "node:crypto"; +import { createHmac, generateKeyPairSync, randomBytes } from "node:crypto"; import { describe, expect, it } from "vitest"; import { ConnectProtocolError, @@ -7,6 +7,7 @@ import { relayEnvelopeSchema, signRequestEnvelope, verifyRelaySignature, + verifyProviderWebhook, verifyRequestEnvelope, type ReplayStore, } from "./index.js"; @@ -76,6 +77,27 @@ describe("connect protocol", () => { })).toBe(false); }); + it("verifies the pinned webhook profile", () => { + const rawBody = Buffer.from('{"type":"deployment.created"}'); + const secret = "vercel-webhook-secret"; + const signature = createRelaySignature({ body: rawBody, relaySecret: Buffer.from(secret) }).slice(3); + expect(verifyProviderWebhook({ + profile: { profile: "vercel@1", scheme: "hmac-sha256", signatureHeader: "x-vercel-signature", encoding: "hex", prefix: "" }, + rawBody, + headers: { "X-Vercel-Signature": signature }, + secret, + })).toBe("verified"); + }); + + it("rejects webhook signature downgrade attempts", () => { + const rawBody = Buffer.from("payload"); + const secret = "secret"; + const sha1 = createHmac("sha1", secret).update(rawBody).digest("hex"); + const profile = { profile: "provider@1", scheme: "hmac-sha256" as const, signatureHeader: "x-signature", encoding: "hex" as const, prefix: "" }; + expect(verifyProviderWebhook({ profile, rawBody, headers: {}, secret })).toBe("rejected"); + expect(verifyProviderWebhook({ profile, rawBody, headers: { "x-signature": sha1, "x-paperclip-verifier-profile": "none" }, secret })).toBe("rejected"); + }); + it("verifies an EdDSA request once and rejects its replay", async () => { const { privateKey, publicKey } = generateKeyPairSync("ed25519"); const store = replayStore(); diff --git a/packages/connect-protocol/src/index.ts b/packages/connect-protocol/src/index.ts index 0931df92dae..f4f85cfa105 100644 --- a/packages/connect-protocol/src/index.ts +++ b/packages/connect-protocol/src/index.ts @@ -84,6 +84,47 @@ export const relayEnvelopeSchema = z.object({ export type RelayEnvelope = z.infer; +export const webhookVerifierProfileSchema = z.object({ + profile: z.string().min(1), + scheme: z.enum(["hmac-sha256", "hmac-sha1", "static-token", "none"]), + signatureHeader: z.string().min(1).transform((value) => value.toLowerCase()), + encoding: z.enum(["hex", "base64"]).default("hex"), + prefix: z.string().default(""), +}); + +export type WebhookVerifierProfile = z.infer; + +function constantTimeEqual(expected: string, actual: string): boolean { + const expectedBytes = Buffer.from(expected, "utf8"); + const actualBytes = Buffer.from(actual, "utf8"); + return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes); +} + +export function verifyProviderWebhook(input: { + profile: WebhookVerifierProfile; + rawBody: Uint8Array; + headers: Record; + secret?: Uint8Array | string; +}): "verified" | "unsigned" | "rejected" { + const profile = webhookVerifierProfileSchema.parse(input.profile); + if (profile.scheme === "none") return "unsigned"; + if (input.secret === undefined) return "rejected"; + + const headers = new Map(Object.entries(input.headers).map(([key, value]) => [key.toLowerCase(), value])); + const headerValue = headers.get(profile.signatureHeader); + const actual = Array.isArray(headerValue) ? headerValue[0] : headerValue; + if (!actual) return "rejected"; + + if (profile.scheme === "static-token") { + const expected = typeof input.secret === "string" ? input.secret : Buffer.from(input.secret).toString("utf8"); + return constantTimeEqual(`${profile.prefix}${expected}`, actual) ? "verified" : "rejected"; + } + + const algorithm = profile.scheme === "hmac-sha256" ? "sha256" : "sha1"; + const digest = createHmac(algorithm, input.secret).update(input.rawBody).digest(profile.encoding); + return constantTimeEqual(`${profile.prefix}${digest}`, actual) ? "verified" : "rejected"; +} + export const requestEnvelopePayloadSchema = z.object({ iss: opaqueId("in"), aud: z.literal(CONNECT_SERVICE_AUDIENCE),