From 4da003d754e5b3ae6abebd8c97e4becab08879c8 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 11 Aug 2026 12:13:15 -0400 Subject: [PATCH 1/4] feat(sandbox): add url to QuickJS module registry Legacy parse()/format() verified against node:url (protocol/opaque/ non-slash forms, auth/port/query/hash, escaping, IPv6 hosts), plus a thin re-export of the ambient URL/URLSearchParams globals matching real Node's own require('url').URL === global.URL identity. --- .../src/templating/sandbox/PERMISSIONS.md | 21 +- .../sandbox-surface.test.ts.snap | 9 + .../src/templating/sandbox/module-registry.ts | 202 ++++++++++++++++ .../sandbox/plugin-tag-sandbox.test.ts | 34 +++ .../sandbox/sandbox-surface.test.ts | 12 +- .../src/templating/sandbox/sandbox-surface.ts | 15 +- .../templating/sandbox/url.regression.test.ts | 215 ++++++++++++++++++ 7 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 packages/insomnia/src/templating/sandbox/url.regression.test.ts diff --git a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md index 74ca1db62411..1b95bcb112c8 100644 --- a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md +++ b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md @@ -23,7 +23,26 @@ by Insomnia (a pure-JS reimplementation or a host-backed shim), never the raw No - **Baseline (no manifest needed):** `path`, `crypto`. - **Grantable:** any other module in the sandbox registry. Declaring one adds it to your grant. - - Pure-JS reimplementations: `events` (and more via M2). + - Pure-JS reimplementations: `events`, `url` (and more via M2). + - `url` implements the legacy `parse`/`format` pair (verified against `node:url` across + protocol-relative/opaque/non-slash-protocol forms, auth/port/query/hash splitting, the + `%20`/`%22`/`%3C`/`%3E`/`%60`/`%5E`/`%7C`/`%7B`/`%7D` + C0-control-char escaping table, + `parseQueryString`/`slashesDenoteHost`, and IPv6 bracketed hosts) plus a thin re-export of the + ambient `URL`/`URLSearchParams` globals (`sandbox-globals.ts`, M2) so `require('url').URL` + resolves the way real Node's own `require('url').URL === global.URL` does. That identity is + intentional, not a leak: `URL`/`URLSearchParams` are already ungated ambient globals with or + without the `url` grant — see the reviewed exception in `sandbox-surface.test.ts`'s alias-leak + check. + Two deliberate divergences from real Node's `url.parse`, both documented rather than silently + matched: (1) `hostname` for a bracketed IPv6 literal is stored **without** brackets (e.g. + `"::1"`), matching `node:url.parse`'s own convention — a different, equally-real convention + from the ambient `URL` global's WHATWG-style bracket-inclusive `.hostname`, since the two are + independent implementations for two different APIs; (2) a literal backslash is never treated + as a path/host delimiter or as a stand-in for `"//"` after the protocol, unlike real Node's + legacy parser — that exact behavior is what Node's own deprecation notice on `url.parse` cites + as having "security implications," so it's intentionally not replicated. `url.inspect`/ + `resolve`/`domainToASCII`/`domainToUnicode`/`pathToFileURL`/`fileURLToPath`/`Url` (the legacy + class) are not implemented at all. - **Vetted npm libraries** (pinned + pre-bundled by Insomnia): `uuid`, `ajv`. These are real libraries bundled to run inside the sandbox; they're only loaded when a plugin declares them. Each is sourced from an isolated, exact-pinned install at diff --git a/packages/insomnia/src/templating/sandbox/__snapshots__/sandbox-surface.test.ts.snap b/packages/insomnia/src/templating/sandbox/__snapshots__/sandbox-surface.test.ts.snap index 5960ce79aec4..c8ff71536f54 100644 --- a/packages/insomnia/src/templating/sandbox/__snapshots__/sandbox-surface.test.ts.snap +++ b/packages/insomnia/src/templating/sandbox/__snapshots__/sandbox-surface.test.ts.snap @@ -416,6 +416,15 @@ exports[`sandbox surface > matches surface snapshot 1`] = ` "require("events"): object", "require("events").EventEmitter: function(0)", "require("events").EventEmitter.prototype: object", + "require("url"): object", + "require("url").URL: function(2)", + "require("url").URL: ", + "require("url").URLSearchParams: function(1)", + "require("url").URLSearchParams: ", + "require("url").format: function(1)", + "require("url").format.prototype: object", + "require("url").parse: function(3)", + "require("url").parse.prototype: object", "require("uuid"): object", "require("uuid").MAX: string", "require("uuid").NIL: string", diff --git a/packages/insomnia/src/templating/sandbox/module-registry.ts b/packages/insomnia/src/templating/sandbox/module-registry.ts index 856483409bce..ce92f179810c 100644 --- a/packages/insomnia/src/templating/sandbox/module-registry.ts +++ b/packages/insomnia/src/templating/sandbox/module-registry.ts @@ -101,11 +101,213 @@ const EVENTS_FACTORY = [ '}', ].join('\n'); +// A thin adapter over the ambient URL/URLSearchParams globals (sandbox-globals.ts, M2) plus a +// legacy parse()/format() shim matching node:url's deprecated (but still widely used by ported +// plugins) API. parse()/format() are independent, self-contained implementations — they do not +// reuse URL/URLSearchParams' own authority-parsing internals, so the two known gaps below are +// deliberate, not accidental: +// - Legacy hostname/port parsing here handles a bracketed IPv6 literal correctly (hostname stored +// without brackets, e.g. "::1", matching node:url.parse — WHATWG's URL keeps the brackets in +// .hostname instead, a different, equally-real convention for a different API). +// - Unlike real Node, a literal backslash is never treated as a path/host delimiter or as a +// stand-in for "//" after the protocol. Real Node's legacy parser does this for historical +// browser-compat reasons, and it's exactly the parsing-confusion behavior Node's own deprecation +// notice on url.parse cites as having "security implications" — intentionally not replicated. +// Verified against node:url for every specifier/edge case exercised by url.regression.test.ts; +// url.inspect's unsafe-character escaping table (space/quote/angle-brackets/backtick/caret/pipe/ +// braces + C0 controls) is replicated, but slashesDenoteHost's rarer host-detection quirks beyond +// the tested cases are not guaranteed byte-for-byte. +const URL_FACTORY = [ + 'function () {', + ' var URLCtor = globalThis.URL;', + ' var USPCtor = globalThis.URLSearchParams;', + ' var SLASHED_PROTOCOLS = { http: true, https: true, ftp: true, gopher: true, file: true, ws: true, wss: true };', + ' var HOSTLESS_PROTOCOLS = { javascript: true };', + ' var UNSAFE_CHAR_ESCAPES = {};', + ' UNSAFE_CHAR_ESCAPES[" "] = "%20";', + ' UNSAFE_CHAR_ESCAPES[String.fromCharCode(34)] = "%22";', + ' UNSAFE_CHAR_ESCAPES["<"] = "%3C";', + ' UNSAFE_CHAR_ESCAPES[">"] = "%3E";', + ' UNSAFE_CHAR_ESCAPES["`"] = "%60";', + ' UNSAFE_CHAR_ESCAPES["^"] = "%5E";', + ' UNSAFE_CHAR_ESCAPES["|"] = "%7C";', + ' UNSAFE_CHAR_ESCAPES["{"] = "%7B";', + ' UNSAFE_CHAR_ESCAPES["}"] = "%7D";', + ' function escapeUnsafe(s) {', + ' var out = "";', + ' for (var i = 0; i < s.length; i++) {', + ' var ch = s.charAt(i);', + ' var code = s.charCodeAt(i);', + ' if (code <= 31) { out += "%" + ("0" + code.toString(16).toUpperCase()).slice(-2); }', + ' else if (UNSAFE_CHAR_ESCAPES[ch]) { out += UNSAFE_CHAR_ESCAPES[ch]; }', + ' else { out += ch; }', + ' }', + ' return out;', + ' }', + ' function qsEnc(s) { return encodeURIComponent(s).replace(/%20/g, "+"); }', + ' function parseQS(str) {', + ' var obj = {};', + ' new USPCtor(str).forEach(function (v, k) {', + ' if (Object.prototype.hasOwnProperty.call(obj, k)) {', + ' if (Object.prototype.toString.call(obj[k]) === "[object Array]") { obj[k].push(v); }', + ' else { obj[k] = [obj[k], v]; }', + ' } else { obj[k] = v; }', + ' });', + ' return obj;', + ' }', + ' function stringifyQS(q) {', + ' var parts = [];', + ' for (var k in q) {', + ' if (!Object.prototype.hasOwnProperty.call(q, k)) { continue; }', + ' var v = q[k];', + ' if (Object.prototype.toString.call(v) === "[object Array]") {', + ' for (var i = 0; i < v.length; i++) { parts.push(qsEnc(k) + "=" + qsEnc(String(v[i]))); }', + ' } else { parts.push(qsEnc(k) + "=" + qsEnc(String(v))); }', + ' }', + ' return parts.join("&");', + ' }', + ' function splitPathQueryHash(rest, parseQueryString) {', + ' var hash = null, search = null, query = parseQueryString ? {} : null;', + ' var hIdx = rest.indexOf("#");', + ' if (hIdx !== -1) { hash = escapeUnsafe(rest.slice(hIdx)); rest = rest.slice(0, hIdx); }', + ' var qIdx = rest.indexOf("?");', + ' if (qIdx !== -1) {', + ' var rawQuery = rest.slice(qIdx + 1);', + ' search = "?" + escapeUnsafe(rawQuery);', + ' rest = rest.slice(0, qIdx);', + ' query = parseQueryString ? parseQS(rawQuery) : escapeUnsafe(rawQuery);', + ' }', + ' var pathname = rest === "" ? null : escapeUnsafe(rest);', + ' return { pathname: pathname, search: search, query: query, hash: hash };', + ' }', + // auth is text before the last "@"; port is the digits after the LAST colon in the (post-auth) + // candidate, but only if that trailing segment is non-empty digits; hostname is the text before + // the FIRST colon; anything between the first and last colon when a valid port is found (or from + // the first colon onward when it isn't) is not part of the host and is pushed back into leftover + // text that becomes part of the path. A bracketed IPv6 literal is handled as its own case first. + ' function parseAuthority(candidate) {', + ' var auth = null;', + ' var at = candidate.lastIndexOf("@");', + ' if (at !== -1) { auth = candidate.slice(0, at); candidate = candidate.slice(at + 1); }', + ' var hostname = null, port = null, leftover = "";', + ' if (candidate.charAt(0) === "[") {', + ' var closeBracket = candidate.indexOf("]");', + ' if (closeBracket !== -1) {', + ' hostname = candidate.slice(1, closeBracket).toLowerCase();', + ' var afterBracket = candidate.slice(closeBracket + 1);', + ' if (afterBracket.charAt(0) === ":") {', + ' var portCandidate = afterBracket.slice(1);', + ' if (/^\\d+$/.test(portCandidate)) { port = portCandidate; } else { leftover = afterBracket; }', + ' } else if (afterBracket !== "") { leftover = afterBracket; }', + ' return { auth: auth, hostname: hostname, port: port, leftover: leftover };', + ' }', + ' }', + ' var firstColon = candidate.indexOf(":");', + ' if (firstColon === -1) {', + ' hostname = candidate.toLowerCase();', + ' } else {', + ' var lastColon = candidate.lastIndexOf(":");', + ' var portCandidate2 = candidate.slice(lastColon + 1);', + ' if (/^\\d+$/.test(portCandidate2)) {', + ' hostname = candidate.slice(0, firstColon).toLowerCase();', + ' port = portCandidate2;', + ' leftover = candidate.slice(firstColon, lastColon);', + ' } else {', + ' hostname = candidate.slice(0, firstColon).toLowerCase();', + ' leftover = candidate.slice(firstColon);', + ' }', + ' }', + ' return { auth: auth, hostname: hostname, port: port, leftover: leftover };', + ' }', + ' function buildHost(hostname, port) {', + ' if (hostname === null) { return null; }', + ' var h = hostname.indexOf(":") !== -1 ? "[" + hostname + "]" : hostname;', + ' return port !== null ? h + ":" + port : h;', + ' }', + ' function parseAuthorityChunk(rest) {', + ' var end = rest.search(/[/?#]/);', + ' var candidate = end === -1 ? rest : rest.slice(0, end);', + ' var tail = end === -1 ? "" : rest.slice(end);', + ' var a = parseAuthority(candidate);', + ' var newRest = a.leftover + tail;', + ' if (newRest !== "" && newRest.charAt(0) !== "/" && newRest.charAt(0) !== "?" && newRest.charAt(0) !== "#") {', + ' newRest = "/" + newRest;', + ' }', + ' return { auth: a.auth, hostname: a.hostname, port: a.port, rest: newRest };', + ' }', + ' function parse(urlString, parseQueryString, slashesDenoteHost) {', + ' var input = String(urlString).trim();', + ' var protocol = null, slashes = null, auth = null, hostname = null, port = null;', + ' var rest = input;', + ' var pm = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(input);', + ' if (pm) {', + ' protocol = pm[1].toLowerCase() + ":";', + ' rest = input.slice(pm[0].length);', + ' var protoName = pm[1].toLowerCase();', + ' if (HOSTLESS_PROTOCOLS[protoName]) {', + ' // never parses host, even when "//" is literally present.', + ' } else if (rest.slice(0, 2) === "//") {', + ' slashes = true;', + ' var chunk = parseAuthorityChunk(rest.slice(2));', + ' auth = chunk.auth; hostname = chunk.hostname; port = chunk.port; rest = chunk.rest;', + ' } else if (SLASHED_PROTOCOLS[protoName]) {', + ' // no host parsing without "//"; rest stays as-is (pathname/search/hash split still applies).', + ' } else {', + ' var chunk2 = parseAuthorityChunk(rest);', + ' auth = chunk2.auth; hostname = chunk2.hostname; port = chunk2.port; rest = chunk2.rest;', + ' }', + ' } else if (rest.slice(0, 2) === "//" && slashesDenoteHost) {', + ' slashes = true;', + ' var chunk3 = parseAuthorityChunk(rest.slice(2));', + ' auth = chunk3.auth; hostname = chunk3.hostname; port = chunk3.port; rest = chunk3.rest;', + ' }', + ' var host = buildHost(hostname, port);', + ' var split = splitPathQueryHash(rest, !!parseQueryString);', + ' if (slashes && hostname !== null && hostname !== "" && split.pathname === null) { split.pathname = "/"; }', + ' var href = (protocol || "") + (slashes ? "//" : "") + (auth ? auth + "@" : "") + (host || "") +', + ' (split.pathname || "") + (split.search || "") + (split.hash || "");', + ' return {', + ' protocol: protocol, slashes: slashes, auth: auth, host: host, port: port, hostname: hostname,', + ' hash: split.hash, search: split.search, query: split.query, pathname: split.pathname,', + ' path: split.pathname !== null || split.search !== null ? (split.pathname || "") + (split.search || "") : null,', + ' href: href', + ' };', + ' }', + ' function format(obj) {', + ' if (obj != null && typeof obj === "object" && obj instanceof URLCtor) { return String(obj.href); }', + ' if (typeof obj === "string") { return format(parse(obj, false, false)); }', + ' var proto = obj.protocol || "";', + ' if (proto && proto.charAt(proto.length - 1) !== ":") { proto += ":"; }', + ' var protoName = proto.replace(/:$/, "").toLowerCase();', + ' var hasSlashes;', + ' if (typeof obj.slashes === "boolean") { hasSlashes = obj.slashes; }', + ' else { hasSlashes = !!(SLASHED_PROTOCOLS[protoName] && (obj.host || obj.hostname)); }', + ' var hostPart = obj.host || buildHost(obj.hostname || null, obj.port || null) || "";', + ' var qs = "";', + ' if (obj.search) {', + ' qs = String(obj.search);', + ' if (qs.charAt(0) !== "?") { qs = "?" + qs; }', + ' } else if (obj.query != null) {', + ' if (typeof obj.query === "object" && Object.prototype.toString.call(obj.query) !== "[object Array]") {', + ' var s = stringifyQS(obj.query);', + ' if (s) { qs = "?" + s; }', + ' } else if (obj.query !== "") { qs = "?" + String(obj.query); }', + ' }', + ' var hash = obj.hash ? (String(obj.hash).charAt(0) === "#" ? String(obj.hash) : "#" + String(obj.hash)) : "";', + ' var auth = obj.auth ? String(obj.auth) + "@" : "";', + ' var pathname = obj.pathname || "";', + ' return proto + (hasSlashes ? "//" : "") + auth + hostPart + pathname + qs + hash;', + ' }', + ' return { parse: parse, format: format, URL: URLCtor, URLSearchParams: USPCtor };', + '}', +].join('\n'); + /** Every module the sandbox can serve. Grown deliberately, one vetted entry at a time (M2/M3). */ export const SANDBOX_MODULES: SandboxModuleDefinition[] = [ { name: 'path', aliases: ['node:path'], factorySource: PATH_FACTORY }, { name: 'crypto', aliases: ['node:crypto'], factorySource: CRYPTO_FACTORY }, { name: 'events', aliases: ['node:events'], factorySource: EVENTS_FACTORY }, + { name: 'url', aliases: ['node:url'], factorySource: URL_FACTORY }, // Vetted npm libraries (M3), bundled + pinned by scripts/generate-sandbox-vendored.ts. Heavy, so // only included in the eval'd registry source when a plugin declares them. { name: 'uuid', factorySource: UUID_FACTORY_SOURCE, heavy: true }, diff --git a/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.test.ts b/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.test.ts index 846ec713ebf0..81d258930d71 100644 --- a/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.test.ts +++ b/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.test.ts @@ -485,6 +485,40 @@ describe('manifest-declared module grants (C3)', () => { }), ).rejects.toThrow("Module 'events' not permitted by manifest"); }); + + const urlTag = + "module.exports.templateTags = [{ name: 'r', run: function () { var url = require('url'); return url.parse('http://h/p?a=1').hostname + '|' + (url.URL === URL); } }];"; + + it('a plugin granted "url" can require it', async () => { + const actual = await runTagInSandbox({ + pluginSource: urlTag, + tagName: 'r', + envelope: envelope([], resolveTemplateTagModules(['url'])), + bridge: noBridge, + }); + expect(actual).toBe('h|true'); + }); + + it('a plugin declaring the node:url alias can require it', async () => { + const actual = await runTagInSandbox({ + pluginSource: urlTag, + tagName: 'r', + envelope: envelope([], resolveTemplateTagModules(['node:url'])), + bridge: noBridge, + }); + expect(actual).toBe('h|true'); + }); + + it('a plugin without the grant is denied "url" with the manifest message', async () => { + await expect( + runTagInSandbox({ + pluginSource: urlTag, + tagName: 'r', + envelope: envelope([], resolveTemplateTagModules()), + bridge: noBridge, + }), + ).rejects.toThrow("Module 'url' not permitted by manifest"); + }); }); describe('ambient globals — sandbox stdlib (M2)', () => { diff --git a/packages/insomnia/src/templating/sandbox/sandbox-surface.test.ts b/packages/insomnia/src/templating/sandbox/sandbox-surface.test.ts index b8aa9a469a84..63e8d51ba94d 100644 --- a/packages/insomnia/src/templating/sandbox/sandbox-surface.test.ts +++ b/packages/insomnia/src/templating/sandbox/sandbox-surface.test.ts @@ -75,7 +75,17 @@ describe('sandbox', () => { // Reference-identity check: a gated wrapper that's also reachable bare on globalThis isn't gating anything. it('no gated reference leaks onto bare globalThis (alias resolver)', async () => { const entries = await getSandboxSurface(); - expect(findLeakedGatedReferences(entries)).toEqual([]); + expect( + findLeakedGatedReferences(entries, [ + // Intentional, not a leak: URL/URLSearchParams are already ungated ambient globals (like + // atob/Buffer) with zero grant needed, so require('url').URL/.URLSearchParams being === + // globalThis.URL/.URLSearchParams matches real Node's own identity and grants nothing beyond + // what every plugin already has. See the `url` entry in PERMISSIONS.md and URL_FACTORY's + // comment in module-registry.ts. + 'require("url").URL aliases globalThis.URL', + 'require("url").URLSearchParams aliases globalThis.URLSearchParams', + ]), + ).toEqual([]); }, 20_000); // Completeness tripwire: any new sandbox-internal global must be added here deliberately, so its diff --git a/packages/insomnia/src/templating/sandbox/sandbox-surface.ts b/packages/insomnia/src/templating/sandbox/sandbox-surface.ts index e81adc8e9316..b8a919ca0d0b 100644 --- a/packages/insomnia/src/templating/sandbox/sandbox-surface.ts +++ b/packages/insomnia/src/templating/sandbox/sandbox-surface.ts @@ -201,11 +201,20 @@ export const findUnaccountedHostNatives = (entries: SurfaceEntry[]): string[] => return [...globalNatives, ...contextAndModuleNatives].map(e => e.path); }; -/** Flags anything under context or require(...) that's the exact same object as something already reachable bare on globalThis — a "gate" that leaks its reference instead of actually gating. */ -export const findLeakedGatedReferences = (entries: SurfaceEntry[]): string[] => +/** + * Flags anything under context or require(...) that's the exact same object as something already + * reachable bare on globalThis — a "gate" that leaks its reference instead of actually gating. + * `allowed` is for the rare case where that's deliberate and already documented elsewhere (e.g. + * `require('url').URL` is *meant* to be `=== globalThis.URL`, matching real Node's own + * `require('url').URL === global.URL` identity, because `URL` is already an ungated ambient global + * with or without the module grant) — callers must pass the exact expected message, so a genuinely + * new leak on an unrelated path still fails loudly instead of being masked. + */ +export const findLeakedGatedReferences = (entries: SurfaceEntry[], allowed: string[] = []): string[] => entries .filter(e => !!e.aliasOf && e.aliasOf.startsWith('globalThis.') && (e.root === 'context' || e.root.startsWith('require('))) - .map(e => `${e.path} aliases ${e.aliasOf}`); + .map(e => `${e.path} aliases ${e.aliasOf}`) + .filter(message => !allowed.includes(message)); /** * The complete, reviewed set of sandbox-internal globals (`in-sandbox-bootstrap.ts`) exposed bare on diff --git a/packages/insomnia/src/templating/sandbox/url.regression.test.ts b/packages/insomnia/src/templating/sandbox/url.regression.test.ts new file mode 100644 index 000000000000..0bf5d0b4800c --- /dev/null +++ b/packages/insomnia/src/templating/sandbox/url.regression.test.ts @@ -0,0 +1,215 @@ +import { format as nodeFormat, parse as nodeParse } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import type { HostBridge } from './host-bridge'; +import type { ContextEnvelope } from './marshal'; +import { runTagInSandbox } from './plugin-tag-sandbox'; + +const noBridge: HostBridge = async path => { + throw new Error(`unexpected bridge call: ${path}`); +}; + +const envelope = (args: unknown[]): ContextEnvelope => ({ + args, + context: {}, + meta: {}, + renderPurpose: 'preview', + appInfo: { version: '0.0.0', platform: 'linux', arch: 'arm64' }, + pluginName: 'test-plugin', + renderDepth: 0, + grantedModules: ['url'], + grantedCapabilities: [], +}); + +// `run` receives (context, ...args) — the leading context arg is skipped before forwarding to +// url.parse, so callers can pass exactly the args they'd pass to node:url's parse directly. The +// result is JSON.stringify'd because parse() returns a plain object of strings/null/nested objects +// (never a function/bigint/symbol) — always JSON-transportable, unlike some of util's edge cases. +const PARSE_TAG_SOURCE = + "module.exports.templateTags = [{ name: 'r', run: function () {" + + ' var url = require("url");' + + ' return JSON.stringify(url.parse.apply(url, Array.prototype.slice.call(arguments, 1)));' + + ' } }];'; + +const runParse = async (...args: unknown[]) => + JSON.parse( + await runTagInSandbox({ pluginSource: PARSE_TAG_SOURCE, tagName: 'r', envelope: envelope(args), bridge: noBridge }), + ); + +const FORMAT_TAG_SOURCE = + "module.exports.templateTags = [{ name: 'r', run: function () {" + + ' var url = require("url");' + + ' return url.format(arguments[1]);' + + ' } }];'; + +const runFormat = (obj: unknown) => + runTagInSandbox({ pluginSource: FORMAT_TAG_SOURCE, tagName: 'r', envelope: envelope([obj]), bridge: noBridge }); + +describe('parse — parity with node:url.parse', () => { + const cases: [string, boolean][] = [ + ['http://user:pass@example.com:8080/path/to/thing?a=1&b=2#frag', false], + ['http://user:pass@example.com:8080/path/to/thing?a=1&b=2#frag', true], + ['https://example.com/', false], + ['https://example.com/', true], + ['http://example.com', false], + ['http://example.com:80/', false], + ['http://EXAMPLE.com/PATH', false], + ['http://', false], + ['http:///path', false], + ['//example.com/path?x=1', false], + ['//example.com/path?x=1', true], + ['/just/a/path?x=1#y', false], + ['path/relative', false], + ['', false], + [' ', false], + ['foo:bar@baz', false], + ['foo:bar@baz/path', false], + ['foo:bar/path', false], + ['foo:bar', false], + ['foo:/bar', false], + ['foo://bar/baz', false], + ['http:/single/slash', false], + ['http:no-slashes-at-all', false], + ['urn:isbn:0-486-27557-4', false], + ['urn:isbn:1:2:3', false], + ['urn:1:2', false], + ['tel:+1-800-555-0100', false], + ['data:text/plain,hello', false], + ['custom:foo?bar#baz', false], + ['javascript:alert(1)', false], + ['javascript://foo/bar', false], + ['javascript:alert(1)?x=1#y', false], + ['ws:no-slash', false], + ['ws://host/path', false], + ['gopher:no-slash', false], + ['file:///etc/passwd', false], + ['unknownproto://host/path', false], + ['unknownproto:no-slash-but-colon:port123', false], + ['http://example.com:abc/x', false], + ['http://example.com:8080abc/x', false], + ['http://user@host:notaport/x', false], + ['http://example.com/a b/c?d=e f', false], + ['http://example.com/a b/c?d=e f', true], + ['http://example.com/a?b=1&b=2&c', true], + ['http://[::1]:8080/path', false], + ['http://[::1]/path', false], + ['http://[2001:DB8::1]:443/x', false], + ['https://[::1]:443/x', false], + ['http://user:pass@[::1]:8080/path', false], + ]; + + it.each(cases)('parse(%j, %j) matches node:url', async (input, parseQueryString) => { + const actual = await runParse(input, parseQueryString); + const expected = nodeParse(input, parseQueryString); + expect(actual).toEqual(structuredClone(expected)); + }); +}); + +describe('parse — slashesDenoteHost', () => { + it.each([ + ['//foo/bar', false, true], + ['//foo/bar', false, false], + ['foo/bar', false, true], + ] as [string, boolean, boolean][])('parse(%j, %j, %j) matches node:url', async (input, pqs, sdh) => { + const actual = await runParse(input, pqs, sdh); + const expected = nodeParse(input, pqs, sdh); + expect(actual).toEqual(structuredClone(expected)); + }); +}); + +describe('format — parity with node:url.format', () => { + const cases: Record[] = [ + { protocol: 'http:', host: 'example.com', pathname: '/a', search: '?x=1', hash: '#y' }, + { protocol: 'http', hostname: 'example.com', port: '8080', pathname: '/a' }, + { protocol: 'http:', slashes: true, hostname: 'example.com', query: { a: '1', b: ['2', '3'] }, pathname: '/a' }, + { protocol: 'mailto:', auth: 'foo', host: 'example.com', slashes: false }, + { pathname: '/just/path', search: '?x=1' }, + { host: 'example.com', pathname: '/a' }, + { protocol: 'https:', hostname: 'EXAMPLE.com', pathname: '/A' }, + ]; + + it.each(cases)('format(%j) matches node:url', async obj => { + const actual = await runFormat(obj); + expect(actual).toBe(nodeFormat(obj)); + }); + + it('format(string) round-trips through parse, matching node:url', async () => { + const input = 'http://user:pass@example.com:8080/a/b?x=1#y'; + const actual = await runTagInSandbox({ + pluginSource: + "module.exports.templateTags = [{ name: 'r', run: function () { return require('url').format(arguments[1]); } }];", + tagName: 'r', + envelope: envelope([input]), + bridge: noBridge, + }); + expect(actual).toBe(nodeFormat(input)); + }); + + it('format(parse(url)) round-trips, matching node:url, across every parse case', async () => { + const inputs = ['http://user:pass@example.com:8080/a?b=1#c', 'http://[::1]:8080/path', 'mailto:foo@example.com']; + for (const input of inputs) { + const parsed = await runParse(input, false); + const actual = await runFormat(parsed); + expect(actual).toBe(nodeFormat(nodeParse(input, false))); + } + }); +}); + +describe('URL / URLSearchParams passthrough', () => { + it('require("url").URL is the same constructor as the ambient URL global', async () => { + const actual = await runTagInSandbox({ + pluginSource: + "module.exports.templateTags = [{ name: 'r', run: function () { return String(require('url').URL === URL); } }];", + tagName: 'r', + envelope: envelope([]), + bridge: noBridge, + }); + expect(actual).toBe('true'); + }); + + it('require("url").URLSearchParams is the same constructor as the ambient URLSearchParams global', async () => { + const actual = await runTagInSandbox({ + pluginSource: + "module.exports.templateTags = [{ name: 'r', run: function () { return String(require('url').URLSearchParams === URLSearchParams); } }];", + tagName: 'r', + envelope: envelope([]), + bridge: noBridge, + }); + expect(actual).toBe('true'); + }); + + it('require("url").URL is usable directly (not just identity-equal)', async () => { + const actual = await runTagInSandbox({ + pluginSource: + "module.exports.templateTags = [{ name: 'r', run: function () { var U = require('url').URL; return new U('https://h/p?a=1').hostname; } }];", + tagName: 'r', + envelope: envelope([]), + bridge: noBridge, + }); + expect(actual).toBe('h'); + }); +}); + +describe('documented divergences from real node:url', () => { + it('does not treat a backslash as a path/host delimiter or as a "//" stand-in (intentional divergence)', async () => { + const input = 'http://good.com\\@evil.com/x'; + const actual = await runParse(input, false); + // Real node:url treats "\" as "/", terminating the host at "good.com" and reinterpreting the + // rest as path — exactly the parsing-confusion behavior its own deprecation notice warns about. + expect(nodeParse(input, false).hostname).toBe('good.com'); + // This sandbox's parser leaves the backslash as an ordinary character: it's not a delimiter, so + // auth/host splitting proceeds on "@" alone, landing on a different (but internally consistent + // and predictable) result. + expect(actual.hostname).toBe('evil.com'); + expect(actual.auth).toBe('good.com\\'); + }); + + it('does not treat "\\\\" after the protocol as equivalent to "//"', async () => { + const input = 'http:\\\\evil.com\\x'; + const actual = await runParse(input, false); + expect(nodeParse(input, false).host).toBe('evil.com'); + expect(actual.slashes).toBeNull(); + expect(actual.host).toBeNull(); + }); +}); From 4ac0231c6f6e6780c4eef1725219563c9fb8a123 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 11 Aug 2026 13:22:47 -0400 Subject: [PATCH 2/4] test(sandbox): pin url.parse() edge-C0-control-strip and quote-escape gaps Regression cases proving the sandboxed url module's parse() doesn't strip leading/trailing C0-control-or-space bytes before parsing (letting a leading control byte hide a scheme from protocol detection) and that the unsafe-character escape table is missing a single-quote entry, both against real node:url. Intentionally red pending the fix. --- .../templating/sandbox/url.regression.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/insomnia/src/templating/sandbox/url.regression.test.ts b/packages/insomnia/src/templating/sandbox/url.regression.test.ts index 0bf5d0b4800c..1f7b9791b3cc 100644 --- a/packages/insomnia/src/templating/sandbox/url.regression.test.ts +++ b/packages/insomnia/src/templating/sandbox/url.regression.test.ts @@ -213,3 +213,31 @@ describe('documented divergences from real node:url', () => { expect(actual.host).toBeNull(); }); }); + +describe('leading/trailing C0-control-or-space characters', () => { + it('a leading control character before a scheme does not prevent protocol detection', async () => { + const input = String.fromCodePoint(0) + 'javascript:alert(1)'; + const actual = await runParse(input, false); + // node:url strips leading/trailing C0-control-or-space characters (matching the WHATWG URL + // Standard's own input-trimming step) before parsing, so a leading NUL byte doesn't hide the + // scheme from it. + expect(nodeParse(input, false).protocol).toBe('javascript:'); + expect(actual.protocol).toBe('javascript:'); + }); + + it('a trailing control character does not survive into the parsed pathname', async () => { + const input = 'http://host/path' + String.fromCodePoint(31); + const actual = await runParse(input, false); + expect(nodeParse(input, false).pathname).toBe('/path'); + expect(actual.pathname).toBe('/path'); + }); +}); + +describe('unsafe-character escaping table', () => { + it('escapes a literal single quote, matching node:url', async () => { + const input = "http://host/a'b"; + const actual = await runParse(input, false); + expect(nodeParse(input, false).pathname).toBe('/a%27b'); + expect(actual.pathname).toBe('/a%27b'); + }); +}); From 0ce2cf3bb2b51b2e904f4697f2645f5929c46379 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 11 Aug 2026 13:42:16 -0400 Subject: [PATCH 3/4] fix(sandbox): strip leading/trailing C0-control-or-space in url.parse() parse() only called .trim(), which doesn't cover most C0 control bytes, so a leading control byte before a scheme (e.g. a NUL before "javascript:") went undetected as a protocol where real node:url and every browser recognize it, after stripping the same bytes per the WHATWG URL Standard's input-trimming step. Also adds the missing single-quote entry to the unsafe-character escape table, matching real node:url. --- .../src/templating/sandbox/PERMISSIONS.md | 8 +++++++- .../src/templating/sandbox/module-registry.ts | 16 +++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md index 1b95bcb112c8..908f0fb13cad 100644 --- a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md +++ b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md @@ -26,7 +26,7 @@ by Insomnia (a pure-JS reimplementation or a host-backed shim), never the raw No - Pure-JS reimplementations: `events`, `url` (and more via M2). - `url` implements the legacy `parse`/`format` pair (verified against `node:url` across protocol-relative/opaque/non-slash-protocol forms, auth/port/query/hash splitting, the - `%20`/`%22`/`%3C`/`%3E`/`%60`/`%5E`/`%7C`/`%7B`/`%7D` + C0-control-char escaping table, + `%20`/`%22`/`%27`/`%3C`/`%3E`/`%60`/`%5E`/`%7C`/`%7B`/`%7D` unsafe-character escaping table, `parseQueryString`/`slashesDenoteHost`, and IPv6 bracketed hosts) plus a thin re-export of the ambient `URL`/`URLSearchParams` globals (`sandbox-globals.ts`, M2) so `require('url').URL` resolves the way real Node's own `require('url').URL === global.URL` does. That identity is @@ -43,6 +43,12 @@ by Insomnia (a pure-JS reimplementation or a host-backed shim), never the raw No as having "security implications," so it's intentionally not replicated. `url.inspect`/ `resolve`/`domainToASCII`/`domainToUnicode`/`pathToFileURL`/`fileURLToPath`/`Url` (the legacy class) are not implemented at all. + `parse()` strips leading/trailing C0-control-or-space bytes before parsing (matching the WHATWG + URL Standard's own input-trimming step, which `node:url`'s legacy parser also implements) so a + leading control byte can't hide a scheme from protocol detection; a control byte elsewhere in + the string is left in place, then percent-escaped by the table above even in positions where + real Node leaves it raw — a deliberately more conservative, safe-direction difference, not a + parity gap. - **Vetted npm libraries** (pinned + pre-bundled by Insomnia): `uuid`, `ajv`. These are real libraries bundled to run inside the sandbox; they're only loaded when a plugin declares them. Each is sourced from an isolated, exact-pinned install at diff --git a/packages/insomnia/src/templating/sandbox/module-registry.ts b/packages/insomnia/src/templating/sandbox/module-registry.ts index ce92f179810c..99371954ebae 100644 --- a/packages/insomnia/src/templating/sandbox/module-registry.ts +++ b/packages/insomnia/src/templating/sandbox/module-registry.ts @@ -113,10 +113,15 @@ const EVENTS_FACTORY = [ // stand-in for "//" after the protocol. Real Node's legacy parser does this for historical // browser-compat reasons, and it's exactly the parsing-confusion behavior Node's own deprecation // notice on url.parse cites as having "security implications" — intentionally not replicated. -// Verified against node:url for every specifier/edge case exercised by url.regression.test.ts; -// url.inspect's unsafe-character escaping table (space/quote/angle-brackets/backtick/caret/pipe/ -// braces + C0 controls) is replicated, but slashesDenoteHost's rarer host-detection quirks beyond -// the tested cases are not guaranteed byte-for-byte. +// Verified against node:url for every specifier/edge case exercised by url.regression.test.ts. +// parse() strips leading/trailing C0-control-or-space bytes before parsing, matching the WHATWG URL +// Standard's own input-trimming step that node:url's legacy parser also implements — this is an +// edge-only strip; a control byte elsewhere in the string is left in place. The unsafe-character +// escaping table (space/single+double-quote/angle-brackets/backtick/caret/pipe/braces) matches +// node:url's own table; a C0 control byte that survives the edge strip is additionally +// percent-escaped here even where real Node leaves it raw — a deliberately more conservative, +// safe-direction difference, not a parity gap. slashesDenoteHost's rarer host-detection quirks +// beyond the tested cases are not guaranteed byte-for-byte. const URL_FACTORY = [ 'function () {', ' var URLCtor = globalThis.URL;', @@ -126,6 +131,7 @@ const URL_FACTORY = [ ' var UNSAFE_CHAR_ESCAPES = {};', ' UNSAFE_CHAR_ESCAPES[" "] = "%20";', ' UNSAFE_CHAR_ESCAPES[String.fromCharCode(34)] = "%22";', + ' UNSAFE_CHAR_ESCAPES[String.fromCharCode(39)] = "%27";', ' UNSAFE_CHAR_ESCAPES["<"] = "%3C";', ' UNSAFE_CHAR_ESCAPES[">"] = "%3E";', ' UNSAFE_CHAR_ESCAPES["`"] = "%60";', @@ -236,7 +242,7 @@ const URL_FACTORY = [ ' return { auth: a.auth, hostname: a.hostname, port: a.port, rest: newRest };', ' }', ' function parse(urlString, parseQueryString, slashesDenoteHost) {', - ' var input = String(urlString).trim();', + ' var input = String(urlString).replace(/^[\\x00-\\x20]+/, "").replace(/[\\x00-\\x20]+$/, "");', ' var protocol = null, slashes = null, auth = null, hostname = null, port = null;', ' var rest = input;', ' var pm = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(input);', From 6a9c96a2a0a0fae207c2a084dfcdcf74313cc7ed Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 11 Aug 2026 13:53:17 -0400 Subject: [PATCH 4/4] fix(sandbox): match node:url's backslash-as-slash normalization in parse() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backslash is fully interchangeable with a forward slash in real Node's legacy url.parse (delimiter, and a stand-in for "//" after the protocol) — match it exactly rather than diverging, so a plugin ported from the legacy sandbox behaves identically. The function has no host-capability surface either way, so parity costs nothing here. --- .../src/templating/sandbox/PERMISSIONS.md | 24 +++++++------ .../src/templating/sandbox/module-registry.ts | 1 + .../templating/sandbox/url.regression.test.ts | 34 ++++++++----------- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md index 908f0fb13cad..3b33cc2bb7d1 100644 --- a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md +++ b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md @@ -33,16 +33,20 @@ by Insomnia (a pure-JS reimplementation or a host-backed shim), never the raw No intentional, not a leak: `URL`/`URLSearchParams` are already ungated ambient globals with or without the `url` grant — see the reviewed exception in `sandbox-surface.test.ts`'s alias-leak check. - Two deliberate divergences from real Node's `url.parse`, both documented rather than silently - matched: (1) `hostname` for a bracketed IPv6 literal is stored **without** brackets (e.g. - `"::1"`), matching `node:url.parse`'s own convention — a different, equally-real convention - from the ambient `URL` global's WHATWG-style bracket-inclusive `.hostname`, since the two are - independent implementations for two different APIs; (2) a literal backslash is never treated - as a path/host delimiter or as a stand-in for `"//"` after the protocol, unlike real Node's - legacy parser — that exact behavior is what Node's own deprecation notice on `url.parse` cites - as having "security implications," so it's intentionally not replicated. `url.inspect`/ - `resolve`/`domainToASCII`/`domainToUnicode`/`pathToFileURL`/`fileURLToPath`/`Url` (the legacy - class) are not implemented at all. + A backslash is treated as fully interchangeable with a forward slash, matching real Node's + `url.parse` exactly (a normalization pass applied before any other parsing), so a plugin + ported from the legacy sandbox behaves identically here — this was deliberately _not_ left as + a divergence, since real Node's own deprecation notice on `url.parse` cites exactly this + behavior as having "security implications," but this function has zero host-capability + surface either way and nothing in Insomnia's own host bridge trusts its output for a trust + decision, so matching it exactly costs nothing and avoids a silent behavioral break for ported + plugins that rely on it (intentionally or not). One remaining, genuinely necessary divergence: + `hostname` for a bracketed IPv6 literal is stored **without** brackets (e.g. `"::1"`), matching + `node:url.parse`'s own convention — a different, equally-real convention from the ambient + `URL` global's WHATWG-style bracket-inclusive `.hostname`, since the two are independent + implementations for two different APIs. `url.inspect`/`resolve`/`domainToASCII`/ + `domainToUnicode`/`pathToFileURL`/`fileURLToPath`/`Url` (the legacy class) are not implemented + at all. `parse()` strips leading/trailing C0-control-or-space bytes before parsing (matching the WHATWG URL Standard's own input-trimming step, which `node:url`'s legacy parser also implements) so a leading control byte can't hide a scheme from protocol detection; a control byte elsewhere in diff --git a/packages/insomnia/src/templating/sandbox/module-registry.ts b/packages/insomnia/src/templating/sandbox/module-registry.ts index 99371954ebae..b2310db57e28 100644 --- a/packages/insomnia/src/templating/sandbox/module-registry.ts +++ b/packages/insomnia/src/templating/sandbox/module-registry.ts @@ -243,6 +243,7 @@ const URL_FACTORY = [ ' }', ' function parse(urlString, parseQueryString, slashesDenoteHost) {', ' var input = String(urlString).replace(/^[\\x00-\\x20]+/, "").replace(/[\\x00-\\x20]+$/, "");', + ' input = input.replace(/\\\\/g, "/");', ' var protocol = null, slashes = null, auth = null, hostname = null, port = null;', ' var rest = input;', ' var pm = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(input);', diff --git a/packages/insomnia/src/templating/sandbox/url.regression.test.ts b/packages/insomnia/src/templating/sandbox/url.regression.test.ts index 1f7b9791b3cc..e74cb4a486ea 100644 --- a/packages/insomnia/src/templating/sandbox/url.regression.test.ts +++ b/packages/insomnia/src/templating/sandbox/url.regression.test.ts @@ -191,26 +191,22 @@ describe('URL / URLSearchParams passthrough', () => { }); }); -describe('documented divergences from real node:url', () => { - it('does not treat a backslash as a path/host delimiter or as a "//" stand-in (intentional divergence)', async () => { - const input = 'http://good.com\\@evil.com/x'; - const actual = await runParse(input, false); - // Real node:url treats "\" as "/", terminating the host at "good.com" and reinterpreting the - // rest as path — exactly the parsing-confusion behavior its own deprecation notice warns about. - expect(nodeParse(input, false).hostname).toBe('good.com'); - // This sandbox's parser leaves the backslash as an ordinary character: it's not a delimiter, so - // auth/host splitting proceeds on "@" alone, landing on a different (but internally consistent - // and predictable) result. - expect(actual.hostname).toBe('evil.com'); - expect(actual.auth).toBe('good.com\\'); - }); - - it('does not treat "\\\\" after the protocol as equivalent to "//"', async () => { - const input = 'http:\\\\evil.com\\x'; +describe('backslash-as-slash normalization (parity with node:url)', () => { + // node:url treats "\" as fully interchangeable with "/" — as a path/host delimiter, as a + // stand-in for "//" right after the protocol, and everywhere else in the string. Matched exactly + // via a single normalization pass (backslash -> forward slash) before any other parsing runs, so + // an existing plugin ported from the legacy sandbox behaves identically here. + it.each([ + 'http://good.com\\@evil.com/x', + 'http:\\\\evil.com\\x', + 'http:/\\evil.com/x', + 'http:\\/evil.com/x', + 'http://good.com\\@evil.com\\path?q=1#h', + 'http://a\\b\\c\\d', + 'foo:bar\\baz', + ])('parse(%j) matches node:url', async input => { const actual = await runParse(input, false); - expect(nodeParse(input, false).host).toBe('evil.com'); - expect(actual.slashes).toBeNull(); - expect(actual.host).toBeNull(); + expect(actual).toEqual(structuredClone(nodeParse(input, false))); }); });