diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 0ec62c2593..fe7ba5b8a7 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -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; diff --git a/src/domain/javascript/javascriptHostRequestRefusals.ts b/src/domain/javascript/javascriptHostRequestRefusals.ts new file mode 100644 index 0000000000..452cb68890 --- /dev/null +++ b/src/domain/javascript/javascriptHostRequestRefusals.ts @@ -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; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.child_process_command.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.child_process_command.test.ts new file mode 100644 index 0000000000..cf59e3abc7 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.child_process_command.test.ts @@ -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]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.host_request_refusals.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.host_request_refusals.test.ts new file mode 100644 index 0000000000..f60188da8f --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.host_request_refusals.test.ts @@ -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"]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.tls_secure_protocol_literal.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.tls_secure_protocol_literal.test.ts new file mode 100644 index 0000000000..647dd1ced4 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.tls_secure_protocol_literal.test.ts @@ -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" }]); +}); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index c85ec82b56..4d523606e0 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -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; +} + +type ChildSpawnContext = JavaScriptExpressionShapeResolver & { + readonly arguments: WeakMap; +}; 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; diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index 9373b2b45c..eb3253eebe 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -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), diff --git a/src/domain/javascript/javascriptSemanticResources.ts b/src/domain/javascript/javascriptSemanticResources.ts index 3020878e82..e1cace0a3c 100644 --- a/src/domain/javascript/javascriptSemanticResources.ts +++ b/src/domain/javascript/javascriptSemanticResources.ts @@ -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)) + ); + }); }; /** diff --git a/tests/boundary/cli/javascriptRejectedEffects.test.ts b/tests/boundary/cli/javascriptRejectedEffects.test.ts new file mode 100644 index 0000000000..a212ba9aa5 --- /dev/null +++ b/tests/boundary/cli/javascriptRejectedEffects.test.ts @@ -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); + }, +);