Skip to content

Latest commit

 

History

History
399 lines (292 loc) · 8.6 KB

File metadata and controls

399 lines (292 loc) · 8.6 KB

CSRF Protection Migration Guide

This guide helps you update existing client-side code to work with CSRF protection.

Overview

As of this update, all state-changing API requests (POST, PUT, DELETE, PATCH) now require a CSRF token. This prevents cross-site request forgery attacks on payment and user management endpoints.

What Changed

Before (Vulnerable)

// Old code - NO CSRF protection
const response = await fetch('/api/checkout/adspace', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(checkoutData),
})

After (Protected)

// New code - WITH CSRF protection
import { fetchWithCSRF } from '@/lib/security/csrf-client'

const response = await fetchWithCSRF('/api/checkout/adspace', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(checkoutData),
})

Migration Steps

Step 1: Identify Affected Code

Search your codebase for state-changing fetch calls:

# Find POST requests
grep -r "method: 'POST'" --include="*.ts" --include="*.tsx"

# Find PUT requests
grep -r "method: 'PUT'" --include="*.ts" --include="*.tsx"

# Find DELETE requests
grep -r "method: 'DELETE'" --include="*.ts" --include="*.tsx"

Step 2: Import CSRF Helper

Add the import at the top of files with state-changing requests:

import { fetchWithCSRF } from '@/lib/security/csrf-client'

Step 3: Replace fetch with fetchWithCSRF

Replace fetch( with fetchWithCSRF( for all state-changing requests:

Before:

const response = await fetch('/api/user/preferences', {
  method: 'POST',
  body: JSON.stringify(preferences),
})

After:

const response = await fetchWithCSRF('/api/user/preferences', {
  method: 'POST',
  body: JSON.stringify(preferences),
})

Step 4: Test Your Changes

  1. Clear browser cookies
  2. Refresh the page to get a new CSRF token
  3. Test the state-changing operation
  4. Verify it works without CSRF errors

Common Patterns

Pattern 1: Form Submissions

Before:

async function handleSubmit(e: FormEvent) {
  e.preventDefault()
  const data = new FormData(e.target)

  const response = await fetch('/api/billing/subscription/update', {
    method: 'POST',
    body: JSON.stringify(Object.fromEntries(data)),
  })
}

After:

import { fetchWithCSRF } from '@/lib/security/csrf-client'

async function handleSubmit(e: FormEvent) {
  e.preventDefault()
  const data = new FormData(e.target)

  const response = await fetchWithCSRF('/api/billing/subscription/update', {
    method: 'POST',
    body: JSON.stringify(Object.fromEntries(data)),
  })
}

Pattern 2: Delete Operations

Before:

async function handleDelete(id: string) {
  const response = await fetch(`/api/user/saved-searches/${id}`, {
    method: 'DELETE',
  })
}

After:

import { fetchWithCSRF } from '@/lib/security/csrf-client'

async function handleDelete(id: string) {
  const response = await fetchWithCSRF(`/api/user/saved-searches/${id}`, {
    method: 'DELETE',
  })
}

Pattern 3: Update Operations

Before:

async function updateProfile(profile: Profile) {
  const response = await fetch('/api/user/preferences', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(profile),
  })
}

After:

import { fetchWithCSRF } from '@/lib/security/csrf-client'

async function updateProfile(profile: Profile) {
  const response = await fetchWithCSRF('/api/user/preferences', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(profile),
  })
}

Pattern 4: React Components with State

Before:

'use client'
import { useState } from 'react'

export function CheckoutForm() {
  const [loading, setLoading] = useState(false)

  async function handleCheckout() {
    setLoading(true)
    try {
      const response = await fetch('/api/checkout/adspace', {
        method: 'POST',
        body: JSON.stringify({ /* data */ }),
      })
      const result = await response.json()
      // Handle result
    } finally {
      setLoading(false)
    }
  }

  return <button onClick={handleCheckout}>Checkout</button>
}

After:

'use client'
import { useState } from 'react'
import { fetchWithCSRF } from '@/lib/security/csrf-client'

export function CheckoutForm() {
  const [loading, setLoading] = useState(false)

  async function handleCheckout() {
    setLoading(true)
    try {
      const response = await fetchWithCSRF('/api/checkout/adspace', {
        method: 'POST',
        body: JSON.stringify({ /* data */ }),
      })
      const result = await response.json()
      // Handle result
    } finally {
      setLoading(false)
    }
  }

  return <button onClick={handleCheckout}>Checkout</button>
}

Advanced Usage

Custom Fetch Wrapper

If you have a custom fetch wrapper, you can integrate CSRF manually:

import { getCSRFHeaders } from '@/lib/security/csrf-client'

async function apiClient(url: string, options: RequestInit) {
  const headers = getCSRFHeaders(options.headers)

  return fetch(url, {
    ...options,
    headers,
  })
}

React Hook for CSRF Token

If you need direct access to the token in a React component:

'use client'
import { useCSRFToken } from '@/lib/security/csrf-client'

export function MyComponent() {
  const csrfToken = useCSRFToken()

  // Token is automatically updated when it changes
  console.log('Current CSRF token:', csrfToken)

  return <div>Token loaded: {csrfToken ? 'Yes' : 'No'}</div>
}

Waiting for Token

If you need to ensure a token exists before making a request:

import { waitForCSRFToken, fetchWithCSRF } from '@/lib/security/csrf-client'

async function criticalOperation() {
  // Wait up to 5 seconds for token
  try {
    await waitForCSRFToken(5000)
  } catch (err) {
    console.error('CSRF token not available')
    return
  }

  // Token is now guaranteed to exist
  await fetchWithCSRF('/api/billing/subscription/cancel', {
    method: 'POST',
  })
}

Exemptions

Some routes are exempt from CSRF protection and don't need updates:

Webhooks (Already Exempt)

// No changes needed - webhooks use signature verification
export async function POST(request: Request) {
  // Validate Stripe signature
  const sig = request.headers.get('stripe-signature')
  // ... webhook logic
}

Cron Jobs (Already Exempt)

// No changes needed - cron uses CRON_SECRET
export async function GET(request: NextRequest) {
  const auth = requireApiKey(request, { allow: ['CRON_SECRET'] })
  // ... cron logic
}

API Key Authenticated (Already Exempt)

// No changes needed - API key provides authentication
export async function POST(request: NextRequest) {
  const auth = requireApiKey(request, { allow: ['ADMIN_API_KEY'] })
  // ... admin logic
}

Troubleshooting

Error: "CSRF validation failed"

Cause: Missing or invalid CSRF token

Solution:

  1. Verify you're using fetchWithCSRF instead of fetch
  2. Check browser cookies for csrf-token
  3. Refresh the page to get a new token
  4. Check browser console for CSRF warnings

Error: "CSRF token not found in cookies"

Cause: Token cookie not set by server

Solution:

  1. Ensure middleware is running (check Network tab)
  2. Clear all cookies and refresh
  3. Verify middleware.ts is properly configured
  4. Check that request goes through middleware

Token Not Updating

Cause: Cookie not being sent with request

Solution:

  1. Add credentials: 'include' to fetch options
  2. Verify same-origin request (CORS may block)
  3. Check that cookie domain matches request domain

Testing Checklist

After migration, verify:

  • All state-changing requests include CSRF token
  • Forms submit successfully
  • Delete operations work
  • Update operations work
  • Error handling works correctly
  • CSRF token refreshes after expiration
  • No console warnings about missing tokens

Rollback Plan

If you need to temporarily disable CSRF protection for a specific route:

  1. Add route pattern to exempt list in /lib/security/csrf.ts:
const CSRF_EXEMPT_PATTERNS = [
  /^\/api\/webhooks\//,
  /^\/api\/cron\//,
  /^\/api\/your-route\//, // Add your route here
]
  1. Document why it's exempt
  2. Add alternative protection (API key, signature verification)
  3. Create ticket to fix properly

Questions?

See full documentation: docs/CSRF_PROTECTION.md

For security concerns, contact the security team.