From 49874545449d8e21e04ddd3d322d4c8ab59f40da Mon Sep 17 00:00:00 2001 From: Bingbing Date: Tue, 28 Jul 2026 21:37:47 +0800 Subject: [PATCH 1/6] fix(smoke-test): add missing /v3/users/me/onboarding mock endpoint (#10309) E2E smoke tests log a lot of error "Failed to load onboarding state CODE-404: Not Found" because the mock API server had no handler for GET /v3/users/me/onboarding, which the app calls via getOnboardingState() on startup. --- packages/insomnia-smoke-test/server/insomnia-api.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/insomnia-smoke-test/server/insomnia-api.ts b/packages/insomnia-smoke-test/server/insomnia-api.ts index 121bcd206e..8d70f2e820 100644 --- a/packages/insomnia-smoke-test/server/insomnia-api.ts +++ b/packages/insomnia-smoke-test/server/insomnia-api.ts @@ -492,6 +492,11 @@ export default function setup(app: Application) { }); }); + // GET /v3/users/me/onboarding + app.get('/v3/users/me/onboarding', (_req, res) => { + res.status(200).send({ is_new_signup: false, first_request_treatment: null }); + }); + app.get('/v1/organizations/:orgId/features', (_req, res) => { res.status(200).send(organizationFeatures); }); From da0a6f7fe63d3dcc222ceb2f03e73ef18333218a Mon Sep 17 00:00:00 2001 From: Ryan Willis Date: Tue, 28 Jul 2026 09:12:56 -0700 Subject: [PATCH 2/6] chore: bump node-libcurl for electron 43 and windows arm support [INS-3111] (#10305) --- .nvmrc | 2 +- package-lock.json | 149 ++++++------------ package.json | 19 ++- packages/insomnia-data/package.json | 2 +- packages/insomnia/package.json | 4 +- ...er-database-protocol-authorization.test.ts | 31 ++++ packages/insomnia/src/main/api.protocol.ts | 3 +- .../src/main/templating-worker-database.ts | 19 ++- 8 files changed, 117 insertions(+), 112 deletions(-) diff --git a/.nvmrc b/.nvmrc index d845d9d88d..ca5c350055 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.14.0 +24.18.0 diff --git a/package-lock.json b/package-lock.json index 43048ce870..1bd64200cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "packages/insomnia-scripting-environment" ], "dependencies": { - "@getinsomnia/node-libcurl": "3.2.2", + "@getinsomnia/node-libcurl": "3.3.0", "ajv": "^8.17.1" }, "devDependencies": { @@ -1942,6 +1942,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -2056,35 +2066,37 @@ } }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" + "undici": "^7.24.4" } }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@electron/notarize": { @@ -3545,22 +3557,22 @@ "license": "Apache-2.0" }, "node_modules/@getinsomnia/node-libcurl": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@getinsomnia/node-libcurl/-/node-libcurl-3.2.2.tgz", - "integrity": "sha512-O4FTODtfwvLpnk+pyPHCIINCmu8Io+UaDDmcY2bU73VumAu6p/p/egQ6ndUOOId6OQ7V2jyAppZFgNb+fev/Uw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@getinsomnia/node-libcurl/-/node-libcurl-3.3.0.tgz", + "integrity": "sha512-cmlwBjzqwiSsg6+wIwZPFTPwcyIbz9piIjV8IYy7HUwLhHtluB6oRF0GlkkyeFCnOdJ+kG3CGtdNgRPsW2ItVw==", "hasInstallScript": true, "license": "MIT", "dependencies": { "@mapbox/node-pre-gyp": "2.0.3", "consola": "^3.2.3", "env-paths": "2.2.0", - "nan": "2.26.2", + "nan": "2.28.0", "node-gyp": "12.2.0", "rimraf": "^3.0.2", "tslib": "2.0.1" }, "engines": { - "node": ">= 24.14.0" + "node": ">= 24.18.0" } }, "node_modules/@getinsomnia/srp-js": { @@ -10695,17 +10707,6 @@ "@types/node": "*" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -14733,22 +14734,22 @@ } }, "node_modules/electron": { - "version": "41.0.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-41.0.3.tgz", - "integrity": "sha512-IDjx8liW1q+r7+MOip5W1Eo1eMwJzVObmYrd9yz2dPCkS7XlgLq3qPVMR80TpiROFp73iY30kTzMdpA6fEVs3A==", + "version": "43.2.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", + "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { @@ -16450,43 +16451,6 @@ "license": "MIT", "optional": true }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", @@ -17052,21 +17016,6 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs-minipass": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", @@ -21529,9 +21478,9 @@ } }, "node_modules/nan": { - "version": "2.26.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", - "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", "license": "MIT" }, "node_modules/nano-css": { @@ -29184,7 +29133,7 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^3.0.2", - "@getinsomnia/node-libcurl": "3.2.2", + "@getinsomnia/node-libcurl": "3.3.0", "@getinsomnia/srp-js": "1.0.0-alpha.1", "@grpc/grpc-js": "^1.14.4", "@grpc/proto-loader": "^0.7.13", @@ -29330,7 +29279,7 @@ "@types/ws": "^8.18.1", "@vitejs/plugin-react": "5.1.2", "cross-env": "^10.1.0", - "electron": "41.0.3", + "electron": "43.2.0", "electron-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1", "electron-devtools-installer": "^3.2.1", @@ -29376,7 +29325,7 @@ "uuid": "^9.0.1" }, "devDependencies": { - "@getinsomnia/node-libcurl": "3.2.2", + "@getinsomnia/node-libcurl": "3.3.0", "@modelcontextprotocol/sdk": "^1.17.5", "@types/deep-equal": "^1.0.4", "mocha": "^11.7.5", diff --git a/package.json b/package.json index 6166031901..10c9afff12 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "homepage": "https://github.com/kong/insomnia#readme", "engines": { - "node": ">=24", + "node": ">=24.18.0", "npm": ">=11" }, "workspaces": [ @@ -31,8 +31,8 @@ "type-check": "npm run type-check --workspaces --if-present", "test": "npm run test --workspaces --if-present", "clean": "git clean -dfX", - "install-libcurl-electron": "node-pre-gyp install --directory node_modules/@getinsomnia/node-libcurl --update-binary --runtime=electron --target=41.0.3", - "install-libcurl-node": "node-pre-gyp install --directory node_modules/@getinsomnia/node-libcurl --update-binary --runtime=node --target=24.14.0", + "install-libcurl-electron": "node-pre-gyp install --directory node_modules/@getinsomnia/node-libcurl --update-binary --runtime=electron --target=43.2.0", + "install-libcurl-node": "node-pre-gyp install --directory node_modules/@getinsomnia/node-libcurl --update-binary --runtime=node --target=24.18.0", "inso-start": "npm start -w insomnia-inso", "inso-package": "npm run build -w insomnia-inso && npm run package -w insomnia-inso", "watch:app": "npm run build:electron-entrypoints -w insomnia && npm run start:dev-server -w insomnia", @@ -83,7 +83,18 @@ } }, "dependencies": { - "@getinsomnia/node-libcurl": "3.2.2", + "@getinsomnia/node-libcurl": "3.3.0", "ajv": "^8.17.1" + }, + "allowScripts": { + "@getinsomnia/node-libcurl@3.3.0": true, + "@scarf/scarf@1.4.0": true, + "electron-winstaller@5.4.0": true, + "esbuild@0.25.12": true, + "esbuild@0.27.5": true, + "fsevents@2.3.2": true, + "fsevents@2.3.3": true, + "node-llama-cpp@3.18.1": true, + "protobufjs@7.6.4": true } } diff --git a/packages/insomnia-data/package.json b/packages/insomnia-data/package.json index 7d357eaa36..6c5d4ec577 100644 --- a/packages/insomnia-data/package.json +++ b/packages/insomnia-data/package.json @@ -41,7 +41,7 @@ "uuid": "^9.0.1" }, "devDependencies": { - "@getinsomnia/node-libcurl": "3.2.2", + "@getinsomnia/node-libcurl": "3.3.0", "@modelcontextprotocol/sdk": "^1.17.5", "@types/deep-equal": "^1.0.4", "mocha": "^11.7.5", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index c27a7a6cbe..2c5b6a30d6 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -59,7 +59,7 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^3.0.2", - "@getinsomnia/node-libcurl": "3.2.2", + "@getinsomnia/node-libcurl": "3.3.0", "@getinsomnia/srp-js": "1.0.0-alpha.1", "@grpc/grpc-js": "^1.14.4", "@grpc/proto-loader": "^0.7.13", @@ -202,7 +202,7 @@ "@types/ws": "^8.18.1", "@vitejs/plugin-react": "5.1.2", "cross-env": "^10.1.0", - "electron": "41.0.3", + "electron": "43.2.0", "electron-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1", "electron-devtools-installer": "^3.2.1", diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts index 3eeab99fd0..d12e969f82 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts @@ -114,6 +114,37 @@ describe('resolveDbByKey requires a valid protocol auth token', () => { }); }); +describe('resolveDbByKey answers CORS preflight and tags responses with CORS headers', () => { + beforeEach(() => { + _testOnlyResetTemplatingDbAuthToken(); + }); + + it('answers an OPTIONS preflight with 204 and CORS headers, without requiring a token', async () => { + const { resolveDbByKey } = await import('../templating-worker-database'); + const req = new Request('insomnia-templating-worker-database://settings.get', { + method: 'OPTIONS', + headers: { Origin: 'https://insomnia-app.local' }, + }); + const res = await resolveDbByKey(req); + expect(res.status).toBe(204); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://insomnia-app.local'); + expect(res.headers.get('Access-Control-Allow-Headers')).toContain(TEMPLATING_DB_AUTH_HEADER); + expect(res.headers.get('Access-Control-Allow-Methods')).toContain('OPTIONS'); + }); + + it('tags an authorized response with an Access-Control-Allow-Origin echoing the request Origin', async () => { + const token = getOrCreateTemplatingDbAuthToken(); + const { resolveDbByKey } = await import('../templating-worker-database'); + const req = new Request('insomnia-templating-worker-database://settings.get', { + method: 'POST', + headers: { [TEMPLATING_DB_AUTH_HEADER]: token, Origin: 'https://insomnia-app.local' }, + body: '{}', + }); + const res = await resolveDbByKey(req); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://insomnia-app.local'); + }); +}); + describe('protocol-dispatch handlers resolve `directory` from the trusted registry, not the request body', () => { let legitDir: string; let foreignDir: string; diff --git a/packages/insomnia/src/main/api.protocol.ts b/packages/insomnia/src/main/api.protocol.ts index 828c6e49d2..ef941e6827 100644 --- a/packages/insomnia/src/main/api.protocol.ts +++ b/packages/insomnia/src/main/api.protocol.ts @@ -28,6 +28,7 @@ export async function registerInsomniaProtocols() { standard: true, supportFetchAPI: true, stream: true, + corsEnabled: true, }, }, { @@ -40,7 +41,7 @@ export async function registerInsomniaProtocols() { }, { scheme: templatingWorkerDatabaseInterface, - privileges: { secure: true, standard: true, supportFetchAPI: true }, + privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true }, }, ]); diff --git a/packages/insomnia/src/main/templating-worker-database.ts b/packages/insomnia/src/main/templating-worker-database.ts index 490b084c60..8292df1d5c 100644 --- a/packages/insomnia/src/main/templating-worker-database.ts +++ b/packages/insomnia/src/main/templating-worker-database.ts @@ -33,11 +33,24 @@ import { isValidTemplatingDbAuthToken, TEMPLATING_DB_AUTH_HEADER } from './templ const bundlePluginModuleMap: Record = {}; +const templatingDbCorsHeaders = (request: Request): Record => ({ + 'Access-Control-Allow-Origin': request.headers.get('Origin') ?? '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': `${TEMPLATING_DB_AUTH_HEADER}, content-type`, + 'Vary': 'Origin', +}); + export const resolveDbByKey = async (request: Request) => { + const cors = templatingDbCorsHeaders(request); + // A CORS preflight never carries the auth header, so answer it before the token check. + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } // Reject before parsing the body, so an unauthenticated call never reaches a handler. if (!isValidTemplatingDbAuthToken(request.headers.get(TEMPLATING_DB_AUTH_HEADER))) { return new Response(JSON.stringify({ error: 'Unauthorized: missing or invalid templating database auth token' }), { status: 401, + headers: cors, }); } const url = new URL(request.url); @@ -45,7 +58,7 @@ export const resolveDbByKey = async (request: Request) => { try { // We expect this to throw if a db call returns undefined body = JSON.parse(await request.text(), stripDangerousKeysReviver); - } catch {} + } catch { } // url get normalized to lowercase, so we need to normalize the keys to lower case as well const withLowercasedKeys = Object.fromEntries( Object.entries(pluginToMainAPI).map(([key, value]) => [key.toLowerCase(), value]), @@ -60,10 +73,10 @@ export const resolveDbByKey = async (request: Request) => { throw new TypeError(`No host bridge handler registered for "${urlHostLowerCase}"`); } const result = await handler(body); - return new Response(JSON.stringify(result)); + return new Response(JSON.stringify(result), { headers: cors }); } catch (err) { console.error(`Error resolving db by key ${urlHostLowerCase}:`, err); - return new Response(JSON.stringify({ error: err.message }), { status: 500 }); + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: cors }); } }; From aed16ec517ca43a097bd6fabef218502a92bd426 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Tue, 28 Jul 2026 20:17:53 +0200 Subject: [PATCH 3/6] feat(templating): (A1) route user-plugin actions through the sandbox (#10293) --- .../fixtures/sandbox-action-collection.yaml | 24 +++++ .../tests/smoke/sandbox-template-tags.test.ts | 95 +++++++++++++++++++ .../insomnia/src/common/templating/types.ts | 1 + .../src/main/templating-worker-database.ts | 60 ++++++++++++ .../insomnia/src/plugins/invoke-method.ts | 15 +++ .../sandbox-surface.test.ts.snap | 2 + .../sandbox/in-sandbox-bootstrap.ts | 25 ++++- .../src/templating/sandbox/marshal.ts | 12 +++ .../templating/sandbox/plugin-tag-sandbox.ts | 10 +- .../sandbox/sandbox-actions.test.ts | 85 +++++++++++++++++ .../src/templating/sandbox/sandbox-surface.ts | 1 + 11 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 packages/insomnia-smoke-test/fixtures/sandbox-action-collection.yaml create mode 100644 packages/insomnia/src/templating/sandbox/sandbox-actions.test.ts diff --git a/packages/insomnia-smoke-test/fixtures/sandbox-action-collection.yaml b/packages/insomnia-smoke-test/fixtures/sandbox-action-collection.yaml new file mode 100644 index 0000000000..8430d21518 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/sandbox-action-collection.yaml @@ -0,0 +1,24 @@ +type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: Sandbox Action Collection +meta: + id: wrk_5a4a100000000000000000000000001 + created: 1751800000000 + modified: 1751800000000 +collection: + - url: http://127.0.0.1:4010/echo + name: Action Probe + meta: + id: req_5a4a100000000000000000000000002 + created: 1751800000001 + modified: 1751800000001 + isPrivate: false + sortKey: -1751800000001 + method: GET + body: + mimeType: text/plain + text: | + {% actionreadback %} + headers: + - name: Content-Type + value: text/plain diff --git a/packages/insomnia-smoke-test/tests/smoke/sandbox-template-tags.test.ts b/packages/insomnia-smoke-test/tests/smoke/sandbox-template-tags.test.ts index 5f1ca82c35..257f9085ea 100644 --- a/packages/insomnia-smoke-test/tests/smoke/sandbox-template-tags.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/sandbox-template-tags.test.ts @@ -813,3 +813,98 @@ test('Response hook sandbox (H1): a user plugin response hook runs in the sandbo ) .toContain('resphook-rewrote-body:ranin-sandboxed'); }); + +test('Plugin action sandbox (A1): a user plugin request action runs in the sandbox and its side effect persists', async ({ + page, + app, + dataPath, + insomnia, +}) => { + // Actions are fire-and-effect — nothing marshals back — so the canary rides the side effect: the + // request action writes which path executed it (INSOMNIA_TEMPLATE_SANDBOX exists only in the + // sandbox) plus whether it received the domain models, into plugin storage; a sibling template tag + // reads that back out. The `storage` capability is declared so context.store is granted on both + // paths. Canary values are chosen so neither is a substring of the other. + writePlugin( + dataPath, + 'insomnia-plugin-action-probe', + { permissions: { capabilities: ['storage'] } }, + ` + module.exports.requestActions = [ + { + label: 'Probe Action', + async action(context, data) { + var ranIn = typeof INSOMNIA_TEMPLATE_SANDBOX !== 'undefined' ? 'ranin-sandboxed' : 'ranin-mainprocess'; + await context.store.setItem('action_ran_in', ranIn); + await context.store.setItem('action_req_id', (data && data.request && data.request._id) || 'no-request'); + }, + }, + ]; + module.exports.templateTags = [ + { + name: 'actionreadback', displayName: 'actionreadback', args: [], + async run(context) { + var ranIn = await context.store.getItem('action_ran_in'); + var reqId = await context.store.getItem('action_req_id'); + return (ranIn || 'unset') + '|' + (reqId === 'req-canary-7t3w' ? 'got-req' : 'no-req'); + }, + }, + ]; + `, + ); + + const fixture = await loadFixture('sandbox-action-collection.yaml'); + await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture); + await clearPluginToast(page); + await page.getByLabel('Import').click(); + await page.locator('[data-test-id="import-from-clipboard"]').click(); + await page.getByRole('button', { name: 'Scan' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); + await page.evaluate(() => (window as any).main.plugins.reloadPlugins()); + + await insomnia.navigationSidebar.clickRequestOrFolder('Action Probe'); + await page.getByText('Body', { exact: true }).click(); + + // Fire the request action directly through the plugin bridge (the same call the request-actions + // dropdown makes), passing domain models so we can prove they reach the action. + const runAction = () => + page.evaluate(() => + (window as any).main.plugins.executeAction({ + type: 'request', + pluginName: 'insomnia-plugin-action-probe', + label: 'Probe Action', + projectId: 'proj_action_probe', + domainData: { request: { _id: 'req-canary-7t3w' } }, + }), + ); + + const readTagPreview = async (tagPrefix: string): Promise => { + await page.locator(`[data-template^="${tagPrefix}"]`).click(); + const modal = page.getByRole('dialog'); + const preview = modal.getByLabel('Live Preview'); + await expect.soft(preview).not.toHaveValue('rendering...'); + const value = (await preview.inputValue()).trim(); + await modal.getByRole('button', { name: 'Done' }).click(); + await expect.soft(modal).toBeHidden(); + return value; + }; + + // Flag OFF (default): the action runs in-process (control). Its write is observable via the tag, + // and the domain models reached it. + await runAction(); + await expect.poll(() => readTagPreview('{% actionreadback'), { timeout: 20_000 }).toContain('ranin-mainprocess|got-req'); + + // Flag ON: the same action now runs in the QuickJS sandbox (its in-process fn is a throw-stub after + // discovery, so a successful write proves routing). Poll run+readback so the just-toggled setting + // has propagated to the main process. + await enableSandbox(page); + await expect + .poll( + async () => { + await runAction(); + return readTagPreview('{% actionreadback'); + }, + { timeout: 25_000 }, + ) + .toContain('ranin-sandboxed|got-req'); +}); diff --git a/packages/insomnia/src/common/templating/types.ts b/packages/insomnia/src/common/templating/types.ts index 6ca969d61f..61327cd8cf 100644 --- a/packages/insomnia/src/common/templating/types.ts +++ b/packages/insomnia/src/common/templating/types.ts @@ -97,6 +97,7 @@ export type PluginToMainAPIPaths = | 'plugin.discoverUserPluginExports' | 'plugin.runUserRequestHook' | 'plugin.runUserResponseHook' + | 'plugin.runUserAction' | 'response.setBody' | 'app.alert' | 'app.dialog' diff --git a/packages/insomnia/src/main/templating-worker-database.ts b/packages/insomnia/src/main/templating-worker-database.ts index 8292df1d5c..b05210d5f0 100644 --- a/packages/insomnia/src/main/templating-worker-database.ts +++ b/packages/insomnia/src/main/templating-worker-database.ts @@ -592,6 +592,47 @@ export const runResponseHookInSandbox = async ( return result.response ?? hookResponse; }; +// A1: run a user plugin's action (matching actionLabel in the actionKind list) inside the sandbox, +// capability-gated. Actions are fire-and-effect — the plugin reads the copied-in domain models and +// performs side effects only through the bridge — so nothing is marshaled back. Reuses the same +// bridge/grants/crypto as tag and hook execution. +export const runActionInSandbox = async ( + plugin: { directory: string; name: string; permissions?: { modules?: string[]; capabilities?: string[] } }, + actionKind: 'request' | 'requestGroup' | 'workspace' | 'document', + actionLabel: string, + actionDomainData: unknown, + renderContext?: Record, +): Promise => { + const { runTagInSandbox } = await import('../templating/sandbox/plugin-tag-sandbox'); + const { resolveTemplateTagModules, resolveTemplateTagCapabilities } = await import( + '../templating/sandbox/surface-profiles' + ); + const grantedCapabilities = resolveTemplateTagCapabilities(plugin.permissions?.capabilities); + const bridge = await buildSandboxBridge(grantedCapabilities); + const { moduleFiles, entryModuleKey } = readPluginModuleMap({ directory: plugin.directory, name: plugin.name }); + await runTagInSandbox({ + tagName: '', + bridge, + hostCrypto: SANDBOX_HOST_CRYPTO, + envelope: { + args: [], + context: renderContext || {}, + meta: {}, + renderPurpose: 'send', + appInfo: { version: app.getVersion(), platform: process.platform, arch: process.arch }, + pluginName: plugin.name, + renderDepth: 0, + grantedModules: resolveTemplateTagModules(plugin.permissions?.modules), + grantedCapabilities, + moduleFiles, + entryModuleKey, + actionKind, + actionLabel, + actionDomainData, + }, + }); +}; + // These are exposed to the templating worker and can be used by plugins from context.util. // Exported so tests can enumerate every registered path. export const pluginToMainAPI: Record Promise> = { @@ -923,6 +964,25 @@ export const pluginToMainAPI: Record P body.renderContext, ); }, + // A1: run a user plugin's action in the sandbox (plugin-window path reaches this over the + // templating-worker-database protocol; the node runtime calls runActionInSandbox directly). + 'plugin.runUserAction': async (body: { + plugin: { directory: string; name: string; permissions?: { modules?: string[]; capabilities?: string[] } }; + actionKind: 'request' | 'requestGroup' | 'workspace' | 'document'; + actionLabel: string; + actionDomainData: unknown; + renderContext?: Record; + }) => { + const trusted = await resolveTrustedPlugin(body.plugin.name); + await runActionInSandbox( + { ...body.plugin, directory: trusted.directory, permissions: trusted.permissions }, + body.actionKind, + body.actionLabel, + body.actionDomainData, + body.renderContext, + ); + return null; + }, // execute a user-installed plugin tag with the given parameters, in the main process where // Node built-ins (e.g. crypto) the plugin requires are available. 'plugin.executeUserPluginTag': async (body: { diff --git a/packages/insomnia/src/plugins/invoke-method.ts b/packages/insomnia/src/plugins/invoke-method.ts index e589d0c557..45f8a9fb3a 100644 --- a/packages/insomnia/src/plugins/invoke-method.ts +++ b/packages/insomnia/src/plugins/invoke-method.ts @@ -137,6 +137,21 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn throw new Error(`[plugin-window] Action not found: ${pluginName}/${label}`); } + // A1: with the sandbox on, a user plugin's action (a throw-stub here after discovery) runs in the + // main-process sandbox over the templating-worker-database protocol; bundle plugins and the + // flag-off path run in-process. + const settings = await fetchFromTemplateWorkerDatabase('settings.get', {}); + const sandboxEnabled = !!settings?.templateTagSandboxEnabled; + if (sandboxEnabled && entry.plugin.directory !== '') { + await fetchFromTemplateWorkerDatabase('plugin.runUserAction', { + plugin: { directory: entry.plugin.directory, name: entry.plugin.name, permissions: entry.plugin.permissions }, + actionKind: type, + actionLabel: label, + actionDomainData: domainData, + }); + return null; + } + const context = { ...pluginApp.init(), ...pluginData.init(projectId), 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 962352a655..5960ce79ae 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 @@ -267,6 +267,8 @@ exports[`sandbox surface > matches surface snapshot 1`] = ` "globalThis.__describeExports.prototype: object", "globalThis.__invoke: function(0)", "globalThis.__invoke.prototype: object", + "globalThis.__invokeAction: function(0)", + "globalThis.__invokeAction.prototype: object", "globalThis.__invokeHook: function(0)", "globalThis.__invokeHook.prototype: object", "globalThis.__loadPluginEntry: function(0)", diff --git a/packages/insomnia/src/templating/sandbox/in-sandbox-bootstrap.ts b/packages/insomnia/src/templating/sandbox/in-sandbox-bootstrap.ts index 72e6f433cd..f4ece5dda1 100644 --- a/packages/insomnia/src/templating/sandbox/in-sandbox-bootstrap.ts +++ b/packages/insomnia/src/templating/sandbox/in-sandbox-bootstrap.ts @@ -522,6 +522,23 @@ export const IN_SANDBOX_BOOTSTRAP = [ ' return Promise.resolve(hook(ctx)).then(function () { return JSON.stringify({ request: env.hookRequest, response: env.hookResponse }); });', ' };', + // --- run a plugin action (A1) --- + // Actions are fire-and-effect: action(context, domainData) returns void and performs side effects + // only through the capability-gated context.* bridge (built by __buildContext). The domain models + // are copied in as plain data; nothing is marshaled back. The action is found by its label within + // the kind-specific list (request/requestGroup/workspace/document Actions). + ' globalThis.__invokeAction = function () {', + ' var env = __env;', + ' var ctx = globalThis.__buildContext(env);', + ' var mod = globalThis.__loadPluginEntry() || {};', + ' var lists = { request: mod.requestActions, requestGroup: mod.requestGroupActions, workspace: mod.workspaceActions, document: mod.documentActions };', + ' var actions = lists[env.actionKind] || [];', + ' var entry = null;', + ' for (var i = 0; i < actions.length; i++) { if (actions[i] && actions[i].label === env.actionLabel) { entry = actions[i]; break; } }', + ' if (!entry || typeof entry.action !== "function") { throw new Error("Plugin " + env.actionKind + " action not found: " + env.actionLabel); }', + ' return Promise.resolve(entry.action(ctx, env.actionDomainData)).then(function () { return "{}"; });', + ' };', + // --- describe the plugin's exports without running any of them (L1 load-time discovery) --- // Evaluates the plugin entry (its top-level code runs here, in the sandbox — never on the host) // and returns a JSON-serializable manifest of what it exports: template-tag metadata, hook counts, @@ -558,7 +575,7 @@ export const IN_SANDBOX_BOOTSTRAP = [ // context/invocation entry points. __registerModule is intentionally left mutable — the module // registry source deletes it once the registry is populated. ' var __lock = function (n) { Object.defineProperty(globalThis, n, { value: globalThis[n], writable: false, configurable: false }); };', - ' __lock("__require"); __lock("__buildContext"); __lock("__invoke"); __lock("__loadPluginEntry"); __lock("__describeExports"); __lock("__invokeHook");', + ' __lock("__require"); __lock("__buildContext"); __lock("__invoke"); __lock("__loadPluginEntry"); __lock("__describeExports"); __lock("__invokeHook"); __lock("__invokeAction");', '})();', ].join('\n'); @@ -576,3 +593,9 @@ export const DESCRIBE_RUNNER = 'globalThis.__task = Promise.resolve(globalThis._ * `globalThis.__task`. Used instead of {@link RUNNER} to run a request/response hook. */ export const HOOK_RUNNER = 'globalThis.__task = globalThis.__invokeHook();'; + +/** + * Action runner (A1): parks a resolved promise on `globalThis.__task` after running the plugin + * action. Actions are fire-and-effect, so the resolved value is an empty JSON object. + */ +export const ACTION_RUNNER = 'globalThis.__task = globalThis.__invokeAction();'; diff --git a/packages/insomnia/src/templating/sandbox/marshal.ts b/packages/insomnia/src/templating/sandbox/marshal.ts index 2ee77e8a7e..2421b6fbc6 100644 --- a/packages/insomnia/src/templating/sandbox/marshal.ts +++ b/packages/insomnia/src/templating/sandbox/marshal.ts @@ -57,6 +57,18 @@ export interface ContextEnvelope { hookRequest?: Record; /** The response patch a response hook may read (getters only; `setBody` bridges to the host). */ hookResponse?: Record; + /** + * Plugin action invocation (A1). When set, the sandbox runs the plugin's action matching + * `actionLabel` in the `actionKind` list (requestActions/requestGroupActions/…) instead of a tag + * or hook. The action reads the copied-in `actionDomainData` (plain models) and performs side + * effects only through the capability-gated `context.*` bridge; it returns void, so nothing is + * marshaled back. + */ + actionKind?: 'request' | 'requestGroup' | 'workspace' | 'document'; + /** The action's `label` (unique per plugin per kind) — how the sandbox finds it in the list. */ + actionLabel?: string; + /** The serializable domain models passed as the action's second argument. */ + actionDomainData?: unknown; } /** What a hook invocation marshals back out of the sandbox: the mutated request/response data. */ diff --git a/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts b/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts index f388b5f813..faf476aa13 100644 --- a/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts +++ b/packages/insomnia/src/templating/sandbox/plugin-tag-sandbox.ts @@ -1,7 +1,7 @@ import type { QuickJSContext, QuickJSHandle } from 'quickjs-emscripten'; import type { HostBridge } from './host-bridge'; -import { DESCRIBE_RUNNER, HOOK_RUNNER, IN_SANDBOX_BOOTSTRAP, RUNNER } from './in-sandbox-bootstrap'; +import { ACTION_RUNNER, DESCRIBE_RUNNER, HOOK_RUNNER, IN_SANDBOX_BOOTSTRAP, RUNNER } from './in-sandbox-bootstrap'; import { type ContextEnvelope, encodeBridgeFailure, encodeBridgeSuccess } from './marshal'; import { buildModuleRegistrySource } from './module-registry'; import { SANDBOX_GLOBALS_SOURCE } from './sandbox-globals'; @@ -97,7 +97,13 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise'); // Plugin source travels as envelope DATA (moduleFiles) and is compiled by the in-sandbox loader // when __invoke()/__describeExports()/__invokeHook() loads the entry — no host-side eval. - const runner = envelope.hookKind ? HOOK_RUNNER : discover ? DESCRIBE_RUNNER : RUNNER; + const runner = envelope.actionKind + ? ACTION_RUNNER + : envelope.hookKind + ? HOOK_RUNNER + : discover + ? DESCRIBE_RUNNER + : RUNNER; evalOrThrow(ctx, runner, ''); return await drivePromiseToString(ctx, timeoutMs); diff --git a/packages/insomnia/src/templating/sandbox/sandbox-actions.test.ts b/packages/insomnia/src/templating/sandbox/sandbox-actions.test.ts new file mode 100644 index 0000000000..f2192af2df --- /dev/null +++ b/packages/insomnia/src/templating/sandbox/sandbox-actions.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { type HostBridge } from './host-bridge'; +import { type ContextEnvelope } from './marshal'; +import { runTagInSandbox } from './plugin-tag-sandbox'; + +// A1: plugin actions run inside the sandbox. An action is fire-and-effect — it reads the copied-in +// domain models and performs side effects only through the capability-gated context.* bridge; nothing +// is marshaled back. These tests drive the action path directly with a hand-built envelope. + +const runAction = async ( + actionSource: string, + opts: { + kind: NonNullable; + label: string; + domainData?: unknown; + grantedCapabilities?: string[]; + }, +): Promise<{ path: string; body: any }[]> => { + const calls: { path: string; body: any }[] = []; + const bridge: HostBridge = async (path, body) => { + calls.push({ path, body }); + if (path === 'pluginData.setItem') { + return null; + } + throw new Error(`unexpected bridge call: ${path}`); + }; + const envelope: ContextEnvelope = { + args: [], + context: {}, + meta: {}, + renderPurpose: 'send', + appInfo: { version: '0.0.0', platform: 'linux', arch: 'x64' }, + pluginName: 'test-plugin', + renderDepth: 0, + grantedModules: ['path', 'crypto'], + grantedCapabilities: opts.grantedCapabilities ?? [], + moduleFiles: { 'index.js': `module.exports.${opts.kind}Actions = [${actionSource}];` }, + entryModuleKey: 'index.js', + actionKind: opts.kind, + actionLabel: opts.label, + actionDomainData: opts.domainData, + }; + await runTagInSandbox({ tagName: '', envelope, bridge }); + return calls; +}; + +describe('plugin actions in the sandbox (A1)', () => { + it('runs the action matching the label, passing domain models, with side effects via a granted capability', async () => { + const calls = await runAction( + `{ label: 'Wrong', action: function () { throw new Error('should not run'); } }, + { label: 'Store It', action: async function (context, models) { + await context.store.setItem('acted-on', models.request._id); + } }`, + { kind: 'request', label: 'Store It', domainData: { request: { _id: 'req_42' } }, grantedCapabilities: ['storage'] }, + ); + const setItem = calls.find(c => c.path === 'pluginData.setItem'); + expect(setItem).toBeTruthy(); + expect(setItem!.body).toMatchObject({ key: 'acted-on', value: 'req_42' }); + }); + + it('denies an ungranted capability (context.network absent when not granted)', async () => { + await expect( + runAction(`{ label: 'Net', action: async function (context) { await context.network.sendRequest({}); } }`, { + kind: 'request', + label: 'Net', + grantedCapabilities: [], + }), + ).rejects.toThrow(/sendRequest/i); + }); + + it('throws a clear error when no action matches the label', async () => { + await expect( + runAction(`{ label: 'Exists', action: function () {} }`, { kind: 'request', label: 'Missing' }), + ).rejects.toThrow(/action not found: Missing/i); + }); + + it('resolves the kind-specific list (documentActions) independently', async () => { + const calls = await runAction( + `{ label: 'Doc', action: async function (context, spec) { await context.store.setItem('doc', spec.name); } }`, + { kind: 'document', label: 'Doc', domainData: { name: 'my-spec' }, grantedCapabilities: ['storage'] }, + ); + expect(calls.find(c => c.path === 'pluginData.setItem')!.body).toMatchObject({ key: 'doc', value: 'my-spec' }); + }); +}); diff --git a/packages/insomnia/src/templating/sandbox/sandbox-surface.ts b/packages/insomnia/src/templating/sandbox/sandbox-surface.ts index 726ad5fc41..e81adc8e93 100644 --- a/packages/insomnia/src/templating/sandbox/sandbox-surface.ts +++ b/packages/insomnia/src/templating/sandbox/sandbox-surface.ts @@ -224,6 +224,7 @@ export const SANDBOX_INTERNAL_GLOBALS = [ '__buildContext', '__describeExports', '__invoke', + '__invokeAction', '__invokeHook', '__loadPluginEntry', '__require', From c74e2d6c45b7ca2f77e73f1bf1baae5ababd9557 Mon Sep 17 00:00:00 2001 From: Ryan Willis Date: Tue, 28 Jul 2026 20:24:32 -0700 Subject: [PATCH 4/6] fix: consolidate user-agent disable logic (#10310) --- packages/insomnia/src/common/render.ts | 9 ++---- .../src/network/__tests__/network.test.ts | 32 +++++++++++++++++++ packages/insomnia/src/network/network.ts | 24 ++++++++++++-- .../src/ui/utils/render-realtime-connect.ts | 9 ++---- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/packages/insomnia/src/common/render.ts b/packages/insomnia/src/common/render.ts index db7685c94b..e190ef19e7 100644 --- a/packages/insomnia/src/common/render.ts +++ b/packages/insomnia/src/common/render.ts @@ -27,7 +27,7 @@ import type { import * as templatingUtils from '~/common/templating/utils'; import { setDefaultProtocol } from '~/common/utils/url/protocol'; -import { getOrInheritAuthentication, getOrInheritHeaders } from '../network/network'; +import { getOrInheritAuthentication, getOrInheritHeaders, shouldSuppressUserAgent } from '../network/network'; import { getRuntime } from '../runtimes'; import { CONTENT_TYPE_GRAPHQL, JSON_ORDER_SEPARATOR } from './constants'; import { database as db } from './database'; @@ -581,6 +581,7 @@ export async function getRenderedRequestAndContext({ const description = request.description; request.description = ''; + const suppressUserAgent = shouldSuppressUserAgent({ request, requestGroups }); request.headers = getOrInheritHeaders({ request, requestGroups }); request.authentication = getOrInheritAuthentication({ request, requestGroups }); // Render all request properties @@ -599,12 +600,6 @@ export async function getRenderedRequestAndContext({ const renderedRequest = renderResult._request; const renderedCookieJar = renderResult._cookieJar; renderedRequest.description = await render(description, renderContext, null, 'keep'); - const userAgentHeaders = request.headers.filter(h => h.name.toLowerCase() === 'user-agent'); - const hasUserAgentHeader = userAgentHeaders.length > 0; - const allUserAgentHeadersDisabled = hasUserAgentHeader && userAgentHeaders.every(h => h.disabled === true); - // Suppress the default User-Agent when the request opts out via disableUserAgentHeader, - // or when the user added their own User-Agent header(s) and disabled all of them. - const suppressUserAgent = request.disableUserAgentHeader || allUserAgentHeadersDisabled; // Remove disabled params renderedRequest.parameters = renderedRequest.parameters.filter(p => !p.disabled); // Remove disabled headers diff --git a/packages/insomnia/src/network/__tests__/network.test.ts b/packages/insomnia/src/network/__tests__/network.test.ts index cfa5b806e6..9733766db7 100644 --- a/packages/insomnia/src/network/__tests__/network.test.ts +++ b/packages/insomnia/src/network/__tests__/network.test.ts @@ -1169,3 +1169,35 @@ describe('getOrInheritHeaders', () => { expect(networkUtils.getOrInheritHeaders({ request, requestGroups })).toEqual([]); }); }); + +describe('getRenderedRequest suppressUserAgent', () => { + it('suppresses default user-agent when a custom user-agent header is disabled', async () => { + const workspace = await services.workspace.create(); + const request = Object.assign(models.request.init(), { + _id: 'req_disabled_ua', + parentId: workspace._id, + url: 'http://localhost', + headers: [{ name: 'User-Agent', value: 'custom-xx', disabled: true }], + }); + + expect((await getRenderedRequest({ request })).suppressUserAgent).toBe(true); + }); + + it('suppresses default user-agent when all inherited user-agent headers are disabled', async () => { + const workspace = await services.workspace.create(); + const folder = await services.requestGroup.create({ + _id: 'fld_disabled_ua', + name: 'Disabled UA Folder', + parentId: workspace._id, + metaSortKey: 0, + headers: [{ name: 'User-Agent', value: 'custom-xx', disabled: true }], + }); + const request = Object.assign(models.request.init(), { + _id: 'req_inherited_disabled_ua', + parentId: folder._id, + url: 'http://localhost', + }); + + expect((await getRenderedRequest({ request })).suppressUserAgent).toBe(true); + }); +}); diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index cb344fda80..cd6ad3a8c7 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -111,6 +111,24 @@ export function getOrInheritHeaders({ .sort(ascendingFirstIndexStringSort) .map(([name, value]) => ({ name: originalCaseMap.get(name)!, value })); } + +// Determine whether the default User-Agent should be suppressed (explicitly disabled, or when all custom User-Agent headers are disabled). +export function shouldSuppressUserAgent({ + request, + requestGroups = [], +}: { + request: Pick; + requestGroups?: Pick[]; +}): boolean { + const userAgentHeaders = [...requestGroups, request] + .flatMap(doc => doc.headers || []) + .filter(h => h.name?.toLowerCase() === 'user-agent'); + return ( + Boolean(request.disableUserAgentHeader) || + (userAgentHeaders.length > 0 && userAgentHeaders.every(h => h.disabled)) + ); +} + // (only used for getOAuth2 token) Intended to gather all required database objects and initialize ids export const fetchRequestGroupData = async (requestGroupId: string) => { const requestGroup = await services.requestGroup.getById(requestGroupId); @@ -544,9 +562,9 @@ const tryToExecuteScript = async (context: RequestAndContextAndOptionalResponse) }, iterationData: userUploadEnvironment ? { - name: userUploadEnvironment.name, - data: userUploadEnvironment.data || {}, - } + name: userUploadEnvironment.name, + data: userUploadEnvironment.data || {}, + } : undefined, execution: { ...execution, // keep some existing properties in the after-response script from the pre-request script diff --git a/packages/insomnia/src/ui/utils/render-realtime-connect.ts b/packages/insomnia/src/ui/utils/render-realtime-connect.ts index 2bbcbbc21d..64582c14cf 100644 --- a/packages/insomnia/src/ui/utils/render-realtime-connect.ts +++ b/packages/insomnia/src/ui/utils/render-realtime-connect.ts @@ -2,7 +2,7 @@ import type { CookieJar, Request, RequestAuthentication, RequestGroup, RequestHe import { models, services } from 'insomnia-data'; import { database as db } from '../../common/database'; -import { getOrInheritAuthentication, getOrInheritHeaders } from '../../network/network'; +import { getOrInheritAuthentication, getOrInheritHeaders, shouldSuppressUserAgent } from '../../network/network'; import { tryToInterpolateRequestOrShowRenderErrorModal } from './try-interpolate'; const { applyPathParametersToUrl } = models.request; @@ -52,12 +52,7 @@ export async function renderRealtimeConnectPayload({ return undefined; } - // getOrInheritHeaders drops disabled headers, so disabled User-Agent headers are only visible on the raw request/folder headers - const userAgentHeaders = [...requestGroups, request] - .flatMap(doc => doc.headers || []) - .filter(h => h.name?.toLowerCase() === 'user-agent'); - const suppressUserAgent = - Boolean(request.disableUserAgentHeader) || (userAgentHeaders.length > 0 && userAgentHeaders.every(h => h.disabled)); + const suppressUserAgent = shouldSuppressUserAgent({ request, requestGroups }); const url = applyPathParametersToUrl(rendered.url, rendered.pathParameters); From 2b16d37dda58205aae278d4645ef96c7dbb28cb3 Mon Sep 17 00:00:00 2001 From: Bingbing Date: Fri, 31 Jul 2026 09:06:28 +0800 Subject: [PATCH 5/6] chore: hide 'generate-docs' command and skip hidden commands in documentation generation (#10314) --- packages/insomnia-inso/src/cli.ts | 2 +- packages/insomnia-inso/src/scripts/docs.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/insomnia-inso/src/cli.ts b/packages/insomnia-inso/src/cli.ts index 9e8c17a85f..b6804bdf4c 100644 --- a/packages/insomnia-inso/src/cli.ts +++ b/packages/insomnia-inso/src/cli.ts @@ -1041,7 +1041,7 @@ export const go = (args?: string[]) => { program.parseAsync(scriptArgs).catch(logErrorAndExit); }); - program.command('generate-docs').action(() => { + program.command('generate-docs', { hidden: true }).action(() => { generateDocumentation(program); return process.exit(1); }); diff --git a/packages/insomnia-inso/src/scripts/docs.ts b/packages/insomnia-inso/src/scripts/docs.ts index 2cf56d4b92..2155e24671 100644 --- a/packages/insomnia-inso/src/scripts/docs.ts +++ b/packages/insomnia-inso/src/scripts/docs.ts @@ -90,7 +90,8 @@ export function generateDocumentation(program: commander.Command): void { const allCommands: any[] = []; - program.commands.forEach(command => { + program.commands.forEach((command: commander.Command & { _hidden?: boolean }) => { + if (command._hidden) return; // Skip hidden commands const commandData = generateCommandMarkdown(command, program.options, ''); allCommands.push({ From dbca2b1654866e8dce836e723e1bdf21168da1c5 Mon Sep 17 00:00:00 2001 From: kwburns-kong Date: Fri, 31 Jul 2026 02:01:42 -0400 Subject: [PATCH 6/6] fix(templating): re-verify symlink containment on response.setBody writes (#10301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(templating): (A1) in-sandbox plugin-action API + marshaling Adds the sandbox core for routing user-plugin actions (request/requestGroup/ workspace/document) through the QuickJS sandbox, mirroring the H1 hook path. - marshal.ts: envelope gains actionKind/actionLabel/actionDomainData. An action is fire-and-effect — it reads the copied-in domain models and performs side effects only through the capability-gated context.* bridge; nothing marshals back. - in-sandbox-bootstrap.ts: __invokeAction() rebuilds context via __buildContext, resolves the kind-specific action list, finds the entry by label, and calls action(context, actionDomainData); ACTION_RUNNER drives it. Locked alongside the other internals. - plugin-tag-sandbox.ts: runner selection picks ACTION_RUNNER when actionKind is set. - sandbox-surface.ts + snapshot: __invokeAction added to the reviewed internal set. - sandbox-actions.test.ts: label match + domainData passthrough, capability gating (ungranted context.network absent), missing-label error, kind-specific list. Host wiring (plugin.runUserAction handler, invoke-method routing, e2e) follows. * feat(templating): (A1) route user-plugin actions through the sandbox Host + plugin-window wiring for the in-sandbox action API. - templating-worker-database.ts: runActionInSandbox() mirrors runRequestHookInSandbox (same bridge/grants/crypto), building an action envelope; nothing is marshaled back. New protocol handler plugin.runUserAction resolves the plugin via resolveTrustedPlugin (registry-trusted directory + permissions, not caller-supplied) before dispatch. - types.ts: PluginToMainAPIPaths gains 'plugin.runUserAction'. - invoke-method.ts (plugin window): executeAction routes user plugins (directory !== '') through plugin.runUserAction when the sandbox is enabled; bundle plugins and the flag-off path stay in-process. Tag actions (templateTag.actions) are unchanged — the L1 manifest does not discover them, so they remain a flag-off surface. Actions are UI-triggered only, so there is no node-runtime/inso path to gate. * test(templating): (A1) e2e — user-plugin request action runs in the sandbox A request action writes a path canary (INSOMNIA_TEMPLATE_SANDBOX) plus whether it received the domain models into plugin storage; a sibling template tag reads it back (actions are fire-and-effect, so the canary rides the side effect). Flag off → the action runs in-process; flag on → the same action runs in the sandbox (its in-process fn is a throw-stub after discovery, so a successful write proves routing) and the domain models still reach it. Asserts on unique canary values, not header names. * fix(templating): re-verify symlink containment on response.setBody writes Mirrors the realpath re-check getPluginEntrySource/readPluginModuleMap already apply, so a symlinked directory under responses/ can't redirect a write outside it despite the string-based containment check passing. --------- Co-authored-by: jackkav --- ...er-database-protocol-authorization.test.ts | 41 +++++++++++++++++++ .../src/main/templating-worker-database.ts | 6 +++ 2 files changed, 47 insertions(+) diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts index d12e969f82..7c23b1eee5 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts @@ -514,6 +514,19 @@ describe('plugin.runUserResponseHook: setBody cannot redirect its write onto a d expect(fs.readFileSync(otherBodyPath, 'utf8')).toBe('other-original-body'); }); + it('rejects a bodyPath reached through a symlinked directory that resolves outside the responses directory', async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-outside-')); + const linkedDir = path.join(responsesDir, 'linked'); + fs.symlinkSync(outsideDir, linkedDir, 'dir'); + const throughSymlink = path.join(linkedDir, 'escaped-body.txt'); + try { + await runResponseHook(throughSymlink, 'req_1'); + expect(fs.existsSync(path.join(outsideDir, 'escaped-body.txt'))).toBe(false); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + // Same check, reached by sending the path-normalization variant directly to response.setBody. it('rejects a path-normalization variant sent directly to response.setBody', async () => { const { services } = await import('insomnia-data'); @@ -539,6 +552,34 @@ describe('plugin.runUserResponseHook: setBody cannot redirect its write onto a d expect(json.error).toMatch(/belongs to a different response/); expect(fs.readFileSync(otherBodyPath, 'utf8')).toBe('other-original-body'); }); + + // Same check, reached by sending a bodyPath through a symlinked directory directly to response.setBody. + it('rejects a bodyPath through a symlinked directory sent directly to response.setBody', async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-outside-')); + const linkedDir = path.join(responsesDir, 'linked'); + fs.symlinkSync(outsideDir, linkedDir, 'dir'); + const throughSymlink = path.join(linkedDir, 'escaped-body.txt'); + const token = getOrCreateTemplatingDbAuthToken(); + const { resolveDbByKey } = await import('../templating-worker-database'); + try { + const req = new Request('insomnia-templating-worker-database://response.setbody', { + method: 'POST', + headers: { [TEMPLATING_DB_AUTH_HEADER]: token }, + body: JSON.stringify({ + bodyPath: throughSymlink, + bodyBase64: Buffer.from('replacement-body', 'utf8').toString('base64'), + parentId: 'req_1', + }), + }); + const res = await resolveDbByKey(req); + expect(res.status).toBe(500); + const json = await res.json(); + expect(json.error).toMatch(/escapes the responses directory/); + expect(fs.existsSync(path.join(outsideDir, 'escaped-body.txt'))).toBe(false); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); }); describe('discoverPluginExportsInSandbox strips dangerous JSON keys, symmetric with the hook path', () => { diff --git a/packages/insomnia/src/main/templating-worker-database.ts b/packages/insomnia/src/main/templating-worker-database.ts index b05210d5f0..0f48408248 100644 --- a/packages/insomnia/src/main/templating-worker-database.ts +++ b/packages/insomnia/src/main/templating-worker-database.ts @@ -707,6 +707,12 @@ export const pluginToMainAPI: Record P if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('response.setBody path escapes the responses directory'); } + // Re-check after resolving symlinks, mirroring getPluginEntrySource/readPluginModuleMap: a + // symlinked directory entry under responsesDir could otherwise resolve outside it despite the + // string-based check above passing. + if (!isContainedIn(fs.realpathSync(responsesDir), fs.realpathSync(path.dirname(target)))) { + throw new Error('response.setBody path escapes the responses directory'); + } fs.writeFileSync(target, Buffer.from(body.bodyBase64, 'base64')); return null; },