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
27 changes: 27 additions & 0 deletions src/app/api/approvals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { validateBody, validateQuery } from '@/lib/validation';
import { ApprovalStatus } from '@/types/approvals';
import { query } from '@/lib/db/pool';
import type { ApprovalItem, ReviewDecision } from '@/types/api';
import { getCsrfTokenFromCookies, getCsrfTokenFromHeaders } from '@/lib/csrfMiddleware';

export const runtime = 'nodejs';

Expand Down Expand Up @@ -81,6 +82,19 @@ export async function POST(request: Request): Promise<NextResponse> {
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse;

// CSRF validation
const cookieToken = getCsrfTokenFromCookies(request as any);
const headerToken = getCsrfTokenFromHeaders(request as any);

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return addHeaders(
NextResponse.json(
{ success: false, message: 'CSRF token validation failed' },
{ status: 403 }
)
);
}

const validation = validateBody(SubmitSchema, await request.json());
if (!validation.ok) return addHeaders(validation.error);

Expand Down Expand Up @@ -123,6 +137,19 @@ export async function PATCH(request: Request): Promise<NextResponse> {
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse;

// CSRF validation
const cookieToken = getCsrfTokenFromCookies(request as any);
const headerToken = getCsrfTokenFromHeaders(request as any);

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return addHeaders(
NextResponse.json(
{ success: false, message: 'CSRF token validation failed' },
{ status: 403 }
)
);
}

const validation = validateBody(ReviewSchema, await request.json());
if (!validation.ok) return addHeaders(validation.error);

Expand Down
40 changes: 40 additions & 0 deletions src/app/api/bookmarks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { VideoBookmark } from '@/types/api';
import { withRateLimit } from '@/lib/ratelimit';
import { logAuditMutation } from '@/middleware/audit';
import { edgeLog } from '@/../infra/edge-config';
import { getCsrfTokenFromCookies, getCsrfTokenFromHeaders } from '@/lib/csrfMiddleware';

import { validateBody, validateQuery } from '@/lib/validation';
import {
Expand Down Expand Up @@ -65,6 +66,19 @@ export async function POST(request: Request): Promise<NextResponse<BookmarkRespo
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse as NextResponse;

// CSRF validation
const cookieToken = getCsrfTokenFromCookies(request as any);
const headerToken = getCsrfTokenFromHeaders(request as any);

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return addHeaders(
NextResponse.json(
{ success: false, message: 'CSRF token validation failed' },
{ status: 403 }
)
) as NextResponse;
}

const result = validateBody(BookmarksCreateBodySchema, await request.json());
if (!result.ok) return addHeaders(result.error) as NextResponse;

Expand Down Expand Up @@ -119,6 +133,19 @@ export async function PATCH(request: Request): Promise<NextResponse<BookmarksSuc
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse as NextResponse;

// CSRF validation
const cookieToken = getCsrfTokenFromCookies(request as any);
const headerToken = getCsrfTokenFromHeaders(request as any);

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return addHeaders(
NextResponse.json(
{ success: false, message: 'CSRF token validation failed' },
{ status: 403 }
)
) as NextResponse;
}

const result = validateBody(BookmarksPatchBodySchema, await request.json());
if (!result.ok) return addHeaders(result.error) as NextResponse;

Expand Down Expand Up @@ -157,6 +184,19 @@ export async function DELETE(request: Request): Promise<NextResponse<BookmarksSu
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse as NextResponse;

// CSRF validation
const cookieToken = getCsrfTokenFromCookies(request as any);
const headerToken = getCsrfTokenFromHeaders(request as any);

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return addHeaders(
NextResponse.json(
{ success: false, message: 'CSRF token validation failed' },
{ status: 403 }
)
) as NextResponse;
}

const result = validateBody(BookmarksDeleteBodySchema, await request.json());
if (!result.ok) return addHeaders(result.error) as NextResponse;

Expand Down
40 changes: 40 additions & 0 deletions src/app/api/notes/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getCsrfTokenFromCookies, getCsrfTokenFromHeaders } from '@/lib/csrfMiddleware';
import { NextResponse } from 'next/server';
import type { VideoNote } from '@/types/api';
import { withRateLimit } from '@/lib/ratelimit';
Expand Down Expand Up @@ -62,6 +63,19 @@ export async function POST(request: Request): Promise<NextResponse<NoteResponseD
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse as NextResponse;

// CSRF validation
const cookieToken = getCsrfTokenFromCookies(request as any);
const headerToken = getCsrfTokenFromHeaders(request as any);

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return addHeaders(
NextResponse.json(
{ success: false, message: 'CSRF token validation failed' },
{ status: 403 }
)
) as NextResponse;
}

const result = validateBody(NotesCreateBodySchema, await request.json());
if (!result.ok) return addHeaders(result.error) as NextResponse;

Expand Down Expand Up @@ -111,6 +125,19 @@ export async function PATCH(request: Request): Promise<NextResponse<NotesSuccess
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse as NextResponse;

// CSRF validation
const cookieToken = getCsrfTokenFromCookies(request as any);
const headerToken = getCsrfTokenFromHeaders(request as any);

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return addHeaders(
NextResponse.json(
{ success: false, message: 'CSRF token validation failed' },
{ status: 403 }
)
) as NextResponse;
}

const result = validateBody(NotesPatchBodySchema, await request.json());
if (!result.ok) return addHeaders(result.error) as NextResponse;

Expand Down Expand Up @@ -148,6 +175,19 @@ export async function DELETE(request: Request): Promise<NextResponse<NotesSucces
const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE');
if (rateLimitResponse) return rateLimitResponse as NextResponse;

// CSRF validation
const cookieToken = getCsrfTokenFromCookies(request as any);
const headerToken = getCsrfTokenFromHeaders(request as any);

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return addHeaders(
NextResponse.json(
{ success: false, message: 'CSRF token validation failed' },
{ status: 403 }
)
) as NextResponse;
}

const result = validateBody(NotesDeleteBodySchema, await request.json());
if (!result.ok) return addHeaders(result.error) as NextResponse;

Expand Down
12 changes: 12 additions & 0 deletions src/app/api/tips/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { evaluateTipCanary } from '@/lib/featureFlags/tipCanary';
import { getCsrfTokenFromCookies, getCsrfTokenFromHeaders } from '@/lib/csrfMiddleware';

interface TipPayload {
recipientId: string;
Expand Down Expand Up @@ -34,6 +35,17 @@ function validateTipPayload(body: unknown): string | null {
}

export async function POST(request: NextRequest) {
// CSRF validation
const cookieToken = getCsrfTokenFromCookies(request as any);
const headerToken = getCsrfTokenFromHeaders(request as any);

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return NextResponse.json(
{ success: false, message: 'CSRF token validation failed' },
{ status: 403 }
);
}

let body: unknown;
try {
body = await request.json();
Expand Down
152 changes: 152 additions & 0 deletions src/lib/__tests__/csrfMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { NextRequest, NextResponse } from 'next/server';
import {
generateCsrfToken,
getCsrfTokenFromCookies,
getCsrfTokenFromHeaders,
validateCsrfToken,
validateCsrfRequest,
setCsrfCookie,
CSRF_COOKIE_NAME,
CSRF_HEADER_NAME,
} from '../csrfMiddleware';

describe('CSRF Middleware', () => {
describe('generateCsrfToken', () => {
it('generates a 64-character hex string', () => {
const token = generateCsrfToken();
expect(token).toHaveLength(64);
expect(token).toMatch(/^[0-9a-f]{64}$/);
});

it('generates different tokens each time', () => {
const token1 = generateCsrfToken();
const token2 = generateCsrfToken();
expect(token1).not.toBe(token2);
});
});

describe('validateCsrfToken', () => {
it('returns true when tokens match', () => {
const token = generateCsrfToken();
const request = new NextRequest('http://localhost/api/test', {
headers: {
[CSRF_HEADER_NAME]: token,
},
});
// Set cookie manually
request.cookies.set(CSRF_COOKIE_NAME, token);

expect(validateCsrfToken(request)).toBe(true);
});

it('returns false when tokens do not match', () => {
const token1 = generateCsrfToken();
const token2 = generateCsrfToken();
const request = new NextRequest('http://localhost/api/test', {
headers: {
[CSRF_HEADER_NAME]: token1,
},
});
request.cookies.set(CSRF_COOKIE_NAME, token2);

expect(validateCsrfToken(request)).toBe(false);
});

it('returns false when cookie token is missing', () => {
const token = generateCsrfToken();
const request = new NextRequest('http://localhost/api/test', {
headers: {
[CSRF_HEADER_NAME]: token,
},
});

expect(validateCsrfToken(request)).toBe(false);
});

it('returns false when header token is missing', () => {
const token = generateCsrfToken();
const request = new NextRequest('http://localhost/api/test');
request.cookies.set(CSRF_COOKIE_NAME, token);

expect(validateCsrfToken(request)).toBe(false);
});
});

describe('validateCsrfRequest', () => {
it('returns null for GET requests (skipped)', () => {
const request = new NextRequest('http://localhost/api/notes', {
method: 'GET',
});
expect(validateCsrfRequest(request)).toBeNull();
});

it('returns null for HEAD requests (skipped)', () => {
const request = new NextRequest('http://localhost/api/notes', {
method: 'HEAD',
});
expect(validateCsrfRequest(request)).toBeNull();
});

it('returns null for OPTIONS requests (skipped)', () => {
const request = new NextRequest('http://localhost/api/notes', {
method: 'OPTIONS',
});
expect(validateCsrfRequest(request)).toBeNull();
});

it('returns null for auth endpoints (exempt)', () => {
const request = new NextRequest('http://localhost/api/auth/login', {
method: 'POST',
});
expect(validateCsrfRequest(request)).toBeNull();
});

it('returns 403 response when CSRF validation fails for POST', () => {
const request = new NextRequest('http://localhost/api/notes', {
method: 'POST',
headers: {
[CSRF_HEADER_NAME]: 'invalid-token',
},
});
request.cookies.set(CSRF_COOKIE_NAME, 'different-token');

const result = validateCsrfRequest(request);
expect(result).not.toBeNull();
expect(result?.status).toBe(403);
});

it('returns null when CSRF validation passes for POST', () => {
const token = generateCsrfToken();
const request = new NextRequest('http://localhost/api/notes', {
method: 'POST',
headers: {
[CSRF_HEADER_NAME]: token,
},
});
request.cookies.set(CSRF_COOKIE_NAME, token);

expect(validateCsrfRequest(request)).toBeNull();
});
});

describe('setCsrfCookie', () => {
it('sets the CSRF cookie with the correct name', () => {
const token = generateCsrfToken();
const response = new NextResponse();
const result = setCsrfCookie(response, token);

const cookieHeader = result.cookies.toString();
expect(cookieHeader).toContain(CSRF_COOKIE_NAME);
expect(cookieHeader).toContain(token);
});

it('sets httpOnly to false', () => {
const token = generateCsrfToken();
const response = new NextResponse();
const result = setCsrfCookie(response, token);

const cookieHeader = result.cookies.toString();
expect(cookieHeader).toContain('HttpOnly=false');
});
});
});
Loading
Loading