UNPKG

@nartix/next-csrf

Version:

A CSRF protection middleware for Next.js

219 lines (218 loc) 7.85 kB
import { NextResponse } from 'next/server'; import { edgeToken } from '@nartix/edge-token'; /** * Default CSRF options */ const DEFAULT_OPTIONS = { headerName: 'X-CSRF-TOKEN', formFieldName: 'csrf_token', excludeMethods: ['GET', 'HEAD', 'OPTIONS'], algorithm: 'SHA-256', tokenByteLength: 32, separator: '.', enableHeaderCheckForJson: false, cookie: { name: 'CSRF-TOKEN', path: '/', httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 60 * 60 * 24 * 7, // 1 week domain: undefined, }, }; /** * Checks if the request method is one that typically modifies state. */ export const isWriteMethod = (method) => { return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase()); }; /** * Determines if the request is a Next.js Server Action by looking for a `next-action` header. */ export const isServerAction = (req) => { return req.headers.has('next-action'); }; /** * Safely parse a request's body as JSON. Returns null if parsing fails. */ async function parseRequestBodyAsJson(req) { try { const text = await req.text(); return JSON.parse(text); } catch (error) { // Error parsing JSON data for CSRF return null; } } /** * Extracts the CSRF token from multipart/form-data or x-www-form-urlencoded. * Handles both regular forms and server action forms (with possible field suffix). */ export const extractCsrfTokenFromForm = async (req, formFieldName) => { try { const formData = await req.formData(); // If it's a server action, the field could be suffixed, e.g., "myfield_csrf_token" if (isServerAction(req)) { const serverActionToken = Array.from(formData.entries()) .find(([name]) => name.endsWith(formFieldName))?.[1] ?.toString() || null; if (serverActionToken) { return serverActionToken; } } // For regular forms, just get the direct field name const token = formData.get(formFieldName)?.toString() || null; return token; } catch (error) { // Error parsing form data for CSRF throw new Error('Invalid form data'); } }; /** * Extracts the CSRF token from JSON or plain-text JSON (like server actions). * Priority is given to a header if present, otherwise we parse JSON from the body. * Also handles arrays vs objects for server actions. */ export const extractCsrfTokenFromJsonOrPlainText = async (req, headerName, formFieldName, enableHeaderCheckForJson) => { // 1) Attempt to extract from header if (enableHeaderCheckForJson) { const csrfTokenFromHeader = req.headers.get(headerName); if (csrfTokenFromHeader) { return csrfTokenFromHeader; } } // 2) Parse the body as JSON const data = await parseRequestBodyAsJson(req); if (!data) { return null; } // 3) Check if it's an array or an object if (Array.isArray(data) && data.length > 0 && data[0][formFieldName]) { return data[0][formFieldName]; } else if (data[formFieldName]) { return data[formFieldName]; } return null; }; /** * Merges user-provided CSRF options with the defaults. */ export const mergeOptions = (options, userOptions) => { return { ...options, ...userOptions, cookie: { ...options.cookie, ...userOptions.cookie, }, }; }; /** * Constructs a 403 response when CSRF validation fails. */ export const invalidCsrfResponse = (message = 'Forbidden: CSRF token validation failed. Please refresh the page and try again.') => { return new NextResponse(JSON.stringify({ error: message }), { status: 403, headers: { 'Content-Type': 'application/json', }, }); }; /** * Helper to set a cookie on the NextResponse. */ function setCookie(res, cookieName, value, cookieOptions) { res.cookies.set(cookieName, value, { path: cookieOptions.path, maxAge: cookieOptions.maxAge, httpOnly: cookieOptions.httpOnly, secure: cookieOptions.secure, sameSite: cookieOptions.sameSite, domain: cookieOptions.domain, }); } /** * Helper to validate the CSRF token from the request against the cookie value and do a cryptographic check. */ async function validateCsrf(csrfCookieValue, csrfTokenFromRequest, csrf) { if (!csrfCookieValue) { // CSRF cookie value is missing return false; } if (!csrfTokenFromRequest || csrfTokenFromRequest !== csrfCookieValue) { // CSRF token from request does not match cookie value return false; } return !!(await csrf.verify(csrfTokenFromRequest)); } /** * Retrieves the CSRF token from the request by delegating to the correct extractor based on content type. */ export const getTokenFromRequest = async (req, options) => { const contentType = (req.headers.get('content-type') || '').toLowerCase(); const { formFieldName, headerName, enableHeaderCheckForJson } = options; // 1) If it's form data if (contentType.includes('multipart/form-data') || contentType.includes('application/x-www-form-urlencoded')) { return extractCsrfTokenFromForm(req, formFieldName); } // 2) If it's JSON or we detect a server action (usually plain text containing JSON) if (contentType.includes('application/json') || contentType.includes('application/ld+json') || isServerAction(req)) { return extractCsrfTokenFromJsonOrPlainText(req, headerName, formFieldName, enableHeaderCheckForJson); } // Default to no token if it's another content type return null; }; /** * Creates a CSRF middleware for Next.js. * * 1) Merges user options with defaults. * 2) Ensures we have a secret for generating tokens. * 3) If we have no CSRF cookie, generate one and set it on the response. * 4) Attempt to extract a CSRF token from the request. * 5) If present, validate it. If invalid, respond with 403. Otherwise, allow. */ const createNextCsrfMiddleware = async (req, res, options) => { const mergedOptions = mergeOptions(DEFAULT_OPTIONS, options); if (!mergedOptions.secret) { throw new Error('CSRF middleware requires a secret'); } try { const csrf = await edgeToken(mergedOptions); const { headerName, cookie, excludeMethods = [] } = mergedOptions; const csrfCookie = req.cookies.get(cookie.name); // If the cookie does not exist, generate a new CSRF token and set it if (!csrfCookie) { const token = await csrf.generate(); setCookie(res, cookie.name, token, cookie); } else { // If the cookie exists, attach its value to the response header res.headers.set(headerName, csrfCookie.value); } // If the method is excluded, skip CSRF validation if (excludeMethods.includes(req.method)) { return res; } // If it's a write or server action scenario if (isWriteMethod(req.method)) { // Now retrieve the CSRF token from the request const csrfTokenFromRequest = await getTokenFromRequest(req, mergedOptions); const csrfCookieValue = csrfCookie?.value; // Validate the CSRF token const isValid = await validateCsrf(csrfCookieValue, csrfTokenFromRequest, csrf); if (!isValid) { return invalidCsrfResponse(); } } } catch (error) { console.error('Error in CSRF middleware:', error); return new NextResponse('Internal Server Error', { status: 500 }); } return res; }; export { createNextCsrfMiddleware };