-
Notifications
You must be signed in to change notification settings - Fork 12
Add form handling and CSRF protection middleware #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add form handling and CSRF protection middleware #52
Conversation
Creates task documentation for implementing: - handleForm: Factory for secure form handling with JSON Schema validation - withCSRF: Stateless CSRF protection using double-submit cookie pattern
Task files belong in tasks/, not ai/commands/
Prevents timing attacks by comparing hashes instead of raw tokens
handleForm: Factory for secure form handling with JSON Schema validation - Validates request body against JSON Schema (ajv) - Honeypot field detection for bot protection - PII field scrubbing via logger - Rejects undeclared fields (additionalProperties: false) - Surfaces processSubmission errors through createRoute withCSRF: Stateless CSRF protection using double-submit cookie pattern - Sets token cookie with SameSite=Strict, Secure (in production) - Validates token from X-CSRF-Token header or _csrf body field - Uses SHA3 hash comparison to prevent timing attacks - Logs rejections without exposing token values Dependencies: ajv, js-sha3
- TypeBox provides TypeScript-native schemas with JSON Schema output - Better DX: schema IS the type (no duplication) - Smaller footprint: removed 4 packages, added 1 - MCP/OpenAPI compatible out of the box
|
@cursor please /review |
🔬 Code Review: Form Handling & CSRF Protection MiddlewareI've completed a thorough code review of the PR following the project standards, JavaScript best practices, TDD methodology, and OWASP Top 10 security guidelines. Here are my findings: ✅ Strengths
🚨 Critical Issues1. Missing TypeScript Definitions (BLOCKING)
Impact: TypeScript users cannot use these middleware without type errors. Required additions to // Form handling middleware
export interface HandleFormOptions {
name: string;
schema: any; // TypeBox schema type
processSubmission: (data: any) => Promise<void>;
pii?: string[];
honeypotField?: string;
}
export function handleForm(options: HandleFormOptions): Middleware;
// CSRF protection middleware
export const withCSRF: Middleware;2. Console.log in Production Code (SHOULD FIX)Location: console.log({
message: "CSRF validation failed",
requestId,
});Issue: Direct console.log violates the project's logging pattern. Other middleware uses proper logger integration. Recommendation: Use structured logging like
|
|
Cursor Agent can help with this pull request. Just |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds comprehensive form handling and CSRF protection middleware to the server framework, enabling secure form submissions with JSON Schema validation and stateless CSRF protection using the double-submit cookie pattern.
Key changes:
- Introduces
handleFormfactory for form validation with TypeBox schema validation, honeypot spam protection, and PII scrubbing support - Implements
withCSRFmiddleware providing stateless CSRF protection using CUID2 tokens and SHA3 hashing for timing attack prevention - Adds dependencies for
@sinclair/typebox(schema validation) andjs-sha3(cryptographic hashing)
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| tasks/form-csrf.md | Task specification documenting requirements for both middleware implementations |
| src/server/middleware/handle-form.js | Factory function creating form validation middleware with TypeBox schema validation, honeypot protection, and PII handling |
| src/server/middleware/handle-form.test.js | Comprehensive test suite covering all 9 functional requirements for form handling |
| src/server/middleware/with-csrf.js | CSRF protection middleware implementing double-submit cookie pattern with SHA3 hashing |
| src/server/middleware/with-csrf.test.js | Test suite covering CSRF token generation, validation, cookie security flags, and rejection logging |
| src/server/middleware/index.js | Updated exports to include new handleForm and withCSRF middleware |
| package.json | Added @sinclair/typebox and js-sha3 dependencies |
| package-lock.json | Lockfile updates for new dependencies and reorganized peer dependencies |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/server/middleware/handle-form.js
Outdated
| ({ name, schema, processSubmission, pii, honeypotField }) => | ||
| async ({ request, response }) => { | ||
| // Register PII fields with logger scrubber | ||
| if (pii?.length && response.locals?.logger?.scrub) { | ||
| response.locals.logger.scrub(pii); | ||
| } |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing parameter validation for required factory parameters. Following the pattern established in other middleware factories (e.g., createWithAuth, createWithCors), required parameters should be validated at factory creation time, not at middleware execution time.
Add validation to throw clear errors for missing required parameters:
const handleForm =
({ name, schema, processSubmission, pii, honeypotField }) => {
if (!name) {
throw new Error("name is required for handleForm configuration");
}
if (!schema) {
throw new Error("schema is required for handleForm configuration");
}
if (!processSubmission) {
throw new Error("processSubmission is required for handleForm configuration");
}
return async ({ request, response }) => {
// ... rest of implementation
};
};| ({ name, schema, processSubmission, pii, honeypotField }) => | |
| async ({ request, response }) => { | |
| // Register PII fields with logger scrubber | |
| if (pii?.length && response.locals?.logger?.scrub) { | |
| response.locals.logger.scrub(pii); | |
| } | |
| ({ name, schema, processSubmission, pii, honeypotField }) => { | |
| if (!name) { | |
| throw new Error("name is required for handleForm configuration"); | |
| } | |
| if (!schema) { | |
| throw new Error("schema is required for handleForm configuration"); | |
| } | |
| if (!processSubmission) { | |
| throw new Error("processSubmission is required for handleForm configuration"); | |
| } | |
| return async ({ request, response }) => { | |
| // Register PII fields with logger scrubber | |
| if (pii?.length && response.locals?.logger?.scrub) { | |
| response.locals.logger.scrub(pii); | |
| } |
src/server/middleware/with-csrf.js
Outdated
| const parseCookies = (cookieHeader) => { | ||
| if (!cookieHeader) return {}; | ||
| return cookieHeader.split(";").reduce((cookies, cookie) => { | ||
| const [name, value] = cookie.trim().split("="); |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cookie parsing bug: The split("=") with array destructuring will fail for cookie values containing = characters. This could cause the CSRF token to be incorrectly parsed if it contains an = sign.
Fix by limiting the split to only the first occurrence:
const parseCookies = (cookieHeader) => {
if (!cookieHeader) return {};
return cookieHeader.split(";").reduce((cookies, cookie) => {
const parts = cookie.trim().split("=");
const name = parts[0];
const value = parts.slice(1).join("="); // Rejoin in case value contains '='
cookies[name] = value;
return cookies;
}, {});
};| const [name, value] = cookie.trim().split("="); | |
| const trimmed = cookie.trim(); | |
| const eqIdx = trimmed.indexOf("="); | |
| if (eqIdx === -1) return cookies; | |
| const name = trimmed.substring(0, eqIdx); | |
| const value = trimmed.substring(eqIdx + 1); |
src/server/middleware/with-csrf.js
Outdated
| }); | ||
| }; | ||
|
|
||
| const withCSRF = async ({ request, response }) => { |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing JSDoc documentation. Following the pattern in other middleware files (e.g., with-cors.js, with-auth.js), this middleware should have comprehensive JSDoc comments including parameter descriptions, return types, and usage examples.
Add documentation like:
/**
* CSRF protection middleware using double-submit cookie pattern
*
* @returns {Function} Async middleware function
*
* @example
* // Add CSRF protection to a route
* import { withCSRF } from './middleware/index.js';
*
* export default createRoute(withCSRF, async ({ request, response }) => {
* // Your route logic
* });
*/
const withCSRF = async ({ request, response }) => {| import { describe, test, vi } from "vitest"; | ||
| import { assert } from "riteway/vitest"; | ||
| import { withCSRF } from "./with-csrf.js"; | ||
|
|
||
| describe("withCSRF", () => { | ||
| // Req 1: GET/HEAD/OPTIONS sets token cookie and response.locals.csrfToken | ||
| test("sets CSRF token cookie and response.locals.csrfToken for GET request", async () => { | ||
| const cookies = {}; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookies.raw = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: "attach csrfToken to response.locals", | ||
| actual: typeof mockResponse.locals.csrfToken, | ||
| expected: "string", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: "set CSRF token cookie", | ||
| actual: cookies.raw?.includes("csrf_token="), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 2: POST with matching token in header allowed | ||
| test("allows POST request with matching token in header", async () => { | ||
| const token = "test-token-123"; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| const result = await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: `csrf_token=${token}`, | ||
| "x-csrf-token": token, | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in header", | ||
| should: "allow request to proceed and return request/response", | ||
| actual: result.request.method, | ||
| expected: "POST", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in header", | ||
| should: "not set error status", | ||
| actual: mockResponse.status.mock.calls.length, | ||
| expected: 0, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 3: POST with matching token in body allowed | ||
| test("allows POST request with matching token in body field", async () => { | ||
| const token = "test-token-456"; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| const result = await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: `csrf_token=${token}`, | ||
| }, | ||
| body: { _csrf: token }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in body", | ||
| should: "allow request to proceed", | ||
| actual: result.request.method, | ||
| expected: "POST", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in body", | ||
| should: "not set error status", | ||
| actual: mockResponse.status.mock.calls.length, | ||
| expected: 0, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 4: Missing cookie token returns 403 | ||
| test("rejects POST request when token is missing from cookie", async () => { | ||
| const mockResponse = { | ||
| locals: { requestId: "req-123" }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| "x-csrf-token": "some-token", | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with no CSRF cookie", | ||
| should: "return 403 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 403, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with no CSRF cookie", | ||
| should: "return error message", | ||
| actual: mockResponse.json.mock.calls[0]?.[0]?.error, | ||
| expected: "CSRF validation failed", | ||
| }); | ||
| }); | ||
|
|
||
| // Req 5: Mismatched token returns 403 | ||
| test("rejects POST request when tokens do not match", async () => { | ||
| const mockResponse = { | ||
| locals: { requestId: "req-456" }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: "csrf_token=cookie-token", | ||
| "x-csrf-token": "different-token", | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with mismatched tokens", | ||
| should: "return 403 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 403, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 6: Tokens generated with CUID2 (verified by format) | ||
| test("generates tokens using CUID2 format", async () => { | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| // CUID2 tokens are 24 characters by default and lowercase alphanumeric | ||
| const token = mockResponse.locals.csrfToken; | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: | ||
| "generate token with CUID2 format (24 chars, lowercase alphanumeric)", | ||
| actual: /^[a-z0-9]{24,}$/.test(token), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 7: Cookie has SameSite=Strict, Secure in production | ||
| test("sets SameSite=Strict on CSRF cookie", async () => { | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "setting CSRF cookie", | ||
| should: "include SameSite=Strict", | ||
| actual: cookieValue.includes("SameSite=Strict"), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| test("sets Secure flag in production", async () => { | ||
| const originalEnv = process.env.NODE_ENV; | ||
| process.env.NODE_ENV = "production"; | ||
|
|
||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| process.env.NODE_ENV = originalEnv; | ||
|
|
||
| assert({ | ||
| given: "production environment", | ||
| should: "include Secure flag on cookie", | ||
| actual: cookieValue.includes("Secure"), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 8: No HttpOnly on cookie | ||
| test("does not set HttpOnly on CSRF cookie", async () => { | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "setting CSRF cookie", | ||
| should: "not include HttpOnly (client must read token)", | ||
| actual: cookieValue.includes("HttpOnly"), | ||
| expected: false, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 9: Log rejections without exposing tokens | ||
| test("logs CSRF rejection with request ID but without token values", async () => { | ||
| const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); | ||
|
|
||
| const mockResponse = { | ||
| locals: { requestId: "req-789" }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: "csrf_token=secret-cookie-token", | ||
| "x-csrf-token": "secret-header-token", | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| const logCall = consoleSpy.mock.calls[0]?.[0]; | ||
| consoleSpy.mockRestore(); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "log the failure", | ||
| actual: logCall?.message?.includes("CSRF") || logCall?.includes?.("CSRF"), | ||
| expected: true, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "include request ID in log", | ||
| actual: JSON.stringify(logCall)?.includes("req-789"), | ||
| expected: true, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "not expose token values in log", | ||
| actual: | ||
| !JSON.stringify(logCall)?.includes("secret-cookie-token") && | ||
| !JSON.stringify(logCall)?.includes("secret-header-token"), | ||
| expected: true, | ||
| }); | ||
| }); | ||
| }); |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage for PUT, PATCH, and DELETE methods. The task specification (Req 2-5) mentions that CSRF protection should apply to all unsafe methods (POST/PUT/PATCH/DELETE), but tests only verify POST requests. While the implementation correctly includes these methods in validation, they should be explicitly tested.
Add test cases for other unsafe methods:
test("validates CSRF token for PUT request", async () => {
const token = "test-token-123";
const result = await withCSRF({
request: {
method: "PUT",
headers: {
cookie: `csrf_token=${token}`,
"x-csrf-token": token,
},
},
response: mockResponse,
});
// Assert successful validation
});
// Similar tests for PATCH and DELETE
src/server/middleware/handle-form.js
Outdated
| ({ name, schema, processSubmission, pii, honeypotField }) => | ||
| async ({ request, response }) => { | ||
| // Register PII fields with logger scrubber | ||
| if (pii?.length && response.locals?.logger?.scrub) { | ||
| response.locals.logger.scrub(pii); | ||
| } | ||
|
|
||
| const body = request.body || {}; | ||
|
|
||
| // Check honeypot field if configured | ||
| if (honeypotField && body[honeypotField]) { | ||
| response.status(400); | ||
| response.json({ | ||
| errors: ["Validation failed"], | ||
| }); | ||
| return { request, response }; | ||
| } | ||
|
|
||
| // Validate against schema using TypeBox compiler | ||
| const validator = TypeCompiler.Compile(schema); | ||
| const valid = validator.Check(body); | ||
|
|
||
| if (!valid) { | ||
| response.status(400); | ||
| response.json({ | ||
| errors: formatErrors(validator.Errors(body)), | ||
| }); | ||
| return { request, response }; | ||
| } | ||
|
|
||
| // Process the validated submission | ||
| await processSubmission(body); | ||
|
|
||
| return { request, response }; |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Performance issue: TypeBox validator is compiled on every request. The TypeCompiler.Compile() should be called once at factory creation time and reused across requests.
Move the compilation outside the middleware function:
const handleForm =
({ name, schema, processSubmission, pii, honeypotField }) => {
// Compile validator once at factory creation
const validator = TypeCompiler.Compile(schema);
return async ({ request, response }) => {
// ... PII and honeypot checks ...
// Use pre-compiled validator
const valid = validator.Check(body);
// ... rest of validation logic
};
};| ({ name, schema, processSubmission, pii, honeypotField }) => | |
| async ({ request, response }) => { | |
| // Register PII fields with logger scrubber | |
| if (pii?.length && response.locals?.logger?.scrub) { | |
| response.locals.logger.scrub(pii); | |
| } | |
| const body = request.body || {}; | |
| // Check honeypot field if configured | |
| if (honeypotField && body[honeypotField]) { | |
| response.status(400); | |
| response.json({ | |
| errors: ["Validation failed"], | |
| }); | |
| return { request, response }; | |
| } | |
| // Validate against schema using TypeBox compiler | |
| const validator = TypeCompiler.Compile(schema); | |
| const valid = validator.Check(body); | |
| if (!valid) { | |
| response.status(400); | |
| response.json({ | |
| errors: formatErrors(validator.Errors(body)), | |
| }); | |
| return { request, response }; | |
| } | |
| // Process the validated submission | |
| await processSubmission(body); | |
| return { request, response }; | |
| ({ name, schema, processSubmission, pii, honeypotField }) => { | |
| // Compile validator once at factory creation | |
| const validator = TypeCompiler.Compile(schema); | |
| return async ({ request, response }) => { | |
| // Register PII fields with logger scrubber | |
| if (pii?.length && response.locals?.logger?.scrub) { | |
| response.locals.logger.scrub(pii); | |
| } | |
| const body = request.body || {}; | |
| // Check honeypot field if configured | |
| if (honeypotField && body[honeypotField]) { | |
| response.status(400); | |
| response.json({ | |
| errors: ["Validation failed"], | |
| }); | |
| return { request, response }; | |
| } | |
| // Validate against schema using pre-compiled TypeBox validator | |
| const valid = validator.Check(body); | |
| if (!valid) { | |
| response.status(400); | |
| response.json({ | |
| errors: formatErrors(validator.Errors(body)), | |
| }); | |
| return { request, response }; | |
| } | |
| // Process the validated submission | |
| await processSubmission(body); | |
| return { request, response }; | |
| }; |
src/server/middleware/handle-form.js
Outdated
| const handleForm = | ||
| ({ name, schema, processSubmission, pii, honeypotField }) => |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing JSDoc documentation. Following the pattern in other middleware factories (e.g., createWithCors, createWithAuth), this factory should have comprehensive JSDoc comments including parameter descriptions, return types, and usage examples.
Add documentation like:
/**
* Form handling middleware factory with TypeBox validation
*
* @param {Object} options
* @param {string} options.name - Form identifier for logging
* @param {Object} options.schema - TypeBox schema for validation
* @param {Function} options.processSubmission - Async function to process validated data
* @param {string[]} options.pii - Field names containing PII to scrub from logs
* @param {string} [options.honeypotField] - Optional honeypot field name
* @returns {Function} Async middleware function
*
* @example
* import { Type } from '@sinclair/typebox';
*
* const contactForm = handleForm({
* name: 'contact',
* schema: Type.Object({
* email: Type.String(),
* message: Type.String()
* }, { additionalProperties: false }),
* processSubmission: async (data) => {
* await saveContact(data);
* },
* pii: ['email'],
* honeypotField: 'website'
* });
*/
const handleForm = ...| const body = request.body || {}; | ||
|
|
||
| // Check honeypot field if configured | ||
| if (honeypotField && body[honeypotField]) { |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage for honeypot field with empty string. The implementation correctly uses a truthy check (line 35) that would allow empty strings, but there's no test verifying that an empty honeypot field doesn't trigger rejection. This is important to verify that legitimate users who leave the field blank aren't incorrectly flagged.
Add a test case:
test("allows submission when honeypot field is empty string", async () => {
const processSubmission = vi.fn().mockResolvedValue({});
const schema = Type.Object(
{
name: Type.String(),
website: Type.String(),
},
{ additionalProperties: false },
);
const middleware = handleForm({
name: "signup",
schema,
processSubmission,
pii: [],
honeypotField: "website",
});
await middleware({
request: {
body: { name: "Human", website: "" },
},
response: mockResponse,
});
assert({
given: "honeypot field with empty string",
should: "allow submission (call processSubmission)",
actual: processSubmission.mock.calls.length,
expected: 1,
});
});
tasks/form-csrf.md
Outdated
| 4. Given a request missing required fields per schema, should return 400 status with validation failures indicating missing fields | ||
| 5. Given `processSubmission` throws an error, should surface error through standard `createRoute` error handling | ||
| 6. Given a successful submission, should return `{ request, response }` without setting status or body (caller handles success response) | ||
| 7. Given PII fields, should pass them to `request.locals.logger.scrub(pii)` |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Documentation error: The task specification states PII fields should be passed to request.locals.logger.scrub(pii), but the correct location is response.locals.logger as shown in the implementation. This is consistent with other middleware patterns in the codebase where state is attached to response.locals.
Update line 54 in the task documentation:
7. Given PII fields, should pass them to `response.locals.logger.scrub(pii)`| 7. Given PII fields, should pass them to `request.locals.logger.scrub(pii)` | |
| 7. Given PII fields, should pass them to `response.locals.logger.scrub(pii)` |
| test("sets CSRF token cookie and response.locals.csrfToken for GET request", async () => { | ||
| const cookies = {}; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookies.raw = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: "attach csrfToken to response.locals", | ||
| actual: typeof mockResponse.locals.csrfToken, | ||
| expected: "string", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: "set CSRF token cookie", | ||
| actual: cookies.raw?.includes("csrf_token="), | ||
| expected: true, | ||
| }); | ||
| }); |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage for HEAD and OPTIONS safe methods. The task specification (Req 1) states that GET/HEAD/OPTIONS requests should set CSRF tokens, and the comment on line 6 acknowledges this, but only GET requests are actually tested. These methods should be explicitly tested to verify the implementation handles all safe methods correctly.
Add test cases:
test("sets CSRF token cookie for HEAD request", async () => {
const mockResponse = {
locals: {},
setHeader: vi.fn(),
};
await withCSRF({
request: { method: "HEAD", headers: {} },
response: mockResponse,
});
assert({
given: "a HEAD request",
should: "attach csrfToken to response.locals",
actual: typeof mockResponse.locals.csrfToken,
expected: "string",
});
});
// Similar test for OPTIONS…uration CSRF middleware: - Add createWithCSRF factory with configurable maxAge (default: 3 hours) - Add Path=/ cookie attribute for cross-route support - Use response.locals.log with console.log fallback - Log attack investigation details (method, url, hasCookie, hasHeader, hasBody) - Add TypeScript definitions for createWithCSRF and withCSRF Form handling: - Log honeypot triggers and validation failures - Use response.locals.log with console.log fallback - Add TypeScript definitions for handleForm and HandleFormOptions Also adds csrfToken, log, and logger to response.locals TypeScript interface
- Add createWithAuth and createWithOptionalAuth docs - Add handleForm with TypeBox validation docs - Add createWithCSRF/withCSRF docs with security features - Add recommended default middleware stack (with CSRF warning) - Add frontend CSRF integration examples (React + vanilla JS) - Add complete backend-for-frontend example
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 8 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/server/middleware/with-csrf.js
Outdated
| const parseCookies = (cookieHeader) => { | ||
| if (!cookieHeader) return {}; | ||
| return cookieHeader.split(";").reduce((cookies, cookie) => { | ||
| const [name, value] = cookie.trim().split("="); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cookie parser uses split("=") which will incorrectly parse cookies with = in their values. For example, token=abc=def would be split into ["token", "abc", "def"] and only "abc" would be used as the value.
Suggestion: Use split("=") with a limit or slice approach:
const parseCookies = (cookieHeader) => {
if (!cookieHeader) return {};
return cookieHeader.split(";").reduce((cookies, cookie) => {
const parts = cookie.trim().split("=");
const name = parts[0];
const value = parts.slice(1).join("="); // Handle = in value
cookies[name] = value;
return cookies;
}, {});
};While CUID2 tokens don't contain =, this makes the parser more robust for future use.
| const [name, value] = cookie.trim().split("="); | |
| const parts = cookie.trim().split("="); | |
| const name = parts[0]; | |
| const value = parts.slice(1).join("="); |
| const ContactSchema = Type.Object({ | ||
| name: Type.String(), | ||
| email: Type.String({ format: 'email' }), | ||
| message: Type.String(), |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation example shows honeypotField: 'website' but the schema doesn't include a website field. This is inconsistent with the more complete example later in the docs (line 874) which correctly includes website: Type.Optional(Type.String()) in the schema.
Without the field in the schema:
- Bots filling the honeypot would be rejected by the honeypot check ✓
- But it would also trigger "undeclared field" validation errors
Suggestion: Add the honeypot field to the schema for clarity and consistency:
const ContactSchema = Type.Object({
name: Type.String(),
email: Type.String({ format: 'email' }),
message: Type.String(),
website: Type.Optional(Type.String()), // Honeypot field
}, { additionalProperties: false });| message: Type.String(), | |
| message: Type.String(), | |
| website: Type.Optional(Type.String()), // Honeypot field |
src/server/middleware/handle-form.js
Outdated
| ({ name, schema, processSubmission, pii, honeypotField }) => | ||
| async ({ request, response }) => { | ||
| // Register PII fields with logger scrubber | ||
| if (pii?.length && response.locals?.logger?.scrub) { | ||
| response.locals.logger.scrub(pii); | ||
| } | ||
|
|
||
| const body = request.body || {}; | ||
|
|
||
| // Check honeypot field if configured | ||
| if (honeypotField && body[honeypotField]) { | ||
| log(response, { | ||
| message: "Form honeypot triggered", | ||
| form: name, | ||
| requestId: response.locals?.requestId, | ||
| }); | ||
| response.status(400); | ||
| response.json({ | ||
| errors: ["Validation failed"], | ||
| }); | ||
| return { request, response }; | ||
| } | ||
|
|
||
| // Validate against schema using TypeBox compiler | ||
| const validator = TypeCompiler.Compile(schema); | ||
| const valid = validator.Check(body); | ||
|
|
||
| if (!valid) { | ||
| const errors = formatErrors(validator.Errors(body)); | ||
| log(response, { | ||
| message: "Form validation failed", | ||
| form: name, | ||
| requestId: response.locals?.requestId, | ||
| errorCount: errors.length, | ||
| }); | ||
| response.status(400); | ||
| response.json({ errors }); | ||
| return { request, response }; | ||
| } | ||
|
|
||
| // Process the validated submission | ||
| await processSubmission(body); | ||
|
|
||
| return { request, response }; |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The TypeBox schema is compiled on every request (line 54), which is inefficient. TypeBox schemas should be compiled once during factory creation and reused across requests for better performance.
Suggestion: Move the compilation outside the middleware function:
const handleForm =
({ name, schema, processSubmission, pii, honeypotField }) => {
const validator = TypeCompiler.Compile(schema); // Compile once
return async ({ request, response }) => {
// ... rest of the code using validator
};
};| ({ name, schema, processSubmission, pii, honeypotField }) => | |
| async ({ request, response }) => { | |
| // Register PII fields with logger scrubber | |
| if (pii?.length && response.locals?.logger?.scrub) { | |
| response.locals.logger.scrub(pii); | |
| } | |
| const body = request.body || {}; | |
| // Check honeypot field if configured | |
| if (honeypotField && body[honeypotField]) { | |
| log(response, { | |
| message: "Form honeypot triggered", | |
| form: name, | |
| requestId: response.locals?.requestId, | |
| }); | |
| response.status(400); | |
| response.json({ | |
| errors: ["Validation failed"], | |
| }); | |
| return { request, response }; | |
| } | |
| // Validate against schema using TypeBox compiler | |
| const validator = TypeCompiler.Compile(schema); | |
| const valid = validator.Check(body); | |
| if (!valid) { | |
| const errors = formatErrors(validator.Errors(body)); | |
| log(response, { | |
| message: "Form validation failed", | |
| form: name, | |
| requestId: response.locals?.requestId, | |
| errorCount: errors.length, | |
| }); | |
| response.status(400); | |
| response.json({ errors }); | |
| return { request, response }; | |
| } | |
| // Process the validated submission | |
| await processSubmission(body); | |
| return { request, response }; | |
| ({ name, schema, processSubmission, pii, honeypotField }) => { | |
| const validator = TypeCompiler.Compile(schema); // Compile once per middleware instance | |
| return async ({ request, response }) => { | |
| // Register PII fields with logger scrubber | |
| if (pii?.length && response.locals?.logger?.scrub) { | |
| response.locals.logger.scrub(pii); | |
| } | |
| const body = request.body || {}; | |
| // Check honeypot field if configured | |
| if (honeypotField && body[honeypotField]) { | |
| log(response, { | |
| message: "Form honeypot triggered", | |
| form: name, | |
| requestId: response.locals?.requestId, | |
| }); | |
| response.status(400); | |
| response.json({ | |
| errors: ["Validation failed"], | |
| }); | |
| return { request, response }; | |
| } | |
| // Validate against schema using TypeBox compiler | |
| const valid = validator.Check(body); | |
| if (!valid) { | |
| const errors = formatErrors(validator.Errors(body)); | |
| log(response, { | |
| message: "Form validation failed", | |
| form: name, | |
| requestId: response.locals?.requestId, | |
| errorCount: errors.length, | |
| }); | |
| response.status(400); | |
| response.json({ errors }); | |
| return { request, response }; | |
| } | |
| // Process the validated submission | |
| await processSubmission(body); | |
| return { request, response }; | |
| }; |
| const schema = Type.Object( | ||
| { | ||
| name: Type.String(), | ||
| website: Type.String(), |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test schema defines website as a required field (Type.String()), but this is inconsistent with honeypot field usage. Honeypot fields should be optional in the schema because:
- Bots fill them (rejected by honeypot check)
- Humans leave them empty (would fail validation if required)
Suggestion: Make the honeypot field optional:
const schema = Type.Object(
{
name: Type.String(),
website: Type.Optional(Type.String()), // Should be optional
},
{ additionalProperties: false },
);This matches real-world usage where honeypot fields are hidden with CSS and should remain empty.
| website: Type.String(), | |
| website: Type.Optional(Type.String()), |
Include npm run typecheck in test and test:unit scripts to catch type errors during CI/test runs, matching how lint is already included.
|
please /review again against the latest commit hash |
|
@cursoragent please validate copilot suggestions, as well against the latest version of the code. |
🔬 Validation of Copilot SuggestionsI've reviewed the latest code (commit ✅ VALID SUGGESTIONS (Should Be Fixed)1. Performance Issue: TypeBox Validator Compiled on Every Request
|
The middleware was generating a new token on every safe method request, which would invalidate tokens stored by clients. Now it reuses existing cookie tokens and only generates new ones when no cookie exists. This prevents the scenario where: 1. Client fetches token A from /api/csrf 2. Client navigates, triggering GET to another withCSRF route 3. Cookie gets overwritten with token B 4. POST with stored token A fails because cookie has token B
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 8 out of 10 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const withCors = createWithCors({ | ||
| allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'] | ||
| }); | ||
|
|
||
| const withConfig = createWithConfig(() => | ||
| loadConfigFromEnv(['DATABASE_URL', 'API_SECRET']) | ||
| ); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing allowedHeaders for CSRF: The CSRF middleware accepts tokens via the X-CSRF-Token header, but the documentation example for the recommended middleware stack uses createWithCors with default allowed headers. The default allowed headers in createWithCors include common headers but may not include X-CSRF-Token.
Verify that X-CSRF-Token is included in the default CORS allowed headers, or update the documentation to explicitly mention it needs to be added:
const withCors = createWithCors({
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
allowedHeaders: [
'Origin',
'X-Requested-With',
'Content-Type',
'Accept',
'Authorization',
'X-CSRF-Token' // Required for CSRF protection
]
});| const schema = Type.Object( | ||
| { | ||
| name: Type.String(), | ||
| website: Type.String(), | ||
| }, | ||
| { additionalProperties: false }, | ||
| ); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Incorrect honeypot test schema: The test at lines 100-153 for honeypot validation uses a schema where the honeypot field is required (website: Type.String()), but in practice, honeypot fields should be optional since legitimate users won't fill them. The schema should be:
const schema = Type.Object(
{
name: Type.String(),
website: Type.Optional(Type.String()), // Optional, not required
},
{ additionalProperties: false },
);This matches the documentation example and reflects real-world usage where the honeypot field is hidden and left empty by legitimate users. The current test passes only because it's testing the filled case, but it doesn't properly represent the intended usage pattern.
| import { describe, test, vi } from "vitest"; | ||
| import { assert } from "riteway/vitest"; | ||
| import { Type } from "@sinclair/typebox"; | ||
| import { handleForm } from "./handle-form.js"; | ||
|
|
||
| describe("handleForm", () => { | ||
| // Req 1: Valid body passes to processSubmission | ||
| test("passes validated fields to processSubmission when body matches schema", async () => { | ||
| const processSubmission = vi.fn().mockResolvedValue({}); | ||
| const schema = Type.Object( | ||
| { | ||
| name: Type.String(), | ||
| email: Type.String(), | ||
| }, | ||
| { additionalProperties: false }, | ||
| ); | ||
|
|
||
| const middleware = handleForm({ | ||
| name: "contact", | ||
| schema, | ||
| processSubmission, | ||
| pii: [], | ||
| }); | ||
|
|
||
| const mockResponse = { | ||
| locals: { logger: { scrub: vi.fn() } }, | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await middleware({ | ||
| request: { | ||
| body: { name: "John", email: "[email protected]" }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a request body matching the schema", | ||
| should: "call processSubmission with validated fields", | ||
| actual: processSubmission.mock.calls[0]?.[0], | ||
| expected: { name: "John", email: "[email protected]" }, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 2: Invalid body returns 400 with validation errors | ||
| test("returns 400 with validation errors when body fails schema", async () => { | ||
| const processSubmission = vi.fn(); | ||
| const schema = Type.Object( | ||
| { | ||
| name: Type.String(), | ||
| age: Type.Number(), | ||
| }, | ||
| { additionalProperties: false }, | ||
| ); | ||
|
|
||
| const middleware = handleForm({ | ||
| name: "profile", | ||
| schema, | ||
| processSubmission, | ||
| pii: [], | ||
| }); | ||
|
|
||
| const mockResponse = { | ||
| locals: { logger: { scrub: vi.fn() } }, | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await middleware({ | ||
| request: { | ||
| body: { name: "John", age: "not a number" }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a request body failing schema validation", | ||
| should: "return 400 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 400, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a request body failing schema validation", | ||
| should: "return array of validation errors", | ||
| actual: Array.isArray(mockResponse.json.mock.calls[0]?.[0]?.errors), | ||
| expected: true, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a request body failing schema validation", | ||
| should: "not call processSubmission", | ||
| actual: processSubmission.mock.calls.length, | ||
| expected: 0, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 3: Honeypot field filled rejects with generic error | ||
| test("rejects with 400 and generic error when honeypot field is filled", async () => { | ||
| const processSubmission = vi.fn(); | ||
| const schema = Type.Object( | ||
| { | ||
| name: Type.String(), | ||
| website: Type.String(), | ||
| }, | ||
| { additionalProperties: false }, | ||
| ); | ||
|
|
||
| const middleware = handleForm({ | ||
| name: "signup", | ||
| schema, | ||
| processSubmission, | ||
| pii: [], | ||
| honeypotField: "website", | ||
| }); | ||
|
|
||
| const mockResponse = { | ||
| locals: { logger: { scrub: vi.fn() } }, | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await middleware({ | ||
| request: { | ||
| body: { name: "Bot", website: "http://spam.com" }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a request with filled honeypot field", | ||
| should: "return 400 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 400, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a request with filled honeypot field", | ||
| should: "return generic validation error (no honeypot indication)", | ||
| actual: mockResponse.json.mock.calls[0]?.[0]?.errors?.some((e) => | ||
| e.toLowerCase().includes("honeypot"), | ||
| ), | ||
| expected: false, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a request with filled honeypot field", | ||
| should: "not call processSubmission", | ||
| actual: processSubmission.mock.calls.length, | ||
| expected: 0, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 4: Missing required fields returns 400 with specific errors | ||
| test("returns 400 with missing field errors when required fields absent", async () => { | ||
| const processSubmission = vi.fn(); | ||
| const schema = Type.Object( | ||
| { | ||
| name: Type.String(), | ||
| email: Type.String(), | ||
| }, | ||
| { additionalProperties: false }, | ||
| ); | ||
|
|
||
| const middleware = handleForm({ | ||
| name: "contact", | ||
| schema, | ||
| processSubmission, | ||
| pii: [], | ||
| }); | ||
|
|
||
| const mockResponse = { | ||
| locals: { logger: { scrub: vi.fn() } }, | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await middleware({ | ||
| request: { | ||
| body: { name: "John" }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a request missing required fields", | ||
| should: "return 400 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 400, | ||
| }); | ||
|
|
||
| const errors = mockResponse.json.mock.calls[0]?.[0]?.errors || []; | ||
| assert({ | ||
| given: "a request missing required fields", | ||
| should: "indicate missing field in error", | ||
| actual: errors.some((e) => e.includes("email")), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 5: processSubmission error surfaces through createRoute | ||
| test("throws error when processSubmission throws", async () => { | ||
| const processSubmission = vi.fn().mockRejectedValue(new Error("DB error")); | ||
| const schema = Type.Object( | ||
| { name: Type.String() }, | ||
| { additionalProperties: false }, | ||
| ); | ||
|
|
||
| const middleware = handleForm({ | ||
| name: "test", | ||
| schema, | ||
| processSubmission, | ||
| pii: [], | ||
| }); | ||
|
|
||
| const mockResponse = { | ||
| locals: { logger: { scrub: vi.fn() } }, | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| let error; | ||
| try { | ||
| await middleware({ | ||
| request: { body: { name: "John" } }, | ||
| response: mockResponse, | ||
| }); | ||
| } catch (e) { | ||
| error = e; | ||
| } | ||
|
|
||
| assert({ | ||
| given: "processSubmission throws an error", | ||
| should: "surface error for createRoute error handling", | ||
| actual: error?.message, | ||
| expected: "DB error", | ||
| }); | ||
| }); | ||
|
|
||
| // Req 6: Successful submission returns { request, response } without setting status/body | ||
| test("returns request/response without setting status on success", async () => { | ||
| const processSubmission = vi.fn().mockResolvedValue({}); | ||
| const schema = Type.Object( | ||
| { name: Type.String() }, | ||
| { additionalProperties: false }, | ||
| ); | ||
|
|
||
| const middleware = handleForm({ | ||
| name: "test", | ||
| schema, | ||
| processSubmission, | ||
| pii: [], | ||
| }); | ||
|
|
||
| const mockResponse = { | ||
| locals: { logger: { scrub: vi.fn() } }, | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| const result = await middleware({ | ||
| request: { body: { name: "John" } }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a successful form submission", | ||
| should: "return request object", | ||
| actual: typeof result.request, | ||
| expected: "object", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a successful form submission", | ||
| should: "return response object", | ||
| actual: typeof result.response, | ||
| expected: "object", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a successful form submission", | ||
| should: "not set status (caller handles response)", | ||
| actual: mockResponse.status.mock.calls.length, | ||
| expected: 0, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 7: PII fields passed to logger.scrub | ||
| test("passes PII fields to logger.scrub", async () => { | ||
| const processSubmission = vi.fn().mockResolvedValue({}); | ||
| const scrubFn = vi.fn(); | ||
| const schema = Type.Object( | ||
| { | ||
| name: Type.String(), | ||
| ssn: Type.String(), | ||
| }, | ||
| { additionalProperties: false }, | ||
| ); | ||
|
|
||
| const middleware = handleForm({ | ||
| name: "sensitive", | ||
| schema, | ||
| processSubmission, | ||
| pii: ["ssn"], | ||
| }); | ||
|
|
||
| const mockResponse = { | ||
| locals: { logger: { scrub: scrubFn } }, | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await middleware({ | ||
| request: { body: { name: "John", ssn: "123-45-6789" }, locals: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "PII fields configured", | ||
| should: "call logger.scrub with PII field names", | ||
| actual: scrubFn.mock.calls[0]?.[0], | ||
| expected: ["ssn"], | ||
| }); | ||
| }); | ||
|
|
||
| // Req 8: Undeclared fields return 400 | ||
| test("returns 400 when request contains undeclared fields", async () => { | ||
| const processSubmission = vi.fn(); | ||
| const schema = Type.Object( | ||
| { | ||
| name: Type.String(), | ||
| }, | ||
| { additionalProperties: false }, | ||
| ); | ||
|
|
||
| const middleware = handleForm({ | ||
| name: "strict", | ||
| schema, | ||
| processSubmission, | ||
| pii: [], | ||
| }); | ||
|
|
||
| const mockResponse = { | ||
| locals: { logger: { scrub: vi.fn() } }, | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await middleware({ | ||
| request: { | ||
| body: { name: "John", extraField: "not allowed" }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a request with undeclared fields", | ||
| should: "return 400 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 400, | ||
| }); | ||
|
|
||
| const errors = mockResponse.json.mock.calls[0]?.[0]?.errors || []; | ||
| assert({ | ||
| given: "a request with undeclared fields", | ||
| should: "indicate undeclared field in error", | ||
| actual: errors.some( | ||
| (e) => e.includes("extraField") || e.includes("additional"), | ||
| ), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 9: Honeypot omitted skips validation | ||
| test("skips honeypot validation when honeypotField not provided", async () => { | ||
| const processSubmission = vi.fn().mockResolvedValue({}); | ||
| const schema = Type.Object( | ||
| { | ||
| name: Type.String(), | ||
| website: Type.String(), | ||
| }, | ||
| { additionalProperties: false }, | ||
| ); | ||
|
|
||
| const middleware = handleForm({ | ||
| name: "no-honeypot", | ||
| schema, | ||
| processSubmission, | ||
| pii: [], | ||
| // honeypotField intentionally omitted | ||
| }); | ||
|
|
||
| const mockResponse = { | ||
| locals: { logger: { scrub: vi.fn() } }, | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await middleware({ | ||
| request: { | ||
| body: { name: "Human", website: "http://real-site.com" }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "no honeypotField configured and website field filled", | ||
| should: "allow submission (call processSubmission)", | ||
| actual: processSubmission.mock.calls.length, | ||
| expected: 1, | ||
| }); | ||
| }); | ||
| }); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage: There are no tests verifying that handleForm throws errors when required parameters (name, schema, processSubmission) are missing. The test file for with-auth includes a test "requires auth parameter" which validates this pattern. Add similar tests:
test("requires name parameter", () => {
let error;
try {
handleForm({ schema: Type.Object({}), processSubmission: vi.fn() });
} catch (e) {
error = e;
}
assert({
given: "no name parameter",
should: "throw error requiring name",
actual: error?.message.includes("name") && error?.message.includes("required"),
expected: true,
});
});
test("requires schema parameter", () => {
let error;
try {
handleForm({ name: "test", processSubmission: vi.fn() });
} catch (e) {
error = e;
}
assert({
given: "no schema parameter",
should: "throw error requiring schema",
actual: error?.message.includes("schema") && error?.message.includes("required"),
expected: true,
});
});
test("requires processSubmission parameter", () => {
let error;
try {
handleForm({ name: "test", schema: Type.Object({}) });
} catch (e) {
error = e;
}
assert({
given: "no processSubmission parameter",
should: "throw error requiring processSubmission",
actual: error?.message.includes("processSubmission") && error?.message.includes("required"),
expected: true,
});
});| import { describe, test, vi } from "vitest"; | ||
| import { assert } from "riteway/vitest"; | ||
| import { withCSRF } from "./with-csrf.js"; | ||
|
|
||
| describe("withCSRF", () => { | ||
| // Req 1: GET/HEAD/OPTIONS sets token cookie and response.locals.csrfToken | ||
| test("sets CSRF token cookie and response.locals.csrfToken for GET request", async () => { | ||
| const cookies = {}; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookies.raw = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: "attach csrfToken to response.locals", | ||
| actual: typeof mockResponse.locals.csrfToken, | ||
| expected: "string", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: "set CSRF token cookie", | ||
| actual: cookies.raw?.includes("csrf_token="), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 2: POST with matching token in header allowed | ||
| test("allows POST request with matching token in header", async () => { | ||
| const token = "test-token-123"; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| const result = await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: `csrf_token=${token}`, | ||
| "x-csrf-token": token, | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in header", | ||
| should: "allow request to proceed and return request/response", | ||
| actual: result.request.method, | ||
| expected: "POST", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in header", | ||
| should: "not set error status", | ||
| actual: mockResponse.status.mock.calls.length, | ||
| expected: 0, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 3: POST with matching token in body allowed | ||
| test("allows POST request with matching token in body field", async () => { | ||
| const token = "test-token-456"; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| const result = await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: `csrf_token=${token}`, | ||
| }, | ||
| body: { _csrf: token }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in body", | ||
| should: "allow request to proceed", | ||
| actual: result.request.method, | ||
| expected: "POST", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in body", | ||
| should: "not set error status", | ||
| actual: mockResponse.status.mock.calls.length, | ||
| expected: 0, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 4: Missing cookie token returns 403 | ||
| test("rejects POST request when token is missing from cookie", async () => { | ||
| const mockResponse = { | ||
| locals: { requestId: "req-123" }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| "x-csrf-token": "some-token", | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with no CSRF cookie", | ||
| should: "return 403 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 403, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with no CSRF cookie", | ||
| should: "return error message", | ||
| actual: mockResponse.json.mock.calls[0]?.[0]?.error, | ||
| expected: "CSRF validation failed", | ||
| }); | ||
| }); | ||
|
|
||
| // Req 5: Mismatched token returns 403 | ||
| test("rejects POST request when tokens do not match", async () => { | ||
| const mockResponse = { | ||
| locals: { requestId: "req-456" }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: "csrf_token=cookie-token", | ||
| "x-csrf-token": "different-token", | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with mismatched tokens", | ||
| should: "return 403 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 403, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 6: Tokens generated with CUID2 (verified by format) | ||
| test("generates tokens using CUID2 format", async () => { | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| // CUID2 tokens are 24 characters by default and lowercase alphanumeric | ||
| const token = mockResponse.locals.csrfToken; | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: | ||
| "generate token with CUID2 format (24 chars, lowercase alphanumeric)", | ||
| actual: /^[a-z0-9]{24,}$/.test(token), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 7: Cookie has SameSite=Strict, Secure in production | ||
| test("sets SameSite=Strict on CSRF cookie", async () => { | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "setting CSRF cookie", | ||
| should: "include SameSite=Strict", | ||
| actual: cookieValue.includes("SameSite=Strict"), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| test("sets Secure flag in production", async () => { | ||
| const originalEnv = process.env.NODE_ENV; | ||
| process.env.NODE_ENV = "production"; | ||
|
|
||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| process.env.NODE_ENV = originalEnv; | ||
|
|
||
| assert({ | ||
| given: "production environment", | ||
| should: "include Secure flag on cookie", | ||
| actual: cookieValue.includes("Secure"), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 8: No HttpOnly on cookie | ||
| test("does not set HttpOnly on CSRF cookie", async () => { | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "setting CSRF cookie", | ||
| should: "not include HttpOnly (client must read token)", | ||
| actual: cookieValue.includes("HttpOnly"), | ||
| expected: false, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 9: Log rejections without exposing tokens | ||
| test("logs CSRF rejection with request ID but without token values", async () => { | ||
| const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); | ||
|
|
||
| const mockResponse = { | ||
| locals: { requestId: "req-789" }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| url: "/api/submit", | ||
| headers: { | ||
| cookie: "csrf_token=secret-cookie-token", | ||
| "x-csrf-token": "secret-header-token", | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| const logCall = consoleSpy.mock.calls[0]?.[0]; | ||
| consoleSpy.mockRestore(); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "log the failure", | ||
| actual: logCall?.message?.includes("CSRF") || logCall?.includes?.("CSRF"), | ||
| expected: true, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "include request ID in log", | ||
| actual: JSON.stringify(logCall)?.includes("req-789"), | ||
| expected: true, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "not expose token values in log", | ||
| actual: | ||
| !JSON.stringify(logCall)?.includes("secret-cookie-token") && | ||
| !JSON.stringify(logCall)?.includes("secret-header-token"), | ||
| expected: true, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "include attack investigation details", | ||
| actual: | ||
| logCall?.method === "POST" && | ||
| logCall?.url === "/api/submit" && | ||
| logCall?.hasCookie === true && | ||
| logCall?.hasHeader === true, | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| test("uses response.locals.log when available", async () => { | ||
| const customLog = vi.fn(); | ||
|
|
||
| const mockResponse = { | ||
| locals: { requestId: "req-custom", log: customLog }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: {}, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "response.locals.log is available", | ||
| should: "use custom logger instead of console.log", | ||
| actual: customLog.mock.calls.length, | ||
| expected: 1, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "response.locals.log is available", | ||
| should: "pass log data to custom logger", | ||
| actual: customLog.mock.calls[0]?.[0]?.message, | ||
| expected: "CSRF validation failed", | ||
| }); | ||
| }); | ||
|
|
||
| // Req 10: Reuse existing token on subsequent GET requests | ||
| test("reuses existing token from cookie on GET request", async () => { | ||
| const existingToken = "existing-token-abc123"; | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "GET", | ||
| headers: { | ||
| cookie: `csrf_token=${existingToken}`, | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request with existing CSRF cookie", | ||
| should: "reuse the existing token in response.locals", | ||
| actual: mockResponse.locals.csrfToken, | ||
| expected: existingToken, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request with existing CSRF cookie", | ||
| should: "set cookie with same token (to refresh expiry)", | ||
| actual: cookieValue.includes(`csrf_token=${existingToken}`), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| test("generates new token on GET when no cookie exists", async () => { | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "GET", | ||
| headers: {}, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request with no existing CSRF cookie", | ||
| should: "generate a new token", | ||
| actual: typeof mockResponse.locals.csrfToken, | ||
| expected: "string", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request with no existing CSRF cookie", | ||
| should: "generate token with CUID2 format", | ||
| actual: /^[a-z0-9]{24,}$/.test(mockResponse.locals.csrfToken), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| test("sets Path=/ on CSRF cookie", async () => { | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "setting CSRF cookie", | ||
| should: "include Path=/ for all routes", | ||
| actual: cookieValue.includes("Path=/"), | ||
| expected: true, | ||
| }); | ||
| }); | ||
| }); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage for PUT, PATCH, and DELETE methods: The tests only cover POST requests for unsafe methods, but the middleware should validate CSRF tokens for PUT, PATCH, and DELETE as well. Add tests to ensure these methods are properly protected:
test("allows PUT request with matching token in header", async () => {
const token = "test-token-put";
const mockResponse = {
locals: {},
setHeader: vi.fn(),
status: vi.fn(),
json: vi.fn(),
};
const result = await withCSRF({
request: {
method: "PUT",
headers: {
cookie: `csrf_token=${token}`,
"x-csrf-token": token,
},
},
response: mockResponse,
});
assert({
given: "a PUT request with matching token in header",
should: "allow request to proceed",
actual: result.request.method,
expected: "PUT",
});
});
// Similar tests for PATCH and DELETE| import { describe, test, vi } from "vitest"; | ||
| import { assert } from "riteway/vitest"; | ||
| import { withCSRF } from "./with-csrf.js"; | ||
|
|
||
| describe("withCSRF", () => { | ||
| // Req 1: GET/HEAD/OPTIONS sets token cookie and response.locals.csrfToken | ||
| test("sets CSRF token cookie and response.locals.csrfToken for GET request", async () => { | ||
| const cookies = {}; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookies.raw = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: "attach csrfToken to response.locals", | ||
| actual: typeof mockResponse.locals.csrfToken, | ||
| expected: "string", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: "set CSRF token cookie", | ||
| actual: cookies.raw?.includes("csrf_token="), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 2: POST with matching token in header allowed | ||
| test("allows POST request with matching token in header", async () => { | ||
| const token = "test-token-123"; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| const result = await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: `csrf_token=${token}`, | ||
| "x-csrf-token": token, | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in header", | ||
| should: "allow request to proceed and return request/response", | ||
| actual: result.request.method, | ||
| expected: "POST", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in header", | ||
| should: "not set error status", | ||
| actual: mockResponse.status.mock.calls.length, | ||
| expected: 0, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 3: POST with matching token in body allowed | ||
| test("allows POST request with matching token in body field", async () => { | ||
| const token = "test-token-456"; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| const result = await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: `csrf_token=${token}`, | ||
| }, | ||
| body: { _csrf: token }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in body", | ||
| should: "allow request to proceed", | ||
| actual: result.request.method, | ||
| expected: "POST", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with matching token in body", | ||
| should: "not set error status", | ||
| actual: mockResponse.status.mock.calls.length, | ||
| expected: 0, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 4: Missing cookie token returns 403 | ||
| test("rejects POST request when token is missing from cookie", async () => { | ||
| const mockResponse = { | ||
| locals: { requestId: "req-123" }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| "x-csrf-token": "some-token", | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with no CSRF cookie", | ||
| should: "return 403 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 403, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with no CSRF cookie", | ||
| should: "return error message", | ||
| actual: mockResponse.json.mock.calls[0]?.[0]?.error, | ||
| expected: "CSRF validation failed", | ||
| }); | ||
| }); | ||
|
|
||
| // Req 5: Mismatched token returns 403 | ||
| test("rejects POST request when tokens do not match", async () => { | ||
| const mockResponse = { | ||
| locals: { requestId: "req-456" }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: { | ||
| cookie: "csrf_token=cookie-token", | ||
| "x-csrf-token": "different-token", | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a POST request with mismatched tokens", | ||
| should: "return 403 status", | ||
| actual: mockResponse.status.mock.calls[0]?.[0], | ||
| expected: 403, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 6: Tokens generated with CUID2 (verified by format) | ||
| test("generates tokens using CUID2 format", async () => { | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| // CUID2 tokens are 24 characters by default and lowercase alphanumeric | ||
| const token = mockResponse.locals.csrfToken; | ||
|
|
||
| assert({ | ||
| given: "a GET request", | ||
| should: | ||
| "generate token with CUID2 format (24 chars, lowercase alphanumeric)", | ||
| actual: /^[a-z0-9]{24,}$/.test(token), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 7: Cookie has SameSite=Strict, Secure in production | ||
| test("sets SameSite=Strict on CSRF cookie", async () => { | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "setting CSRF cookie", | ||
| should: "include SameSite=Strict", | ||
| actual: cookieValue.includes("SameSite=Strict"), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| test("sets Secure flag in production", async () => { | ||
| const originalEnv = process.env.NODE_ENV; | ||
| process.env.NODE_ENV = "production"; | ||
|
|
||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| process.env.NODE_ENV = originalEnv; | ||
|
|
||
| assert({ | ||
| given: "production environment", | ||
| should: "include Secure flag on cookie", | ||
| actual: cookieValue.includes("Secure"), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 8: No HttpOnly on cookie | ||
| test("does not set HttpOnly on CSRF cookie", async () => { | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "setting CSRF cookie", | ||
| should: "not include HttpOnly (client must read token)", | ||
| actual: cookieValue.includes("HttpOnly"), | ||
| expected: false, | ||
| }); | ||
| }); | ||
|
|
||
| // Req 9: Log rejections without exposing tokens | ||
| test("logs CSRF rejection with request ID but without token values", async () => { | ||
| const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); | ||
|
|
||
| const mockResponse = { | ||
| locals: { requestId: "req-789" }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| url: "/api/submit", | ||
| headers: { | ||
| cookie: "csrf_token=secret-cookie-token", | ||
| "x-csrf-token": "secret-header-token", | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| const logCall = consoleSpy.mock.calls[0]?.[0]; | ||
| consoleSpy.mockRestore(); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "log the failure", | ||
| actual: logCall?.message?.includes("CSRF") || logCall?.includes?.("CSRF"), | ||
| expected: true, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "include request ID in log", | ||
| actual: JSON.stringify(logCall)?.includes("req-789"), | ||
| expected: true, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "not expose token values in log", | ||
| actual: | ||
| !JSON.stringify(logCall)?.includes("secret-cookie-token") && | ||
| !JSON.stringify(logCall)?.includes("secret-header-token"), | ||
| expected: true, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a CSRF rejection", | ||
| should: "include attack investigation details", | ||
| actual: | ||
| logCall?.method === "POST" && | ||
| logCall?.url === "/api/submit" && | ||
| logCall?.hasCookie === true && | ||
| logCall?.hasHeader === true, | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| test("uses response.locals.log when available", async () => { | ||
| const customLog = vi.fn(); | ||
|
|
||
| const mockResponse = { | ||
| locals: { requestId: "req-custom", log: customLog }, | ||
| setHeader: vi.fn(), | ||
| status: vi.fn().mockReturnThis(), | ||
| json: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "POST", | ||
| headers: {}, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "response.locals.log is available", | ||
| should: "use custom logger instead of console.log", | ||
| actual: customLog.mock.calls.length, | ||
| expected: 1, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "response.locals.log is available", | ||
| should: "pass log data to custom logger", | ||
| actual: customLog.mock.calls[0]?.[0]?.message, | ||
| expected: "CSRF validation failed", | ||
| }); | ||
| }); | ||
|
|
||
| // Req 10: Reuse existing token on subsequent GET requests | ||
| test("reuses existing token from cookie on GET request", async () => { | ||
| const existingToken = "existing-token-abc123"; | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "GET", | ||
| headers: { | ||
| cookie: `csrf_token=${existingToken}`, | ||
| }, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request with existing CSRF cookie", | ||
| should: "reuse the existing token in response.locals", | ||
| actual: mockResponse.locals.csrfToken, | ||
| expected: existingToken, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request with existing CSRF cookie", | ||
| should: "set cookie with same token (to refresh expiry)", | ||
| actual: cookieValue.includes(`csrf_token=${existingToken}`), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| test("generates new token on GET when no cookie exists", async () => { | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn(), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { | ||
| method: "GET", | ||
| headers: {}, | ||
| }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request with no existing CSRF cookie", | ||
| should: "generate a new token", | ||
| actual: typeof mockResponse.locals.csrfToken, | ||
| expected: "string", | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "a GET request with no existing CSRF cookie", | ||
| should: "generate token with CUID2 format", | ||
| actual: /^[a-z0-9]{24,}$/.test(mockResponse.locals.csrfToken), | ||
| expected: true, | ||
| }); | ||
| }); | ||
|
|
||
| test("sets Path=/ on CSRF cookie", async () => { | ||
| let cookieValue = ""; | ||
| const mockResponse = { | ||
| locals: {}, | ||
| setHeader: vi.fn((name, value) => { | ||
| if (name === "Set-Cookie") cookieValue = value; | ||
| }), | ||
| }; | ||
|
|
||
| await withCSRF({ | ||
| request: { method: "GET", headers: {} }, | ||
| response: mockResponse, | ||
| }); | ||
|
|
||
| assert({ | ||
| given: "setting CSRF cookie", | ||
| should: "include Path=/ for all routes", | ||
| actual: cookieValue.includes("Path=/"), | ||
| expected: true, | ||
| }); | ||
| }); | ||
| }); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage for HEAD and OPTIONS methods: The tests only cover GET requests for safe methods, but the middleware is designed to handle HEAD and OPTIONS as well (line 20: const SAFE_METHODS = ["GET", "HEAD", "OPTIONS"];). Add tests to verify HEAD and OPTIONS requests also set CSRF cookies and expose tokens:
test("sets CSRF token cookie and response.locals.csrfToken for HEAD request", async () => {
const cookies = {};
const mockResponse = {
locals: {},
setHeader: vi.fn((name, value) => {
if (name === "Set-Cookie") cookies.raw = value;
}),
};
await withCSRF({
request: { method: "HEAD", headers: {} },
response: mockResponse,
});
assert({
given: "a HEAD request",
should: "attach csrfToken to response.locals",
actual: typeof mockResponse.locals.csrfToken,
expected: "string",
});
});
test("sets CSRF token cookie and response.locals.csrfToken for OPTIONS request", async () => {
const cookies = {};
const mockResponse = {
locals: {},
setHeader: vi.fn((name, value) => {
if (name === "Set-Cookie") cookies.raw = value;
}),
};
await withCSRF({
request: { method: "OPTIONS", headers: {} },
response: mockResponse,
});
assert({
given: "an OPTIONS request",
should: "attach csrfToken to response.locals",
actual: typeof mockResponse.locals.csrfToken,
expected: "string",
});
});| response.status(403); | ||
| response.json({ | ||
| error: "CSRF validation failed", | ||
| requestId, | ||
| }); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Inconsistent error response format: The CSRF validation failure returns { error: "CSRF validation failed", requestId } but this differs from the pattern used in other middleware. The createWithAuth middleware returns { error: "Unauthorized" } without requestId. For consistency and better debugging, the requestId inclusion is actually helpful. However, consider whether this should be standardized across all error responses in the codebase.
| const hashToken = (token) => sha3_256(token || ""); | ||
|
|
||
| const tokensMatch = (token1, token2) => hashToken(token1) === hashToken(token2); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Security issue: Using SHA3 hash comparison for CSRF tokens provides timing attack protection, but the hash is computed on-the-fly for each comparison. For better security and consistency with the task requirement "compare SHA3 hashes of tokens to prevent timing attacks", consider using a constant-time comparison function after hashing. However, since SHA3 hashes are of fixed length and the comparison is of hash outputs (not the original tokens), this implementation does provide timing attack protection as intended.
Note: The current implementation is secure because it compares hashes, but the hash comparison itself (=== operator) could theoretically leak timing information. For maximum security, consider using a constant-time comparison:
import { timingSafeEqual } from 'crypto';
const tokensMatch = (token1, token2) => {
const hash1 = Buffer.from(hashToken(token1), 'hex');
const hash2 = Buffer.from(hashToken(token2), 'hex');
return hash1.length === hash2.length && timingSafeEqual(hash1, hash2);
};However, this is likely overkill since SHA3 outputs are compared, not the tokens themselves.
src/server/middleware/handle-form.js
Outdated
| const handleForm = | ||
| ({ name, schema, processSubmission, pii, honeypotField }) => | ||
| async ({ request, response }) => { |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing validation: The handleForm factory should validate that required parameters name, schema, and processSubmission are provided, throwing clear errors at factory creation time rather than allowing runtime failures. This follows the established pattern seen in createWithAuth and createWithCors where required parameters are validated immediately.
Example from createWithAuth:
if (!auth) {
throw new Error("auth is required. Pass your better-auth instance.");
}Add similar validation:
const handleForm = ({ name, schema, processSubmission, pii, honeypotField }) => {
if (!name) {
throw new Error("name is required for form identification in logs");
}
if (!schema) {
throw new Error("schema is required for form validation");
}
if (!processSubmission) {
throw new Error("processSubmission is required to handle validated form data");
}
return async ({ request, response }) => {
// ... rest of implementation
};
};Performance: - Move TypeCompiler.Compile outside request handler (compile once) Correctness: - Fix cookie parser to handle values containing '=' - Make honeypot field optional in test schemas (matches real usage) Documentation: - Add JSDoc to handleForm with full parameter docs and example - Fix task doc: request.locals → response.locals - Add detailed timing attack rationale comments for SHA3 hashing Test coverage: - Add tests for HEAD and OPTIONS methods (safe methods) - Add test for empty string honeypot (should allow submission)
Add universal security rule that prohibits timing-safe compare functions on raw secret values. Instead, always hash both tokens with SHA3 before comparison. Covers all languages: Node.js, Ruby, Python, Go, Java, etc. Added to /review checklist for code review enforcement.
4a378c3 to
21ea9c2
Compare
|
@cursoragent please review again |
🔬 Fresh Code Review - Latest CommitI've reviewed the latest code (commit 🚨 CRITICAL BUGS (Must Fix Before Merge)1. CSRF Body Token Incompatible with Form Validation ⛔ BLOCKINGSeverity: HIGH - This breaks the documented integration pattern Issue: When Root Cause:
Example of Broken Code (from docs): const ContactSchema = Type.Object({
name: Type.String(),
email: Type.String(),
message: Type.String(),
}, { additionalProperties: false }); // ← This breaks _csrf in body
const withContactForm = handleForm({
schema: ContactSchema, // ← Will reject any body with _csrf
// ...
});Solutions (pick one): Option A: Strip // In handleForm, line 65-66
const body = request.body || {};
const { _csrf, ...validationBody } = body; // Strip CSRF token
// Then validate validationBody instead of bodyOption B: Document that body tokens are incompatible with strict schemas: // Update docs to show _csrf must be optional in schema:
const ContactSchema = Type.Object({
name: Type.String(),
email: Type.String(),
_csrf: Type.Optional(Type.String()), // ← Required if using body tokens
}, { additionalProperties: false });Option C: Recommend header-only CSRF tokens in docs and update examples. Recommendation: Option A - Auto-strip
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 10 out of 12 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| processSubmission, | ||
| pii, | ||
| honeypotField, | ||
| }) => { |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The handleForm factory should validate required parameters (name, schema, processSubmission) at creation time, not at middleware execution time. Following the established pattern in this codebase (see with-auth.js:42-44 and with-cors.js:46-52), required parameters should be validated when the factory is called to fail fast and provide clear errors during application startup.
Add validation at the beginning of handleForm:
const handleForm = ({
name,
schema,
processSubmission,
pii,
honeypotField,
}) => {
if (!name) {
throw new Error("handleForm: name is required");
}
if (!schema) {
throw new Error("handleForm: schema is required");
}
if (!processSubmission) {
throw new Error("handleForm: processSubmission is required");
}
// ... rest of implementation
};| }) => { | |
| }) => { | |
| if (!name) { | |
| throw new Error("handleForm: name is required"); | |
| } | |
| if (!schema) { | |
| throw new Error("handleForm: schema is required"); | |
| } | |
| if (!processSubmission) { | |
| throw new Error("handleForm: processSubmission is required"); | |
| } |
| import { Type } from "@sinclair/typebox"; | ||
| import { handleForm } from "./handle-form.js"; | ||
|
|
||
| describe("handleForm", () => { |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage for required parameter validation in the handleForm factory. The test suite should include tests that verify the factory throws clear errors when required parameters (name, schema, processSubmission) are missing or invalid.
Example test to add:
test("requires name parameter", () => {
let error;
try {
handleForm({ schema: Type.Object({}), processSubmission: vi.fn() });
} catch (e) {
error = e;
}
assert({
given: "no name parameter",
should: "throw error requiring name",
actual: error?.message.includes("name") && error?.message.includes("required"),
expected: true,
});
});Similar tests should be added for schema and processSubmission.
|
@cursor - please check the latest hash and create a simple report of all remaining bugs. Ignore nice to have things like "update all the other endpoints to include requestId in the error messages" |
🐛 Bug Report - Commit
|
| Bug | Severity | Impact | Fix Complexity |
|---|---|---|---|
| #1: Missing exports | HIGH | Complete breakage | 3 lines |
| #2: CSRF + form incompatibility | HIGH | Documentation pattern broken | 1-2 lines |
| #3: No param validation | MEDIUM | Poor error messages | 3 lines |
Recommendation: Fix bugs #1 and #2 before merge. Bug #3 is nice-to-have but follows existing patterns in the codebase.
Learn more about Cursor Agents
Bug #1: Missing exports - Add handleForm, createWithCSRF, withCSRF to src/server/index.js Bug #2: CSRF body token breaks form validation - Strip _csrf from request.body before schema validation - Allows withCSRF + handleForm to work together with strict schemas Bug #3: Missing parameter validation - Add fail-fast validation for name, schema, processSubmission - Throws clear errors at factory creation time, not runtime Added 4 new tests for the fixes.
|
@cursoragent - please /review again to ensure your suggested changes have been implemented correctly |
🐛 Bug Report - Commit
|
|
@cursoragent please check the correct commit hash: 66b80e6 |
✅ Review Complete - Commit
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 11 out of 13 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const { _csrf, ...body } = request.body || {}; | ||
|
|
||
| // Check honeypot field if configured | ||
| if (honeypotField && body[honeypotField]) { |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The honeypot check uses truthy evaluation which treats empty strings as valid (empty). However, the check body[honeypotField] will trigger for 0, false, and other falsy values that are not empty strings.
Since the functional requirement states "must be empty" and the test at line 182 checks for empty string specifically, the condition should be:
if (honeypotField && body[honeypotField] !== undefined && body[honeypotField] !== '') {This ensures that:
undefined(field not submitted) is allowed""(empty string) is allowed- Any other value triggers rejection
| if (honeypotField && body[honeypotField]) { | |
| if (honeypotField && body[honeypotField] !== undefined && body[honeypotField] !== '') { |
| // Compile schema once when middleware is created, not on every request | ||
| const validator = TypeCompiler.Compile(schema); | ||
|
|
||
| return async ({ request, response }) => { |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The handleForm middleware doesn't initialize response.locals if it doesn't exist. This follows the existing pattern from other middleware like with-auth.js which uses ensureLocals(), but handleForm accesses response.locals with optional chaining throughout without ensuring it exists first.
While the current code uses optional chaining (response.locals?.logger?.scrub), it should still initialize the object to follow the established pattern seen in other middleware (with-auth.js:10-12, with-csrf.js:88).
Add this at the start of the middleware function:
return async ({ request, response }) => {
if (!response.locals) response.locals = {};
// ... rest of code
};| return async ({ request, response }) => { | |
| return async ({ request, response }) => { | |
| if (!response.locals) response.locals = {}; |
|
|
||
| // Unsafe method - validate CSRF token | ||
| const cookies = parseCookies(request.headers?.cookie); | ||
| const cookieToken = cookies[COOKIE_NAME]; |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Consider adding a comment explaining that Node.js normalizes all incoming headers to lowercase, similar to the pattern in with-cors.js:72. This helps developers understand why the header name is lowercase despite the documentation showing 'X-CSRF-Token'.
// Unsafe method - validate CSRF token
const cookies = parseCookies(request.headers?.cookie);
const cookieToken = cookies[COOKIE_NAME];
// Node.js normalizes all incoming headers to lowercase
const headerToken = request.headers?.["x-csrf-token"];
const bodyToken = request.body?._csrf;| const cookieToken = cookies[COOKIE_NAME]; | |
| const cookieToken = cookies[COOKIE_NAME]; | |
| // Node.js normalizes all incoming header names to lowercase. | |
| // So although documentation may refer to 'X-CSRF-Token', access as 'x-csrf-token'. |
| hasBody: Boolean(bodyToken), | ||
| }); | ||
| return { request, response }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: CSRF failure doesn't prevent form processing execution
When withCSRF rejects a request with 403, it still returns { request, response } allowing the middleware pipeline to continue. This means handleForm's processSubmission callback will still execute even after CSRF validation fails. In the documented usage pattern (createRoute(withCSRF, handleForm(...), handler)), business logic like sending emails or creating database records will run without CSRF protection. The middleware should either throw an error to halt the pipeline, or handleForm should check response.statusCode or a flag in response.locals before calling processSubmission.
Additional Locations (1)
| } | ||
|
|
||
| // Process the validated submission | ||
| await processSubmission(body); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: CSRF bypass allows form processing after rejection
When withCSRF rejects a request with 403, the asyncPipe composition pattern continues executing subsequent middleware. The handleForm middleware has no check for prior rejection and will still call processSubmission(body) if the body passes schema validation. This means CSRF-protected endpoints can still have their side effects (database writes, emails, etc.) executed by attackers, completely bypassing the CSRF protection. The handleForm middleware needs to check if the response has already been sent or if a prior middleware set an error status before calling processSubmission.
Additional Locations (1)
Bug: When withCSRF rejected a request with 403, the asyncPipe pipeline continued executing subsequent middleware. handleForm would still call processSubmission() if the body passed schema validation, allowing attackers to trigger side effects (database writes, emails, etc.) despite CSRF rejection. Fix: handleForm now checks response.statusCode before processing. If a prior middleware set an error status (>= 400), it skips calling processSubmission and returns early. Added integration tests verifying: - CSRF rejection returns 403 with proper error message - processSubmission is NOT called after CSRF rejection - Valid CSRF tokens allow normal form processing
|
@cursoragent please validate fix and new copilot issues and /review with a BRIEF report hash cb12d1c |
✅ Brief Review - Commit
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 11 out of 13 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!name) throw new Error("handleForm: name is required"); | ||
| if (!schema) throw new Error("handleForm: schema is required"); | ||
| if (!processSubmission) | ||
| throw new Error("handleForm: processSubmission is required"); |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The middleware should use createError from the error-causes library for all thrown errors. Based on the existing middleware patterns (e.g., with-auth.js, with-cors.js, with-config.js), parameter validation errors at factory creation time should use createError instead of new Error().
Example from with-cors.js:
import { createError } from "error-causes";
if (!allowedOrigins) {
throw createError({
name: "ConfigurationError",
message: "CORS configuration error: allowedOrigins is required..."
});
}This should be applied to the parameter validation in handleForm as well.
| - Use a JSON Schema validation library (suggest ajv) | ||
| - Configure ajv with `additionalProperties: false` behavior for requirement 8 |
Copilot
AI
Dec 8, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The task specification (line 60) suggests using ajv for JSON Schema validation, but the implementation correctly uses @sinclair/typebox with TypeCompiler instead, which aligns with the codebase convention.
However, the task document should be updated to reflect the actual implementation decision. Consider updating line 60 from:
- Use a JSON Schema validation library (suggest ajv)
to:
- Use @sinclair/typebox with TypeCompiler for schema validation
This would match the actual implementation and the established pattern in the codebase.
| - Use a JSON Schema validation library (suggest ajv) | |
| - Configure ajv with `additionalProperties: false` behavior for requirement 8 | |
| - Use @sinclair/typebox with TypeCompiler for schema validation | |
| - Configure the schema with `additionalProperties: false` for requirement 8 |
| response.json({ | ||
| error: "CSRF validation failed", | ||
| requestId, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: CSRF middleware doesn't set statusCode for downstream detection
The withCSRF middleware calls response.status(403) when rejecting requests but doesn't explicitly set response.statusCode. The downstream handleForm middleware checks response.statusCode >= 400 to skip processing if a prior middleware rejected the request. In real response objects where status() doesn't automatically set the statusCode property, handleForm won't detect the CSRF rejection and will proceed to call processSubmission - executing side effects that should have been blocked. Tests pass only because mocks artificially synchronize these properties.
Note
Introduces
handleFormand CSRF (createWithCSRF/withCSRF) middleware with TypeBox validation, honeypot/PII scrubbing, SHA3 token matching, plus docs, tests, type updates, and dependencies.handleForm(TypeBox validation, honeypot, PII scrubbing) insrc/server/middleware/handle-form.jswith comprehensive tests.createWithCSRF/withCSRF(double-submit cookie, SHA3 hash comparison) insrc/server/middleware/with-csrf.jswith tests and integration tests withhandleForm.src/server/middleware/index.jsandsrc/server/index.js.Response.localsinsrc/server/index.d.tswithcsrfToken,log,logger; add types forhandleForm,createWithCSRF, andwithCSRF.docs/server/README.mdwith API sections forhandleForm,createWithCSRF/withCSRF, examples (backend/frontend), and recommended default middleware stack.ai/rules/security/timing-safe-compare.mdcprescribing SHA3 hashing over timing-safe compare for secrets.ai/rules/review.mdcreferences to new rule paths.@sinclair/typeboxandjs-sha3; update test scripts to includetypecheck.tasks/form-csrf.mddetailing requirements for form handling and CSRF middleware.Written by Cursor Bugbot for commit cb12d1c. This will update automatically on new commits. Configure here.