Skip to content

XSS via raw input inclusion when reportInput: 'typeAndValue' #613

@kallal79

Description

@kallal79

This repository already contains a demonstrative test at lib/v4/security.xss.test.ts that proves the raw HTML is included in the message. The issue is not limited to the single test; several errorMap parsers include raw user input in string messages via calls to stringify(...).

Tests that demonstrate the behavior:

  • lib/v4/security.xss.test.ts (for invalidType, invalidValue, invalidStringFormat)
  • lib/v4/errorMap/errorMap.test.ts has many test cases, but not a dedicated XSS case — recommend adding more tests here

run the included set up script (Windows):

.\scripts\setup.ps1
  • Cross-Site Scripting (XSS): If an app renders the message directly as HTML in client-side code (e.g., innerHTML) this can lead to arbitrary script execution in user browsers.
  • Sensitive information leakage: if the reported values contain sensitive data (passwords, tokens), they could be included in the message.
  1. Update errorMap functions to pass escapeHtml into stringify when building messages including input values. The following parsers were observed to include issue.input via stringify and must be updated:
  • lib/v4/errorMap/invalidType.ts
  • lib/v4/errorMap/invalidValue.ts
  • lib/v4/errorMap/invalidStringFormat.ts
  • lib/v4/errorMap/notMultipleOf.ts
  • lib/v4/errorMap/tooSmall.ts
  • lib/v4/errorMap/tooBig.ts

Reproduction steps

  1. Configure the library to include raw values in error messages:
import * as zod from 'zod/v4';
import { createErrorMap } from 'zod-validation-error';

zod.config({ customError: createErrorMap({ reportInput: 'typeAndValue' }) });

// or use schema.parse(payload, { reportInput: 'typeAndValue' })
  1. Create a simple schema and payload with an HTML payload:
const schema = zod.object({ data: zod.number() });
const payload = { data: '<img src=x onerror="alert(1)" />' };

try {
  schema.parse(payload);
} catch (err) {
  // err.issues[0].message contains the raw HTML string
  console.log(err.issues[0].message);
}
  1. The logged message contains: ... received "<img src=x onerror=\"alert(1)\" />" or equivalent — raw input is included verbatim.

  2. If a consumer uses innerHTML or naively injects the message as HTML, it will execute scripts in the message.


Impact

  • Cross-Origin Scripting (XSS): If an app renders the message directly as HTML in client-side code.
  • Sensitive information leakage: if the reported values contain sensitive data.
  • Medium/High impact for many web apps using reportInput: 'typeAndValue' in production.

Suggested fixes (non-breaking & breaking options)

  1. Opt-in safe (non-breaking): Keep the existing default behavior but add an option to escape HTML.

    • Add escapeHtml?: boolean (default true) to ErrorMapOptions.
    • Update stringify or add a helper escapeHtml function to safely escape &, <, >, ", \'.
    • Update parseInvalidTypeIssue, parseInvalidValueIssue, etc., to pass escapeHtml to stringify when producing string messages.
    • Tests:
      • Add a test asserting HTML is escaped when escapeHtml is enabled.
      • Add a test asserting the library returns raw values when escapeHtml = false (for backward compatibility).
  2. Safe default (breaking change): Make escapeHtml default to true to avoid surprising consumers.

    • This may change output for some consumers; if we choose this path, include migration documentation and bump the major version.
  3. Documentation and warning: If the team prefers not to adjust behavior now, add explicit warnings in the README and code comments: "If you enable reportInput: 'typeAndValue' then DON’T render the resulting message into DOM unescaped." This is a quick mitigation but not ideal.


  1. Add a new escapeHtml setting to ErrorMapOptions in lib/v4/errorMap/types.ts.
  2. Update lib/v4/errorMap/errorMap.ts default options to include escapeHtml: true (or false if needed).
  3. Update lib/utils/stringify.ts to add an escapeHtml option to StringifyValueOptions, and implement escapeHtml behavior; or add a new util function escapeHtml to a utils module.
  4. Update parseInvalidTypeIssue and similar functions to pass escapeHtml into stringify for the issue.input when reportInput==='typeAndValue'.
  5. Add tests: both tests for escapeHtml: true and false under lib/v4/errorMap/errorMap.test.ts and update lib/v4/security.xss.test.ts to assert escaped or raw output accordingly.
  6. Update README.md to include advice in the configuration and a security note.
  7. Run tests, typecheck, and lint; update docs and release notes as appropriate.

Code flow overview (how the user input ends up in DOM if app is unsafe):

  1. createErrorMap in lib/v4/errorMap/* constructs a message string (message AST) using stringify(issue.input) when reportInput === 'typeAndValue'.
  2. Zod receives the message AST and attaches issue.message to the ZodIssue.
  3. createMessageBuilder in lib/v4/MessageBuilder.ts consumes issue.message and combines multiple issues into a single human-friendly message (no escaping performed here).
  4. fromZodError or toValidationError wrap that message into the ValidationError class so consumers get ValidationError.message and ValidationError.details (which can hold issue.input when enabled).
  5. An application that renders ValidationError.message directly in the DOM with innerHTML will execute dangerous markup and functions included in issue.input (XSS).

Additional affected files and types to audit

  • lib/v4/errorMap/*.ts — all files here that call stringify(issue.input) or construct messages using issue.input.
  • lib/v4/MessageBuilder.ts — message assembly code; it should assume message string is already safe and must not be the primary sanitization layer.
  • lib/v4/ValidationError.tsValidationError.message is the final message; consider adding safeMessage or documented guidelines for consumers.

Suggested patch (conceptual)

The following steps show a minimal example:

  1. Add to ErrorMapOptions:
export type ErrorMapOptions = {
  // ... existing properties
  escapeHtml?: boolean;
}
  1. Add default to defaultErrorMapOptions:
export const defaultErrorMapOptions = {
  // ... existing options
  escapeHtml: true,
} as const satisfies ErrorMapOptions;
  1. Update stringify to add escapeHtml parameter and escaping logic:
export type StringifyValueOptions = {
  wrapStringValueInQuote?: boolean;
  localization?: boolean | Intl.LocalesArgument;
  escapeHtml?: boolean;
};

function escapeHtml(value: string) {
  return value.replace(/&/g, '&amp;')
              .replace(/</g, '&lt;')
              .replace(/>/g, '&gt;')
              .replace(/"/g, '&quot;')
              .replace(/'/g, '&#39;');
}

case 'string': {
  const val = options.escapeHtml ? escapeHtml(value) : value;
  if (options.wrapStringValueInQuote) {
    return `"${val}"`;
  }
  return val;
}
  1. Update errorMap functions to pass escapeHtml into stringify when building messages including input values.

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionFurther information is requested

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions