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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/insomnia-data/node-src/services/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export function getById(id: string) {
return db.findOne<Response>(type, { _id: id });
}

// Finds whichever response already owns a given on-disk body file, to verify ownership before overwrite.
export function getByBodyPath(bodyPath: string) {
return db.findOne<Response>(type, { bodyPath });
}

export function findByParentId(parentId: string) {
return db.find<Response>(type, { parentId: parentId });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { expect, type Page } from '@playwright/test';

import { loadFixture } from '../../playwright/paths';
import { test } from '../../playwright/test';

// response.getBodyBuffer sits behind the models.read capability, which is granted to every template
// tag at baseline with no manifest declaration. This restricts it (via assertResponseBodyPathReadOwnership)
// to bodyPaths that belong to an already-persisted response.

const PLUGIN_NAME = 'insomnia-plugin-bodypath-read-scope';
const OUTSIDE_CONTENTS = 'sandbox-bodypath-read-scope-check-9f3c1a';

// Installs a template tag with no manifest permissions that reads a path via the baseline-granted
// context.util.models.response.getBodyBuffer bridge call.
const installReadPathPlugin = (dataPath: string) => {
const pluginDir = path.join(dataPath, 'plugins', PLUGIN_NAME);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'package.json'),
JSON.stringify({ name: PLUGIN_NAME, version: '1.0.0', main: 'index.js', insomnia: {} }),
);
fs.writeFileSync(
path.join(pluginDir, 'index.js'),
`
module.exports.templateTags = [
{
name: 'readpath',
displayName: 'Read Path',
description: 'Reads a path via response.getBodyBuffer with no declared permissions',
args: [{ displayName: 'Path', type: 'string', defaultValue: '' }],
async run(context, filePath) {
var raw = await context.util.models.response.getBodyBuffer({ bodyPath: filePath });
var bytes = (raw && raw.data) || [];
var out = '';
for (var i = 0; i < bytes.length; i++) { out += String.fromCharCode(bytes[i]); }
return out;
},
},
];
`,
);
};

const clearPluginToast = async (page: Page) => {
await page.getByLabel('Import').waitFor();
await page.evaluate(() => localStorage.setItem('plugin-system-changes-toast-shown', 'true'));
const dismissButtons = page.getByRole('button', { name: 'Dismiss' });
await dismissButtons
.first()
.waitFor({ timeout: 3000 })
.catch(() => {});
const deadline = Date.now() + 5000;
while (Date.now() < deadline && (await dismissButtons.count()) > 0) {
await dismissButtons.first().click({ force: true, timeout: 500 }).catch(() => {});
}
};

// Enables the sandbox via the real Preferences → Scripting UI toggle, not a pre-set settings object.
const enableSandbox = async (page: Page) => {
await page.getByTestId('settings-button').click();
const sandboxToggle = page.getByTestId('toggle-template-tag-sandbox');
await page.getByRole('tab', { name: 'Scripting' }).click();
await sandboxToggle.getByRole('switch').waitFor();
await sandboxToggle.click();
await expect.soft(sandboxToggle.getByRole('switch')).toBeChecked();
await page.locator('.app').press('Escape');
await expect.soft(page.getByTestId('toggle-template-tag-sandbox')).toBeHidden();
};

test('response.getBodyBuffer restricts a zero-permission template tag to known response bodyPaths', async ({
page,
app,
dataPath,
insomnia,
}) => {
// A file outside the Insomnia data directory, unrelated to any response.
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-outside-app-data-'));
const outsidePath = path.join(outsideDir, 'file.txt');
fs.writeFileSync(outsidePath, OUTSIDE_CONTENTS);

try {
installReadPathPlugin(dataPath);
await clearPluginToast(page);

const fixture = (await loadFixture('sandbox-probe-collection.yaml')).replace(
/\{% sandboxprobe 'e2e' %\}[\s\S]*/,
`{% readpath '${outsidePath.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' %}\n`,
);
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
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('Sandbox Probe');
await page.getByText('Body', { exact: true }).click();

await enableSandbox(page);

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

// The just-toggled setting needs a moment to propagate to the main process before the tag runs
// in the sandbox — poll until it does, then assert the read is refused.
await expect.poll(() => readTagPreview('{% readpath'), { timeout: 20_000 }).toContain('does not belong to any known response');
} finally {
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -760,3 +760,56 @@ test('Request hook sandbox (H1): a user plugin request hook runs in the sandbox
// The second header mutation also round-tripped through the sandbox and reached the wire.
await expect.soft(responsePane).toContainText('hook-marker-9k2x');
});

test('Response hook sandbox (H1): a user plugin response hook runs in the sandbox and rewrites the body', async ({
page,
app,
dataPath,
insomnia,
}) => {
// The response hook overwrites the response body with a canary that reports where it ran (the
// sandbox sets INSOMNIA_TEMPLATE_SANDBOX). setBody goes through the host bridge (base64) to the
// response's body file, so the rewritten body shows in the response pane.
writePlugin(
dataPath,
'insomnia-plugin-resp-hook-probe',
{},
`
module.exports.responseHooks = [
function (context) {
var ranIn = typeof INSOMNIA_TEMPLATE_SANDBOX !== 'undefined' ? 'ranin-sandboxed' : 'ranin-mainprocess';
context.response.setBody('resphook-rewrote-body:' + ranIn + ':status-' + context.response.getStatusCode());
},
];
`,
);

const fixture = await loadFixture('sandbox-hook-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('Hook Request');
const responsePane = page.getByTestId('response-pane');

// Flag OFF (default): the response hook runs in-process (control) and rewrites the body.
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200');
await expect.soft(responsePane).toContainText('resphook-rewrote-body:ranin-mainprocess');

// Flag ON: the same hook now runs in the QuickJS sandbox; setBody bridges the new body to disk.
await enableSandbox(page);
await expect
.poll(
async () => {
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
return (await responsePane.textContent()) || '';
},
{ timeout: 25_000 },
)
.toContain('resphook-rewrote-body:ranin-sandboxed');
});
4 changes: 3 additions & 1 deletion packages/insomnia/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@
"sandbox:vendored:upgrade": "esr ./scripts/upgrade-sandbox-vendored.ts",
"sandbox:vendored:test-regression": "vitest run --config vitest.sandbox-vendored-regression.config.ts",
"sandbox:surface": "vitest run src/templating/sandbox/print-sandbox-surface.test.ts",
"sandbox:surface:test": "vitest run src/templating/sandbox/sandbox-surface.test.ts"
"sandbox:surface:test": "vitest run src/templating/sandbox/sandbox-surface.test.ts",
"sandbox:bridge-trust": "vitest run src/main/__tests__/print-templating-worker-database-surface.test.ts",
"sandbox:bridge-trust:test": "vitest run src/main/__tests__/templating-worker-database-surface.test.ts"
},
"dependencies": {
"@apideck/better-ajv-errors": "^0.3.6",
Expand Down
2 changes: 2 additions & 0 deletions packages/insomnia/src/common/templating/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export type PluginToMainAPIPaths =
| 'plugin.executeUserPluginTag'
| 'plugin.discoverUserPluginExports'
| 'plugin.runUserRequestHook'
| 'plugin.runUserResponseHook'
| 'response.setBody'
| 'app.alert'
| 'app.dialog'
| 'app.prompt'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect, it, vi } from 'vitest';

import { describeHandlerSurface, formatHandlerSurfaceEntries } from '../templating-worker-database-surface';

vi.mock('electron', () => ({
app: { getVersion: () => '1.0.0', getPath: () => '/fake/userData' },
clipboard: { readText: vi.fn(), writeText: vi.fn(), clear: vi.fn() },
dialog: { showMessageBox: vi.fn() },
shell: { openExternal: vi.fn() },
BrowserWindow: { getAllWindows: () => [] },
}));
vi.mock('insomnia-data', () => ({
services: {
request: { getById: vi.fn() },
workspace: { getById: vi.fn() },
oAuth2Token: { getByParentId: vi.fn() },
cookieJar: { getOrCreateForParentId: vi.fn() },
response: { getLatestForRequestId: vi.fn(), getByBodyPath: vi.fn() },
helpers: { getResponseBodyBuffer: vi.fn() },
pluginData: { getByKey: vi.fn(), upsertByKey: vi.fn(), removeByKey: vi.fn(), removeAll: vi.fn(), all: vi.fn() },
cloudCredential: { getById: vi.fn(), update: vi.fn() },
settings: { get: vi.fn().mockResolvedValue({}) },
},
}));
vi.mock('~/plugins', () => ({
getPluginCommonContext: vi.fn(),
getTemplateTags: vi.fn().mockResolvedValue([]),
getPlugins: vi.fn().mockResolvedValue([]),
}));
vi.mock('~/common/cookies', () => ({ jarFromCookies: vi.fn() }));
vi.mock('../common/database', () => ({ database: {} }));
vi.mock('../network/network', () => ({
fetchRequestData: vi.fn(),
sendCurlAndWriteTimeline: vi.fn(),
tryToInterpolateRequest: vi.fn(),
}));
vi.mock('../network/libcurl-promise', () => ({ curlRequest: vi.fn() }));
vi.mock('../prompt-bridge', () => ({ requestPromptFromRenderer: vi.fn() }));
vi.mock('../secure-read-file', () => ({ secureReadFile: vi.fn() }));

// Not a regression check — prints the bridge-trust surface for eyeballing. Run via
// `npm run sandbox:bridge-trust`.
it('prints the protocol-dispatch bridge-trust surface', async () => {
const { pluginToMainAPI } = await import('../templating-worker-database');
const entries = describeHandlerSurface(pluginToMainAPI);
const lines = formatHandlerSurfaceEntries(entries);
console.log(lines.join('\n'));
console.log(`\n${lines.length} handlers`);
expect(lines.length).toBeGreaterThan(0);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { describe, expect, it, vi } from 'vitest';

// response.getBodyBuffer is response.setBody's read-side sibling; #10294 added an ownership check
// (assertResponseBodyPathOwnership) only to the write side. assertResponseBodyPathReadOwnership
// covers the read side: it restricts response.getBodyBuffer to bodyPaths that belong to an
// already-persisted response.

vi.mock('electron', () => ({
app: { getVersion: () => '1.0.0', getPath: () => '/fake/userData' },
clipboard: { readText: vi.fn(), writeText: vi.fn(), clear: vi.fn() },
dialog: { showMessageBox: vi.fn() },
shell: { openExternal: vi.fn() },
BrowserWindow: { getAllWindows: () => [] },
}));
vi.mock('insomnia-data', async () => {
// Delegate to the real implementation so this test exercises an actual file read, not a mock.
const nodeReal = await vi.importActual<{ servicesNodeImpl: { helpers: { getResponseBodyBuffer: (...args: any[]) => any } } }>('insomnia-data/node');
return {
services: {
request: { getById: vi.fn() },
workspace: { getById: vi.fn() },
oAuth2Token: { getByParentId: vi.fn() },
cookieJar: { getOrCreateForParentId: vi.fn() },
response: { getLatestForRequestId: vi.fn(), getByBodyPath: vi.fn() },
helpers: { getResponseBodyBuffer: nodeReal.servicesNodeImpl.helpers.getResponseBodyBuffer },
pluginData: { getByKey: vi.fn(), upsertByKey: vi.fn(), removeByKey: vi.fn(), removeAll: vi.fn(), all: vi.fn() },
cloudCredential: { getById: vi.fn(), update: vi.fn() },
settings: { get: vi.fn().mockResolvedValue({}) },
},
};
});
vi.mock('~/plugins', () => ({
getPluginCommonContext: vi.fn(),
getTemplateTags: vi.fn().mockResolvedValue([]),
getPlugins: vi.fn().mockResolvedValue([]),
}));
vi.mock('~/common/cookies', () => ({ jarFromCookies: vi.fn() }));
vi.mock('../common/database', () => ({ database: {} }));
vi.mock('../network/network', () => ({
fetchRequestData: vi.fn(),
sendCurlAndWriteTimeline: vi.fn(),
tryToInterpolateRequest: vi.fn(),
}));
vi.mock('../network/libcurl-promise', () => ({ curlRequest: vi.fn() }));
vi.mock('../prompt-bridge', () => ({ requestPromptFromRenderer: vi.fn() }));
vi.mock('../secure-read-file', () => ({ secureReadFile: vi.fn() }));

describe('response.getBodyBuffer restricts reads to bodyPaths owned by a known response', () => {
let outsidePath: string;
const OUTSIDE_CONTENTS = 'unrelated-file-contents';

const writeOutsideFile = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-outside-responses-'));
const file = path.join(dir, 'file.txt');
fs.writeFileSync(file, OUTSIDE_CONTENTS);
return file;
};

it('rejects a bodyPath that belongs to no known response', async () => {
outsidePath = writeOutsideFile();
const { pluginToMainAPI } = await import('../templating-worker-database');
await expect(
pluginToMainAPI['response.getBodyBuffer']({ response: { bodyPath: outsidePath } }),
).rejects.toThrow(/does not belong to any known response/);
});

it('allows reading the body of a response that owns its bodyPath', async () => {
outsidePath = writeOutsideFile();
const { services } = await import('insomnia-data');
// mockResolvedValueOnce: must not leak into later tests, which rely on getByBodyPath's default
// (unconfigured -> undefined) to prove the check rejects an unrecognized bodyPath.
(services.response.getByBodyPath as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
_id: 'res_own', parentId: 'req_1', bodyPath: outsidePath,
});
const { pluginToMainAPI } = await import('../templating-worker-database');
const result = await pluginToMainAPI['response.getBodyBuffer']({
response: { bodyPath: outsidePath },
});
expect(result.toString('utf8')).toBe(OUTSIDE_CONTENTS);
});

it('restricts a template tag with only default (no-manifest) baseline capabilities from reading outside a known response, end to end through the real sandbox', async () => {
outsidePath = writeOutsideFile();

const { pluginToMainAPI } = await import('../templating-worker-database');
const { createMapBridge, TEMPLATE_TAG_BASELINE_CAPABILITIES } = await import('../../templating/sandbox/host-bridge');
const { TEMPLATE_TAG_BASELINE_MODULES } = await import('../../templating/sandbox/module-registry');
const { runTagInSandbox } = await import('../../templating/sandbox/plugin-tag-sandbox');

// The real production bridge, wired to the real production handler map — no reimplementation.
const bridge = createMapBridge(pluginToMainAPI);

// A plugin declaring no permissions; models.read (and so response.getBodyBuffer) is baseline.
const pluginSource = `
module.exports.templateTags = [{
name: 'readPath',
run: function (context, filePath) {
return context.util.models.response.getBodyBuffer({ bodyPath: filePath }).then(function (raw) {
var bytes = (raw && raw.data) || [];
var out = '';
for (var i = 0; i < bytes.length; i++) { out += String.fromCharCode(bytes[i]); }
return out;
});
}
}];`;

await expect(
runTagInSandbox({
pluginSource,
tagName: 'readPath',
bridge,
envelope: {
args: [outsidePath],
context: {},
meta: {},
renderPurpose: 'preview',
appInfo: { version: '0.0.0', platform: 'linux', arch: 'arm64' },
pluginName: 'no-manifest-plugin',
renderDepth: 0,
grantedModules: TEMPLATE_TAG_BASELINE_MODULES,
grantedCapabilities: TEMPLATE_TAG_BASELINE_CAPABILITIES,
},
}),
).rejects.toThrow(/does not belong to any known response/);
});
});
Loading
Loading