From ebdf73470c623b6cb8bf018c724905131a09e234 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Thu, 19 Mar 2026 11:52:41 -0700 Subject: [PATCH] add optional prefix stripping to hmac verification --- README.md | 2 + packages/core/src/__tests__/verify.test.ts | 45 ++++++++++++++++++++++ packages/core/src/schema.ts | 11 +++++- packages/core/src/types.ts | 13 +++++-- packages/core/src/verify.ts | 13 +++++-- 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1bb33f8..5cead1f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Markdown file with frontmatter. Here is a contrived example: verify: hmac: header: X-My-Header-Signature + prefix: "sha256=" # optional: stripped before comparing the digest secret: $MY_WEBHOOK_SECRET payload: contentType: json @@ -118,6 +119,7 @@ HMAC (e.g. GitHub, GitLab — computes SHA-256 over the request body): verify: hmac: header: X-Hub-Signature-256 # or query: param-name + prefix: "sha256=" # optional: stripped before comparing the digest secret: $ENV_VAR_NAME # must be an environment variable reference ``` diff --git a/packages/core/src/__tests__/verify.test.ts b/packages/core/src/__tests__/verify.test.ts index 63e254d..d753e34 100644 --- a/packages/core/src/__tests__/verify.test.ts +++ b/packages/core/src/__tests__/verify.test.ts @@ -108,6 +108,51 @@ describe("verifyRequest", () => { expect(await verifyRequest(lure, req)).toBe(false); }); + it("returns true for valid HMAC with matching prefix stripped", async () => { + const secret = "mysecret"; + process.env["TEST_SECRET"] = secret; + const body = new Uint8Array(Buffer.from("hello world")); + const sig = "sha256=" + computeHmac(secret, body); + + const lure = makeLure({ + frontmatter: { + verify: { hmac: { secret: "$TEST_SECRET", header: "X-Sig", prefix: "sha256=" } }, + }, + }); + const req = makeRequest({ headers: new Headers({ "X-Sig": sig }), rawBody: body }); + expect(await verifyRequest(lure, req)).toBe(true); + }); + + it("returns false when prefix is declared but absent from signature", async () => { + const secret = "mysecret"; + process.env["TEST_SECRET"] = secret; + const body = new Uint8Array(Buffer.from("hello world")); + const sig = computeHmac(secret, body); // no prefix + + const lure = makeLure({ + frontmatter: { + verify: { hmac: { secret: "$TEST_SECRET", header: "X-Sig", prefix: "sha256=" } }, + }, + }); + const req = makeRequest({ headers: new Headers({ "X-Sig": sig }), rawBody: body }); + expect(await verifyRequest(lure, req)).toBe(false); + }); + + it("returns false when prefix does not match", async () => { + const secret = "mysecret"; + process.env["TEST_SECRET"] = secret; + const body = new Uint8Array(Buffer.from("hello world")); + const sig = "sha1=" + computeHmac(secret, body); + + const lure = makeLure({ + frontmatter: { + verify: { hmac: { secret: "$TEST_SECRET", header: "X-Sig", prefix: "sha256=" } }, + }, + }); + const req = makeRequest({ headers: new Headers({ "X-Sig": sig }), rawBody: body }); + expect(await verifyRequest(lure, req)).toBe(false); + }); + it("returns true for valid HMAC via query param", async () => { const secret = "mysecret"; process.env["TEST_SECRET"] = secret; diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 744033e..99480ad 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -4,14 +4,21 @@ import type { LureFrontmatter } from "./types.js"; const SECRET_PATTERN = /^\$[A-Z_][A-Z0-9_]*$/; -const verifyParamsShape = { +const hmacParamsShape = { + secret: "string", + "header?": "string", + "query?": "string", + "prefix?": "string", +} as const; + +const literalParamsShape = { secret: "string", "header?": "string", "query?": "string", } as const; const frontmatterValidator = type({ - "verify?": type({ hmac: verifyParamsShape }).or({ literal: verifyParamsShape }), + "verify?": type({ hmac: hmacParamsShape }).or({ literal: literalParamsShape }), "payload?": { "contentType?": '"json"', }, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6a40ba2..88f6282 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7,15 +7,22 @@ export interface LureRequest { rawBody: Uint8Array; } -export interface LureVerifyParams { +export interface LureHmacParams { + secret: string; + header?: string; + query?: string; + prefix?: string; +} + +export interface LureLiteralParams { secret: string; header?: string; query?: string; } export type LureVerify = - | { hmac: LureVerifyParams } - | { literal: LureVerifyParams }; + | { hmac: LureHmacParams } + | { literal: LureLiteralParams }; export interface LurePayload { contentType?: "json"; diff --git a/packages/core/src/verify.ts b/packages/core/src/verify.ts index b70698b..4dbe05b 100644 --- a/packages/core/src/verify.ts +++ b/packages/core/src/verify.ts @@ -1,5 +1,5 @@ import { createHmac, timingSafeEqual } from "node:crypto"; -import type { ParsedLure, LureRequest, LureVerifyParams } from "./types.js"; +import type { ParsedLure, LureRequest, LureHmacParams, LureLiteralParams } from "./types.js"; function safeEqual(a: string, b: string): boolean { const aBuf = Buffer.from(a); @@ -11,7 +11,7 @@ function safeEqual(a: string, b: string): boolean { } function extractSignature( - params: LureVerifyParams, + params: LureHmacParams | LureLiteralParams, req: LureRequest, ): string | null { if (params.header !== undefined) { @@ -31,7 +31,14 @@ function verifyHmac( secretValue: string, signature: string, rawBody: Uint8Array, + prefix: string | undefined, ): boolean { + if (prefix !== undefined) { + if (!signature.startsWith(prefix)) { + return false; + } + signature = signature.slice(prefix.length); + } const digest = createHmac("sha256", secretValue).update(rawBody).digest("hex"); return safeEqual(signature, digest); } @@ -63,7 +70,7 @@ export async function verifyRequest( } if ("hmac" in verify) { - return verifyHmac(secretValue, signature, req.rawBody); + return verifyHmac(secretValue, signature, req.rawBody, verify.hmac.prefix); } else { return verifyLiteral(secretValue, signature); } -- 2.51.2