diff --git a/netlify/functions/execution-ledger.ts b/netlify/functions/execution-ledger.ts index 204fca5..c1a1695 100644 --- a/netlify/functions/execution-ledger.ts +++ b/netlify/functions/execution-ledger.ts @@ -1,5 +1,6 @@ import { connectLambda, getStore } from "@netlify/blobs"; import { Redis } from "@upstash/redis"; +import { timingSafeEqual } from "node:crypto"; type NetlifyEvent = { blobs?: string; @@ -58,6 +59,29 @@ function normalizeLambdaHeaders(headers: NetlifyEvent["headers"]) { ); } +function getHeader(headers: NetlifyEvent["headers"], name: string) { + const direct = headers[name]; + const lower = headers[name.toLowerCase()]; + return direct ?? lower; +} + +function safeTokenEquals(actual: string, expected: string) { + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); +} + +function isWriteAuthorized(event: NetlifyEvent) { + const expectedToken = process.env.EXECUTION_LEDGER_WRITE_TOKEN; + if (!expectedToken) return false; + + const authHeader = getHeader(event.headers, "authorization"); + const bearerToken = authHeader?.match(/^Bearer\s+(.+)$/i)?.[1]; + const token = bearerToken ?? getHeader(event.headers, "x-execution-ledger-token"); + + return Boolean(token && safeTokenEquals(token, expectedToken)); +} + function isLedgerRows(value: unknown): value is HistoricalExecutionLedgerEntry[] { return Array.isArray(value); } @@ -177,6 +201,14 @@ function parseBody(event: NetlifyEvent) { } export const handler = async (event: NetlifyEvent) => { + if (event.httpMethod === "POST" && !isWriteAuthorized(event)) { + return response(403, { + configured: true, + rows: [], + message: "Shared execution ledger writes require a server-side write token.", + }); + } + const store = getLedgerStore(event); if (!store) {