diff --git a/tools/src/main/js/linter/index.js b/tools/src/main/js/linter/index.js index 9e371e63..f09b2345 100644 --- a/tools/src/main/js/linter/index.js +++ b/tools/src/main/js/linter/index.js @@ -380,28 +380,85 @@ export class SchemaLinter { } /** - * Utility to traverse a JSON schema and call a visitor function + * Build an unambiguous path segment for a key (bracket notation when key contains . or non-identifier chars) + * @param {string} base - Current path (e.g. '$' or '$.definitions') + * @param {string} key - Property key + * @returns {string} + */ +function safePathJoin(base, key) { + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) { + return `${base}.${key}`; + } + return `${base}[${JSON.stringify(key)}]`; +} + +/** Keys that, if present in schema and later used in a path-based setter (e.g. lodash.set), could lead to prototype pollution. Exported so path consumers can reject/escape these segments; in traverseSchema used only for optional onDangerousKey notification (traversal is not skipped). */ +export const DANGEROUS_PATH_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** Default max traversal depth: no limit (Infinity), so default behaviour matches original—no truncation. Set a finite maxDepth (e.g. 1000) via config to guard against depth-only DoS; use onDepthLimit to warn when hit. */ +export const DEFAULT_MAX_DEPTH = Number.POSITIVE_INFINITY; + +/** + * Utility to traverse a JSON schema and call a visitor function. + * + * Behavior notes: + * - **Cycle detection (stack-based):** Objects/arrays on the current recursion path are kept in a WeakSet + * (inProgress). When leaving a node it is removed. So the same object reachable via different paths + * (shared refs / DAG / YAML aliases) is visited once per path; only when we would re-enter a node + * already on the current path do we skip (true cycle). This preserves coverage for path-sensitive + * checks while preventing infinite recursion. + * - **Paths:** Keys that are not simple identifiers (e.g. contain '.' or special chars) use bracket + * notation so paths are unambiguous (e.g. `$["a.b"]` instead of `$.a.b`). + * - **Depth limit:** Beyond maxDepth, traversal stops without visiting deeper nodes. Pass onDepthLimit + * to be notified when this happens so lint results are not silently incomplete. Recommended: have + * the runner (e.g. SchemaLinter) always pass onDepthLimit and emit a LintIssue so truncation is never silent. + * - **Dangerous path keys:** This function does not traverse the prototype chain and does not mutate + * objects. If path strings are only used for reporting, prototype pollution risk is negligible. If + * any consumer uses a path in a path-based setter (e.g. lodash.set), segments like __proto__, + * constructor, prototype can be dangerous. Pass onDangerousKey to be notified when such a key is + * encountered (traversal is not skipped; coverage is unchanged). + * * @param {object} schema - The schema to traverse * @param {function} visitor - Function called for each node: (node, path, key, parent) * @param {string} [path] - Current path (used internally) * @param {string} [key] - Current key (used internally) * @param {object} [parent] - Parent node (used internally) + * @param {WeakSet} [inProgress] - Set of objects on the current recursion path for cycle detection (used internally) + * @param {number} [depth] - Current depth (used internally) + * @param {number} [maxDepth] - Stop recursing beyond this depth (used internally; default no limit so behaviour is unchanged unless set) + * @param {function(string, number): void} [onDepthLimit] - When provided, called with (path, depth) when traversal stops due to depth limit + * @param {function(string, string|null, object|null): void} [onCycle] - When provided, called with (path, key, parent) when a cycle is detected (node already on current path) + * @param {function(string, string): void} [onDangerousKey] - When provided, called with (path, key) when key is __proto__, constructor, or prototype (path could be dangerous if given to a setter); traversal still continues */ -export function traverseSchema(schema, visitor, path = '$', key = null, parent = null) { - visitor(schema, path, key, parent); - +export function traverseSchema(schema, visitor, path = '$', key = null, parent = null, inProgress = new WeakSet(), depth = 0, maxDepth = DEFAULT_MAX_DEPTH, onDepthLimit = undefined, onCycle = undefined, onDangerousKey = undefined) { if (typeof schema !== 'object' || schema === null) { + visitor(schema, path, key, parent); + return; + } + if (inProgress.has(schema)) { + if (typeof onCycle === 'function') onCycle(path, key, parent); return; } - - if (Array.isArray(schema)) { - schema.forEach((item, index) => { - traverseSchema(item, visitor, `${path}[${index}]`, index, schema); - }); - } else { - for (const [k, v] of Object.entries(schema)) { - traverseSchema(v, visitor, `${path}.${k}`, k, schema); + inProgress.add(schema); + try { + visitor(schema, path, key, parent); + if (depth >= maxDepth) { + if (typeof onDepthLimit === 'function') onDepthLimit(path, depth); + return; + } + if (Array.isArray(schema)) { + schema.forEach((item, index) => { + traverseSchema(item, visitor, `${path}[${index}]`, index, schema, inProgress, depth + 1, maxDepth, onDepthLimit, onCycle, onDangerousKey); + }); + } else { + for (const [k, v] of Object.entries(schema)) { + const nextPath = safePathJoin(path, k); + if (DANGEROUS_PATH_KEYS.has(k) && typeof onDangerousKey === 'function') onDangerousKey(nextPath, k); + traverseSchema(v, visitor, nextPath, k, schema, inProgress, depth + 1, maxDepth, onDepthLimit, onCycle, onDangerousKey); + } } + } finally { + inProgress.delete(schema); } }