Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion packages/insomnia/src/templating/sandbox/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,36 @@ 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`/`%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
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.
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
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: <alias of globalThis.URL>",
"require("url").URLSearchParams: function(1)",
"require("url").URLSearchParams: <alias of globalThis.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",
Expand Down
209 changes: 209 additions & 0 deletions packages/insomnia/src/templating/sandbox/module-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,220 @@ 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.
// 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;',
' 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[String.fromCharCode(39)] = "%27";',
' 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).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);',
' 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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
12 changes: 11 additions & 1 deletion packages/insomnia/src/templating/sandbox/sandbox-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading