Skip to content
Open
Changes from all 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
81 changes: 69 additions & 12 deletions tools/src/main/js/linter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down