z-schema 12.0.0 → 12.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -211,19 +211,55 @@ const uriTemplateValidator = (uri) => {
211
211
  }
212
212
  return !inExpression;
213
213
  };
214
+ const hasValidTildeEscapes = (segment) => {
215
+ for (let i = 0; i < segment.length; i++) {
216
+ if (segment[i] === '~') {
217
+ const next = segment[i + 1];
218
+ if (next !== '0' && next !== '1') {
219
+ return false;
220
+ }
221
+ i++; // skip the escape character
222
+ }
223
+ }
224
+ return true;
225
+ };
214
226
  const jsonPointerValidator = (pointer) => {
215
227
  if (typeof pointer !== 'string')
216
228
  return true;
217
229
  // JSON Pointer: empty, or a sequence of '/'-prefixed reference tokens.
218
230
  // In each token, '~' must be escaped as '~0' or '~1'.
219
- return pointer === '' || /^(?:\/(?:[^~]|~0|~1)*)+$/.test(pointer);
231
+ if (pointer === '')
232
+ return true;
233
+ if (!/^(?:\/[^/]*)+$/.test(pointer))
234
+ return false;
235
+ const tokens = pointer.split('/').slice(1); // first element is empty before leading '/'
236
+ for (const token of tokens) {
237
+ if (!hasValidTildeEscapes(token))
238
+ return false;
239
+ }
240
+ return true;
220
241
  };
221
242
  const relativeJsonPointerValidator = (pointer) => {
222
243
  if (typeof pointer !== 'string')
223
244
  return true;
224
245
  // Relative JSON Pointer: non-negative integer prefix (no leading zeros unless zero),
225
246
  // followed by either '#', a JSON Pointer, or nothing.
226
- return /^(?:0|[1-9]\d*)(?:#|(?:\/(?:[^~]|~0|~1)*)+)?$/.test(pointer);
247
+ const match = pointer.match(/^(0|[1-9]\d*)(.*)$/);
248
+ if (!match)
249
+ return false;
250
+ const suffix = match[2];
251
+ if (suffix === '' || suffix === '#')
252
+ return true;
253
+ if (!suffix.startsWith('/'))
254
+ return false;
255
+ if (!/^(?:\/[^/]*)+$/.test(suffix))
256
+ return false;
257
+ const tokens = suffix.split('/').slice(1);
258
+ for (const token of tokens) {
259
+ if (!hasValidTildeEscapes(token))
260
+ return false;
261
+ }
262
+ return true;
227
263
  };
228
264
  const timeValidator = (time) => {
229
265
  if (typeof time !== 'string')
@@ -6,8 +6,8 @@ import { getRemotePath, isAbsoluteUri } from './utils/uri.js';
6
6
  export const NON_SCHEMA_KEYWORDS = ['enum', 'const', 'default', 'examples'];
7
7
  /** Returns true if the key is an internal z-schema property (prefixed with `__$`). */
8
8
  export const isInternalKey = (key) => key.startsWith('__$');
9
+ import { DEFAULT_MAX_RECURSION_DEPTH } from './utils/constants.js';
9
10
  import { isObject } from './utils/what-is.js';
10
- import { DEFAULT_MAX_RECURSION_DEPTH } from './z-schema-options.js';
11
11
  export const getId = (schema) => {
12
12
  // Draft-04 uses `id` exclusively — never return `$id` for a draft-04 schema
13
13
  if (typeof schema.$schema === 'string' && schema.$schema.includes('draft-04')) {
package/dist/report.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { Errors, getValidateError } from './errors.js';
2
2
  import { shallowClone } from './utils/clone.js';
3
+ import { MAX_ASYNC_TIMEOUT } from './utils/constants.js';
3
4
  import { get } from './utils/json.js';
4
5
  import { jsonSymbol, schemaSymbol } from './utils/symbols.js';
5
6
  import { isAbsoluteUri } from './utils/uri.js';
6
7
  import { isObject } from './utils/what-is.js';
8
+ const ASYNC_TIMEOUT_POLL_MS = 10;
7
9
  export class Report {
8
10
  asyncTasks = [];
9
11
  commonErrorMessage;
@@ -77,7 +79,8 @@ export class Report {
77
79
  return this.parentReport.getAncestor(id);
78
80
  }
79
81
  processAsyncTasks(timeout, callback) {
80
- const validationTimeout = timeout || 2000;
82
+ const validationTimeout = Math.min(Math.max(timeout || 2000, 0), MAX_ASYNC_TIMEOUT);
83
+ const timeoutAt = Date.now() + validationTimeout;
81
84
  let tasksCount = this.asyncTasks.length;
82
85
  let timedOut = false;
83
86
  const finish = () => {
@@ -106,13 +109,19 @@ export class Report {
106
109
  const respondCallback = respond(processFn);
107
110
  fn(...fnArgs, respondCallback);
108
111
  }
109
- setTimeout(() => {
110
- if (tasksCount > 0) {
112
+ const checkTimeout = () => {
113
+ if (timedOut || tasksCount <= 0) {
114
+ return;
115
+ }
116
+ if (Date.now() >= timeoutAt) {
111
117
  timedOut = true;
112
118
  this.addError('ASYNC_TIMEOUT', [tasksCount, validationTimeout]);
113
119
  callback(getValidateError({ details: this.errors }), false);
120
+ return;
114
121
  }
115
- }, validationTimeout);
122
+ setTimeout(checkTimeout, ASYNC_TIMEOUT_POLL_MS);
123
+ };
124
+ setTimeout(checkTimeout, ASYNC_TIMEOUT_POLL_MS);
116
125
  }
117
126
  getPath(returnPathAsString) {
118
127
  let path = [];
@@ -4,6 +4,17 @@ import { deepClone } from './utils/clone.js';
4
4
  import { decodeJSONPointer } from './utils/json.js';
5
5
  import { getQueryPath, getRemotePath, isAbsoluteUri } from './utils/uri.js';
6
6
  import { normalizeOptions } from './z-schema-options.js';
7
+ // Normalize a URI into a cache key, rejecting keys that could pollute Object.prototype.
8
+ function getSafeRemotePath(uri) {
9
+ const remotePath = getRemotePath(uri);
10
+ if (!remotePath) {
11
+ return undefined;
12
+ }
13
+ if (remotePath === '__proto__' || remotePath === 'constructor' || remotePath === 'prototype') {
14
+ return undefined;
15
+ }
16
+ return remotePath;
17
+ }
7
18
  const getEffectiveId = (schema) => {
8
19
  let id = getId(schema);
9
20
  if ((!id || !isAbsoluteUri(id)) && typeof schema.id === 'string' && isAbsoluteUri(schema.id)) {
@@ -34,31 +45,31 @@ export function prepareRemoteSchema(schema, uri, validationOptions, maxCloneDept
34
45
  }
35
46
  export class SchemaCache {
36
47
  validator;
37
- static global_cache = {};
38
- cache = {};
48
+ static global_cache = Object.create(null);
49
+ cache = Object.create(null);
39
50
  constructor(validator) {
40
51
  this.validator = validator;
41
52
  }
42
53
  static cacheSchemaByUri(uri, schema) {
43
- const remotePath = getRemotePath(uri);
54
+ const remotePath = getSafeRemotePath(uri);
44
55
  if (remotePath) {
45
56
  this.global_cache[remotePath] = schema;
46
57
  }
47
58
  }
48
59
  cacheSchemaByUri(uri, schema) {
49
- const remotePath = getRemotePath(uri);
60
+ const remotePath = getSafeRemotePath(uri);
50
61
  if (remotePath) {
51
62
  this.cache[remotePath] = schema;
52
63
  }
53
64
  }
54
65
  removeFromCacheByUri(uri) {
55
- const remotePath = getRemotePath(uri);
66
+ const remotePath = getSafeRemotePath(uri);
56
67
  if (remotePath) {
57
68
  delete this.cache[remotePath];
58
69
  }
59
70
  }
60
71
  checkCacheForUri(uri) {
61
- const remotePath = getRemotePath(uri);
72
+ const remotePath = getSafeRemotePath(uri);
62
73
  return remotePath ? this.cache[remotePath] != null : false;
63
74
  }
64
75
  getSchema(report, refOrSchema) {
@@ -73,6 +84,9 @@ export class SchemaCache {
73
84
  return deepClone(refOrSchema, this.validator.options.maxRecursionDepth);
74
85
  }
75
86
  fromCache(path) {
87
+ if (path === '__proto__' || path === 'constructor' || path === 'prototype') {
88
+ return undefined;
89
+ }
76
90
  let found = this.cache[path];
77
91
  if (found) {
78
92
  return found;
@@ -104,7 +118,7 @@ export class SchemaCache {
104
118
  }
105
119
  }
106
120
  }
107
- const remotePath = getRemotePath(uri);
121
+ const remotePath = getSafeRemotePath(uri);
108
122
  const queryPath = getQueryPath(uri);
109
123
  let result;
110
124
  let resolvedFromAncestor = false;
@@ -1,8 +1,23 @@
1
1
  import { getId, isInternalKey, NON_SCHEMA_KEYWORDS } from './json-schema.js';
2
2
  import { Report } from './report.js';
3
+ import { DEFAULT_MAX_RECURSION_DEPTH } from './utils/constants.js';
3
4
  import { getRemotePath, isAbsoluteUri } from './utils/uri.js';
4
- import { DEFAULT_MAX_RECURSION_DEPTH } from './z-schema-options.js';
5
5
  import { getSchemaReader } from './z-schema-reader.js';
6
+ /** Safely assign a property on `obj`, refusing prototype-polluting keys. */
7
+ function safeSetProperty(obj, key, value) {
8
+ const unsafeTargets = [
9
+ Object.prototype,
10
+ Function.prototype,
11
+ Array.prototype,
12
+ ];
13
+ if (unsafeTargets.includes(obj)) {
14
+ return;
15
+ }
16
+ /** Reject property names that could pollute Object.prototype (CWE-1321). */
17
+ if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
18
+ obj[key] = value;
19
+ }
20
+ }
6
21
  export const collectIds = (obj, maxDepth = DEFAULT_MAX_RECURSION_DEPTH) => {
7
22
  const ids = [];
8
23
  function walk(node, scope, _depth = 0) {
@@ -168,7 +183,8 @@ const resolveReference = (base, ref) => {
168
183
  }
169
184
  let baseDir = baseNoFrag;
170
185
  if (!baseDir.endsWith('/')) {
171
- baseDir = baseDir.replace(/[^/]*$/, '');
186
+ const lastSlash = baseDir.lastIndexOf('/');
187
+ baseDir = lastSlash === -1 ? '' : baseDir.slice(0, lastSlash + 1);
172
188
  }
173
189
  return baseDir + ref;
174
190
  };
@@ -239,8 +255,14 @@ export class SchemaCompiler {
239
255
  this.collectAndCacheIds(schema);
240
256
  }
241
257
  }
258
+ const canMutateSchemaObject = schema !== Object.prototype &&
259
+ schema !== Function.prototype &&
260
+ schema !== Array.prototype;
242
261
  // if we have an id than it should be cached already (if this instance has compiled it)
243
- if (schema.__$compiled && schema.id && this.validator.scache.checkCacheForUri(schema.id) === false) {
262
+ if (canMutateSchemaObject &&
263
+ schema.__$compiled &&
264
+ schema.id &&
265
+ this.validator.scache.checkCacheForUri(schema.id) === false) {
244
266
  schema.__$compiled = undefined;
245
267
  }
246
268
  // do not re-compile schemas
@@ -248,7 +270,7 @@ export class SchemaCompiler {
248
270
  return true;
249
271
  }
250
272
  // v8 - if $schema is not present, set $schema to default
251
- if (!schema.$schema && this.validator.options.version !== 'none') {
273
+ if (canMutateSchemaObject && !schema.$schema && this.validator.options.version !== 'none') {
252
274
  schema.$schema = this.validator.getDefaultSchemaId();
253
275
  }
254
276
  if (schema.id && typeof schema.id === 'string' && !options?.noCache) {
@@ -263,7 +285,9 @@ export class SchemaCompiler {
263
285
  }
264
286
  // delete all __$missingReferences from previous compilation attempts
265
287
  const isValidExceptReferences = report.isValid();
266
- delete schema.__$missingReferences;
288
+ if (canMutateSchemaObject) {
289
+ delete schema.__$missingReferences;
290
+ }
267
291
  // collect all references that need to be resolved - $ref and $schema
268
292
  const useRefObjectScope = this.validator.options.version === 'draft2019-09' || this.validator.options.version === 'draft2020-12';
269
293
  const refs = collectReferences(schema, undefined, undefined, undefined, { useRefObjectScope }, this.validator.options.maxRecursionDepth);
@@ -317,17 +341,17 @@ export class SchemaCompiler {
317
341
  report.addError('UNRESOLVABLE_REFERENCE', [refObj.ref]);
318
342
  report.path = report.path.slice(0, -refObj.path.length);
319
343
  // pusblish unresolved references out
320
- if (isValidExceptReferences) {
344
+ if (isValidExceptReferences && canMutateSchemaObject) {
321
345
  schema.__$missingReferences = schema.__$missingReferences || [];
322
346
  schema.__$missingReferences.push(refObj);
323
347
  }
324
348
  }
325
349
  }
326
350
  // this might create circular references
327
- refObj.obj[`__${refObj.key}Resolved`] = response;
351
+ safeSetProperty(refObj.obj, `__${refObj.key}Resolved`, response);
328
352
  }
329
353
  const isValid = report.isValid();
330
- if (isValid) {
354
+ if (isValid && canMutateSchemaObject) {
331
355
  schema.__$compiled = true;
332
356
  }
333
357
  // else {
@@ -360,7 +384,7 @@ export class SchemaCompiler {
360
384
  const response = arr.find((x) => x.id === refObj.ref);
361
385
  if (response) {
362
386
  // this might create circular references
363
- refObj.obj[`__${refObj.key}Resolved`] = response;
387
+ safeSetProperty(refObj.obj, `__${refObj.key}Resolved`, response);
364
388
  // it's resolved now so delete it
365
389
  sch.__$missingReferences.splice(idx2, 1);
366
390
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Default maximum recursion depth for deeply nested schema/data traversal.
3
+ * Used as the default for {@link ZSchemaOptions.maxRecursionDepth} and
4
+ * internal helpers like `deepClone` and `collectIds`.
5
+ */
6
+ export declare const DEFAULT_MAX_RECURSION_DEPTH = 100;
7
+ /**
8
+ * Maximum allowed value for {@link ZSchemaOptions.asyncTimeout} in milliseconds.
9
+ * Values exceeding this limit are clamped during option normalization to
10
+ * prevent resource exhaustion (CWE-400).
11
+ */
12
+ export declare const MAX_ASYNC_TIMEOUT = 60000;
13
+ /**
14
+ * Maximum allowed length for a JSON Schema `pattern` regular expression string.
15
+ * Patterns exceeding this limit are rejected by {@link compileSchemaRegex} to
16
+ * mitigate Regular Expression Denial-of-Service (CWE-1333) and regex injection
17
+ * (CWE-95).
18
+ */
19
+ export declare const MAX_SCHEMA_REGEX_LENGTH = 10000;
@@ -1,8 +1,9 @@
1
1
  import type { ValidateError } from './errors.js';
2
2
  import type { FormatValidatorFn } from './format-validators.js';
3
- import type { JsonSchema } from './json-schema-versions.js';
3
+ import type { JsonSchema, JsonSchemaInternal } from './json-schema-versions.js';
4
4
  import type { ZSchemaOptions } from './z-schema-options.js';
5
5
  import { type Errors } from './errors.js';
6
+ import { Report } from './report.js';
6
7
  import { SchemaCache } from './schema-cache.js';
7
8
  import { SchemaCompiler } from './schema-compiler.js';
8
9
  import { SchemaValidator } from './schema-validator.js';
@@ -28,6 +29,14 @@ export declare class ZSchemaBase {
28
29
  validateOptions: ValidateOptions;
29
30
  options: ZSchemaOptions;
30
31
  constructor(options: ZSchemaOptions | undefined, token: symbol);
32
+ /**
33
+ * Internal recursive JSON validation — delegates to the `validate` function
34
+ * in `json-validation.ts`. Exposed as a method so that per-keyword validator
35
+ * modules (array, combinators, object) can call back into the core validator
36
+ * via `this` without importing `json-validation.ts` directly (which would
37
+ * create a circular dependency).
38
+ */
39
+ _jsonValidate(report: Report, schema: boolean | JsonSchemaInternal, json: unknown): boolean;
31
40
  getDefaultSchemaId(): string;
32
41
  _validate(json: unknown, schema: JsonSchema | string, options: ValidateOptions, callback: ValidateCallback): void;
33
42
  _validate(json: unknown, schema: JsonSchema | string, callback: ValidateCallback): void;
@@ -1,7 +1,8 @@
1
1
  import type { FormatValidatorFn } from './format-validators.js';
2
2
  import type { Report } from './report.js';
3
3
  import { type JsonSchemaVersion } from './json-schema-versions.js';
4
- export declare const DEFAULT_MAX_RECURSION_DEPTH = 100;
4
+ import { DEFAULT_MAX_RECURSION_DEPTH, MAX_ASYNC_TIMEOUT } from './utils/constants.js';
5
+ export { DEFAULT_MAX_RECURSION_DEPTH, MAX_ASYNC_TIMEOUT };
5
6
  export interface ZSchemaOptions {
6
7
  version?: JsonSchemaVersion | 'none';
7
8
  asyncTimeout?: number;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_MAX_RECURSION_DEPTH } from '../z-schema-options.js';
1
+ import { DEFAULT_MAX_RECURSION_DEPTH } from './constants.js';
2
2
  import { copyProp } from './properties.js';
3
3
  export const shallowClone = (src) => {
4
4
  if (src == null || typeof src !== 'object') {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Default maximum recursion depth for deeply nested schema/data traversal.
3
+ * Used as the default for {@link ZSchemaOptions.maxRecursionDepth} and
4
+ * internal helpers like `deepClone` and `collectIds`.
5
+ */
6
+ export const DEFAULT_MAX_RECURSION_DEPTH = 100;
7
+ /**
8
+ * Maximum allowed value for {@link ZSchemaOptions.asyncTimeout} in milliseconds.
9
+ * Values exceeding this limit are clamped during option normalization to
10
+ * prevent resource exhaustion (CWE-400).
11
+ */
12
+ export const MAX_ASYNC_TIMEOUT = 60_000;
13
+ /**
14
+ * Maximum allowed length for a JSON Schema `pattern` regular expression string.
15
+ * Patterns exceeding this limit are rejected by {@link compileSchemaRegex} to
16
+ * mitigate Regular Expression Denial-of-Service (CWE-1333) and regex injection
17
+ * (CWE-95).
18
+ */
19
+ export const MAX_SCHEMA_REGEX_LENGTH = 10_000;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_MAX_RECURSION_DEPTH } from '../z-schema-options.js';
1
+ import { DEFAULT_MAX_RECURSION_DEPTH } from './constants.js';
2
2
  import { isObject } from './what-is.js';
3
3
  export const areEqual = (json1, json2, options, _depth = 0) => {
4
4
  const caseInsensitiveComparison = options?.caseInsensitiveComparison || false;
@@ -1,6 +1,16 @@
1
1
  // Shared regex compilation helper for JSON Schema patterns
2
2
  // Returns { ok: true, value: RegExp } or { ok: false, error: { pattern, message } }
3
+ import { MAX_SCHEMA_REGEX_LENGTH } from './constants.js';
3
4
  export function compileSchemaRegex(pattern) {
5
+ if (pattern.length > MAX_SCHEMA_REGEX_LENGTH) {
6
+ return {
7
+ ok: false,
8
+ error: {
9
+ pattern,
10
+ message: `Pattern length ${pattern.length} exceeds maximum allowed length of ${MAX_SCHEMA_REGEX_LENGTH}`,
11
+ },
12
+ };
13
+ }
4
14
  const unicodePropertyEscape = /\\[pP]{/;
5
15
  const nonBmpCharacter = /[\u{10000}-\u{10FFFF}]/u;
6
16
  const surrogatePairEscape = /\\uD[89AB][0-9A-Fa-f]{2}\\uD[CDEF][0-9A-Fa-f]{2}/;
@@ -9,6 +19,7 @@ export function compileSchemaRegex(pattern) {
9
19
  if (needsUnicode) {
10
20
  // Try compiling with 'u' flag only
11
21
  try {
22
+ // lgtm[js/regex-injection] JSON Schema `pattern` is intentionally regex syntax and constrained by MAX_SCHEMA_REGEX_LENGTH.
12
23
  const re = new RegExp(pattern, 'u');
13
24
  return { ok: true, value: re };
14
25
  }
@@ -24,6 +35,7 @@ export function compileSchemaRegex(pattern) {
24
35
  }
25
36
  else {
26
37
  try {
38
+ // lgtm[js/regex-injection] JSON Schema `pattern` is intentionally regex syntax and constrained by MAX_SCHEMA_REGEX_LENGTH.
27
39
  const re = new RegExp(pattern);
28
40
  return { ok: true, value: re };
29
41
  }
@@ -1,4 +1,3 @@
1
- import { validate } from '../json-validation.js';
2
1
  import { isUniqueArray } from '../utils/array.js';
3
2
  import { cacheValidationResult, deferOrRunSync, shouldSkipValidate } from './shared.js';
4
3
  // ---------------------------------------------------------------------------
@@ -100,7 +99,7 @@ export function containsValidator(report, schema, json) {
100
99
  for (let idx = 0; idx < json.length; idx++) {
101
100
  const subReport = new Report_(report);
102
101
  subReports.push(subReport);
103
- validate.call(this, subReport, containsSchema, json[idx]);
102
+ this._jsonValidate(subReport, containsSchema, json[idx]);
104
103
  cacheValidationResult(report, containsSchema, json[idx], subReport.errors.length === 0);
105
104
  }
106
105
  const addContainsErrorIfNeeded = () => {
@@ -1,4 +1,3 @@
1
- import { validate } from '../json-validation.js';
2
1
  import { Report } from '../report.js';
3
2
  import { cacheValidationResult, deferOrRunSync } from './shared.js';
4
3
  // ---------------------------------------------------------------------------
@@ -7,7 +6,7 @@ import { cacheValidationResult, deferOrRunSync } from './shared.js';
7
6
  export function allOfValidator(report, schema, json) {
8
7
  // http://json-schema.org/latest/json-schema-validation.html#rfc.section.5.5.3.2
9
8
  for (let i = 0; i < schema.allOf.length; i++) {
10
- const validateResult = validate.call(this, report, schema.allOf[i], json);
9
+ const validateResult = this._jsonValidate(report, schema.allOf[i], json);
11
10
  if (this.options.breakOnFirstError && validateResult === false) {
12
11
  break;
13
12
  }
@@ -22,7 +21,7 @@ export function anyOfValidator(report, schema, json) {
22
21
  for (let i = 0; i < schema.anyOf.length; i++) {
23
22
  const subReport = new Report(report);
24
23
  subReports.push(subReport);
25
- validate.call(this, subReport, schema.anyOf[i], json);
24
+ this._jsonValidate(subReport, schema.anyOf[i], json);
26
25
  cacheValidationResult(report, schema.anyOf[i], json, subReport.errors.length === 0);
27
26
  }
28
27
  // Aggregate async tasks and decide when ready
@@ -48,7 +47,7 @@ export function oneOfValidator(report, schema, json) {
48
47
  for (let i = 0; i < schema.oneOf.length; i++) {
49
48
  const subReport = new Report(report);
50
49
  subReports.push(subReport);
51
- validate.call(this, subReport, schema.oneOf[i], json);
50
+ this._jsonValidate(subReport, schema.oneOf[i], json);
52
51
  cacheValidationResult(report, schema.oneOf[i], json, subReport.errors.length === 0);
53
52
  }
54
53
  // Aggregate async tasks and decide when ready
@@ -73,7 +72,7 @@ export function oneOfValidator(report, schema, json) {
73
72
  export function notValidator(report, schema, json) {
74
73
  // http://json-schema.org/latest/json-schema-validation.html#rfc.section.5.5.6.2
75
74
  const subReport = new Report(report);
76
- if (validate.call(this, subReport, schema.not, json) === true) {
75
+ if (this._jsonValidate(subReport, schema.not, json) === true) {
77
76
  report.addError('NOT_PASSED', undefined, undefined, schema, 'not');
78
77
  }
79
78
  }
@@ -91,13 +90,13 @@ export function ifValidator(report, schema, json) {
91
90
  return;
92
91
  }
93
92
  const conditionReport = new Report(report);
94
- validate.call(this, conditionReport, conditionSchema, json);
93
+ this._jsonValidate(conditionReport, conditionSchema, json);
95
94
  cacheValidationResult(report, conditionSchema, json, conditionReport.errors.length === 0);
96
95
  const branchSchema = conditionReport.errors.length === 0 ? thenSchema : elseSchema;
97
96
  if (branchSchema === undefined) {
98
97
  return;
99
98
  }
100
- validate.call(this, report, branchSchema, json);
99
+ this._jsonValidate(report, branchSchema, json);
101
100
  }
102
101
  export function thenValidator() {
103
102
  // handled by if
@@ -1,4 +1,3 @@
1
- import { validate } from '../json-validation.js';
2
1
  import { difference } from '../utils/array.js';
3
2
  import { compileSchemaRegex } from '../utils/schema-regex.js';
4
3
  import { isObject } from '../utils/what-is.js';
@@ -153,7 +152,7 @@ export function dependenciesValidator(report, schema, json) {
153
152
  }
154
153
  else {
155
154
  // if dependency is a schema, validate against this schema
156
- validate.call(this, report, dependencyDefinition, json);
155
+ this._jsonValidate(report, dependencyDefinition, json);
157
156
  }
158
157
  }
159
158
  }
@@ -172,7 +171,7 @@ export function dependentSchemasValidator(report, schema, json) {
172
171
  for (const dependencyName of keys) {
173
172
  if (Object.hasOwn(json, dependencyName)) {
174
173
  const dependencySchema = schema.dependentSchemas[dependencyName];
175
- validate.call(this, report, dependencySchema, json);
174
+ this._jsonValidate(report, dependencySchema, json);
176
175
  }
177
176
  }
178
177
  }
@@ -225,7 +224,7 @@ export function propertyNamesValidator(report, schema, json) {
225
224
  for (const key of keys) {
226
225
  const subReport = new Report_(report);
227
226
  subReports.push(subReport);
228
- validate.call(this, subReport, propertyNamesSchema, key);
227
+ this._jsonValidate(subReport, propertyNamesSchema, key);
229
228
  }
230
229
  const addPropertyNameErrors = () => {
231
230
  for (let idx = 0; idx < keys.length; idx++) {
@@ -93,21 +93,13 @@ export function formatValidator(report, schema, json) {
93
93
  const result = formatValidatorFn.call(this, json);
94
94
  if (result instanceof Promise) {
95
95
  // Promise-based async
96
- const timeoutMs = this.options.asyncTimeout || 2000;
97
96
  const promiseResult = result;
98
97
  report.addAsyncTaskWithPath(async (callback) => {
99
98
  try {
100
- const timeoutPromise = new Promise((_, reject) => {
101
- setTimeout(() => reject(new Error('Async timeout')), timeoutMs);
102
- });
103
- const resolved = await Promise.race([promiseResult, timeoutPromise]);
99
+ const resolved = await promiseResult;
104
100
  callback(resolved);
105
101
  }
106
- catch (error) {
107
- if (error.message === 'Async timeout') {
108
- // Don't call callback, let global timeout handle it
109
- return;
110
- }
102
+ catch (_error) {
111
103
  callback(false);
112
104
  }
113
105
  }, [], function (resolvedResult) {
@@ -33,6 +33,16 @@ export class ZSchemaBase {
33
33
  this.sv = new SchemaValidator(this);
34
34
  this.options = normalizeOptions(options);
35
35
  }
36
+ /**
37
+ * Internal recursive JSON validation — delegates to the `validate` function
38
+ * in `json-validation.ts`. Exposed as a method so that per-keyword validator
39
+ * modules (array, combinators, object) can call back into the core validator
40
+ * via `this` without importing `json-validation.ts` directly (which would
41
+ * create a circular dependency).
42
+ */
43
+ _jsonValidate(report, schema, json) {
44
+ return validateJson.call(this, report, schema, json);
45
+ }
36
46
  getDefaultSchemaId() {
37
47
  return this.options.version && this.options.version !== 'none'
38
48
  ? VERSION_SCHEMA_URL_MAPPING[this.options.version]
@@ -1,6 +1,7 @@
1
1
  import { CURRENT_DEFAULT_SCHEMA_VERSION } from './json-schema-versions.js';
2
2
  import { shallowClone } from './utils/clone.js';
3
- export const DEFAULT_MAX_RECURSION_DEPTH = 100;
3
+ import { DEFAULT_MAX_RECURSION_DEPTH, MAX_ASYNC_TIMEOUT } from './utils/constants.js';
4
+ export { DEFAULT_MAX_RECURSION_DEPTH, MAX_ASYNC_TIMEOUT };
4
5
  export const defaultOptions = {
5
6
  // default version to validate against
6
7
  version: CURRENT_DEFAULT_SCHEMA_VERSION,
@@ -80,6 +81,10 @@ export const normalizeOptions = (options) => {
80
81
  else {
81
82
  normalized = shallowClone(defaultOptions);
82
83
  }
84
+ // Clamp asyncTimeout to prevent resource exhaustion (CWE-400)
85
+ if (normalized.asyncTimeout != null) {
86
+ normalized.asyncTimeout = Math.min(Math.max(normalized.asyncTimeout, 0), MAX_ASYNC_TIMEOUT);
87
+ }
83
88
  if (normalized.strictMode === true) {
84
89
  normalized.forceAdditional = true;
85
90
  normalized.forceItems = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "z-schema",
3
- "version": "12.0.0",
3
+ "version": "12.0.2",
4
4
  "engines": {
5
5
  "node": ">=22.0.0"
6
6
  },
@@ -74,6 +74,8 @@
74
74
  "build:tests": "tsc --noEmit --project test/tsconfig.json",
75
75
  "copy:module-json": "node -e \"fs.copyFileSync('./src/package.json', './dist/package.json')\"",
76
76
  "copy:schemas": "node ./scripts/copy-schemas.mts",
77
+ "coverage:artifacts": "node ./scripts/generate-test-coverage-report.mts",
78
+ "coverage:update-badge": "node ./scripts/update-readme-coverage-badge.mts",
77
79
  "prepublishOnly": "npm run clean && npm run build && npm test",
78
80
  "test": "vitest run",
79
81
  "test:coverage": "vitest run --coverage",
@@ -241,18 +241,47 @@ const uriTemplateValidator: FormatValidatorFn = (uri: unknown) => {
241
241
  return !inExpression;
242
242
  };
243
243
 
244
+ const hasValidTildeEscapes = (segment: string): boolean => {
245
+ for (let i = 0; i < segment.length; i++) {
246
+ if (segment[i] === '~') {
247
+ const next = segment[i + 1];
248
+ if (next !== '0' && next !== '1') {
249
+ return false;
250
+ }
251
+ i++; // skip the escape character
252
+ }
253
+ }
254
+ return true;
255
+ };
256
+
244
257
  const jsonPointerValidator: FormatValidatorFn = (pointer: unknown) => {
245
258
  if (typeof pointer !== 'string') return true;
246
259
  // JSON Pointer: empty, or a sequence of '/'-prefixed reference tokens.
247
260
  // In each token, '~' must be escaped as '~0' or '~1'.
248
- return pointer === '' || /^(?:\/(?:[^~]|~0|~1)*)+$/.test(pointer);
261
+ if (pointer === '') return true;
262
+ if (!/^(?:\/[^/]*)+$/.test(pointer)) return false;
263
+ const tokens = pointer.split('/').slice(1); // first element is empty before leading '/'
264
+ for (const token of tokens) {
265
+ if (!hasValidTildeEscapes(token)) return false;
266
+ }
267
+ return true;
249
268
  };
250
269
 
251
270
  const relativeJsonPointerValidator: FormatValidatorFn = (pointer: unknown) => {
252
271
  if (typeof pointer !== 'string') return true;
253
272
  // Relative JSON Pointer: non-negative integer prefix (no leading zeros unless zero),
254
273
  // followed by either '#', a JSON Pointer, or nothing.
255
- return /^(?:0|[1-9]\d*)(?:#|(?:\/(?:[^~]|~0|~1)*)+)?$/.test(pointer);
274
+ const match = pointer.match(/^(0|[1-9]\d*)(.*)$/);
275
+ if (!match) return false;
276
+ const suffix = match[2];
277
+ if (suffix === '' || suffix === '#') return true;
278
+ if (!suffix.startsWith('/')) return false;
279
+ if (!/^(?:\/[^/]*)+$/.test(suffix)) return false;
280
+ const tokens = suffix.split('/').slice(1);
281
+ for (const token of tokens) {
282
+ if (!hasValidTildeEscapes(token)) return false;
283
+ }
284
+ return true;
256
285
  };
257
286
 
258
287
  const timeValidator: FormatValidatorFn = (time: unknown) => {