From 311da56779cb26f544a1c17b758542a3ad439a6a Mon Sep 17 00:00:00 2001 From: rissrice2105-agent <289161642+rissrice2105-agent@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:42:52 -0600 Subject: [PATCH] fix(auth): compare webhook secrets by byte length --- src/app/api/auth/backfill-dids/route.ts | 12 +++++------- src/app/api/auth/confirmed/route.test.ts | 5 +++++ src/app/api/auth/confirmed/route.ts | 12 +++++------- src/lib/security/constant-time.test.ts | 13 +++++++++++++ src/lib/security/constant-time.ts | 7 +++++++ 5 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 src/lib/security/constant-time.test.ts create mode 100644 src/lib/security/constant-time.ts diff --git a/src/app/api/auth/backfill-dids/route.ts b/src/app/api/auth/backfill-dids/route.ts index 589d4574..3d11b7cb 100644 --- a/src/app/api/auth/backfill-dids/route.ts +++ b/src/app/api/auth/backfill-dids/route.ts @@ -6,14 +6,9 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { timingSafeEqual } from "crypto"; import { createServiceClient } from "@/lib/supabase/service"; import { generateAndStoreDid } from "@/lib/auth/did"; - -function safeCompare(a: string, b: string): boolean { - if (a.length !== b.length) return false; - return timingSafeEqual(Buffer.from(a), Buffer.from(b)); -} +import { timingSafeStringEqual } from "@/lib/security/constant-time"; export async function POST(request: NextRequest) { const authHeader = request.headers.get("authorization") || ""; @@ -24,7 +19,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); } - if (!safeCompare(authHeader, `Bearer ${secret}`) && !safeCompare(authHeader, secret)) { + if ( + !timingSafeStringEqual(authHeader, `Bearer ${secret}`) && + !timingSafeStringEqual(authHeader, secret) + ) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/auth/confirmed/route.test.ts b/src/app/api/auth/confirmed/route.test.ts index 3f438fce..c7dae08d 100644 --- a/src/app/api/auth/confirmed/route.test.ts +++ b/src/app/api/auth/confirmed/route.test.ts @@ -162,6 +162,11 @@ describe("POST /api/auth/confirmed", () => { expect(res.status).toBe(401); }); + it("rejects Unicode secrets with mismatched byte lengths", async () => { + const res = await POST(makeRequest(confirmationPayload(), "éest-webhook-secret")); + expect(res.status).toBe(401); + }); + it("returns 500 when AUTH_WEBHOOK_SECRET is not configured", async () => { delete process.env.AUTH_WEBHOOK_SECRET; const res = await POST(makeRequest(confirmationPayload(), null)); diff --git a/src/app/api/auth/confirmed/route.ts b/src/app/api/auth/confirmed/route.ts index 4e94aa66..89b9a10f 100644 --- a/src/app/api/auth/confirmed/route.ts +++ b/src/app/api/auth/confirmed/route.ts @@ -13,13 +13,13 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { timingSafeEqual } from "crypto"; import { createClient } from "@/lib/supabase/server"; import { createServiceClient } from "@/lib/supabase/service"; import { sendEmail, welcomeEmail } from "@/lib/email"; import { generateAndStoreDid } from "@/lib/auth/did"; import { createUserLnWallet } from "@/lib/lightning/create-wallet"; import { safeParseBody } from "@/lib/sanitize"; +import { timingSafeStringEqual } from "@/lib/security/constant-time"; type AuthWebhookPayload = { type?: string; @@ -33,11 +33,6 @@ type AuthWebhookPayload = { }; }; -function safeCompare(a: string, b: string): boolean { - if (a.length !== b.length) return false; - return timingSafeEqual(Buffer.from(a), Buffer.from(b)); -} - export async function POST(request: NextRequest) { try { // Verify the webhook secret to prevent unauthorized calls @@ -49,7 +44,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); } - if (!safeCompare(authHeader, `Bearer ${webhookSecret}`) && !safeCompare(authHeader, webhookSecret)) { + if ( + !timingSafeStringEqual(authHeader, `Bearer ${webhookSecret}`) && + !timingSafeStringEqual(authHeader, webhookSecret) + ) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/lib/security/constant-time.test.ts b/src/lib/security/constant-time.test.ts new file mode 100644 index 00000000..4cf31f16 --- /dev/null +++ b/src/lib/security/constant-time.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { timingSafeStringEqual } from "./constant-time"; + +describe("timingSafeStringEqual", () => { + it("compares equal and unequal ASCII strings", () => { + expect(timingSafeStringEqual("secret", "secret")).toBe(true); + expect(timingSafeStringEqual("secret", "public")).toBe(false); + }); + + it("rejects strings with equal character length but unequal byte length", () => { + expect(timingSafeStringEqual("é", "a")).toBe(false); + }); +}); diff --git a/src/lib/security/constant-time.ts b/src/lib/security/constant-time.ts new file mode 100644 index 00000000..5b55f81c --- /dev/null +++ b/src/lib/security/constant-time.ts @@ -0,0 +1,7 @@ +import { timingSafeEqual } from "crypto"; + +export function timingSafeStringEqual(a: string, b: string): boolean { + const aBytes = Buffer.from(a); + const bBytes = Buffer.from(b); + return aBytes.length === bBytes.length && timingSafeEqual(aBytes, bBytes); +}