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):
- 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.
- 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
- 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' })
- 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);
}
-
The logged message contains: ... received "<img src=x onerror=\"alert(1)\" />" or equivalent — raw input is included verbatim.
-
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)
-
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).
-
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.
-
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.
- Add a new
escapeHtml setting to ErrorMapOptions in lib/v4/errorMap/types.ts.
- Update
lib/v4/errorMap/errorMap.ts default options to include escapeHtml: true (or false if needed).
- 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.
- Update
parseInvalidTypeIssue and similar functions to pass escapeHtml into stringify for the issue.input when reportInput==='typeAndValue'.
- 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.
- Update
README.md to include advice in the configuration and a security note.
- 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):
createErrorMap in lib/v4/errorMap/* constructs a message string (message AST) using stringify(issue.input) when reportInput === 'typeAndValue'.
- Zod receives the message AST and attaches
issue.message to the ZodIssue.
createMessageBuilder in lib/v4/MessageBuilder.ts consumes issue.message and combines multiple issues into a single human-friendly message (no escaping performed here).
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).
- 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.ts — ValidationError.message is the final message; consider adding safeMessage or documented guidelines for consumers.
Suggested patch (conceptual)
The following steps show a minimal example:
- Add to
ErrorMapOptions:
export type ErrorMapOptions = {
// ... existing properties
escapeHtml?: boolean;
}
- Add default to
defaultErrorMapOptions:
export const defaultErrorMapOptions = {
// ... existing options
escapeHtml: true,
} as const satisfies ErrorMapOptions;
- 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
case 'string': {
const val = options.escapeHtml ? escapeHtml(value) : value;
if (options.wrapStringValueInQuote) {
return `"${val}"`;
}
return val;
}
- Update errorMap functions to pass
escapeHtml into stringify when building messages including input values.
This repository already contains a demonstrative test at
lib/v4/security.xss.test.tsthat proves the raw HTML is included in the message. The issue is not limited to the single test; severalerrorMapparsers include raw user input in string messages via calls tostringify(...).Tests that demonstrate the behavior:
lib/v4/security.xss.test.ts(forinvalidType,invalidValue,invalidStringFormat)lib/v4/errorMap/errorMap.test.tshas many test cases, but not a dedicated XSS case — recommend adding more tests hererun the included set up script (Windows):
innerHTML) this can lead to arbitrary script execution in user browsers.escapeHtmlintostringifywhen building messages including input values. The following parsers were observed to includeissue.inputviastringifyand must be updated:lib/v4/errorMap/invalidType.tslib/v4/errorMap/invalidValue.tslib/v4/errorMap/invalidStringFormat.tslib/v4/errorMap/notMultipleOf.tslib/v4/errorMap/tooSmall.tslib/v4/errorMap/tooBig.tsReproduction steps
The logged message contains:
... received "<img src=x onerror=\"alert(1)\" />"or equivalent — raw input is included verbatim.If a consumer uses
innerHTMLor naively injects the message as HTML, it will execute scripts in the message.Impact
reportInput: 'typeAndValue'in production.Suggested fixes (non-breaking & breaking options)
Opt-in safe (non-breaking): Keep the existing default behavior but add an option to escape HTML.
escapeHtml?: boolean(defaulttrue) toErrorMapOptions.stringifyor add a helperescapeHtmlfunction to safely escape&,<,>,",\'.parseInvalidTypeIssue,parseInvalidValueIssue, etc., to passescapeHtmltostringifywhen producing string messages.escapeHtmlis enabled.escapeHtml= false (for backward compatibility).Safe default (breaking change): Make
escapeHtmldefault totrueto avoid surprising consumers.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.escapeHtmlsetting toErrorMapOptionsinlib/v4/errorMap/types.ts.lib/v4/errorMap/errorMap.tsdefault options to includeescapeHtml: true(or false if needed).lib/utils/stringify.tsto add anescapeHtmloption toStringifyValueOptions, and implementescapeHtmlbehavior; or add a new util functionescapeHtmlto a utils module.parseInvalidTypeIssueand similar functions to passescapeHtmlintostringifyfor theissue.inputwhenreportInput==='typeAndValue'.escapeHtml: trueandfalseunderlib/v4/errorMap/errorMap.test.tsand updatelib/v4/security.xss.test.tsto assert escaped or raw output accordingly.README.mdto include advice in the configuration and a security note.Code flow overview (how the user input ends up in DOM if app is unsafe):
createErrorMapinlib/v4/errorMap/*constructs a message string (message AST) usingstringify(issue.input)whenreportInput === 'typeAndValue'.issue.messageto theZodIssue.createMessageBuilderinlib/v4/MessageBuilder.tsconsumesissue.messageand combines multiple issues into a single human-friendly message (no escaping performed here).fromZodErrorortoValidationErrorwrap that message into theValidationErrorclass so consumers getValidationError.messageandValidationError.details(which can holdissue.inputwhen enabled).ValidationError.messagedirectly in the DOM withinnerHTMLwill execute dangerous markup and functions included inissue.input(XSS).Additional affected files and types to audit
lib/v4/errorMap/*.ts— all files here that callstringify(issue.input)or construct messages usingissue.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.ts—ValidationError.messageis the final message; consider addingsafeMessageor documented guidelines for consumers.Suggested patch (conceptual)
The following steps show a minimal example:
ErrorMapOptions:defaultErrorMapOptions:escapeHtmlparameter and escaping logic:escapeHtmlinto stringify when building messages including input values.