From fe4e0117da1aa5ac1e359fd5ec805d6d040987f1 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 15:51:34 +0300 Subject: [PATCH] fix(flows): constant-time compare for cron secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/api/flows/cron` route gated on `supplied !== expected`, which compares strings byte-by-byte with early exit on first mismatch. An attacker who can hit the endpoint could in principle recover the secret one byte at a time by measuring response-time deltas. With a high-entropy 64-char secret this is mostly theoretical, but the upgrade is a one-liner. Switch to crypto.timingSafeEqual with a length pre-check (the function throws on mismatched lengths; the length itself isn't sensitive when the expected value is set by the operator). Not touching the equivalent line in /api/automations/cron in this PR — one fix per branch keeps the diff easy to review and revert. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/flows/cron/route.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/api/flows/cron/route.ts b/src/app/api/flows/cron/route.ts index 5f4a226..01cf2b2 100644 --- a/src/app/api/flows/cron/route.ts +++ b/src/app/api/flows/cron/route.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto' import { NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/flows/admin-client' import { resolveFallbackPolicy } from '@/lib/flows/fallback' @@ -30,8 +31,17 @@ export async function GET(request: Request) { if (!expected) { return NextResponse.json({ error: 'cron not configured' }, { status: 503 }) } - const supplied = request.headers.get('x-cron-secret') - if (supplied !== expected) { + // Constant-time compare so an attacker who can hit the endpoint + // can't recover the secret byte-by-byte from response-time deltas. + // Length pre-check is required by timingSafeEqual (throws otherwise) + // and leaks only the length itself, which isn't sensitive. + const supplied = request.headers.get('x-cron-secret') ?? '' + const suppliedBuf = Buffer.from(supplied) + const expectedBuf = Buffer.from(expected) + if ( + suppliedBuf.length !== expectedBuf.length || + !timingSafeEqual(suppliedBuf, expectedBuf) + ) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }