diff --git a/bun.lockb b/bun.lockb index e63e129..0b684a1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts new file mode 100644 index 0000000..06064c2 --- /dev/null +++ b/src/lib/server/auth.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { timingSafeStringEqual } from './auth'; + +describe('timingSafeStringEqual', () => { + it('should return true for identical strings', () => { + expect(timingSafeStringEqual('secret_token', 'secret_token')).toBe(true); + }); + + it('should return false for different strings of the same length', () => { + expect(timingSafeStringEqual('secret_token', 'wrong__token')).toBe(false); + }); + + it('should return false for different strings of different lengths', () => { + expect(timingSafeStringEqual('secret', 'very_long_wrong_token')).toBe(false); + }); + + it('should return false if actual string is null', () => { + // Ensure it doesn't throw when null is passed + expect(timingSafeStringEqual('secret_token', null)).toBe(false); + }); + + it('should return false for substring matches', () => { + expect(timingSafeStringEqual('secret', 'sec')).toBe(false); + expect(timingSafeStringEqual('secret', 'secrett')).toBe(false); + }); +}); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..d1ebac4 --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,28 @@ +import { timingSafeEqual } from 'node:crypto'; + +/** + * Securely compares two strings to prevent timing attacks. + * Uses `node:crypto.timingSafeEqual` under the hood. + * + * @param expected The expected string (e.g., the secret). + * @param actual The actual string provided (e.g., the header value). + * @returns true if the strings match perfectly, false otherwise. + */ +export function timingSafeStringEqual(expected: string, actual: string | null): boolean { + if (actual === null) { + return false; + } + + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(actual); + + if (expectedBuffer.length !== actualBuffer.length) { + // To prevent timing attacks based on length, we still do a comparison + // even if lengths differ, comparing the expected against itself. + // We use a throw-away operation. + timingSafeEqual(expectedBuffer, expectedBuffer); + return false; + } + + return timingSafeEqual(expectedBuffer, actualBuffer); +} diff --git a/src/routes/api/newsletter/daily-digest/+server.ts b/src/routes/api/newsletter/daily-digest/+server.ts index 4ad303d..7ef8597 100644 --- a/src/routes/api/newsletter/daily-digest/+server.ts +++ b/src/routes/api/newsletter/daily-digest/+server.ts @@ -10,12 +10,13 @@ import { json, error } from '@sveltejs/kit'; import { Resend } from 'resend'; import { escapeHtml } from '$lib/utils'; import type { RequestHandler } from './$types'; +import { timingSafeStringEqual } from '$lib/server/auth'; const resend = new Resend(RESEND_API_KEY); export const POST: RequestHandler = async ({ locals: { supabase }, request }) => { const authHeader = request.headers.get('Authorization'); - if (authHeader !== `Bearer ${DAILY_DIGEST_SECRET_KEY}`) { + if (!timingSafeStringEqual(`Bearer ${DAILY_DIGEST_SECRET_KEY}`, authHeader)) { return json({ message: 'Unauthorized' }, { status: 401 }); } diff --git a/src/routes/api/webhooks/google-sheets/+server.ts b/src/routes/api/webhooks/google-sheets/+server.ts index feb93a2..7805cb1 100644 --- a/src/routes/api/webhooks/google-sheets/+server.ts +++ b/src/routes/api/webhooks/google-sheets/+server.ts @@ -3,6 +3,7 @@ import { WEBHOOK_SECRET, SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private'; import { PUBLIC_SUPABASE_URL } from '$env/static/public'; import { createClient } from '@supabase/supabase-js'; import { getDefaultExpirationDate } from '$lib/utils'; +import { timingSafeStringEqual } from '$lib/server/auth'; // Use the service role key to bypass RLS policies. // The anon key is subject to RLS and will silently block inserts @@ -12,7 +13,7 @@ const supabaseAdmin = createClient(PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KE export const POST = async ({ request }) => { const authHeader = request.headers.get('Authorization'); - if (!authHeader || authHeader !== `Bearer ${WEBHOOK_SECRET}`) { + if (!timingSafeStringEqual(`Bearer ${WEBHOOK_SECRET}`, authHeader)) { return new Response('Unauthorized', { status: 401 }); } diff --git a/src/routes/api/webhooks/post/+server.ts b/src/routes/api/webhooks/post/+server.ts index cc9c700..90fe35c 100644 --- a/src/routes/api/webhooks/post/+server.ts +++ b/src/routes/api/webhooks/post/+server.ts @@ -1,13 +1,14 @@ import { ADMIN_EMAIL, FROM_EMAIL, RESEND_API_KEY, WEBHOOK_SECRET } from '$env/static/private'; import { json } from '@sveltejs/kit'; import { Resend } from 'resend'; +import { timingSafeStringEqual } from '$lib/server/auth'; const resend = new Resend(RESEND_API_KEY); export async function POST({ request }) { const authHeader = request.headers.get('Authorization'); - if (!authHeader || authHeader !== `Bearer ${WEBHOOK_SECRET}`) { + if (!timingSafeStringEqual(`Bearer ${WEBHOOK_SECRET}`, authHeader)) { return new Response('Unauthorized', { status: 401 }); }