feat: implement comprehensive CSRF protection

This commit is contained in:
2025-07-11 18:06:51 +02:00
committed by Kaj Kowalski
parent e7818f5e4f
commit 3e9e75e854
44 changed files with 14964 additions and 6413 deletions

View File

@ -0,0 +1,156 @@
/**
* CSRF Protected Form Component
*
* A wrapper component that automatically adds CSRF protection to forms.
* This component demonstrates how to integrate CSRF tokens into form submissions.
*/
"use client";
import React, { FormEvent, ReactNode } from "react";
import { useCSRFForm } from "../../lib/hooks/useCSRF";
interface CSRFProtectedFormProps {
children: ReactNode;
action: string;
method?: "POST" | "PUT" | "DELETE" | "PATCH";
onSubmit?: (formData: FormData) => Promise<void> | void;
className?: string;
encType?: string;
}
/**
* Form component with automatic CSRF protection
*/
export function CSRFProtectedForm({
children,
action,
method = "POST",
onSubmit,
className,
encType,
}: CSRFProtectedFormProps) {
const { token, submitForm, addTokenToFormData } = useCSRFForm();
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
// Add CSRF token to form data
addTokenToFormData(formData);
try {
if (onSubmit) {
// Use custom submit handler
await onSubmit(formData);
} else {
// Use default form submission with CSRF protection
const response = await submitForm(action, formData);
if (!response.ok) {
throw new Error(`Form submission failed: ${response.status}`);
}
// Handle successful submission
console.log("Form submitted successfully");
}
} catch (error) {
console.error("Form submission error:", error);
// You might want to show an error message to the user here
}
};
return (
<form
onSubmit={handleSubmit}
method={method}
action={action}
className={className}
encType={encType}
>
{/* Hidden CSRF token field for non-JS fallback */}
{token && (
<input
type="hidden"
name="csrf_token"
value={token}
/>
)}
{children}
</form>
);
}
/**
* Example usage component showing how to use CSRF protected forms
*/
export function ExampleCSRFForm() {
const handleCustomSubmit = async (formData: FormData) => {
// Custom form submission logic
const data = Object.fromEntries(formData.entries());
console.log("Form data:", data);
// You can process the form data here before submission
// The CSRF token is automatically included in formData
};
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
<h2 className="text-xl font-semibold mb-4">CSRF Protected Form Example</h2>
<CSRFProtectedForm
action="/api/example-endpoint"
onSubmit={handleCustomSubmit}
className="space-y-4"
>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Submit
</button>
</CSRFProtectedForm>
</div>
);
}

View File

@ -0,0 +1,153 @@
/**
* CSRF Provider Component
*
* Provides CSRF token management for the entire application.
* Automatically fetches and manages CSRF tokens for client-side requests.
*/
"use client";
import React, { createContext, useContext, useEffect, useState } from "react";
import { CSRFClient } from "../../lib/csrf";
interface CSRFContextType {
token: string | null;
loading: boolean;
error: string | null;
refreshToken: () => Promise<void>;
addTokenToFetch: (options: RequestInit) => RequestInit;
addTokenToFormData: (formData: FormData) => FormData;
addTokenToObject: <T extends Record<string, unknown>>(obj: T) => T & { csrfToken: string };
}
const CSRFContext = createContext<CSRFContextType | undefined>(undefined);
interface CSRFProviderProps {
children: React.ReactNode;
}
/**
* CSRF Provider Component
*/
export function CSRFProvider({ children }: CSRFProviderProps) {
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
/**
* Fetch CSRF token from server
*/
const fetchToken = async () => {
try {
setLoading(true);
setError(null);
// First check if we already have a token in cookies
const existingToken = CSRFClient.getToken();
if (existingToken) {
setToken(existingToken);
setLoading(false);
return;
}
// Fetch new token from server
const response = await fetch("/api/csrf-token", {
method: "GET",
credentials: "include",
});
if (!response.ok) {
throw new Error(`Failed to fetch CSRF token: ${response.status}`);
}
const data = await response.json();
if (data.success && data.token) {
setToken(data.token);
} else {
throw new Error("Invalid response from CSRF endpoint");
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to fetch CSRF token";
setError(errorMessage);
console.error("CSRF token fetch error:", errorMessage);
} finally {
setLoading(false);
}
};
/**
* Refresh token manually
*/
const refreshToken = async () => {
await fetchToken();
};
/**
* Initialize token on mount
*/
useEffect(() => {
fetchToken();
}, []);
/**
* Monitor token changes in cookies
*/
useEffect(() => {
const checkToken = () => {
const currentToken = CSRFClient.getToken();
if (currentToken !== token) {
setToken(currentToken);
}
};
// Check token every 30 seconds
const interval = setInterval(checkToken, 30 * 1000);
return () => clearInterval(interval);
}, [token]);
const contextValue: CSRFContextType = {
token,
loading,
error,
refreshToken,
addTokenToFetch: CSRFClient.addTokenToFetch,
addTokenToFormData: CSRFClient.addTokenToFormData,
addTokenToObject: CSRFClient.addTokenToObject,
};
return (
<CSRFContext.Provider value={contextValue}>
{children}
</CSRFContext.Provider>
);
}
/**
* Hook to use CSRF context
*/
export function useCSRFContext(): CSRFContextType {
const context = useContext(CSRFContext);
if (context === undefined) {
throw new Error("useCSRFContext must be used within a CSRFProvider");
}
return context;
}
/**
* Higher-order component to wrap components with CSRF protection
*/
export function withCSRF<P extends object>(Component: React.ComponentType<P>) {
const WrappedComponent = (props: P) => (
<CSRFProvider>
<Component {...props} />
</CSRFProvider>
);
WrappedComponent.displayName = `withCSRF(${Component.displayName || Component.name})`;
return WrappedComponent;
}