Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
417 changes: 417 additions & 0 deletions docs/server/README.md

Large diffs are not rendered by default.

324 changes: 65 additions & 259 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@
"homepage": "https://github.com/paralleldrive/aidd#readme",
"dependencies": {
"@paralleldrive/cuid2": "^3.1.0",
"@sinclair/typebox": "^0.34.41",
"chalk": "^4.1.2",
"commander": "^11.1.0",
"error-causes": "^3.0.2",
"fs-extra": "^11.1.1"
"fs-extra": "^11.1.1",
"js-sha3": "^0.9.3"
},
"peerDependencies": {
"better-auth": "^1.4.5"
Expand Down
82 changes: 82 additions & 0 deletions src/server/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export interface Response {
config?: ConfigObject;
serverError?: (options?: ErrorOptions) => ErrorResponse;
auth?: { user: User; session: Session } | null;
csrfToken?: string;
log?: (data: Record<string, unknown>) => void;
logger?: { scrub: (fields: string[]) => void };
[key: string]: any;
};
[key: string]: any;
Expand Down Expand Up @@ -244,3 +247,82 @@ export function createWithAuth(options: WithAuthOptions): Middleware;
* });
*/
export function createWithOptionalAuth(options: WithOptionalAuthOptions): Middleware;

// Form handling middleware
import type { TObject } from "@sinclair/typebox";

export interface HandleFormOptions<T extends TObject = TObject> {
/** Identifier for the form, used in logging */
name: string;
/** TypeBox schema for validating request body */
schema: T;
/** Async function receiving validated form data */
processSubmission: (data: Record<string, unknown>) => Promise<void>;
/** Field names to register with logger scrubber for PII protection */
pii?: string[];
/** Field name that must be empty - rejects submission if filled (bot protection) */
honeypotField?: string;
}

/**
* Creates middleware for secure form submission handling with TypeBox validation
*
* @example
* import { Type } from '@sinclair/typebox';
* import { handleForm } from 'aidd/server';
*
* const ContactSchema = Type.Object({
* name: Type.String(),
* email: Type.String({ format: 'email' }),
* message: Type.String(),
* }, { additionalProperties: false });
*
* const withContactForm = handleForm({
* name: 'contact',
* schema: ContactSchema,
* processSubmission: async (data) => {
* await sendEmail(data.email, data.message);
* },
* pii: ['email'],
* honeypotField: 'website',
* });
*/
export function handleForm<T extends TObject>(options: HandleFormOptions<T>): Middleware;

// CSRF middleware
export interface CSRFOptions {
/** Cookie max age in seconds (default: 3 hours = 10800) */
maxAge?: number;
}

/**
* Creates CSRF protection middleware with configurable options
*
* Uses double-submit cookie pattern:
* - GET/HEAD/OPTIONS: Sets cookie, exposes token via response.locals.csrfToken
* - POST/PUT/PATCH/DELETE: Validates token from header or body against cookie
* - Compares using SHA3 hash (timing-attack safe)
*
* @example
* // Custom 1-hour cookie lifetime
* const withCSRF = createWithCSRF({ maxAge: 60 * 60 });
*/
export function createWithCSRF(options?: CSRFOptions): Middleware;

/**
* Default CSRF middleware with 3-hour cookie lifetime
*
* @example
* import { createRoute, withCSRF } from 'aidd/server';
*
* // Form page - sets cookie and provides token
* export const getForm = createRoute(withCSRF, async ({ response }) => {
* response.json({ csrfToken: response.locals.csrfToken });
* });
*
* // Form submission - validates token
* export const submitForm = createRoute(withCSRF, handleContactForm, async ({ response }) => {
* response.json({ success: true });
* });
*/
export const withCSRF: Middleware;
76 changes: 76 additions & 0 deletions src/server/middleware/handle-form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Form handling middleware factory with TypeBox validation
*/

import { TypeCompiler } from "@sinclair/typebox/compiler";

const log = (response, data) => {
const logger = response.locals?.log || console.log;
logger(data);
};

const formatErrors = (errors) => {
return [...errors].map((err) => {
const path = err.path.slice(1) || "root";

if (err.message.includes("Required")) {
return `Missing required field: ${path}`;
}
if (err.message.includes("Unexpected property")) {
return `Undeclared field not allowed: ${path}`;
}
if (err.message.includes("Expected")) {
return `Field '${path}' ${err.message.toLowerCase()}`;
}
return err.message;
});
};

const handleForm =
({ name, schema, processSubmission, pii, honeypotField }) =>

Copilot AI Dec 7, 2025

Copy link

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 = ...

Copilot uses AI. Check for mistakes.
async ({ request, response }) => {

Copilot AI Dec 8, 2025

Copy link

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
  };
};

Copilot uses AI. Check for mistakes.
// Register PII fields with logger scrubber
if (pii?.length && response.locals?.logger?.scrub) {
response.locals.logger.scrub(pii);
}

Copilot AI Dec 7, 2025

Copy link

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
    };
  };
Suggested change
({ 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);
}

Copilot uses AI. Check for mistakes.

const body = request.body || {};

// Check honeypot field if configured
if (honeypotField && body[honeypotField]) {

Copilot AI Dec 7, 2025

Copy link

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,
  });
});

Copilot uses AI. Check for mistakes.

Copilot AI Dec 8, 2025

Copy link

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
Suggested change
if (honeypotField && body[honeypotField]) {
if (honeypotField && body[honeypotField] !== undefined && body[honeypotField] !== '') {

Copilot uses AI. Check for mistakes.
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);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
const valid = validator.Check(body);
Comment thread
cursor[bot] marked this conversation as resolved.

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);

Copy link
Copy Markdown
Contributor

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)

Fix in Cursor Fix in Web


return { request, response };

Copilot AI Dec 8, 2025

Copy link

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
    };
  };
Suggested change
({ 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 };
};

Copilot uses AI. Check for mistakes.
};

export { handleForm };
Loading