Skip to content
Open
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
10 changes: 9 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,15 @@ 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`, `querystring` (and more via M2).
- `querystring`'s `parse()` returns an `Object.create(null)` result (like real Node), so a crafted
`__proto__`/`toString` key is just an own data property, never the `Object.prototype` accessor.
`unescape()` falls back to the original input unchanged if it contains any malformed
percent-encoding; real Node partially decodes valid escapes and passes through only the invalid
segment, which this shim does not replicate. `parse()`'s own per-key/value decoding has the same
all-or-nothing fallback, scoped to the individual key or value: a malformed escape anywhere in
one key/value leaves that whole token undecoded (e.g. `"a=100%zzhello%20"` stays `"100%zzhello%20"`
instead of real Node's `"100%zzhello "`), even though the rest of the query string decodes normally.
- **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,19 @@ exports[`sandbox surface > matches surface snapshot 1`] = `
"require("events"): object",
"require("events").EventEmitter: function(0)",
"require("events").EventEmitter.prototype: object",
"require("querystring"): object",
"require("querystring").decode: function(4)",
"require("querystring").decode.prototype: object",
"require("querystring").encode: function(4)",
"require("querystring").encode.prototype: object",
"require("querystring").escape: function(1)",
"require("querystring").escape.prototype: object",
"require("querystring").parse: function(4)",
"require("querystring").parse: <alias of require("querystring").decode>",
"require("querystring").stringify: function(4)",
"require("querystring").stringify: <alias of require("querystring").encode>",
"require("querystring").unescape: function(1)",
"require("querystring").unescape.prototype: object",
"require("uuid"): object",
"require("uuid").MAX: string",
"require("uuid").NIL: string",
Expand Down
83 changes: 83 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,94 @@ const EVENTS_FACTORY = [
'}',
].join('\n');

// A pure-JS `querystring` reimplementation. `parse()` returns an `Object.create(null)` result (like
// real Node) so a crafted key of "__proto__" is just an own data property, never triggers the
// Object.prototype accessor — no prototype-pollution path through parsed query keys. `unescape()`
// falls back to the input value unchanged if it contains any malformed percent-encoding; real Node
// partially decodes valid escapes and passes through only the invalid segment, which this shim does
// not replicate (documented in PERMISSIONS.md). `parse()`'s own per-key/value decode (`decodeQSValue`)
// has the identical all-or-nothing fallback, scoped to the individual key or value token rather than
// the whole query string: a malformed escape anywhere in one key/value leaves that whole token
// undecoded, including any well-formed escapes elsewhere in the same token, where real Node would
// still decode them (also documented in PERMISSIONS.md).
const QUERYSTRING_FACTORY = [
'function () {',
' function isFiniteNumber(v) { return typeof v === "number" && v === v && v !== Infinity && v !== -Infinity; }',
' function stringifyPrimitive(v) {',
' if (typeof v === "string") { return v; }',
' if (isFiniteNumber(v)) { return String(v); }',
' if (typeof v === "boolean") { return String(v); }',
' return "";',
' }',
' function encodeQS(s) { return encodeURIComponent(String(s)); }',
' function decodeQSValue(s) {',
' s = String(s).replace(/\\+/g, " ");',
' try { return decodeURIComponent(s); } catch (e) { return s; }',
' }',
'',
' function parse(str, sep, eq, options) {',
' str = str === undefined || str === null ? "" : String(str);',
' sep = sep === undefined || sep === null ? "&" : sep;',
' eq = eq === undefined || eq === null ? "=" : eq;',
' var maxKeys = 1000;',
' if (options && typeof options.maxKeys === "number") { maxKeys = options.maxKeys; }',
' var result = Object.create(null);',
' if (str.length === 0) { return result; }',
' var pairs = str.split(sep);',
' if (maxKeys > 0 && pairs.length > maxKeys) { pairs = pairs.slice(0, maxKeys); }',
' var i, pair, eqIdx, key, val;',
' for (i = 0; i < pairs.length; i++) {',
' pair = pairs[i];',
' if (pair.length === 0) { continue; }',
' eqIdx = pair.indexOf(eq);',
' if (eqIdx === -1) { key = decodeQSValue(pair); val = ""; } else {',
' key = decodeQSValue(pair.slice(0, eqIdx));',
' val = decodeQSValue(pair.slice(eqIdx + eq.length));',
' }',
' if (key in result) {',
' if (Array.isArray(result[key])) { result[key].push(val); } else { result[key] = [result[key], val]; }',
' } else {',
' result[key] = val;',
' }',
' }',
' return result;',
' }',
'',
' function stringify(obj, sep, eq, options) {',
' sep = sep === undefined || sep === null ? "&" : sep;',
' eq = eq === undefined || eq === null ? "=" : eq;',
' obj = obj || {};',
' var parts = [];',
' var keys = Object.keys(obj);',
' var i, j, key, value;',
' for (i = 0; i < keys.length; i++) {',
' key = keys[i];',
' value = obj[key];',
' if (Array.isArray(value)) {',
' for (j = 0; j < value.length; j++) { parts.push(encodeQS(key) + eq + encodeQS(stringifyPrimitive(value[j]))); }',
' } else {',
' parts.push(encodeQS(key) + eq + encodeQS(stringifyPrimitive(value)));',
' }',
' }',
' return parts.join(sep);',
' }',
'',
' function escapeFn(str) { return encodeURIComponent(String(str)); }',
' function unescapeFn(str) {',
' str = String(str);',
' try { return decodeURIComponent(str); } catch (e) { return str; }',
' }',
'',
' return { parse: parse, stringify: stringify, decode: parse, encode: stringify, escape: escapeFn, unescape: unescapeFn };',
'}',
].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: 'querystring', aliases: ['node:querystring'], factorySource: QUERYSTRING_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 querystringTag =
"module.exports.templateTags = [{ name: 'r', run: function () { var qs = require('querystring'); return JSON.stringify(qs.parse('a=1&a=2')); } }];";

it('a plugin granted "querystring" can use it', async () => {
const actual = await runTagInSandbox({
pluginSource: querystringTag,
tagName: 'r',
envelope: envelope([], resolveTemplateTagModules(['querystring'])),
bridge: noBridge,
});
expect(JSON.parse(actual)).toEqual({ a: ['1', '2'] });
});

it('a plugin declaring the node:querystring alias can use it', async () => {
const actual = await runTagInSandbox({
pluginSource: querystringTag,
tagName: 'r',
envelope: envelope([], resolveTemplateTagModules(['node:querystring'])),
bridge: noBridge,
});
expect(JSON.parse(actual)).toEqual({ a: ['1', '2'] });
});

it('a plugin without the grant is denied "querystring" with the manifest message', async () => {
await expect(
runTagInSandbox({
pluginSource: querystringTag,
tagName: 'r',
envelope: envelope([], resolveTemplateTagModules()),
bridge: noBridge,
}),
).rejects.toThrow("Module 'querystring' not permitted by manifest");
});
});

describe('ambient globals — sandbox stdlib (M2)', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import nodeQuerystring from 'node:querystring';

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 bridgePath => {
throw new Error(`unexpected bridge call: ${bridgePath}`);
};

const envelope = (grantedModules: string[]): ContextEnvelope => ({
args: [],
context: {},
meta: {},
renderPurpose: 'preview',
appInfo: { version: '0.0.0', platform: 'linux', arch: 'x64' },
pluginName: 'test-plugin',
renderDepth: 0,
grantedModules,
grantedCapabilities: [],
});

const runQuerystringTag = (body: string) =>
runTagInSandbox({
pluginSource: `module.exports.templateTags = [{ name: 't', run: function () {
var qs = require('querystring');
${body}
} }];`,
tagName: 't',
envelope: envelope(['path', 'crypto', 'querystring']),
bridge: noBridge,
});

describe('querystring regression suite', () => {
describe('behavior', () => {
it('parse() decodes percent-encoding and "+" as space', async () => {
const result = await runQuerystringTag('return JSON.stringify(qs.parse("a=hello%20world&b=x%2By&c=one+two"));');
expect(JSON.parse(result)).toEqual({ a: 'hello world', b: 'x+y', c: 'one two' });
});

it('parse() collects repeated keys into an array, in order', async () => {
const result = await runQuerystringTag('return JSON.stringify(qs.parse("a=1&a=2&a=3"));');
expect(JSON.parse(result)).toEqual({ a: ['1', '2', '3'] });
});

it('parse() treats a key with no "=" as an empty-string value', async () => {
const result = await runQuerystringTag('return JSON.stringify(qs.parse("a"));');
expect(JSON.parse(result)).toEqual({ a: '' });
});

it('parse() honors custom separator and equals characters', async () => {
const result = await runQuerystringTag('return JSON.stringify(qs.parse("a:1;b:2", ";", ":"));');
expect(JSON.parse(result)).toEqual({ a: '1', b: '2' });
});

it('parse() truncates to options.maxKeys, and 0 means unlimited', async () => {
const truncated = await runQuerystringTag('return JSON.stringify(qs.parse("a=1&b=2&c=3", "&", "=", { maxKeys: 2 }));');
expect(JSON.parse(truncated)).toEqual({ a: '1', b: '2' });
const unlimited = await runQuerystringTag('return JSON.stringify(qs.parse("a=1&b=2&c=3", "&", "=", { maxKeys: 0 }));');
expect(JSON.parse(unlimited)).toEqual({ a: '1', b: '2', c: '3' });
});

it('parse() result has a null prototype, so a "__proto__" key is just a data property', async () => {
const result = await runQuerystringTag(`
var r = qs.parse("__proto__=1&toString=2");
return JSON.stringify({
protoIsNull: Object.getPrototypeOf(r) === null,
protoValue: r.__proto__,
toStringValue: r.toString,
globalProtoUnaffected: Object.getPrototypeOf({}) === Object.prototype,
});
`);
expect(JSON.parse(result)).toEqual({
protoIsNull: true,
protoValue: '1',
toStringValue: '2',
globalProtoUnaffected: true,
});
});

it('stringify() encodes values and repeats the key for array values', async () => {
const result = await runQuerystringTag('return qs.stringify({ a: ["1", "2"], b: "hello world" });');
expect(result).toBe('a=1&a=2&b=hello%20world');
});

it('stringify() maps null/undefined/NaN/Infinity/objects/functions to an empty value', async () => {
const result = await runQuerystringTag(`
return qs.stringify({ a: null, b: undefined, c: NaN, d: Infinity, e: {}, f: function () {}, g: -0, h: true });
`);
expect(result).toBe('a=&b=&c=&d=&e=&f=&g=0&h=true');
});

it('decode/encode are aliases of parse/stringify', async () => {
const result = await runQuerystringTag('return JSON.stringify({ same1: qs.decode === qs.parse, same2: qs.encode === qs.stringify });');
expect(JSON.parse(result)).toEqual({ same1: true, same2: true });
});

it('escape()/unescape() round-trip and unescape() does not treat "+" as space', async () => {
const result = await runQuerystringTag(`
return JSON.stringify({
escaped: qs.escape("a b+c/d"),
unescaped: qs.unescape("a%20b%2Bc"),
plusUnchanged: qs.unescape("a+b"),
});
`);
expect(JSON.parse(result)).toEqual({
escaped: 'a%20b%2Bc%2Fd',
unescaped: 'a b+c',
plusUnchanged: 'a+b',
});
});

it('unescape() falls back to the original string on malformed percent-encoding (documented gap)', async () => {
const result = await runQuerystringTag('return qs.unescape("bad%");');
expect(result).toBe('bad%');
});

it('parse() leaves a whole key/value undecoded if it contains malformed percent-encoding, even alongside well-formed escapes in the same token (documented gap, same root cause as unescape())', async () => {
const input = 'a=100%zzhello%20';
const result = await runQuerystringTag(`return JSON.stringify(qs.parse(${JSON.stringify(input)}));`);
// The shim's fallback is all-or-nothing per key/value: because "%zz" is malformed, the
// trailing well-formed "%20" is left undecoded too, unlike real Node (pinned below).
expect(JSON.parse(result)).toEqual({ a: '100%zzhello%20' });
expect(nodeQuerystring.parse(input)).toEqual({ a: '100%zzhello ' });
});

it('adversarial: a crafted "__proto__" query key cannot be used to write through to a real prototype, and repeated "__proto__" keys collect into an array like any other key', async () => {
const result = await runQuerystringTag(`
var r = qs.parse("__proto__=whatever");
var beforeHasPolluted = ({}).polluted;
try { r.__proto__.polluted = 'x'; } catch (e) { /* r.__proto__ is a string primitive, not a live prototype reference */ }
var afterHasPolluted = ({}).polluted;
var repeated = qs.parse("__proto__=1&__proto__=2");
return JSON.stringify({
typeofProto: typeof r.__proto__,
beforeHasPolluted: beforeHasPolluted,
afterHasPolluted: afterHasPolluted,
repeatedIsArray: Array.isArray(repeated.__proto__),
repeatedValue: repeated.__proto__,
chainOk: Object.getPrototypeOf({}) === Object.prototype,
});
`);
expect(JSON.parse(result)).toEqual({
typeofProto: 'string',
beforeHasPolluted: undefined,
afterHasPolluted: undefined,
repeatedIsArray: true,
repeatedValue: ['1', '2'],
chainOk: true,
});
});

it('adversarial: a crafted "constructor" query key cannot reach the real Function constructor or its prototype', async () => {
const result = await runQuerystringTag(`
var r = qs.parse("constructor=zzz&constructor.prototype.polluted=1");
var probe = {};
return JSON.stringify({ probeHasPolluted: probe.polluted, ctorType: typeof r.constructor });
`);
expect(JSON.parse(result)).toEqual({ probeHasPolluted: undefined, ctorType: 'string' });
});

it('adversarial: a pollution attempt in one render cannot leak into a later, independent render', async () => {
await runQuerystringTag('qs.parse("__proto__=1"); return "done";');
const second = await runQuerystringTag(
'return JSON.stringify({ chainOk: Object.getPrototypeOf({}) === Object.prototype, hasLeak: "polluted" in {} });',
);
expect(JSON.parse(second)).toEqual({ chainOk: true, hasLeak: false });
});
});

describe('parity with real node:querystring', () => {
it('parse() matches for a representative mixed query string', async () => {
const input = 'a=1&a=2&b=hello%20world&c=x%2By&d&e=%E2%98%83';
const sandboxResult = JSON.parse(await runQuerystringTag(`return JSON.stringify(qs.parse(${JSON.stringify(input)}));`));
expect(sandboxResult).toEqual(nodeQuerystring.parse(input));
});

it('stringify() matches for representative value types', async () => {
// `undefined` can't cross the JSON boundary into the sandbox source (JSON.stringify drops it) —
// that case is covered directly in the behavioral test above instead.
const obj = { a: [1, 2], b: 'hello world', c: null, e: true, f: -5 };
const sandboxResult = await runQuerystringTag(`return qs.stringify(${JSON.stringify(obj)});`);
expect(sandboxResult).toBe(nodeQuerystring.stringify(obj as unknown as Record<string, string>));
});

it('escape()/unescape() match for well-formed input', async () => {
const sandboxResult = JSON.parse(
await runQuerystringTag('return JSON.stringify({ escaped: qs.escape("a b/c+d"), unescaped: qs.unescape("a%20b%2Fc") });'),
);
expect(sandboxResult).toEqual({
escaped: nodeQuerystring.escape('a b/c+d'),
unescaped: nodeQuerystring.unescape('a%20b%2Fc'),
});
});
});
});
Loading