Merge branch 'develop' into feat/utility-data-process

This commit is contained in:
Bingbing
2026-07-31 15:23:01 +08:00
committed by GitHub
25 changed files with 556 additions and 134 deletions

2
.nvmrc
View File

@@ -1 +1 @@
24.14.0
24.18.0

149
package-lock.json generated
View File

@@ -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",

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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);
});

View File

@@ -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({

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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<string> => {
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');
});

View File

@@ -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",

View File

@@ -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

View File

@@ -97,6 +97,7 @@ export type PluginToMainAPIPaths =
| 'plugin.discoverUserPluginExports'
| 'plugin.runUserRequestHook'
| 'plugin.runUserResponseHook'
| 'plugin.runUserAction'
| 'response.setBody'
| 'app.alert'
| 'app.dialog'

View File

@@ -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;
@@ -483,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');
@@ -508,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', () => {

View File

@@ -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 },
},
]);

View File

@@ -33,11 +33,24 @@ import { isValidTemplatingDbAuthToken, TEMPLATING_DB_AUTH_HEADER } from './templ
const bundlePluginModuleMap: Record<string, Plugin['module']> = {};
const templatingDbCorsHeaders = (request: Request): Record<string, string> => ({
'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 });
}
};
@@ -579,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<string, any>,
): Promise<void> => {
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<PluginToMainAPIPaths, (...args: any[]) => Promise<any>> = {
@@ -653,6 +707,12 @@ export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => 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;
},
@@ -910,6 +970,25 @@ export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => 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<string, any>;
}) => {
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: {

View File

@@ -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);
});
});

View File

@@ -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<Request | WebSocketRequest | SocketIORequest, 'headers' | 'disableUserAgentHeader'>;
requestGroups?: Pick<RequestGroup, 'headers'>[];
}): 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

View File

@@ -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),

View File

@@ -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)",

View File

@@ -537,6 +537,23 @@ export const IN_SANDBOX_BOOTSTRAP = [
' });',
' };',
// --- 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,
@@ -573,7 +590,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');
@@ -591,3 +608,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();';

View File

@@ -57,6 +57,18 @@ export interface ContextEnvelope {
hookRequest?: Record<string, unknown>;
/** The response patch a response hook may read (getters only; `setBody` bridges to the host). */
hookResponse?: Record<string, unknown>;
/**
* 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. */

View File

@@ -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<str
evalOrThrow(ctx, buildModuleRegistrySource(fullEnvelope.grantedModules), '<sandbox-modules>');
// 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, '<runner>');
return await drivePromiseToString(ctx, timeoutMs);

View File

@@ -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<ContextEnvelope['actionKind']>;
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' });
});
});

View File

@@ -224,6 +224,7 @@ export const SANDBOX_INTERNAL_GLOBALS = [
'__buildContext',
'__describeExports',
'__invoke',
'__invokeAction',
'__invokeHook',
'__loadPluginEntry',
'__require',

View File

@@ -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);