fix(security): require script/js code to be a literal (block untrusted-code RCE)

script/js executes its `code` as host JavaScript (via new Function), and a step's
`code` is a *resolved* input — so it could be written as `{ $from: <chat-step> }`,
turning model/API output into the body of the executed function (untrusted data
-> arbitrary host code execution). Pipeline validation now requires script/js
`code` to be a literal string: any $from/expression-sourced code is rejected.

Authoring a literal script/js step remains supported (the pipeline file is the
trust boundary, like a shell/npm script). Combined with "dry-run never executes
$js", this closes the path where untrusted text reaches the JS sink.

Adds regression tests: $from-sourced code rejected, literal code accepted,
dry-run does not execute $js, getByJsonPointer blocks prototype/inherited keys,
and concurrency clamps to the maximum.

https://claude.ai/code/session_017ZGQCjwNQF5Pz96gLUnnG1
This commit is contained in:
Claude
2026-05-29 12:44:48 +00:00
parent 8b9986bb47
commit bb9f941849
2 changed files with 88 additions and 0 deletions
+15
View File
@@ -125,6 +125,21 @@ function collectPipelineSemanticIssues(
`semantic: step "${stepLabel}" timeout must be a positive number of seconds or duration string`,
);
}
// `script/js` executes its `code` as host JavaScript. Require it to be a
// literal string in the pipeline definition: code sourced from another step
// ($from) or any expression is rejected, so untrusted/model-generated text
// can never become the body of the executed function.
if (step.type === "script/js") {
const code = isRecord(step.input) ? step.input.code : undefined;
if (typeof code !== "string") {
issues.push(
`semantic: step "${stepLabel}" (script/js) requires a literal string "code"; ` +
`code sourced from another step ($from) or an expression is not allowed, ` +
`since it would execute untrusted text as host code`,
);
}
}
}
// Check dependency references
+73
View File
@@ -1,6 +1,9 @@
import { expect, test } from "vite-plus/test";
import { createStepDispatcher } from "../src/pipeline/dispatcher.ts";
import { executePipeline } from "../src/pipeline/executor.ts";
import { collectPipelineIssues } from "../src/pipeline/validation.ts";
import { getByJsonPointer } from "../src/pipeline/schema.ts";
import { normalizeConcurrency } from "../src/pipeline/scheduler.ts";
import { WORKFLOW_VERSION, type PipelineDefinition } from "../src/pipeline/types.ts";
test("cli package skeleton", () => {
@@ -34,3 +37,73 @@ test("pipeline execution can use an isolated step dispatcher", async () => {
hasSignal: true,
});
});
test("dry-run never executes $js expressions (preview must not run code)", async () => {
const dispatcher = createStepDispatcher();
dispatcher.registerStep("test/echo", (input) => ({ data: input }));
const flag = "__bailian_dryrun_should_not_run__";
delete (globalThis as Record<string, unknown>)[flag];
const pipeline: PipelineDefinition = {
version: WORKFLOW_VERSION,
steps: [
{
id: "s1",
type: "test/echo",
input: { probe: { $js: `(globalThis[${JSON.stringify(flag)}] = true), 1` } },
},
],
};
const report = await executePipeline(pipeline, {}, { stepDispatcher: dispatcher, dryRun: true });
expect(report.status).toBe("planned");
expect((globalThis as Record<string, unknown>)[flag]).toBeUndefined();
});
test("script/js rejects non-literal code sourced from another step ($from)", () => {
const dispatcher = createStepDispatcher();
dispatcher.registerStep("test/echo", (input) => ({ data: input }));
dispatcher.registerStep("script/js", () => ({ data: {} }));
const pipeline: PipelineDefinition = {
version: WORKFLOW_VERSION,
steps: [
{ id: "gen", type: "test/echo", input: { message: "x" } },
{
id: "run",
type: "script/js",
input: { code: { $from: "gen", path: "/data/message" } as never },
},
],
};
const issues = collectPipelineIssues(pipeline, dispatcher);
expect(issues.some((issue) => issue.includes('literal string "code"'))).toBe(true);
});
test("script/js accepts a literal string code", () => {
const dispatcher = createStepDispatcher();
dispatcher.registerStep("script/js", () => ({ data: {} }));
const pipeline: PipelineDefinition = {
version: WORKFLOW_VERSION,
steps: [{ id: "run", type: "script/js", input: { code: "return 1" } }],
};
expect(collectPipelineIssues(pipeline, dispatcher)).toEqual([]);
});
test("getByJsonPointer refuses prototype keys and inherited properties", () => {
const obj = { a: { b: 1 } };
expect(getByJsonPointer(obj, "/a/b")).toBe(1);
expect(getByJsonPointer(obj, "/__proto__")).toBeUndefined();
expect(getByJsonPointer(obj, "/constructor")).toBeUndefined();
expect(getByJsonPointer(obj, "/a/constructor/constructor")).toBeUndefined();
expect(getByJsonPointer(obj, "/toString")).toBeUndefined();
});
test("normalizeConcurrency clamps to a safe maximum", () => {
expect(normalizeConcurrency(undefined)).toBe(1);
expect(normalizeConcurrency(4)).toBe(4);
expect(normalizeConcurrency(100000)).toBe(64);
});