Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
26 changes: 26 additions & 0 deletions src/lib/server/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 28 additions & 0 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion src/routes/api/newsletter/daily-digest/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
3 changes: 2 additions & 1 deletion src/routes/api/webhooks/google-sheets/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 });
}

Expand Down
3 changes: 2 additions & 1 deletion src/routes/api/webhooks/post/+server.ts
Original file line number Diff line number Diff line change
@@ -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 });
}

Expand Down
Loading