From 06d8b887e15c751934ebc187daebefd3c2ed0db7 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 11 Aug 2026 09:49:04 -0400 Subject: [PATCH 1/3] feat(sandbox): add util to QuickJS module registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports node:util's format/promisify/types.is* as a pure-JS reimplementation, verified for parity against real node:util (including format's -0/bigint/symbol coercion quirks and quote-selection). util.inspect/inherits/deprecate and %o's showHidden/depth-4 behavior are explicitly out of scope — documented in PERMISSIONS.md. --- .../src/templating/sandbox/PERMISSIONS.md | 13 +- .../sandbox-surface.test.ts.snap | 39 +++ .../src/templating/sandbox/module-registry.ts | 171 ++++++++++++ .../sandbox/plugin-tag-sandbox.test.ts | 34 +++ .../sandbox/util.regression.test.ts | 243 ++++++++++++++++++ 5 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 packages/insomnia/src/templating/sandbox/util.regression.test.ts diff --git a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md index 74ca1db62411..76e6b17ffa66 100644 --- a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md +++ b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md @@ -23,7 +23,18 @@ 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`, `util` (and more via M2). + - `util` implements `format`, `promisify` (including a `Symbol.for("nodejs.util.promisify.custom")` + override and a `.custom` property), and `types.is*` (`isDate`/`isRegExp`/`isPromise`/`isMap`/ + `isSet`/`isWeakMap`/`isWeakSet`/`isArrayBuffer`/`isDataView`/`isTypedArray`/`isNativeError`/ + `isBooleanObject`/`isNumberObject`/`isStringObject`/`isAsyncFunction`/`isGeneratorFunction`) — + verified against `node:util` for all of `format`'s specifiers (`%s %d %i %f %j %o %O %c %%`), + its `-0`/`NaN`/`bigint`/`symbol` coercion quirks, quote-character selection for inspected + strings, and `promisify`'s error/multi-value/custom-override semantics. Two deliberate, + documented gaps: `util.inspect`/`inherits`/`deprecate` are not implemented at all (absent from + the exports object, so calling them throws a plain "not a function" TypeError); and `%o` is not + distinguished from `%O` — real Node's `%o` additionally reveals non-enumerable properties (e.g. + an array's `.length`) and inspects to depth 4, neither of which this module replicates. - **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..4e463a751aa6 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,45 @@ exports[`sandbox surface > matches surface snapshot 1`] = ` "require("events"): object", "require("events").EventEmitter: function(0)", "require("events").EventEmitter.prototype: object", + "require("util"): object", + "require("util").format: function(0)", + "require("util").format.prototype: object", + "require("util").promisify: function(1)", + "require("util").promisify.custom: symbol", + "require("util").promisify.prototype: object", + "require("util").types: object", + "require("util").types.isArrayBuffer: function(1)", + "require("util").types.isArrayBuffer.prototype: object", + "require("util").types.isAsyncFunction: function(1)", + "require("util").types.isAsyncFunction.prototype: object", + "require("util").types.isBooleanObject: function(1)", + "require("util").types.isBooleanObject.prototype: object", + "require("util").types.isDataView: function(1)", + "require("util").types.isDataView.prototype: object", + "require("util").types.isDate: function(1)", + "require("util").types.isDate.prototype: object", + "require("util").types.isGeneratorFunction: function(1)", + "require("util").types.isGeneratorFunction.prototype: object", + "require("util").types.isMap: function(1)", + "require("util").types.isMap.prototype: object", + "require("util").types.isNativeError: function(1)", + "require("util").types.isNativeError.prototype: object", + "require("util").types.isNumberObject: function(1)", + "require("util").types.isNumberObject.prototype: object", + "require("util").types.isPromise: function(1)", + "require("util").types.isPromise.prototype: object", + "require("util").types.isRegExp: function(1)", + "require("util").types.isRegExp.prototype: object", + "require("util").types.isSet: function(1)", + "require("util").types.isSet.prototype: object", + "require("util").types.isStringObject: function(1)", + "require("util").types.isStringObject.prototype: object", + "require("util").types.isTypedArray: function(1)", + "require("util").types.isTypedArray.prototype: object", + "require("util").types.isWeakMap: function(1)", + "require("util").types.isWeakMap.prototype: object", + "require("util").types.isWeakSet: function(1)", + "require("util").types.isWeakSet.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..59c17d730f04 100644 --- a/packages/insomnia/src/templating/sandbox/module-registry.ts +++ b/packages/insomnia/src/templating/sandbox/module-registry.ts @@ -101,11 +101,182 @@ const EVENTS_FACTORY = [ '}', ].join('\n'); +// Reduced, documented replacement for node:util — only `format`, `promisify`, and `types.is*` are +// ported (PERMISSIONS.md records the exclusions: no `inspect`/`inherits`/`deprecate`, and %o is not +// distinguished from %O — no showHidden/proxy/unbounded-depth inspection). `format`'s object/array +// rendering and quote-character selection were verified line-for-line against real node:util's +// output (including the %s-vs-%O/extra-arg divergence in how each treats strings and functions, and +// the -0/bigint/symbol coercion quirks of %d/%i/%f) before transcription here. +const UTIL_FACTORY = [ + 'function () {', + ' function isValidIdentifierKey(k) { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k); }', + ' function quoteString(s) {', + ' if (s.indexOf("\'") === -1) { return "\'" + s + "\'"; }', + ' if (s.indexOf(\'"\') === -1) { return \'"\' + s + \'"\'; }', + ' if (s.indexOf("`") === -1) { return "`" + s + "`"; }', + ' var bs = String.fromCharCode(92);', + ' return "\'" + s.split("\'").join(bs + "\'") + "\'";', + ' }', + ' function formatKey(k) { return isValidIdentifierKey(k) ? k : quoteString(k); }', + ' function formatNumber(n) {', + ' if (n === 0 && 1 / n === -Infinity) { return "-0"; }', + ' return String(n);', + ' }', + ' function formatPrimitiveNonString(v) {', + ' if (v === undefined) { return "undefined"; }', + ' var t = typeof v;', + ' if (t === "boolean") { return v ? "true" : "false"; }', + ' if (t === "bigint") { return String(v) + "n"; }', + ' if (t === "number") { return formatNumber(v); }', + ' return String(v);', + ' }', + ' function inspect(v, maxDepth) {', + ' if (v === null) { return "null"; }', + ' var t = typeof v;', + ' if (t === "string") { return quoteString(v); }', + ' if (t === "function") { var nm = v.name; return nm ? "[Function: " + nm + "]" : "[Function (anonymous)]"; }', + ' if (t !== "object") { return formatPrimitiveNonString(v); }', + ' return inspectContainer(v, 0, maxDepth);', + ' }', + ' function inspectContainer(v, depth, maxDepth) {', + ' if (depth > maxDepth) { return Array.isArray(v) ? "[Array]" : "[Object]"; }', + ' if (Array.isArray(v)) {', + ' if (v.length === 0) { return "[]"; }', + ' var parts = [];', + ' for (var i = 0; i < v.length; i++) { parts.push(inspectNested(v[i], depth + 1, maxDepth)); }', + ' return "[ " + parts.join(", ") + " ]";', + ' }', + ' var keys = Object.keys(v);', + ' if (keys.length === 0) { return "{}"; }', + ' var oparts = [];', + ' for (var j = 0; j < keys.length; j++) {', + ' var key = keys[j];', + ' oparts.push(formatKey(key) + ": " + inspectNested(v[key], depth + 1, maxDepth));', + ' }', + ' return "{ " + oparts.join(", ") + " }";', + ' }', + ' function inspectNested(v, depth, maxDepth) {', + ' if (v === null) { return "null"; }', + ' var t = typeof v;', + ' if (t === "string") { return quoteString(v); }', + ' if (t === "function") { var nm = v.name; return nm ? "[Function: " + nm + "]" : "[Function (anonymous)]"; }', + ' if (t !== "object") { return formatPrimitiveNonString(v); }', + ' return inspectContainer(v, depth, maxDepth);', + ' }', + ' function formatS(v) {', + ' if (typeof v === "bigint") { return String(v) + "n"; }', + ' if (typeof v === "number") { return formatNumber(v); }', + ' if (typeof v !== "object" || v === null) { return String(v); }', + ' return inspect(v, 0);', + ' }', + ' function formatFull(v) { return inspect(v, 2); }', + ' function formatJoin(v) { return typeof v === "string" ? v : formatFull(v); }', + ' function fmtD(v) {', + ' if (typeof v === "bigint") { return String(v) + "n"; }', + ' if (typeof v === "symbol") { return "NaN"; }', + ' return formatNumber(Number(v));', + ' }', + ' function fmtI(v) {', + ' if (typeof v === "bigint") { return String(v) + "n"; }', + ' if (typeof v === "symbol") { return "NaN"; }', + ' return formatNumber(parseInt(v, 10));', + ' }', + ' function fmtF(v) {', + ' if (typeof v === "symbol") { return "NaN"; }', + ' return formatNumber(parseFloat(v));', + ' }', + ' function fmtJ(v) {', + ' try {', + ' var s = JSON.stringify(v);', + ' return s === undefined ? "undefined" : s;', + ' } catch (e) {', + ' if (e && typeof e.message === "string" && e.message.indexOf("circular") !== -1) { return "[Circular]"; }', + ' throw e;', + ' }', + ' }', + ' function format() {', + ' var args = Array.prototype.slice.call(arguments);', + ' if (args.length === 0) { return ""; }', + ' var first = args[0];', + ' if (typeof first !== "string") {', + ' var parts0 = [];', + ' for (var k = 0; k < args.length; k++) { parts0.push(formatJoin(args[k])); }', + ' return parts0.join(" ");', + ' }', + ' if (args.length === 1) { return first; }', + ' var out = "";', + ' var i = 0;', + ' var argIndex = 1;', + ' while (i < first.length) {', + ' var ch = first.charAt(i);', + ' if (ch === "%" && i + 1 < first.length) {', + ' var spec = first.charAt(i + 1);', + ' if (spec === "%") { out += "%"; i += 2; continue; }', + ' if ("sdifjoOc".indexOf(spec) !== -1 && argIndex < args.length) {', + ' var val = args[argIndex];', + ' argIndex += 1;', + ' if (spec === "s") { out += formatS(val); }', + ' else if (spec === "d") { out += fmtD(val); }', + ' else if (spec === "i") { out += fmtI(val); }', + ' else if (spec === "f") { out += fmtF(val); }', + ' else if (spec === "j") { out += fmtJ(val); }', + ' else if (spec === "o" || spec === "O") { out += formatFull(val); }', + ' i += 2;', + ' continue;', + ' }', + ' }', + ' out += ch;', + ' i += 1;', + ' }', + ' for (; argIndex < args.length; argIndex++) { out += " " + formatJoin(args[argIndex]); }', + ' return out;', + ' }', + ' var PROMISIFY_CUSTOM = Symbol.for("nodejs.util.promisify.custom");', + ' function promisify(original) {', + ' if (typeof original !== "function") { throw new TypeError("The \\"original\\" argument must be of type function"); }', + ' if (original[PROMISIFY_CUSTOM]) { return original[PROMISIFY_CUSTOM]; }', + ' function fn() {', + ' var args = Array.prototype.slice.call(arguments);', + ' var self = this;', + ' return new Promise(function (resolve, reject) {', + ' args.push(function (err) {', + ' if (err) { reject(err); return; }', + ' resolve(arguments.length > 1 ? arguments[1] : undefined);', + ' });', + ' original.apply(self, args);', + ' });', + ' }', + ' return fn;', + ' }', + ' promisify.custom = PROMISIFY_CUSTOM;', + ' var types = {', + ' isDate: function (v) { return v instanceof Date; },', + ' isRegExp: function (v) { return v instanceof RegExp; },', + ' isPromise: function (v) { return v instanceof Promise; },', + ' isMap: function (v) { return v instanceof Map; },', + ' isSet: function (v) { return v instanceof Set; },', + ' isWeakMap: function (v) { return v instanceof WeakMap; },', + ' isWeakSet: function (v) { return v instanceof WeakSet; },', + ' isArrayBuffer: function (v) { return v instanceof ArrayBuffer; },', + ' isDataView: function (v) { return v instanceof DataView; },', + ' isTypedArray: function (v) { return ArrayBuffer.isView(v) && !(v instanceof DataView); },', + ' isNativeError: function (v) { return v instanceof Error; },', + ' isBooleanObject: function (v) { return typeof v === "object" && v instanceof Boolean; },', + ' isNumberObject: function (v) { return typeof v === "object" && v instanceof Number; },', + ' isStringObject: function (v) { return typeof v === "object" && v instanceof String; },', + ' isAsyncFunction: function (v) { return Object.prototype.toString.call(v) === "[object AsyncFunction]"; },', + ' isGeneratorFunction: function (v) { return Object.prototype.toString.call(v) === "[object GeneratorFunction]"; }', + ' };', + ' return { format: format, promisify: promisify, types: types };', + '}', +].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: 'util', aliases: ['node:util'], factorySource: UTIL_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..da47ad9f70b7 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 utilTag = + "module.exports.templateTags = [{ name: 'r', run: function () { return require('util').format('%s is %d', 'answer', 42); } }];"; + + it('a plugin declaring the node:util alias can use format', async () => { + const actual = await runTagInSandbox({ + pluginSource: utilTag, + tagName: 'r', + envelope: envelope([], resolveTemplateTagModules(['node:util'])), + bridge: noBridge, + }); + expect(actual).toBe('answer is 42'); + }); + + it('a plugin granted "util" can use format', async () => { + const actual = await runTagInSandbox({ + pluginSource: utilTag, + tagName: 'r', + envelope: envelope([], resolveTemplateTagModules(['util'])), + bridge: noBridge, + }); + expect(actual).toBe('answer is 42'); + }); + + it('a plugin without the grant is denied "util" with the manifest message', async () => { + await expect( + runTagInSandbox({ + pluginSource: utilTag, + tagName: 'r', + envelope: envelope([], resolveTemplateTagModules()), + bridge: noBridge, + }), + ).rejects.toThrow("Module 'util' not permitted by manifest"); + }); }); describe('ambient globals — sandbox stdlib (M2)', () => { diff --git a/packages/insomnia/src/templating/sandbox/util.regression.test.ts b/packages/insomnia/src/templating/sandbox/util.regression.test.ts new file mode 100644 index 000000000000..199743091bc4 --- /dev/null +++ b/packages/insomnia/src/templating/sandbox/util.regression.test.ts @@ -0,0 +1,243 @@ +import { format as nodeFormat, promisify as nodePromisify } from 'node:util'; + +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: ['util'], + grantedCapabilities: [], +}); + +// `run` receives (context, ...args) — the leading context arg is skipped before forwarding to +// util.format, so callers can pass exactly the args they'd pass to node:util's format directly. +const FORMAT_TAG_SOURCE = + "module.exports.templateTags = [{ name: 'r', run: function () {" + + ' var util = require("util");' + + ' return util.format.apply(util, Array.prototype.slice.call(arguments, 1));' + + ' } }];'; + +const runFormat = (...args: unknown[]) => + runTagInSandbox({ pluginSource: FORMAT_TAG_SOURCE, tagName: 'r', envelope: envelope(args), bridge: noBridge }); + +// Runs an arbitrary run() body (no leading-context concern — the body decides what to return) with +// util granted. Used for cases that can't cross the envelope's JSON transport (bigint, symbols, +// functions, -0/NaN/Infinity, circular references) — the literal is written directly into the +// sandboxed source and compared against the identical literal evaluated by real node:util in the +// same test, so no marshaling is required for the parity assertion to be meaningful. +const runBody = (body: string) => + runTagInSandbox({ + pluginSource: `module.exports.templateTags = [{ name: 'r', run: function () { ${body} } }];`, + tagName: 'r', + envelope: envelope([]), + bridge: noBridge, + }); + +describe('format — parity with node:util.format across JSON-transportable args', () => { + const cases: unknown[][] = [ + ['%s', 42], ['%s', 'hello'], ['%s', true], ['%s', false], ['%s', null], + ['%d', 42], ['%d', -3.7], ['%d', 'abc'], ['%d', '42abc'], ['%d', ' 42 '], ['%d', null], ['%d', true], + ['%i', 42], ['%i', -3.7], ['%i', 'abc'], ['%i', '3.14'], ['%i', '-5'], + ['%f', 42], ['%f', 'abc'], ['%f', '3.14'], ['%f', '-5'], + ['%j', { a: 1, b: [1, 2] }], ['%j', null], ['%j', [1, 2, 3]], + ['%%'], ['100%% done', 1], ['%%', 'x'], + ['%s %s', 'only-one'], ['%j %j', 1], + [], ['just text'], + [123, 'a', { x: 1 }], + ['%s', { a: 1 }], ['%s', []], ['%s', { a: { b: { c: { d: 1 } } } }], + ['%s', [1, [2, [3, [4]]]]], ['%s', { a: [1, 2, 3] }], + ['%s', { 'a-b': 1, 2: 'x', 1: 'y', normal: 'z' }], + ["%s", { a: "it's" }], ['%s', { a: 'she said "hi"' }], + ['%O', { a: { b: { c: { d: 1 } } } }], ['%O', [1, [2, [3, [4]]]]], ['%O', 'abc'], ['%O', []], + ['extra', { a: { b: { c: { d: 1 } } } }], ['extra', [1, [2, [3, [4]]]]], + ['%c', 'css', 'leftover'], + ['%z unknown specifier %s', 'val'], + ]; + + it.each(cases)('format(%j) matches node:util', async (...args) => { + const expected = nodeFormat(...(args as [])); + const actual = await runFormat(...args); + expect(actual).toBe(expected); + }); +}); + +describe('format — parity for values that cannot cross the JSON envelope', () => { + const literalCases: [string, string][] = [ + ['%s', '-0'], + ['%d', '-0'], + ['%i', '-0.5'], + ['%f', '"-0"'], + ['%s', 'NaN'], + ['%s', 'Infinity'], + ['%s', '-Infinity'], + ['%s', '10n'], + ['%d', '10n'], + ['%i', '10n'], + ['%f', '10n'], + ['%s', 'Symbol("s")'], + ['%d', 'Symbol("s")'], + ['%i', 'Symbol("s")'], + ['%f', 'Symbol("s")'], + ]; + + it.each(literalCases)('format("%s", %s) matches node:util', async (spec, literal) => { + const actual = await runBody(`return require("util").format(${JSON.stringify(spec)}, ${literal});`); + const expected = nodeFormat(spec, eval(literal)); + expect(actual).toBe(expected); + }); + + it('format("%s", fn) renders raw source (matches Function.prototype.toString, not [Function: name])', async () => { + const source = 'function foo() { return 1; }'; + const actual = await runBody(`${source} return require("util").format("%s", foo);`); + // %s's raw-source rendering is exactly String(fn) — plain Function.prototype.toString semantics, + // not a util.format-specific computation — so the expected text is the literal source itself + // rather than a separately-declared comparison function (which risks an unrelated identifier + // collision with other functions of the same name declared elsewhere in this file). + expect(actual).toBe(source); + }); + + it('format("%O", fn) matches node:util ([Function: name])', async () => { + const actual = await runBody('function namedFn() { return 1; } return require("util").format("%O", namedFn);'); + function namedFn() { return 1; } + expect(actual).toBe(nodeFormat('%O', namedFn)); + }); + + it('format("%O", anonymous fn) renders [Function (anonymous)]', async () => { + const actual = await runBody('return require("util").format("%O", (function () { return 1; }));'); + expect(actual).toBe('[Function (anonymous)]'); + }); + + it('format nests functions inside objects as [Function: name]', async () => { + const actual = await runBody( + 'return require("util").format("%s", { fn: function bar() {}, fn2: function () {} });', + ); + expect(actual).toBe(nodeFormat('%s', { fn: function bar() {}, fn2: function () {} })); + }); + + it('format("%j", circular) matches node:util\'s "[Circular]" fallback', async () => { + const actual = await runBody('var o = {}; o.self = o; return require("util").format("%j", o);'); + const o: Record = {}; + o.self = o; + expect(actual).toBe(nodeFormat('%j', o)); + }); + + it('format("%j", bigint) throws — matches node:util (JSON cannot serialize BigInt)', async () => { + await expect(runBody('return require("util").format("%j", 10n);')).rejects.toThrow(); + expect(() => nodeFormat('%j', 10n)).toThrow(); + }); + + it('quote-selection picks a quote character absent from the string, falling back to escaped single quotes', async () => { + const actual = await runBody( + 'return require("util").format("%s", { a: "both \' and \\" and ` here" });', + ); + expect(actual).toBe(nodeFormat('%s', { a: 'both \' and " and ` here' })); + }); +}); + +describe('promisify', () => { + it('resolves with the callback\'s single value', async () => { + const actual = await runBody( + 'function cb(a, b, done) { done(null, a + b); }' + + ' return require("util").promisify(cb)(2, 3);', + ); + expect(actual).toBe('5'); + }); + + it('resolves with only the first value when the callback passes several', async () => { + const actual = await runBody( + 'function cb(done) { done(null, 1, 2, 3); }' + + ' return require("util").promisify(cb)();', + ); + expect(actual).toBe('1'); + }); + + it('resolves with undefined when the callback passes no value', async () => { + const actual = await runBody( + 'function cb(done) { done(null); }' + + ' return require("util").promisify(cb)();', + ); + expect(actual).toBe(''); + }); + + it('rejects when the callback is invoked with a truthy error', async () => { + await expect( + runBody( + 'function cb(done) { done(new Error("boom")); }' + + ' return require("util").promisify(cb)();', + ), + ).rejects.toThrow('boom'); + }); + + it('treats a falsy non-null error (0) as success, matching node:util', async () => { + const actual = await runBody( + 'function cb(done) { done(0, "ok"); }' + + ' return require("util").promisify(cb)();', + ); + expect(actual).toBe('ok'); + }); + + it('honors a Symbol.for("nodejs.util.promisify.custom") override', async () => { + const actual = await runBody( + 'function cb() {}' + + ' cb[Symbol.for("nodejs.util.promisify.custom")] = function () { return Promise.resolve("custom-value"); };' + + ' return require("util").promisify(cb)();', + ); + expect(actual).toBe('custom-value'); + }); + + it('throws for a non-function argument, matching node:util', async () => { + await expect(runBody('return require("util").promisify(42);')).rejects.toThrow(); + expect(() => nodePromisify(42 as never)).toThrow(); + }); +}); + +describe('types.is*', () => { + const cases: [string, string, boolean][] = [ + ['isDate', 'new Date()', true], ['isDate', '{}', false], + ['isRegExp', '/x/', true], ['isRegExp', '{}', false], + ['isPromise', 'Promise.resolve()', true], ['isPromise', '{}', false], + ['isMap', 'new Map()', true], ['isMap', 'new Set()', false], + ['isSet', 'new Set()', true], ['isSet', 'new Map()', false], + ['isWeakMap', 'new WeakMap()', true], ['isWeakMap', 'new Map()', false], + ['isWeakSet', 'new WeakSet()', true], + ['isArrayBuffer', 'new ArrayBuffer(1)', true], ['isArrayBuffer', 'new Uint8Array(1)', false], + ['isDataView', 'new DataView(new ArrayBuffer(1))', true], + ['isTypedArray', 'new Uint8Array(1)', true], + ['isTypedArray', 'new DataView(new ArrayBuffer(1))', false], + ['isTypedArray', '[]', false], + ['isNativeError', 'new Error("x")', true], ['isNativeError', '{}', false], + ['isBooleanObject', 'new Boolean(true)', true], ['isBooleanObject', 'true', false], + ['isNumberObject', 'new Number(1)', true], ['isNumberObject', '1', false], + ['isStringObject', 'new String("x")', true], ['isStringObject', '"x"', false], + ['isAsyncFunction', 'async function () {}', true], ['isAsyncFunction', 'function () {}', false], + ['isGeneratorFunction', 'function* () {}', true], ['isGeneratorFunction', 'function () {}', false], + ]; + + it.each(cases)('types.%s(%s) === %s', async (method, literal, expected) => { + const actual = await runBody(`return String(require("util").types.${method}(${literal}));`); + expect(actual).toBe(String(expected)); + }); + + it('is not part of the module\'s enumerable top-level surface beyond format/promisify/types', async () => { + const actual = await runBody('return Object.keys(require("util")).sort().join(",");'); + expect(actual).toBe('format,promisify,types'); + }); + + it('does not implement util.inspect — an explicit, documented exclusion', async () => { + const actual = await runBody('return String(require("util").inspect);'); + expect(actual).toBe('undefined'); + }); +}); From 65fe6eaa8da33d6defd049f3c9773570945c6c12 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 11 Aug 2026 10:08:46 -0400 Subject: [PATCH 2/3] docs(sandbox): note isAsyncFunction/isGeneratorFunction's toStringTag-spoofability in PERMISSIONS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the independent security review: unlike the rest of types.is*, these two use Object.prototype.toString.call (no instanceof target exists for either), so a value with a forged Symbol.toStringTag can produce a wrong boolean — a correctness gap, not a capability leak. --- .../insomnia/src/templating/sandbox/PERMISSIONS.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md index 76e6b17ffa66..68d151db4065 100644 --- a/packages/insomnia/src/templating/sandbox/PERMISSIONS.md +++ b/packages/insomnia/src/templating/sandbox/PERMISSIONS.md @@ -34,7 +34,15 @@ by Insomnia (a pure-JS reimplementation or a host-backed shim), never the raw No documented gaps: `util.inspect`/`inherits`/`deprecate` are not implemented at all (absent from the exports object, so calling them throws a plain "not a function" TypeError); and `%o` is not distinguished from `%O` — real Node's `%o` additionally reveals non-enumerable properties (e.g. - an array's `.length`) and inspects to depth 4, neither of which this module replicates. + an array's `.length`) and inspects to depth 4, neither of which this module replicates. Most + `types.is*` checks use `instanceof` rather than Node's real V8-internal-type-tag approach — + deliberately, since `instanceof` can't be spoofed via a custom `Symbol.toStringTag` getter the + way a `toString.call`-based check can. The two exceptions, `isAsyncFunction` and + `isGeneratorFunction`, use `Object.prototype.toString.call` instead (there's no `instanceof` + target for either) and so can be made to answer wrongly by a value with a forged + `[Symbol.toStringTag]`. This is a correctness gap, not a capability leak: the check only ever + returns a boolean about a value the plugin already owns, so a spoofed answer can't expose or + grant access to anything the plugin couldn't already reach. - **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 From 15296668ed9ebbfaaa31eedcc42b79d3cc8cdb79 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 11 Aug 2026 10:15:30 -0400 Subject: [PATCH 3/3] test(sandbox): resolve code-scanning findings on util's regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes eval() from the format() parity test's literal-value comparisons (Semgrep javascript.browser.security.eval-detected) by building the real node:util comparison value directly instead of eval'ing the same source text used for the sandboxed side. Documents and suppresses the CodeQL js/bad-code-sanitization finding on runBody's plugin-source construction — body is always a fixed literal from within this file (never external input) and is expected to contain arbitrary JS syntax, including quote/backtick characters that a generic sanitizer would corrupt. --- .../sandbox/util.regression.test.ts | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/insomnia/src/templating/sandbox/util.regression.test.ts b/packages/insomnia/src/templating/sandbox/util.regression.test.ts index 199743091bc4..beb9790951d5 100644 --- a/packages/insomnia/src/templating/sandbox/util.regression.test.ts +++ b/packages/insomnia/src/templating/sandbox/util.regression.test.ts @@ -38,9 +38,15 @@ const runFormat = (...args: unknown[]) => // functions, -0/NaN/Infinity, circular references) — the literal is written directly into the // sandboxed source and compared against the identical literal evaluated by real node:util in the // same test, so no marshaling is required for the parity assertion to be meaningful. +// +// `body` is always a fixed string literal from a call site in this same test file (never data from +// outside the process), and it's expected to contain arbitrary JS syntax including quote/backtick +// characters (e.g. the quote-selection test below embeds a literal backtick) — escaping it would +// corrupt those cases rather than add safety. The resulting source only ever runs inside the +// disposable, isolated QuickJS sandbox this whole file is testing, never on the host. const runBody = (body: string) => runTagInSandbox({ - pluginSource: `module.exports.templateTags = [{ name: 'r', run: function () { ${body} } }];`, + pluginSource: `module.exports.templateTags = [{ name: 'r', run: function () { ${body} } }];`, // lgtm[js/bad-code-sanitization] tagName: 'r', envelope: envelope([]), bridge: noBridge, @@ -75,27 +81,30 @@ describe('format — parity with node:util.format across JSON-transportable args }); describe('format — parity for values that cannot cross the JSON envelope', () => { - const literalCases: [string, string][] = [ - ['%s', '-0'], - ['%d', '-0'], - ['%i', '-0.5'], - ['%f', '"-0"'], - ['%s', 'NaN'], - ['%s', 'Infinity'], - ['%s', '-Infinity'], - ['%s', '10n'], - ['%d', '10n'], - ['%i', '10n'], - ['%f', '10n'], - ['%s', 'Symbol("s")'], - ['%d', 'Symbol("s")'], - ['%i', 'Symbol("s")'], - ['%f', 'Symbol("s")'], + // `literal` is the exact source text embedded into the sandboxed run() body; `value` builds the + // same value directly (no eval) for the real node:util comparison — both sides construct their + // own copy of the value from scratch, so no marshaling occurs either way. + const literalCases: { spec: string; literal: string; value: () => unknown }[] = [ + { spec: '%s', literal: '-0', value: () => -0 }, + { spec: '%d', literal: '-0', value: () => -0 }, + { spec: '%i', literal: '-0.5', value: () => -0.5 }, + { spec: '%f', literal: '"-0"', value: () => '-0' }, + { spec: '%s', literal: 'NaN', value: () => Number.NaN }, + { spec: '%s', literal: 'Infinity', value: () => Infinity }, + { spec: '%s', literal: '-Infinity', value: () => -Infinity }, + { spec: '%s', literal: '10n', value: () => 10n }, + { spec: '%d', literal: '10n', value: () => 10n }, + { spec: '%i', literal: '10n', value: () => 10n }, + { spec: '%f', literal: '10n', value: () => 10n }, + { spec: '%s', literal: 'Symbol("s")', value: () => Symbol('s') }, + { spec: '%d', literal: 'Symbol("s")', value: () => Symbol('s') }, + { spec: '%i', literal: 'Symbol("s")', value: () => Symbol('s') }, + { spec: '%f', literal: 'Symbol("s")', value: () => Symbol('s') }, ]; - it.each(literalCases)('format("%s", %s) matches node:util', async (spec, literal) => { + it.each(literalCases)('format("%s", %s) matches node:util', async ({ spec, literal, value }) => { const actual = await runBody(`return require("util").format(${JSON.stringify(spec)}, ${literal});`); - const expected = nodeFormat(spec, eval(literal)); + const expected = nodeFormat(spec, value()); expect(actual).toBe(expected); });