fix(flows): constant-time compare for cron secret

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-22 15:51:34 +03:00
parent 9ff4a8d1ba
commit fe4e0117da
No known key found for this signature in database

View File

@ -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 })
}