mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
fix(javascript): omit effects refused by host construction (#11122)
Filter child-process, fetch/WebSocket and TLS effects for literal calls the host refuses to construct. Share copied child options across validation and projection, preserving uncertainty from own and inherited getters. Includes the original implementations from #10528, #10529 and #10530. Validation: 394 domain/public CLI/MCP tests, then 54 request/public tests after the review correction, static checks and discovery passed. Also verified 29 Node 24.22.1 probes and actual Chromium 156 header/Request constructors. Full browser app/network behavior was not tested. Analysis does not execute target source. Fixes #10522 Fixes #10523 Fixes #10524 Related: #10528, #10529, #10530 Co-authored-by: RRFZ <247457381+Plastic-time@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
9 files changed
+1109
-42
No files matched your search
@@ -188,11 +188,14 @@ export const staticJavaScriptTruthiness = (
|
||||
t.isNumericLiteral(value.argument)
|
||||
)
|
||||
return value.argument.value !== 0 && !Number.isNaN(value.argument.value);
|
||||
// Each of these evaluates to a fresh object, and every object is truthy.
|
||||
if (
|
||||
t.isObjectExpression(value) ||
|
||||
t.isArrayExpression(value) ||
|
||||
t.isFunctionExpression(value) ||
|
||||
t.isArrowFunctionExpression(value)
|
||||
t.isArrowFunctionExpression(value) ||
|
||||
t.isClassExpression(value) ||
|
||||
t.isRegExpLiteral(value)
|
||||
)
|
||||
return true;
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
isObjectPrototypeSetter,
|
||||
objectDataPropertyValue,
|
||||
primitiveOwnKeys,
|
||||
readExactJavaScriptLiteral,
|
||||
staticObjectPropertyReader,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
|
||||
/** RFC 9110 `token`, the grammar of methods, header names and subprotocols. */
|
||||
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
|
||||
|
||||
/** Fetch's forbidden methods, matched byte-case-insensitively. */
|
||||
const FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]);
|
||||
|
||||
const WEBSOCKET_SCHEMES = new Set(["ws:", "wss:", "http:", "https:"]);
|
||||
|
||||
const literalString = (node: t.Node | undefined): string | undefined => {
|
||||
if (node === undefined || t.isSpreadElement(node)) return undefined;
|
||||
const literal = readExactJavaScriptLiteral(node);
|
||||
return literal.found && typeof literal.value === "string"
|
||||
? literal.value
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an absolute URL literal names a user or password. Without `//` after
|
||||
* the scheme, a browser page with the same scheme reads the rest as a relative
|
||||
* path: on an http page, "http:user@host/x" names no user.
|
||||
*/
|
||||
const carriesCredentials = (url: string): boolean => {
|
||||
// The URL parser first trims leading and trailing C0 controls and spaces,
|
||||
// then drops every tab and newline, so judge the string it actually reads.
|
||||
const parsedText = url
|
||||
.replace(/^[\u0000- ]+|[\u0000- ]+$/gu, "")
|
||||
.replace(/[\t\n\r]/gu, "");
|
||||
if (
|
||||
!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(parsedText) ||
|
||||
!URL.canParse(parsedText)
|
||||
)
|
||||
return false;
|
||||
const parsed = new URL(parsedText);
|
||||
return parsed.username !== "" || parsed.password !== "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Own header names of an object literal, when they are all that a host reads.
|
||||
* Headers init uses an object's `@@iterator` when it has one, so a computed
|
||||
* key, a spread, an accessor or a `__proto__` setter leaves the names unknown.
|
||||
*/
|
||||
const recordHeaderNames = (
|
||||
record: t.ObjectExpression,
|
||||
): readonly string[] | undefined => {
|
||||
const names: string[] = [];
|
||||
for (const property of record.properties) {
|
||||
if (
|
||||
t.isSpreadElement(property) ||
|
||||
property.computed ||
|
||||
objectDataPropertyValue(property) === undefined
|
||||
)
|
||||
return undefined;
|
||||
const { key } = property;
|
||||
const name = t.isIdentifier(key)
|
||||
? key.name
|
||||
: t.isStringLiteral(key)
|
||||
? key.value
|
||||
: t.isNumericLiteral(key)
|
||||
? String(key.value)
|
||||
: undefined;
|
||||
if (name === undefined || name === "__proto__") return undefined;
|
||||
names.push(name);
|
||||
}
|
||||
return names;
|
||||
};
|
||||
|
||||
/** A body member proven present and not null or undefined. */
|
||||
const provenBody = (node: t.Node): boolean => {
|
||||
const value = unwrapJavaScriptExpression(node).node;
|
||||
const literal = readExactJavaScriptLiteral(value);
|
||||
if (literal.found) return literal.value !== null;
|
||||
return (
|
||||
t.isTemplateLiteral(value) ||
|
||||
t.isObjectExpression(value) ||
|
||||
t.isArrayExpression(value) ||
|
||||
t.isNewExpression(value) ||
|
||||
t.isNumericLiteral(value) ||
|
||||
t.isBigIntLiteral(value) ||
|
||||
t.isRegExpLiteral(value)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The Request constructor refuses these before any fetch starts, so `fetch`
|
||||
* returns a rejected promise and sends nothing. Measured on Node 24.22.1 (undici),
|
||||
* which follows the Fetch standard that browsers implement:
|
||||
*
|
||||
* a URL with a user name or password -> TypeError
|
||||
* method "G ET" / "" / "GET\n" -> TypeError, not a token
|
||||
* method "CONNECT" / "connect" / "TRACE" / "Track" -> TypeError, forbidden
|
||||
* GET or HEAD (any case, or the default) with a body that is not null
|
||||
* or undefined, including "" -> TypeError
|
||||
* a header name that is not a token, such as "a b", "" or "x-é" -> TypeError
|
||||
*
|
||||
* Only a string URL proves the default GET; a Request object may carry its own method.
|
||||
*/
|
||||
const fetchRefused = (node: t.Node): boolean => {
|
||||
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
|
||||
return false;
|
||||
const [input, init] = node.arguments;
|
||||
// A spread can move every later literal to a slot the host ignores.
|
||||
if (input === undefined || t.isSpreadElement(input)) return false;
|
||||
const url = literalString(input);
|
||||
if (url !== undefined && carriesCredentials(url)) return true;
|
||||
if (init === undefined || t.isSpreadElement(init)) return false;
|
||||
const options = unwrapJavaScriptExpression(init).node;
|
||||
if (!t.isObjectExpression(options)) return false;
|
||||
// WebIDL reads the dictionary's getters before constructing the Request;
|
||||
// those bodies may replace or delete the later method, body or headers.
|
||||
if (
|
||||
options.properties.some(
|
||||
(property) =>
|
||||
(t.isObjectMethod(property) && property.kind !== "method") ||
|
||||
(t.isObjectProperty(property) &&
|
||||
isObjectPrototypeSetter(property) &&
|
||||
primitiveOwnKeys(property.value) === null),
|
||||
)
|
||||
)
|
||||
return false;
|
||||
const readOption = staticObjectPropertyReader(options);
|
||||
const methodMember = readOption("method");
|
||||
const method =
|
||||
methodMember.status === "explicit"
|
||||
? literalString(methodMember.value)
|
||||
: methodMember.status === "missing" && url !== undefined
|
||||
? "GET"
|
||||
: undefined;
|
||||
if (method !== undefined) {
|
||||
if (!TOKEN.test(method)) return true;
|
||||
const upper = method.toUpperCase();
|
||||
if (FORBIDDEN_METHODS.has(upper)) return true;
|
||||
const body = readOption("body");
|
||||
if (
|
||||
(upper === "GET" || upper === "HEAD") &&
|
||||
body.status === "explicit" &&
|
||||
provenBody(body.value)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
const headers = readOption("headers");
|
||||
if (headers.status !== "explicit") return false;
|
||||
const record = unwrapJavaScriptExpression(headers.value).node;
|
||||
if (!t.isObjectExpression(record)) return false;
|
||||
const names = recordHeaderNames(record);
|
||||
return names !== undefined && names.some((name) => !TOKEN.test(name));
|
||||
};
|
||||
|
||||
/**
|
||||
* The WebSocket constructor throws a SyntaxError before it opens a connection.
|
||||
* Measured on Node 24.22.1 (undici), which follows the WHATWG WebSockets
|
||||
* standard that browsers implement:
|
||||
*
|
||||
* a URL with a fragment, even an empty one or a relative one -> SyntaxError
|
||||
* an absolute URL whose scheme is not ws, wss, http or https -> SyntaxError
|
||||
* a subprotocol that is not a token, such as "", "a b", "a/b" or "é" -> SyntaxError
|
||||
* the same subprotocol twice -> SyntaxError
|
||||
*
|
||||
* A relative URL without a fragment stays: a browser resolves it against the
|
||||
* document. A non-string subprotocol becomes its string form, so it stays too.
|
||||
*/
|
||||
const webSocketRefused = (node: t.Node): boolean => {
|
||||
if (!t.isNewExpression(node)) return false;
|
||||
const [target, protocols] = node.arguments;
|
||||
// A spread can move every later literal to a slot the host ignores.
|
||||
if (target === undefined || t.isSpreadElement(target)) return false;
|
||||
const url = literalString(target);
|
||||
if (url !== undefined) {
|
||||
// The URL parser treats any "#" as the start of the fragment.
|
||||
if (url.includes("#")) return true;
|
||||
if (URL.canParse(url) && !WEBSOCKET_SCHEMES.has(new URL(url).protocol))
|
||||
return true;
|
||||
}
|
||||
if (protocols === undefined || t.isSpreadElement(protocols)) return false;
|
||||
const value = unwrapJavaScriptExpression(protocols).node;
|
||||
const single = literalString(value);
|
||||
if (single !== undefined) return !TOKEN.test(single);
|
||||
if (!t.isArrayExpression(value)) return false;
|
||||
const known = value.elements.flatMap((element) => {
|
||||
const name = literalString(element ?? undefined);
|
||||
return name === undefined ? [] : [name];
|
||||
});
|
||||
return (
|
||||
known.some((name) => !TOKEN.test(name)) ||
|
||||
new Set(known).size !== known.length
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a host request is refused while it is constructed, so nothing is sent.
|
||||
* Only `fetch` and `WebSocket` are judged here; other methods keep their own rules.
|
||||
*/
|
||||
export const hostRefusesJavaScriptRequest = (
|
||||
node: t.Node,
|
||||
method: string,
|
||||
): boolean =>
|
||||
method === "fetch"
|
||||
? fetchRefused(node)
|
||||
: method === "WebSocket"
|
||||
? webSocketRefused(node)
|
||||
: false;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
|
||||
/**
|
||||
* Node validates the command before anything else, so a command it refuses
|
||||
* throws synchronously and no child process exists.
|
||||
*
|
||||
* Measured on Node 24.22.1 for spawn, execFile, exec and fork:
|
||||
*
|
||||
* spawn() / spawn(null) / spawn(undefined) -> THREW ERR_INVALID_ARG_TYPE
|
||||
* spawn(5) / spawn(true) / spawn({}) -> THREW ERR_INVALID_ARG_TYPE
|
||||
* spawn([]) -> THREW ERR_INVALID_ARG_TYPE
|
||||
* spawn("no\0de") -> THREW ERR_INVALID_ARG_VALUE
|
||||
* spawn("") / execFile("") / exec("") -> THREW ERR_INVALID_ARG_VALUE
|
||||
* fork("") -> child
|
||||
* spawn(" ") / spawn("node") -> child
|
||||
*/
|
||||
const spawnsFor = (call: string) =>
|
||||
analyzeJavaScriptSemantics(
|
||||
`import { spawn, execFile, exec, fork } from "node:child_process";\nfunction g(command, args) { ${call}; }`,
|
||||
).childProcessSpawns.map(({ method }) => method);
|
||||
|
||||
it.each([
|
||||
["a missing command", "spawn()"],
|
||||
["a null command", "spawn(null)"],
|
||||
["an undefined command", "spawn(undefined)"],
|
||||
["a void command", "spawn(void 0)"],
|
||||
["a numeric command", "spawn(5)"],
|
||||
["a negative numeric command", "spawn(-1)"],
|
||||
["a BigInt command", "spawn(0n)"],
|
||||
["a boolean command", "spawn(true)"],
|
||||
["a negated command", "spawn(!command)"],
|
||||
["an object command", 'spawn({ command: "node" })'],
|
||||
["an array command", 'spawn(["node"])'],
|
||||
["a function command", "spawn(() => {})"],
|
||||
["a constructed command", 'spawn(new String("node"))'],
|
||||
["a command holding a NUL byte", 'spawn("no\\0de")'],
|
||||
["an empty spawn command", 'spawn("")'],
|
||||
["an empty template command", "spawn(``)"],
|
||||
["an empty execFile command", 'execFile("", [], () => {})'],
|
||||
["an empty exec command", 'exec("", () => {})'],
|
||||
["a numeric execFile command", "execFile(5, () => {})"],
|
||||
["a numeric exec command", "exec(5, () => {})"],
|
||||
["a numeric fork module", "fork(5)"],
|
||||
["a null fork module", "fork(null)"],
|
||||
["an array fork module", 'fork(["worker.js"])'],
|
||||
["a function fork module", "fork(() => {})"],
|
||||
["a fork module holding a NUL byte", 'fork("wor\\0ker.js")'],
|
||||
] as const)("emits no spawn for %s", (_name, call) => {
|
||||
expect(spawnsFor(call)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a command name", 'spawn("node")', "spawn"],
|
||||
["a blank command", 'spawn(" ")', "spawn"],
|
||||
["a template command", "spawn(`node`)", "spawn"],
|
||||
["a computed command", "spawn(command)", "spawn"],
|
||||
["a spread command", "spawn(...args)", "spawn"],
|
||||
["an empty fork module", 'fork("")', "fork"],
|
||||
[
|
||||
"a URL fork module",
|
||||
'fork(new URL("./worker.js", import.meta.url))',
|
||||
"fork",
|
||||
],
|
||||
[
|
||||
"a URL-like fork module",
|
||||
'fork({ href: "file:///worker.js", protocol: "file:" })',
|
||||
"fork",
|
||||
],
|
||||
["an exec command line", 'exec("node --version", () => {})', "exec"],
|
||||
["an execFile command", 'execFile("node", [], () => {})', "execFile"],
|
||||
] as const)("keeps the spawn for %s", (_name, call, method) => {
|
||||
expect(spawnsFor(call)).toEqual([method]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Options Node validates while normalizing the spawn, measured on Node 24.22.1.
|
||||
* Each member is checked only by the methods that forward it: execFile drops the
|
||||
* boolean flags and serialization, and exec replaces a non-string shell.
|
||||
*/
|
||||
it.each([
|
||||
["a negative timeout", 'spawn("node", [], { timeout: -1 })'],
|
||||
["a fractional timeout", 'execFile("node", [], { timeout: 1.5 }, () => {})'],
|
||||
["a string timeout", 'exec("node", { timeout: "1" }, () => {})'],
|
||||
["a boolean timeout", 'fork("worker.js", [], { timeout: true })'],
|
||||
["a NaN timeout", 'spawn("node", [], { timeout: NaN })'],
|
||||
["an Infinity timeout", 'spawn("node", [], { timeout: Infinity })'],
|
||||
["an overflowing timeout", 'spawn("node", [], { timeout: 1e400 })'],
|
||||
["a BigInt timeout", 'spawn("node", [], { timeout: 0n })'],
|
||||
["a negative BigInt timeout", 'spawn("node", [], { timeout: -1n })'],
|
||||
[
|
||||
"a negative overflowing maxBuffer",
|
||||
'execFile("node", [], { maxBuffer: -1e400 }, () => {})',
|
||||
],
|
||||
["a timeout after argv is omitted", 'fork("worker.js", { timeout: -1 })'],
|
||||
["a numeric detached flag", 'spawn("node", [], { detached: 1 })'],
|
||||
[
|
||||
"a string windowsHide flag",
|
||||
'fork("worker.js", [], { windowsHide: "true" })',
|
||||
],
|
||||
[
|
||||
"a numeric windowsVerbatimArguments flag",
|
||||
'spawn("node", [], { windowsVerbatimArguments: 1 })',
|
||||
],
|
||||
["a null serialization", 'fork("worker.js", [], { serialization: null })'],
|
||||
["an unknown serialization", 'spawn("node", [], { serialization: "JSON" })'],
|
||||
["a numeric shell", 'spawn("node", [], { shell: 0 })'],
|
||||
["an array shell", 'execFile("node", [], { shell: [] }, () => {})'],
|
||||
["a negative maxBuffer", 'execFile("node", [], { maxBuffer: -1 }, () => {})'],
|
||||
["a NaN maxBuffer", 'exec("node", { maxBuffer: NaN }, () => {})'],
|
||||
["a string maxBuffer", 'exec("node", { maxBuffer: "1" }, () => {})'],
|
||||
] as const)("emits no spawn for %s", (_name, call) => {
|
||||
expect(spawnsFor(call)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a detached flag changed by an option getter",
|
||||
'spawn("node", [], { get a() { this.detached = true; return 0; }, detached: 1 })',
|
||||
"spawn",
|
||||
],
|
||||
[
|
||||
"stdio changed by an option getter",
|
||||
'spawn("node", [], { get a() { this.stdio = "ignore"; return 0; }, stdio: "ipc" })',
|
||||
"spawn",
|
||||
],
|
||||
[
|
||||
"cwd changed by an option getter",
|
||||
'spawn("node", [], { get a() { this.cwd = "/"; return 0; }, cwd: 5 })',
|
||||
"spawn",
|
||||
],
|
||||
["a zero timeout", 'spawn("node", [], { timeout: 0 })', "spawn"],
|
||||
[
|
||||
"a large integer timeout",
|
||||
'spawn("node", [], { timeout: 2 ** 53 })',
|
||||
"spawn",
|
||||
],
|
||||
["a null timeout", 'spawn("node", [], { timeout: null })', "spawn"],
|
||||
["a negative-zero timeout", 'spawn("node", [], { timeout: -0 })', "spawn"],
|
||||
["a computed timeout", 'spawn("node", [], { timeout: args })', "spawn"],
|
||||
["a boolean detached flag", 'spawn("node", [], { detached: true })', "spawn"],
|
||||
[
|
||||
"a null windowsHide flag",
|
||||
'fork("worker.js", [], { windowsHide: null })',
|
||||
"fork",
|
||||
],
|
||||
[
|
||||
"an execFile numeric detached flag",
|
||||
'execFile("node", [], { detached: 1 }, () => {})',
|
||||
"execFile",
|
||||
],
|
||||
[
|
||||
"an exec string windowsHide flag",
|
||||
'exec("node", { windowsHide: "true" }, () => {})',
|
||||
"exec",
|
||||
],
|
||||
[
|
||||
"an advanced serialization",
|
||||
'fork("worker.js", [], { serialization: "advanced" })',
|
||||
"fork",
|
||||
],
|
||||
[
|
||||
"an undefined serialization",
|
||||
'spawn("node", [], { serialization: undefined })',
|
||||
"spawn",
|
||||
],
|
||||
[
|
||||
"an execFile null serialization",
|
||||
'execFile("node", [], { serialization: null }, () => {})',
|
||||
"execFile",
|
||||
],
|
||||
["a string shell", 'spawn("node", [], { shell: "cmd.exe" })', "spawn"],
|
||||
["an empty shell", 'spawn("node", [], { shell: "" })', "spawn"],
|
||||
["an exec numeric shell", 'exec("node", { shell: 0 }, () => {})', "exec"],
|
||||
["a fork numeric shell", 'fork("worker.js", [], { shell: 0 })', "fork"],
|
||||
[
|
||||
"a fractional maxBuffer",
|
||||
'execFile("node", [], { maxBuffer: 1.5 }, () => {})',
|
||||
"execFile",
|
||||
],
|
||||
[
|
||||
"an Infinity maxBuffer",
|
||||
'exec("node", { maxBuffer: Infinity }, () => {})',
|
||||
"exec",
|
||||
],
|
||||
[
|
||||
"a spawn negative maxBuffer",
|
||||
'spawn("node", [], { maxBuffer: -1 })',
|
||||
"spawn",
|
||||
],
|
||||
] as const)("keeps the spawn for %s", (_name, call, method) => {
|
||||
expect(spawnsFor(call)).toEqual([method]);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
|
||||
/**
|
||||
* `fetch` and `new WebSocket` validate their arguments while the Request or the
|
||||
* socket object is constructed, before anything is sent. Node's undici follows
|
||||
* the Fetch and WebSockets standards that browsers implement.
|
||||
*
|
||||
* Measured on Node 24.22.1:
|
||||
*
|
||||
* fetch("http://user@host/x") / "http://:pw@host/x" -> TypeError
|
||||
* fetch(url, { method: "G ET" | "" | "CONNECT" | "Track" }) -> TypeError
|
||||
* fetch(url, { body: "x" }) / { method: "get", body: "" } -> TypeError
|
||||
* fetch(url, { method: "head", body: 0 }) -> TypeError
|
||||
* fetch(url, { headers: { "a b": "1" } }) / "" / "x-é" -> TypeError
|
||||
* fetch(url, { method: "PATCH" | "M-SEARCH" }) -> sent
|
||||
* fetch(url, { method: "GET", body: null | undefined }) -> sent
|
||||
* fetch("http://:@host/x") -> sent
|
||||
* new WebSocket("ws://host/x#a") / "ws://host/x#" / "/s#a" -> SyntaxError
|
||||
* new WebSocket("ftp://host/x") / "file:///x" / "mailto:x" -> SyntaxError
|
||||
* new WebSocket(url, "a b" | "" | "a/b" | ["a", "a"]) -> SyntaxError
|
||||
* new WebSocket("http://host/x") / "WSS://host/x" -> socket
|
||||
* new WebSocket(url, null | 5 | ["a", null] | []) -> socket
|
||||
*/
|
||||
const requestsFor = (call: string) =>
|
||||
analyzeJavaScriptSemantics(`function g(url, init, list) { ${call}; }`)
|
||||
.requestOperations.filter(({ kind }) => kind === "request")
|
||||
.map(({ method }) => method);
|
||||
|
||||
it.each([
|
||||
["a URL with a user name", 'fetch("https://user@api.example.test/x")'],
|
||||
["a URL with a password", 'fetch("https://:pw@api.example.test/x")'],
|
||||
[
|
||||
"a URL with a user name and surrounding spaces",
|
||||
'fetch(" https://user@api.example.test/x ")',
|
||||
],
|
||||
["a method that is not a token", 'fetch(url, { method: "G ET" })'],
|
||||
["an empty method", 'fetch(url, { method: "" })'],
|
||||
["the CONNECT method", 'fetch(url, { method: "CONNECT" })'],
|
||||
[
|
||||
"the CONNECT method without a prototype",
|
||||
'fetch(url, { __proto__: null, method: "CONNECT" })',
|
||||
],
|
||||
["a lowercase TRACE method", 'fetch(url, { method: "trace" })'],
|
||||
["a mixed-case TRACK method", 'fetch(url, { method: "Track" })'],
|
||||
[
|
||||
"a body with the default GET method",
|
||||
'fetch("https://api.example.test/x", { body: "x" })',
|
||||
],
|
||||
["an empty body with GET", 'fetch(url, { method: "get", body: "" })'],
|
||||
["a body with HEAD", 'fetch(url, { method: "head", body: 0 })'],
|
||||
["an object body with GET", 'fetch(url, { method: "GET", body: {} })'],
|
||||
["a header name with a space", 'fetch(url, { headers: { "a b": "1" } })'],
|
||||
["an empty header name", 'fetch(url, { headers: { "": "1" } })'],
|
||||
["a non-ASCII header name", 'fetch(url, { headers: { "x-é": "1" } })'],
|
||||
[
|
||||
"a WebSocket URL with a fragment",
|
||||
'new WebSocket("wss://chat.example.test/ws#a")',
|
||||
],
|
||||
[
|
||||
"a WebSocket URL with an empty fragment",
|
||||
'new WebSocket("wss://chat.example.test/ws#")',
|
||||
],
|
||||
["a relative WebSocket URL with a fragment", 'new WebSocket("/ws#a")'],
|
||||
["an ftp WebSocket URL", 'new WebSocket("ftp://chat.example.test/ws")'],
|
||||
["a file WebSocket URL", 'new WebSocket("file:///ws")'],
|
||||
["a subprotocol with a space", 'new WebSocket(url, "a b")'],
|
||||
["an empty subprotocol", 'new WebSocket(url, "")'],
|
||||
["a subprotocol with a slash", 'new WebSocket(url, "chat/v1")'],
|
||||
["a repeated subprotocol", 'new WebSocket(url, ["chat", "chat"])'],
|
||||
[
|
||||
"an invalid subprotocol beside an unknown one",
|
||||
'new WebSocket(url, [list, "a b"])',
|
||||
],
|
||||
] as const)("emits no request for %s", (_name, call) => {
|
||||
expect(requestsFor(call)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a method removed while the Request dictionary is read",
|
||||
'fetch(url, { get cache() { delete this.method; return "default"; }, method: "CONNECT" })',
|
||||
"fetch",
|
||||
],
|
||||
[
|
||||
"a method removed by an inherited Request dictionary getter",
|
||||
'fetch(url, { __proto__: { get cache() { delete this.method; return "default"; } }, method: "CONNECT" })',
|
||||
"fetch",
|
||||
],
|
||||
["a plain fetch", 'fetch("https://api.example.test/x")', "fetch"],
|
||||
["an extension method", 'fetch(url, { method: "M-SEARCH" })', "fetch"],
|
||||
["a lowercase PATCH method", 'fetch(url, { method: "patch" })', "fetch"],
|
||||
["a POST body", 'fetch(url, { method: "POST", body: "x" })', "fetch"],
|
||||
[
|
||||
"a null body with GET",
|
||||
'fetch(url, { method: "GET", body: null })',
|
||||
"fetch",
|
||||
],
|
||||
[
|
||||
"an undefined body with GET",
|
||||
'fetch(url, { method: "GET", body: undefined })',
|
||||
"fetch",
|
||||
],
|
||||
["a body beside a Request input", 'fetch(init, { body: "x" })', "fetch"],
|
||||
["a computed method", "fetch(url, { method: init })", "fetch"],
|
||||
["a token header name", 'fetch(url, { headers: { "x-ok": "1" } })', "fetch"],
|
||||
["empty credentials", 'fetch("http://:@api.example.test/x")', "fetch"],
|
||||
["a relative fetch URL", 'fetch("/api/items")', "fetch"],
|
||||
[
|
||||
"a header record that iterates its own pairs",
|
||||
'fetch(url, { headers: { "a b": "x", *[Symbol.iterator]() { yield ["x-ok", "1"]; } } })',
|
||||
"fetch",
|
||||
],
|
||||
[
|
||||
"a header record after a spread",
|
||||
'fetch(url, { headers: { ...init, "a b": "x" } })',
|
||||
"fetch",
|
||||
],
|
||||
[
|
||||
"a same-scheme relative URL with an at sign",
|
||||
'fetch("http:user@api.example.test/x")',
|
||||
"fetch",
|
||||
],
|
||||
[
|
||||
"a forbidden method after a spread",
|
||||
'fetch(...list, { method: "CONNECT" })',
|
||||
"fetch",
|
||||
],
|
||||
[
|
||||
"a subprotocol after a spread",
|
||||
'new WebSocket(...list, "a b")',
|
||||
"WebSocket",
|
||||
],
|
||||
["a ws URL", 'new WebSocket("wss://chat.example.test/ws")', "WebSocket"],
|
||||
[
|
||||
"an http WebSocket URL",
|
||||
'new WebSocket("https://chat.example.test/ws")',
|
||||
"WebSocket",
|
||||
],
|
||||
[
|
||||
"an uppercase scheme",
|
||||
'new WebSocket("WSS://chat.example.test/ws")',
|
||||
"WebSocket",
|
||||
],
|
||||
["a relative WebSocket URL", 'new WebSocket("/ws")', "WebSocket"],
|
||||
["a token subprotocol", 'new WebSocket(url, "chat.v1")', "WebSocket"],
|
||||
["a null subprotocol", "new WebSocket(url, null)", "WebSocket"],
|
||||
["distinct subprotocols", 'new WebSocket(url, ["a", "b"])', "WebSocket"],
|
||||
["an unknown subprotocol list", "new WebSocket(url, list)", "WebSocket"],
|
||||
] as const)("keeps the request for %s", (_name, call, method) => {
|
||||
expect(requestsFor(call)).toEqual([method]);
|
||||
});
|
||||
|
||||
it("keeps header enumeration with getters unknown across hosts", () => {
|
||||
// Chromium 156 accepts this after WebIDL's per-key descriptor checks;
|
||||
// Node 24.22.1 snapshots the invalid name and refuses it. Neither behavior
|
||||
// establishes that every supported host refuses the request.
|
||||
expect(
|
||||
requestsFor(
|
||||
'fetch(url, { headers: { get x() { delete this["a b"]; return "1"; }, "a b": "1" } })',
|
||||
),
|
||||
).toEqual(["fetch"]);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
|
||||
/**
|
||||
* `tls.connect` builds its secure context before it opens a socket, so a
|
||||
* `secureProtocol` Node refuses throws from the call itself and no socket exists.
|
||||
*
|
||||
* Measured on Node 24.22.1:
|
||||
*
|
||||
* secureProtocol "TLS_method", "TLSv1_2_client_method", "SSLv23_method" -> socket
|
||||
* secureProtocol "bogus" / "" / "SSLv3_method" / "TLSv1_3_method" /
|
||||
* "tls_method" / "TLS_METHOD" -> THREW ERR_TLS_INVALID_PROTOCOL_METHOD
|
||||
* secureProtocol 5 / true / {} / null -> socket (only a string is checked)
|
||||
* { secureProtocol: "TLS_method", minVersion: "TLSv1.2" }
|
||||
* { secureProtocol: 5, maxVersion: "TLSv1.3" }
|
||||
* -> THREW ERR_TLS_PROTOCOL_VERSION_CONFLICT
|
||||
* { secureProtocol: "TLS_method", minVersion: null } -> socket
|
||||
* { secureProtocol: 0, minVersion: "TLSv1.2" } -> socket
|
||||
* a truthy secureContext beside any of these -> socket
|
||||
*
|
||||
* `net` ignores the member entirely.
|
||||
*/
|
||||
const acquisitionsFor = (body: string) =>
|
||||
analyzeJavaScriptSemantics(
|
||||
`import tls from "node:tls";\nimport net from "node:net";\nfunction g(p, ctx) { ${body}; }`,
|
||||
)
|
||||
.resourceOperations.filter(({ kind }) => kind === "acquire")
|
||||
.map(({ kind, method }) => ({ kind, method }));
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an unknown method name",
|
||||
`tls.connect({ port: 80, secureProtocol: "bogus" })`,
|
||||
],
|
||||
["an empty method name", `tls.connect({ port: 80, secureProtocol: "" })`],
|
||||
[
|
||||
"the SSLv3 method",
|
||||
`tls.connect({ port: 80, secureProtocol: "SSLv3_method" })`,
|
||||
],
|
||||
[
|
||||
"a TLS 1.3 method name",
|
||||
`tls.connect({ port: 80, secureProtocol: "TLSv1_3_method" })`,
|
||||
],
|
||||
[
|
||||
"a lowercase method name",
|
||||
`tls.connect({ port: 80, secureProtocol: "tls_method" })`,
|
||||
],
|
||||
[
|
||||
"a template method name",
|
||||
"tls.connect({ port: 80, secureProtocol: `bogus` })",
|
||||
],
|
||||
[
|
||||
"an options object in the port slot",
|
||||
`tls.connect(443, { secureProtocol: "bogus" })`,
|
||||
],
|
||||
[
|
||||
"an options object after port and host",
|
||||
`tls.connect(443, "127.0.0.1", { secureProtocol: "bogus" })`,
|
||||
],
|
||||
[
|
||||
"a method name beside a minimum version",
|
||||
`tls.connect({ port: 80, secureProtocol: "TLS_method", minVersion: "TLSv1.2" })`,
|
||||
],
|
||||
[
|
||||
"a method name beside a maximum version",
|
||||
`tls.connect({ port: 80, secureProtocol: "TLSv1_2_method", maxVersion: "TLSv1.3" })`,
|
||||
],
|
||||
[
|
||||
"a template method name beside a version",
|
||||
'tls.connect({ port: 80, secureProtocol: `TLS_method`, minVersion: "TLSv1.2" })',
|
||||
],
|
||||
[
|
||||
"a regular-expression method beside a version",
|
||||
`tls.connect({ port: 80, secureProtocol: /x/, minVersion: "TLSv1.2" })`,
|
||||
],
|
||||
[
|
||||
"a class method beside a version",
|
||||
`tls.connect({ port: 80, secureProtocol: class {}, minVersion: "TLSv1.2" })`,
|
||||
],
|
||||
[
|
||||
"a truthy non-string method beside a version",
|
||||
`tls.connect({ port: 80, secureProtocol: 5, minVersion: "TLSv1.2" })`,
|
||||
],
|
||||
[
|
||||
"a null secure context beside an unknown method",
|
||||
`tls.connect({ port: 80, secureContext: null, secureProtocol: "bogus" })`,
|
||||
],
|
||||
] as const)("emits no socket acquisition for %s", (_name, call) => {
|
||||
expect(acquisitionsFor(call)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"the generic method",
|
||||
`tls.connect({ port: 80, secureProtocol: "TLS_method" })`,
|
||||
],
|
||||
[
|
||||
"a client method",
|
||||
`tls.connect({ port: 80, secureProtocol: "TLSv1_2_client_method" })`,
|
||||
],
|
||||
[
|
||||
"the SSLv23 method",
|
||||
`tls.connect({ port: 80, secureProtocol: "SSLv23_method" })`,
|
||||
],
|
||||
["a numeric method", `tls.connect({ port: 80, secureProtocol: 5 })`],
|
||||
["an object method", `tls.connect({ port: 80, secureProtocol: {} })`],
|
||||
["a null method", `tls.connect({ port: 80, secureProtocol: null })`],
|
||||
["a computed method", `tls.connect({ port: 80, secureProtocol: p })`],
|
||||
[
|
||||
"a method name beside a null version",
|
||||
`tls.connect({ port: 80, secureProtocol: "TLS_method", minVersion: null })`,
|
||||
],
|
||||
[
|
||||
"a method name beside an undefined version",
|
||||
`tls.connect({ port: 80, secureProtocol: "TLS_method", maxVersion: undefined })`,
|
||||
],
|
||||
[
|
||||
"a method name beside a computed version",
|
||||
`tls.connect({ port: 80, secureProtocol: "TLS_method", minVersion: p })`,
|
||||
],
|
||||
[
|
||||
"a falsy method beside a version",
|
||||
`tls.connect({ port: 80, secureProtocol: 0, minVersion: "TLSv1.2" })`,
|
||||
],
|
||||
[
|
||||
"a supplied secure context beside an unknown method",
|
||||
`tls.connect({ port: 80, secureContext: ctx, secureProtocol: "bogus" })`,
|
||||
],
|
||||
[
|
||||
"a later accepted method over an unknown one",
|
||||
`tls.connect({ port: 443, secureProtocol: "bogus" }, { secureProtocol: "TLS_method" })`,
|
||||
],
|
||||
] as const)("keeps the socket acquisition for %s", (_name, call) => {
|
||||
expect(acquisitionsFor(call)).toEqual([
|
||||
{ kind: "acquire", method: "connect" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a net socket whatever its secureProtocol", () => {
|
||||
expect(
|
||||
acquisitionsFor(`net.connect({ port: 80, secureProtocol: "bogus" })`),
|
||||
).toEqual([{ kind: "acquire", method: "connect" }]);
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
readExactJavaScriptLiteral,
|
||||
semanticStaticPropertyName,
|
||||
staticJavaScriptTruthiness,
|
||||
staticObjectProperty,
|
||||
staticObjectCopyPropertyReader,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import {
|
||||
@@ -50,7 +50,15 @@ interface ChildProcessAnalysis {
|
||||
readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[];
|
||||
}
|
||||
|
||||
type ChildSpawnContext = JavaScriptExpressionShapeResolver;
|
||||
interface ChildSpawnArguments {
|
||||
readonly options: t.Node | undefined;
|
||||
readonly argvCount: number | null;
|
||||
readonly readOption: ReturnType<typeof staticObjectCopyPropertyReader>;
|
||||
}
|
||||
|
||||
type ChildSpawnContext = JavaScriptExpressionShapeResolver & {
|
||||
readonly arguments: WeakMap<SpawnCandidate, ChildSpawnArguments>;
|
||||
};
|
||||
|
||||
interface SpawnCandidate {
|
||||
readonly builtinAuthority?: "candidate";
|
||||
@@ -73,12 +81,17 @@ export const collectJavaScriptSemanticChildProcesses = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
callables: readonly JavaScriptSemanticCallable[],
|
||||
): ChildProcessAnalysis => {
|
||||
const context = createSemanticExpressionShapeResolver(state);
|
||||
const context: ChildSpawnContext = {
|
||||
...createSemanticExpressionShapeResolver(state),
|
||||
arguments: new WeakMap(),
|
||||
};
|
||||
const candidates = collectSpawns(program, state, callables).filter(
|
||||
(candidate) =>
|
||||
!rejectsCommand(candidate, context) &&
|
||||
!forkRejectsStdioArray(candidate, context) &&
|
||||
!rejectsStdioLiteral(candidate, context) &&
|
||||
!rejectsProcessOptionLiteral(candidate, context) &&
|
||||
!rejectsValidatedOption(candidate, context, state) &&
|
||||
!rejectsOptionsShape(candidate, context, state) &&
|
||||
!rejectsCallback(candidate, context),
|
||||
);
|
||||
@@ -150,7 +163,7 @@ const immutableSpawn = (
|
||||
context: ChildSpawnContext,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticChildProcessSpawn => {
|
||||
const { options, argvCount } = spawnArguments(candidate, context);
|
||||
const args = spawnArguments(candidate, context);
|
||||
const command = dataEffectLiteralString(candidate.node.arguments[0]);
|
||||
return {
|
||||
...(candidate.builtinAuthority === undefined
|
||||
@@ -163,9 +176,9 @@ const immutableSpawn = (
|
||||
resultBindingId: candidate.resultBindingId,
|
||||
projectedResultBindings: candidate.projectedResultBindings,
|
||||
command,
|
||||
argvCount,
|
||||
environmentSupplied: environmentSupplied(options, context, state),
|
||||
stdioMode: stdioMode(options, candidate.method, context),
|
||||
argvCount: args.argvCount,
|
||||
environmentSupplied: environmentSupplied(args.readOption, context, state),
|
||||
stdioMode: stdioMode(args, candidate.method, context),
|
||||
resolution: command === null ? "partial" : "complete",
|
||||
};
|
||||
};
|
||||
@@ -417,30 +430,35 @@ const resolveChildCandidates = (
|
||||
const spawnArguments = (
|
||||
candidate: SpawnCandidate,
|
||||
context: ChildSpawnContext,
|
||||
): {
|
||||
readonly options: t.Node | undefined;
|
||||
readonly argvCount: number | null;
|
||||
} => {
|
||||
): ChildSpawnArguments => {
|
||||
const cached = context.arguments.get(candidate);
|
||||
if (cached !== undefined) return cached;
|
||||
const args = candidate.node.arguments;
|
||||
if (t.isSpreadElement(args[0])) return { options: args[0], argvCount: null };
|
||||
const second = args[1] && unwrapJavaScriptExpression(args[1]).node;
|
||||
const argv =
|
||||
candidate.method !== "exec" && t.isArrayExpression(second)
|
||||
? second
|
||||
: undefined;
|
||||
// exec(command[, options][, callback]) has no argv slot.
|
||||
const options =
|
||||
candidate.method === "exec"
|
||||
const options = t.isSpreadElement(args[0])
|
||||
? args[0]
|
||||
: candidate.method === "exec"
|
||||
? second
|
||||
: optionsAfterOptionalArgv(args, context);
|
||||
return {
|
||||
const result: ChildSpawnArguments = {
|
||||
options,
|
||||
argvCount:
|
||||
!t.isSpreadElement(args[0]) &&
|
||||
argv !== undefined &&
|
||||
!argv.elements.some((element) => t.isSpreadElement(element))
|
||||
? argv.elements.length
|
||||
: null,
|
||||
// Node copies its options before validation. Accessors in that copy may
|
||||
// change later members, so every consumer shares the same parsed facts.
|
||||
readOption: staticObjectCopyPropertyReader(options, context.isNullish),
|
||||
};
|
||||
context.arguments.set(candidate, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -449,11 +467,11 @@ const spawnArguments = (
|
||||
* through instead of supplying one.
|
||||
*/
|
||||
const environmentSupplied = (
|
||||
options: t.Node | undefined,
|
||||
readOption: ChildSpawnArguments["readOption"],
|
||||
context: ChildSpawnContext,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
const lookup = staticObjectProperty(options, "env", context.isNullish);
|
||||
const lookup = readOption("env");
|
||||
if (lookup.status !== "explicit") return false;
|
||||
const value = unwrapJavaScriptExpression(lookup.value).node;
|
||||
if (context.isNullish(value)) return false;
|
||||
@@ -502,7 +520,7 @@ const rejectsStdioString = (
|
||||
: method === "fork" || value !== "";
|
||||
|
||||
const stdioMode = (
|
||||
options: t.Node | undefined,
|
||||
{ options, readOption }: ChildSpawnArguments,
|
||||
method: JavaScriptSemanticChildProcessSpawn["method"],
|
||||
context: ChildSpawnContext,
|
||||
): string => {
|
||||
@@ -511,18 +529,18 @@ const stdioMode = (
|
||||
if (options === undefined) return "default";
|
||||
const expression = unwrapJavaScriptExpression(options).node;
|
||||
if (context.isNullish(expression)) return "default";
|
||||
const lookup = staticObjectProperty(expression, "stdio", context.isNullish);
|
||||
const lookup = readOption("stdio");
|
||||
if (lookup.status === "missing")
|
||||
return unsetStdioMode(expression, method, context);
|
||||
return unsetStdioMode(readOption, method, context);
|
||||
if (lookup.status !== "explicit") return "dynamic";
|
||||
const value = unwrapJavaScriptExpression(lookup.value).node;
|
||||
if (context.isNullish(value))
|
||||
return unsetStdioMode(expression, method, context);
|
||||
return unsetStdioMode(readOption, method, context);
|
||||
if (t.isStringLiteral(value)) return value.value;
|
||||
if (t.isArrayExpression(value)) return "array";
|
||||
// fork ignores any other known stdio value and falls back to `silent`.
|
||||
if (method === "fork" && staticJavaScriptTruthiness(value) !== undefined)
|
||||
return unsetStdioMode(expression, method, context);
|
||||
return unsetStdioMode(readOption, method, context);
|
||||
return "dynamic";
|
||||
};
|
||||
|
||||
@@ -542,11 +560,8 @@ const rejectsStdioLiteral = (
|
||||
// Async exec/execFile do not forward options.stdio to their underlying spawn.
|
||||
if (candidate.method === "exec" || candidate.method === "execFile")
|
||||
return false;
|
||||
const { options } = spawnArguments(candidate, context);
|
||||
if (options === undefined) return false;
|
||||
const expression = unwrapJavaScriptExpression(options).node;
|
||||
if (context.isNullish(expression)) return false;
|
||||
const lookup = staticObjectProperty(expression, "stdio", context.isNullish);
|
||||
const { readOption } = spawnArguments(candidate, context);
|
||||
const lookup = readOption("stdio");
|
||||
if (lookup.status !== "explicit") return false;
|
||||
const literal = readExactJavaScriptLiteral(lookup.value);
|
||||
if (!literal.found || typeof literal.value !== "string") return false;
|
||||
@@ -567,11 +582,8 @@ const forkRejectsStdioArray = (
|
||||
context: ChildSpawnContext,
|
||||
): boolean => {
|
||||
if (candidate.method !== "fork") return false;
|
||||
const { options } = spawnArguments(candidate, context);
|
||||
if (options === undefined) return false;
|
||||
const expression = unwrapJavaScriptExpression(options).node;
|
||||
if (context.isNullish(expression)) return false;
|
||||
const lookup = staticObjectProperty(expression, "stdio", context.isNullish);
|
||||
const { readOption } = spawnArguments(candidate, context);
|
||||
const lookup = readOption("stdio");
|
||||
if (lookup.status !== "explicit") return false;
|
||||
const value = unwrapJavaScriptExpression(lookup.value).node;
|
||||
if (!t.isArrayExpression(value)) return false;
|
||||
@@ -594,12 +606,9 @@ const rejectsProcessOptionLiteral = (
|
||||
candidate: SpawnCandidate,
|
||||
context: ChildSpawnContext,
|
||||
): boolean => {
|
||||
const { options } = spawnArguments(candidate, context);
|
||||
if (options === undefined) return false;
|
||||
const expression = unwrapJavaScriptExpression(options).node;
|
||||
if (context.isNullish(expression)) return false;
|
||||
const { readOption } = spawnArguments(candidate, context);
|
||||
for (const member of ["cwd", "argv0"] as const) {
|
||||
const lookup = staticObjectProperty(expression, member, context.isNullish);
|
||||
const lookup = readOption(member);
|
||||
if (lookup.status !== "explicit") continue;
|
||||
const value = unwrapJavaScriptExpression(lookup.value).node;
|
||||
if (context.isNullish(value)) continue;
|
||||
@@ -670,6 +679,203 @@ const rejectsOptionsShape = (
|
||||
return isUnpricedPrimitiveLiteral(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Node validates the command before it normalizes anything else, so a command it
|
||||
* refuses throws synchronously and no child process exists.
|
||||
*
|
||||
* Measured on Node 24.22.1 for spawn, execFile, exec and fork:
|
||||
*
|
||||
* a missing command, null, undefined, a number, a boolean,
|
||||
* an object or an array -> THREW ERR_INVALID_ARG_TYPE
|
||||
* a string holding a NUL byte -> THREW ERR_INVALID_ARG_VALUE
|
||||
* "" -> THREW ERR_INVALID_ARG_VALUE, except fork("")
|
||||
* which starts a child
|
||||
* " " or any other non-empty string -> child
|
||||
*
|
||||
* fork validates its module as a path, not a string: a WHATWG URL, an object
|
||||
* with own `href` and `protocol`, a class with static ones, and a Uint8Array
|
||||
* all start a child, so an object, class or `new` value stays for fork.
|
||||
*
|
||||
* Only a command proven by source syntax counts; a spread or computed value stays.
|
||||
*/
|
||||
const rejectsCommand = (
|
||||
candidate: SpawnCandidate,
|
||||
context: ChildSpawnContext,
|
||||
): boolean => {
|
||||
const [first] = candidate.node.arguments;
|
||||
if (first === undefined) return true;
|
||||
if (t.isSpreadElement(first)) return false;
|
||||
const value = unwrapJavaScriptExpression(first).node;
|
||||
if (context.isNullish(value)) return true;
|
||||
const literal = readExactJavaScriptLiteral(value);
|
||||
if (literal.found) {
|
||||
if (typeof literal.value !== "string") return true;
|
||||
if (literal.value.includes("\u0000")) return true;
|
||||
return literal.value === "" && candidate.method !== "fork";
|
||||
}
|
||||
if (isUnpricedPrimitiveLiteral(value)) return true;
|
||||
if (candidate.method === "fork")
|
||||
return (
|
||||
context.isArray(value) ||
|
||||
t.isArrowFunctionExpression(value) ||
|
||||
t.isFunctionExpression(value) ||
|
||||
t.isRegExpLiteral(value)
|
||||
);
|
||||
// `new` always yields an object, which Node's string check refuses.
|
||||
return isObjectValuedSyntax(value, context);
|
||||
};
|
||||
|
||||
/** Syntax whose value is always an object, never a primitive. */
|
||||
const isObjectValuedSyntax = (
|
||||
value: t.Node,
|
||||
context: ChildSpawnContext,
|
||||
): boolean =>
|
||||
context.isArray(value) ||
|
||||
t.isObjectExpression(value) ||
|
||||
t.isArrowFunctionExpression(value) ||
|
||||
t.isFunctionExpression(value) ||
|
||||
t.isClassExpression(value) ||
|
||||
t.isRegExpLiteral(value) ||
|
||||
t.isNewExpression(value);
|
||||
|
||||
type ChildOptionValue =
|
||||
| { readonly kind: "nullish" }
|
||||
| { readonly kind: "null" }
|
||||
| { readonly kind: "primitive"; readonly value: unknown }
|
||||
| { readonly kind: "bigint" | "object" | "unknown" };
|
||||
|
||||
/** Classify an option member only as far as source syntax proves it. */
|
||||
const childOptionValue = (
|
||||
node: t.Node,
|
||||
context: ChildSpawnContext,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): ChildOptionValue => {
|
||||
const value = unwrapJavaScriptExpression(node).node;
|
||||
const literal = readExactJavaScriptLiteral(value);
|
||||
if (literal.found)
|
||||
return literal.value === null
|
||||
? { kind: "null" }
|
||||
: { kind: "primitive", value: literal.value };
|
||||
if (context.isNullish(value)) return { kind: "nullish" };
|
||||
if (
|
||||
t.isIdentifier(value) &&
|
||||
(value.name === "NaN" || value.name === "Infinity") &&
|
||||
isUnshadowedGlobal(value, state, value.name)
|
||||
)
|
||||
return { kind: "primitive", value: Number(value.name) };
|
||||
// The shared reader refuses a non-finite numeric literal such as `1e400` and a
|
||||
// signed one such as `-0` or `-1e400`; `-0` stays an accepted timeout.
|
||||
if (t.isNumericLiteral(value))
|
||||
return { kind: "primitive", value: value.value };
|
||||
if (
|
||||
t.isUnaryExpression(value) &&
|
||||
(value.operator === "-" || value.operator === "+")
|
||||
) {
|
||||
const operand = unwrapJavaScriptExpression(value.argument).node;
|
||||
if (t.isNumericLiteral(operand))
|
||||
return {
|
||||
kind: "primitive",
|
||||
value: value.operator === "-" ? -operand.value : operand.value,
|
||||
};
|
||||
// `-1n` is a BigInt; `+1n` throws before any options object exists.
|
||||
if (t.isBigIntLiteral(operand)) return { kind: "bigint" };
|
||||
}
|
||||
if (t.isBigIntLiteral(value)) return { kind: "bigint" };
|
||||
if (isObjectValuedSyntax(value, context)) return { kind: "object" };
|
||||
return { kind: "unknown" };
|
||||
};
|
||||
|
||||
/** A value proven to be something other than what `accepts` admits. */
|
||||
const provenOtherThan =
|
||||
(accepts: (value: unknown) => boolean) =>
|
||||
(option: ChildOptionValue): boolean =>
|
||||
option.kind === "bigint" ||
|
||||
option.kind === "object" ||
|
||||
(option.kind === "primitive" && !accepts(option.value));
|
||||
|
||||
const ALL_SPAWN_METHODS = ["spawn", "execFile", "exec", "fork"] as const;
|
||||
const isBoolean = (value: unknown) => typeof value === "boolean";
|
||||
|
||||
/**
|
||||
* Options Node validates while normalizing a spawn, before any child exists.
|
||||
* Measured on Node 24.22.1; each rule applies only to the methods that forward
|
||||
* the member to that validation:
|
||||
*
|
||||
* timeout: -1 / 1.5 / "1" / true / NaN / Infinity / 0n -> THREW ERR_OUT_OF_RANGE
|
||||
* timeout: 0 / 1e3 / 2 ** 53 / null / undefined -> child
|
||||
* spawn and fork: detached, windowsHide or windowsVerbatimArguments that is
|
||||
* not a boolean, null or undefined -> THREW ERR_INVALID_ARG_TYPE
|
||||
* spawn and fork: serialization other than "json", "advanced" or undefined,
|
||||
* including null -> THREW ERR_INVALID_ARG_VALUE
|
||||
* spawn and execFile: shell that is not a boolean, string, null or
|
||||
* undefined -> THREW ERR_INVALID_ARG_TYPE
|
||||
* execFile and exec: maxBuffer -1 / NaN / "1" / 0n -> THREW ERR_OUT_OF_RANGE
|
||||
* execFile and exec: maxBuffer 0 / 1.5 / Infinity -> child
|
||||
*
|
||||
* execFile does not forward the boolean flags or serialization to its spawn,
|
||||
* and exec replaces a non-string shell with the default one.
|
||||
*/
|
||||
const VALIDATED_CHILD_OPTIONS: readonly {
|
||||
readonly member: string;
|
||||
readonly methods: readonly JavaScriptSemanticChildProcessSpawn["method"][];
|
||||
readonly rejects: (option: ChildOptionValue) => boolean;
|
||||
}[] = [
|
||||
{
|
||||
member: "timeout",
|
||||
methods: ALL_SPAWN_METHODS,
|
||||
rejects: provenOtherThan(
|
||||
(value) =>
|
||||
typeof value === "number" && Number.isInteger(value) && value >= 0,
|
||||
),
|
||||
},
|
||||
...(["detached", "windowsHide", "windowsVerbatimArguments"] as const).map(
|
||||
(member) => ({
|
||||
member,
|
||||
methods: ["spawn", "fork"] as const,
|
||||
rejects: provenOtherThan(isBoolean),
|
||||
}),
|
||||
),
|
||||
{
|
||||
member: "serialization",
|
||||
methods: ["spawn", "fork"],
|
||||
rejects: (option) =>
|
||||
option.kind === "null" ||
|
||||
provenOtherThan((value) => value === "json" || value === "advanced")(
|
||||
option,
|
||||
),
|
||||
},
|
||||
{
|
||||
member: "shell",
|
||||
methods: ["spawn", "execFile"],
|
||||
rejects: provenOtherThan(
|
||||
(value) => isBoolean(value) || typeof value === "string",
|
||||
),
|
||||
},
|
||||
{
|
||||
member: "maxBuffer",
|
||||
methods: ["execFile", "exec"],
|
||||
rejects: provenOtherThan(
|
||||
(value) => typeof value === "number" && value >= 0,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rejectsValidatedOption = (
|
||||
candidate: SpawnCandidate,
|
||||
context: ChildSpawnContext,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
const { readOption } = spawnArguments(candidate, context);
|
||||
return VALIDATED_CHILD_OPTIONS.some(({ member, methods, rejects }) => {
|
||||
if (!methods.includes(candidate.method)) return false;
|
||||
const lookup = readOption(member);
|
||||
return (
|
||||
lookup.status === "explicit" &&
|
||||
rejects(childOptionValue(lookup.value, context, state))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/** Select only callback slots whose overload is established by source syntax. */
|
||||
const rejectsCallback = (
|
||||
candidate: SpawnCandidate,
|
||||
@@ -741,12 +947,12 @@ const isUnpricedPrimitiveLiteral = (value: t.Node): boolean => {
|
||||
* when `silent` is truthy and inherits them otherwise.
|
||||
*/
|
||||
const unsetStdioMode = (
|
||||
options: t.Node,
|
||||
readOption: ChildSpawnArguments["readOption"],
|
||||
method: JavaScriptSemanticChildProcessSpawn["method"],
|
||||
context: ChildSpawnContext,
|
||||
): string => {
|
||||
if (method !== "fork") return "default";
|
||||
const silent = staticObjectProperty(options, "silent", context.isNullish);
|
||||
const silent = readOption("silent");
|
||||
if (silent.status === "missing") return "default";
|
||||
if (silent.status !== "explicit") return "dynamic";
|
||||
const value = unwrapJavaScriptExpression(silent.value).node;
|
||||
|
||||
@@ -42,6 +42,7 @@ import { isUnmodifiedGlobalReference } from "./javascriptSemanticGlobalReference
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import { semanticPrimitiveSet } from "./javascriptSemanticPrimitives.js";
|
||||
import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js";
|
||||
import { hostRefusesJavaScriptRequest } from "./javascriptHostRequestRefusals.js";
|
||||
|
||||
interface DataEffectAnalysis {
|
||||
readonly configurationOperations: readonly JavaScriptSemanticConfigurationOperation[];
|
||||
@@ -72,7 +73,11 @@ export const collectJavaScriptSemanticDataEffects = (
|
||||
ancestors: [],
|
||||
};
|
||||
const configuration = collectConfigurations(program, context);
|
||||
const requests = collectRequests(program, context);
|
||||
// A fetch or WebSocket the host refuses to construct sends nothing.
|
||||
const requests = collectRequests(program, context).filter(
|
||||
({ node, operation }) =>
|
||||
!hostRefusesJavaScriptRequest(node, operation.method),
|
||||
);
|
||||
return {
|
||||
configurationOperations: [
|
||||
...configuration.map(({ operation }) => operation),
|
||||
|
||||
@@ -907,7 +907,70 @@ const rejectsTlsProtocolVersionLiteral = (
|
||||
if (read === undefined || read === "unknown") continue;
|
||||
if (rejectsValue(read)) return true;
|
||||
}
|
||||
return false;
|
||||
return rejectsSecureProtocolLiteral(effective, state);
|
||||
};
|
||||
|
||||
/**
|
||||
* The `secureProtocol` method names Node accepts. Measured on Node 24.22.1, these
|
||||
* connect while `"SSLv2_method"`, `"SSLv3_method"` and their client/server forms,
|
||||
* `"TLSv1_3_method"`, `"DTLS_method"`, `"tls_method"`, `"TLS_METHOD"`, `"bogus"`
|
||||
* and `""` throw `ERR_TLS_INVALID_PROTOCOL_METHOD`. The set is exact and
|
||||
* case-sensitive.
|
||||
*/
|
||||
const TLS_SECURE_PROTOCOLS = new Set(
|
||||
["TLS", "TLSv1", "TLSv1_1", "TLSv1_2", "SSLv23"].flatMap((family) => [
|
||||
`${family}_method`,
|
||||
`${family}_client_method`,
|
||||
`${family}_server_method`,
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether `secureProtocol` makes `tls.connect` throw before a socket exists.
|
||||
*
|
||||
* Measured on Node 24.22.1, after the same truthy-`secureContext` bypass as the
|
||||
* version members:
|
||||
*
|
||||
* a string outside TLS_SECURE_PROTOCOLS, including "" -> THREW ERR_TLS_INVALID_PROTOCOL_METHOD
|
||||
* a non-string value such as 5, true or {} -> socket (Node ignores it)
|
||||
* a truthy value beside a minVersion or maxVersion that
|
||||
* is not null or undefined, even "TLS_method" -> THREW ERR_TLS_PROTOCOL_VERSION_CONFLICT
|
||||
* "TLS_method" beside a null or undefined version -> socket
|
||||
* 0 beside a minVersion -> socket
|
||||
*/
|
||||
const rejectsSecureProtocolLiteral = (
|
||||
effective: (member: string) => t.Node | "unknown" | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean => {
|
||||
const protocol = effective("secureProtocol");
|
||||
if (protocol === undefined || protocol === "unknown") return false;
|
||||
const read = unwrapJavaScriptExpression(protocol).node;
|
||||
if (isNullishPlaceholder(read, state)) return false;
|
||||
const literal = readExactJavaScriptLiteral(read);
|
||||
if (
|
||||
literal.found &&
|
||||
typeof literal.value === "string" &&
|
||||
!TLS_SECURE_PROTOCOLS.has(literal.value)
|
||||
)
|
||||
return true;
|
||||
// A literal read exactly, including a no-substitution template, decides its own
|
||||
// truthiness; the shared truthiness reader does not price templates.
|
||||
const truthy = literal.found
|
||||
? Boolean(literal.value)
|
||||
: staticJavaScriptTruthiness(read);
|
||||
if (truthy !== true) return false;
|
||||
return (["minVersion", "maxVersion"] as const).some((member) => {
|
||||
const version = effective(member);
|
||||
if (version === undefined || version === "unknown") return false;
|
||||
const value = unwrapJavaScriptExpression(version).node;
|
||||
return (
|
||||
!isNullishPlaceholder(value, state) &&
|
||||
(readExactJavaScriptLiteral(value).found ||
|
||||
t.isTemplateLiteral(value) ||
|
||||
t.isObjectExpression(value) ||
|
||||
t.isArrayExpression(value))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const report = (value: unknown) =>
|
||||
javascriptApplicationAnalysisResultSchema.parse(
|
||||
toolContract("analyze_javascript_application").outputSchema.parse(value)
|
||||
.normalized_result,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"keeps only possible host effects through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-js-rejected-effects-");
|
||||
const path = join(root, "main.mjs");
|
||||
const source = `import { spawn } from "node:child_process";
|
||||
import tls from "node:tls";
|
||||
export function rejected(url) {
|
||||
spawn("");
|
||||
fetch(url, { method: "CONNECT" });
|
||||
fetch(url, { headers: { "a b": "1" } });
|
||||
new WebSocket("wss://example.test/ws#fragment");
|
||||
tls.connect({ port: 443, secureProtocol: "bogus" });
|
||||
}
|
||||
export function possible(url) {
|
||||
spawn("node");
|
||||
fetch(url, { method: "PATCH" });
|
||||
new WebSocket("wss://example.test/ws");
|
||||
tls.connect({ port: 443, secureProtocol: "TLS_method" });
|
||||
spawn("node", [], { get a() { this.detached = true; return 0; }, detached: 1 });
|
||||
fetch(url, { get cache() { delete this.method; return "default"; }, method: "CONNECT" });
|
||||
fetch(url, { headers: { get x() { delete this["a b"]; return "1"; }, "a b": "1" } });
|
||||
}
|
||||
`;
|
||||
await writeFile(path, source);
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"analyze-javascript-application",
|
||||
root,
|
||||
"--detail",
|
||||
"complete",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const analysis = report(result.json);
|
||||
const methods = (kind: string) =>
|
||||
analysis.semantic_graph.nodes
|
||||
.filter((node) => node.kind === kind)
|
||||
.map((node) => node.properties.method)
|
||||
.sort();
|
||||
expect(methods("child-process")).toEqual(["spawn", "spawn"]);
|
||||
expect(methods("request")).toEqual([
|
||||
"WebSocket",
|
||||
"fetch",
|
||||
"fetch",
|
||||
"fetch",
|
||||
]);
|
||||
expect(methods("resource")).toEqual(["connect"]);
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const mcp = await call("analyze_javascript_application", {
|
||||
input_path: root,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
|
||||
expect(report(mcp.structuredContent).semantic_graph).toEqual(
|
||||
analysis.semantic_graph,
|
||||
);
|
||||
expect(await readFile(path, "utf8")).toBe(source);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user