diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 4e5603f6..d9f45057 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,7 +1,10 @@ -import { signOut } from "@/src/lib/auth"; +import { NextRequest } from "next/server"; +import { signOut, checkSameOrigin } from "@/src/lib/auth"; export const dynamic = 'force-dynamic'; -export async function POST() { +export async function POST(request: NextRequest) { + const originCheck = checkSameOrigin(request); + if (originCheck) return originCheck; await signOut({ redirectTo: "/login" }); } diff --git a/app/api/user/change-password/route.ts b/app/api/user/change-password/route.ts index 5f591984..ae0d85a0 100644 --- a/app/api/user/change-password/route.ts +++ b/app/api/user/change-password/route.ts @@ -1,10 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/src/lib/auth"; +import { auth, checkSameOrigin } from "@/src/lib/auth"; import { getUserById, updateUserPassword } from "@/src/lib/models/user"; import { createAuditEvent } from "@/src/lib/models/audit"; import bcrypt from "bcryptjs"; export async function POST(request: NextRequest) { + const originCheck = checkSameOrigin(request); + if (originCheck) return originCheck; + try { const session = await auth(); if (!session?.user?.id) { diff --git a/app/api/user/link-oauth-start/route.ts b/app/api/user/link-oauth-start/route.ts index fc36267d..07330776 100644 --- a/app/api/user/link-oauth-start/route.ts +++ b/app/api/user/link-oauth-start/route.ts @@ -1,11 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/src/lib/auth"; +import { auth, checkSameOrigin } from "@/src/lib/auth"; import db, { nowIso } from "@/src/lib/db"; import { pendingOAuthLinks } from "@/src/lib/db/schema"; import { eq, and, lt } from "drizzle-orm"; import { registerFailedAttempt } from "@/src/lib/rate-limit"; export async function POST(request: NextRequest) { + const originCheck = checkSameOrigin(request); + if (originCheck) return originCheck; + try { const session = await auth(); if (!session?.user?.id) { diff --git a/app/api/user/unlink-oauth/route.ts b/app/api/user/unlink-oauth/route.ts index 39158695..54bc0a3f 100644 --- a/app/api/user/unlink-oauth/route.ts +++ b/app/api/user/unlink-oauth/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/src/lib/auth"; +import { auth, checkSameOrigin } from "@/src/lib/auth"; import { getUserById } from "@/src/lib/models/user"; import { createAuditEvent } from "@/src/lib/models/audit"; import db from "@/src/lib/db"; @@ -8,6 +8,9 @@ import { eq } from "drizzle-orm"; import { nowIso } from "@/src/lib/db"; export async function POST(request: NextRequest) { + const originCheck = checkSameOrigin(request); + if (originCheck) return originCheck; + try { const session = await auth(); if (!session?.user?.id) { diff --git a/app/api/user/update-avatar/route.ts b/app/api/user/update-avatar/route.ts index d2d4e9e0..200a9502 100644 --- a/app/api/user/update-avatar/route.ts +++ b/app/api/user/update-avatar/route.ts @@ -1,9 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/src/lib/auth"; +import { auth, checkSameOrigin } from "@/src/lib/auth"; import { updateUserProfile } from "@/src/lib/models/user"; import { createAuditEvent } from "@/src/lib/models/audit"; export async function POST(request: NextRequest) { + const originCheck = checkSameOrigin(request); + if (originCheck) return originCheck; + try { const session = await auth(); if (!session?.user?.id) { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 78e55f6d..772307ef 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,5 @@ import NextAuth, { type DefaultSession } from "next-auth"; +import { type NextRequest, NextResponse } from "next/server"; import Credentials from "next-auth/providers/credentials"; import type { OAuthConfig } from "next-auth/providers"; import bcrypt from "bcryptjs"; @@ -425,3 +426,23 @@ export async function requireAdmin() { } return session; } + +/** + * Defense-in-depth CSRF check: verifies the Origin header matches the Host. + * Returns a 403 response if the origin is present and mismatched; otherwise null. + * Browsers always include Origin on cross-origin requests, so a mismatch means + * the request came from a different site. + */ +export function checkSameOrigin(request: NextRequest): NextResponse | null { + const origin = request.headers.get("origin"); + if (!origin) return null; // same-origin requests may omit Origin + + const host = request.headers.get("host"); + try { + const originHost = new URL(origin).host; + if (originHost === host) return null; + } catch { + // unparseable origin — treat as mismatch + } + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); +}