Skip to content
Merged
Show file tree
Hide file tree
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
26 changes: 22 additions & 4 deletions typescript/src/ts/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ export type ResultReference = {
"@ref": number;
};

/**
* Determines whether a function name is safe to use as an `@func` value. The name must be a plain
* ASCII identifier — a letter, underscore, or `$`, followed by letters, digits, underscores, or `$`
* — and must not be a member of `Object.prototype` (e.g. `constructor`, `__proto__`, `toString`,
* `valueOf`, `hasOwnProperty`), which could otherwise be used to escape the intended API surface
* during code generation or dispatch.
*/
function isValidFunctionName(name: unknown): name is string {
return typeof name === "string" &&
/^[a-zA-Z_$][0-9a-zA-Z_$]*$/.test(name) &&
!Object.prototype.hasOwnProperty.call(Object.prototype, name);
}

/**
* Transforms a JSON program object into an equivalent TypeScript module suitable for type checking.
* The generated module takes the form:
Expand Down Expand Up @@ -116,7 +129,7 @@ export function createModuleTextFromProgram(jsonObject: object): Result<string>
const func = obj["@func"];
const hasArgs = obj.hasOwnProperty("@args");
const args = hasArgs ? obj["@args"] : [];
if (typeof func === "string" && (Array.isArray(args)) && Object.keys(obj).length === (hasArgs ? 2 : 1)) {
if (isValidFunctionName(func) && (Array.isArray(args)) && Object.keys(obj).length === (hasArgs ? 2 : 1)) {
return `api.${func}(${arrayToString(args)})`;
}
}
Expand Down Expand Up @@ -167,10 +180,15 @@ export async function evaluateJsonProgram(program: Program, onCall: (func: strin
}
else if (obj.hasOwnProperty("@func")) {
const func = obj["@func"];
const args = obj.hasOwnProperty("@args") ? obj["@args"] : [];
if (typeof func === "string" && Array.isArray(args)) {
return await onCall(func, await evaluateArray(args));
const hasArgs = obj.hasOwnProperty("@args");
const args = hasArgs ? obj["@args"] : [];
if (!isValidFunctionName(func)) {
throw new Error(`Invalid function name: ${JSON.stringify(func)}`);
}
Comment thread
TalZaccai marked this conversation as resolved.
if (!Array.isArray(args) || Object.keys(obj).length !== (hasArgs ? 2 : 1)) {
throw new Error(`Invalid function call: ${JSON.stringify(obj)}`);
}
return await onCall(func, await evaluateArray(args));
}
else if (Array.isArray(obj)) {
return evaluateArray(obj);
Expand Down
131 changes: 130 additions & 1 deletion typescript/test/program.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { evaluateJsonProgram, createModuleTextFromProgram, Program } from "../dist/ts/index.js";
import { evaluateJsonProgram, createModuleTextFromProgram, Program, FunctionCall } from "../dist/ts/index.js";

// ---------------------------------------------------------------------------
// evaluateJsonProgram result-reference bounds checking
Expand Down Expand Up @@ -104,3 +104,132 @@ describe("createModuleTextFromProgram result references", () => {
assert.equal(result.success, false);
});
});

// ---------------------------------------------------------------------------
// @func name validation (source injection / unchecked dispatch)
// ---------------------------------------------------------------------------

describe("@func name validation", () => {
const maliciousProgram: Program = {
"@steps": [
{ "@func": "play('x'); /*", "@args": [] },
{ "@func": "deleteEverything", "@args": ["rm", "-rf", 0] },
{ "@func": "*/ api.play", "@args": ["x"] },
],
};
Comment thread
Copilot marked this conversation as resolved.

it("createModuleTextFromProgram rejects a comment-injection @func value", () => {
const result = createModuleTextFromProgram(maliciousProgram);
assert.equal(result.success, false);
});

it("createModuleTextFromProgram accepts a normal valid program", () => {
const result = createModuleTextFromProgram({
"@steps": [
{ "@func": "first", "@args": [] },
{ "@func": "second", "@args": [{ "@ref": 0 }] },
],
});
assert.equal(result.success, true);
assert.match((result as { data: string }).data, /api\.first\(\)/);
assert.match((result as { data: string }).data, /api\.second\(step1\)/);
});

it("evaluateJsonProgram throws instead of dispatching a comment-injection @func value", async () => {
const calls: Array<{ func: string; args: unknown[] }> = [];
await assert.rejects(
evaluateJsonProgram(maliciousProgram, async (func, args) => {
calls.push({ func, args });
return undefined;
}),
/Invalid function name/
);
assert.equal(calls.length, 0, "no function should have been dispatched");
});

for (const badName of ["constructor", "__proto__", "toString", "valueOf", "hasOwnProperty"]) {
it(`evaluateJsonProgram throws instead of dispatching prototype member "${badName}"`, async () => {
const program: Program = { "@steps": [{ "@func": badName, "@args": [] }] };
let dispatched = false;
await assert.rejects(
evaluateJsonProgram(program, async (func, args) => {
dispatched = true;
return undefined;
}),
/Invalid function name/
);
assert.equal(dispatched, false);
});
}

it("evaluateJsonProgram dispatches a normal valid program correctly", async () => {
const calls: Array<{ func: string; args: unknown[] }> = [];
const result = await evaluateJsonProgram(
{
"@steps": [
{ "@func": "first", "@args": [] },
{ "@func": "second", "@args": [{ "@ref": 0 }] },
],
},
async (func, args) => {
calls.push({ func, args });
return `${func}-result`;
}
);
assert.deepEqual(calls[0], { func: "first", args: [] });
assert.deepEqual(calls[1], { func: "second", args: ["first-result"] });
assert.equal(result, "second-result");
});

it("createModuleTextFromProgram rejects a call object with a non-array @args", () => {
const result = createModuleTextFromProgram({
"@steps": [
{ "@func": "first", "@args": "not-an-array" as unknown as [] },
],
});
assert.equal(result.success, false);
});

it("createModuleTextFromProgram rejects a call object with extra unexpected keys", () => {
const result = createModuleTextFromProgram({
"@steps": [
{ "@func": "first", "@args": [], "extra": true } as unknown as FunctionCall,
],
});
assert.equal(result.success, false);
});

it("evaluateJsonProgram throws instead of dispatching a call object with a non-array @args", async () => {
const program = {
"@steps": [
{ "@func": "first", "@args": "not-an-array" },
],
} as unknown as Program;
let dispatched = false;
await assert.rejects(
evaluateJsonProgram(program, async (func, args) => {
dispatched = true;
return undefined;
}),
/Invalid function call/
);
assert.equal(dispatched, false);
});

it("evaluateJsonProgram throws instead of dispatching a call object with extra unexpected keys", async () => {
const program = {
"@steps": [
{ "@func": "first", "@args": [], "extra": true },
],
} as unknown as Program;
let dispatched = false;
await assert.rejects(
evaluateJsonProgram(program, async (func, args) => {
dispatched = true;
return undefined;
}),
/Invalid function call/
);
assert.equal(dispatched, false);
});
});