From 21cddfe5f0b8d0522d9deeebd5df709fbacd55c6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:47:36 +0100 Subject: [PATCH 1/2] fix(web): use constant-time comparison for media-server webhook secret --- apps/web/app/api/webhooks/media-server/progress/route.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/app/api/webhooks/media-server/progress/route.ts b/apps/web/app/api/webhooks/media-server/progress/route.ts index 57a1ba79dce..f5d5f96f169 100644 --- a/apps/web/app/api/webhooks/media-server/progress/route.ts +++ b/apps/web/app/api/webhooks/media-server/progress/route.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; @@ -67,7 +68,12 @@ export async function POST(request: NextRequest) { try { const webhookSecret = serverEnv().MEDIA_SERVER_WEBHOOK_SECRET; const authHeader = request.headers.get("x-media-server-secret"); - if (!webhookSecret || authHeader !== webhookSecret) { + if ( + !webhookSecret || + !authHeader || + authHeader.length !== webhookSecret.length || + !timingSafeEqual(Buffer.from(authHeader), Buffer.from(webhookSecret)) + ) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } From 9b64e57d544b570cba4f50538a564cc61d6a8b31 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:11:52 +0100 Subject: [PATCH 2/2] fix(web): hash media-server webhook secret before constant-time compare --- .../app/api/webhooks/media-server/progress/route.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/app/api/webhooks/media-server/progress/route.ts b/apps/web/app/api/webhooks/media-server/progress/route.ts index f5d5f96f169..2248e07a22d 100644 --- a/apps/web/app/api/webhooks/media-server/progress/route.ts +++ b/apps/web/app/api/webhooks/media-server/progress/route.ts @@ -1,4 +1,4 @@ -import { timingSafeEqual } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; @@ -68,11 +68,16 @@ export async function POST(request: NextRequest) { try { const webhookSecret = serverEnv().MEDIA_SERVER_WEBHOOK_SECRET; const authHeader = request.headers.get("x-media-server-secret"); + // Hash both sides to a fixed-length digest before the constant-time + // compare. This avoids comparing raw inputs whose UTF-8 byte length can + // differ from their UTF-16 `.length` (which would throw a RangeError) and + // removes the length pre-check that would otherwise leak the secret size. + const digest = (value: string) => + createHash("sha256").update(value, "utf8").digest(); if ( !webhookSecret || !authHeader || - authHeader.length !== webhookSecret.length || - !timingSafeEqual(Buffer.from(authHeader), Buffer.from(webhookSecret)) + !timingSafeEqual(digest(authHeader), digest(webhookSecret)) ) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); }