fix(security): harden pipeline planning, pointer traversal, and concurrency

- expressions: never execute $js during planning/dry-run. `pipeline run --dry-run`
  is the command a cautious user runs to preview an unfamiliar pipeline; it must
  not run embedded JavaScript. Planning now returns the expression placeholder
  instead of calling new Function.
- schema (getByJsonPointer): block __proto__/constructor/prototype and require
  own properties, so a crafted $from/$input path cannot pull object internals
  (e.g. constructor) out of step output and feed them downstream.
- scheduler: clamp --concurrency to a maximum (64) to bound fan-out so a single
  run cannot launch an unbounded number of concurrent API calls / downloads.

Note: the runtime new Function sinks in script/js and $js (arbitrary host code
execution) are intentionally left unchanged here — remediating them is a design
decision (sandbox vs. literal-only code) for the maintainers; see PR notes.

https://claude.ai/code/session_017ZGQCjwNQF5Pz96gLUnnG1
This commit is contained in:
Claude
2026-05-29 12:34:24 +00:00
parent d24f203d68
commit 8b9986bb47
3 changed files with 17 additions and 20 deletions
+4 -19
View File
@@ -289,25 +289,10 @@ function resolvePlannedExpression(
return combineResolved(undefined, undefined, false, false);
}
if ("$js" in expression) {
const argsExpressions = (expression.args ?? {}) as Record<string, PipelineInputExpression>;
const hasFrom = Object.values(argsExpressions).some((v) => isRecord(v) && "$from" in v);
if (hasFrom) return combineResolved({ ...expression }, { ...expression }, false);
const code = expression.$js as string;
const resolvedArgs: Record<string, unknown> = {};
let sensitive = false;
for (const [key, argExpr] of Object.entries(argsExpressions)) {
const resolved = resolvePlannedExpression(argExpr, pipeline, runtimeInput);
resolvedArgs[key] = resolved.value;
sensitive = sensitive || resolved.sensitive;
}
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function("args", `return (${code})`);
const value = fn(resolvedArgs);
return combineResolved(value, sensitive ? REDACTED : value, sensitive);
} catch {
return combineResolved({ ...expression }, { ...expression }, false);
}
// Planning / dry-run must be a non-executing preview: never run user
// JavaScript here. Surface the expression as an unresolved placeholder so a
// `--dry-run` of an untrusted pipeline cannot trigger code execution.
return combineResolved({ ...expression }, { ...expression }, false);
}
return combineResolved(expression, expression, false);
}
+5 -1
View File
@@ -70,6 +70,8 @@ export function orderReports(
return [...reports].sort((a, b) => (index.get(a.id) ?? 0) - (index.get(b.id) ?? 0));
}
const MAX_CONCURRENCY = 64;
export function normalizeConcurrency(value: number | undefined): number {
if (value === undefined) return 1;
if (!Number.isInteger(value) || value < 1) {
@@ -77,5 +79,7 @@ export function normalizeConcurrency(value: number | undefined): number {
details: { issues: ["concurrency must be a positive integer"] },
});
}
return value;
// Cap fan-out so a single run cannot launch an unbounded number of concurrent
// API calls / downloads and exhaust sockets, file descriptors, or memory.
return Math.min(value, MAX_CONCURRENCY);
}
+8
View File
@@ -91,6 +91,14 @@ export function getByJsonPointer(value: unknown, pointer: string): unknown {
continue;
}
if (isRecord(current)) {
// A JSON pointer over data must not reach object internals. Block
// prototype-polluting keys and only follow own properties so a crafted
// `$from`/`$input` path cannot pull out `constructor`/`__proto__` and feed
// it into downstream consumers.
if (segment === "__proto__" || segment === "constructor" || segment === "prototype") {
return undefined;
}
if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined;
current = current[segment];
continue;
}