diff --git a/src/app/api/init/route.ts b/src/app/api/init/route.ts index 062c7f6..83b029d 100644 --- a/src/app/api/init/route.ts +++ b/src/app/api/init/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { initializeDatabase } from '@/lib/db'; import { logger, sanitizeError } from '@/lib/logging'; +import { safeCompare } from '@/lib/security'; export async function POST(request: NextRequest) { try { @@ -13,7 +14,7 @@ export async function POST(request: NextRequest) { const initSecret = process.env.INIT_SECRET; const authHeader = request.headers.get('authorization'); - if (initSecret && authHeader !== `Bearer ${initSecret}`) { + if (initSecret && (!authHeader || !safeCompare(authHeader, `Bearer ${initSecret}`))) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } diff --git a/src/app/api/paste/[id]/rotate/route.ts b/src/app/api/paste/[id]/rotate/route.ts index bdaae49..0229bad 100644 --- a/src/app/api/paste/[id]/rotate/route.ts +++ b/src/app/api/paste/[id]/rotate/route.ts @@ -7,6 +7,7 @@ import { getPasteMetadata, getDb } from '@/lib/db'; import { getPaste, storePaste, getPasteTTL, deletePaste } from '@/lib/redis'; import { logger, sanitizeError } from '@/lib/logging'; import { generatePasteId } from '@/lib/crypto'; +import { safeCompare } from '@/lib/security'; export async function POST( request: NextRequest, @@ -33,7 +34,7 @@ export async function POST( ); } - if (metadata.deletionToken !== token) { + if (!metadata.deletionToken || !safeCompare(metadata.deletionToken, token)) { return NextResponse.json( { error: 'Invalid authorization token. Rotation denied.' }, { status: 403 } diff --git a/src/app/api/paste/[id]/route.ts b/src/app/api/paste/[id]/route.ts index caf8e0f..b0862d8 100644 --- a/src/app/api/paste/[id]/route.ts +++ b/src/app/api/paste/[id]/route.ts @@ -13,6 +13,7 @@ import { } from '@/lib/db'; import { getPaste, deletePaste } from '@/lib/redis'; import { logger, sanitizeError } from '@/lib/logging'; +import { safeCompare } from '@/lib/security'; /** * GET - Retrieve paste and update view state @@ -140,7 +141,7 @@ export async function DELETE( ); } - if (metadata.deletionToken !== token) { + if (!metadata.deletionToken || !safeCompare(metadata.deletionToken, token)) { return NextResponse.json( { error: 'Invalid authorization token. Revocation denied.' }, { status: 403 } diff --git a/src/lib/security.test.ts b/src/lib/security.test.ts new file mode 100644 index 0000000..d557b51 --- /dev/null +++ b/src/lib/security.test.ts @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { safeCompare } from './security.ts'; + +test('safeCompare function', async (t) => { + await t.test('should return true for identical strings', () => { + assert.strictEqual(safeCompare('hello', 'hello'), true); + assert.strictEqual(safeCompare('', ''), true); + assert.strictEqual(safeCompare('a'.repeat(100), 'a'.repeat(100)), true); + }); + + await t.test('should return false for different strings of same length', () => { + assert.strictEqual(safeCompare('hello', 'world'), false); + assert.strictEqual(safeCompare('abcde', 'abcdf'), false); + }); + + await t.test('should return false for different strings of different length', () => { + assert.strictEqual(safeCompare('hello', 'hello world'), false); + assert.strictEqual(safeCompare('short', 'longer string'), false); + assert.strictEqual(safeCompare('', 'not empty'), false); + }); + + await t.test('should handle unicode correctly', () => { + assert.strictEqual(safeCompare('🔒', '🔒'), true); + assert.strictEqual(safeCompare('🔒', '🔓'), false); + }); +}); diff --git a/src/lib/security.ts b/src/lib/security.ts new file mode 100644 index 0000000..4f14fe6 --- /dev/null +++ b/src/lib/security.ts @@ -0,0 +1,24 @@ +import { timingSafeEqual } from 'node:crypto'; + +/** + * Perform a timing-safe string comparison to prevent timing attacks. + * This is particularly important for comparing sensitive tokens like + * deletion tokens or API secrets. + * + * @param a The first string to compare + * @param b The second string to compare + * @returns True if the strings are equal, false otherwise + */ +export function safeCompare(a: string, b: string): boolean { + const aBuffer = Buffer.from(a); + const bBuffer = Buffer.from(b); + + if (aBuffer.length !== bBuffer.length) { + // Still perform a comparison to mitigate timing differences, + // even though the lengths don't match. + timingSafeEqual(aBuffer, aBuffer); + return false; + } + + return timingSafeEqual(aBuffer, bBuffer); +}