mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user