feat(connect-protocol): add pinned webhook verification

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 16:01:08 +00:00
1 parent a19254d59b
commit 91f53375b9
2 files changed
+64 -1

No files matched your search

+23 -1
View File
@@ -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();
+41
View File
@@ -84,6 +84,47 @@ export const relayEnvelopeSchema = z.object({
export type RelayEnvelope = z.infer<typeof relayEnvelopeSchema>;
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<typeof webhookVerifierProfileSchema>;
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<string, string | string[] | undefined>;
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),