From 92b25cbf2afa468990e4f831797f52e0f4036c45 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sat, 25 Jul 2026 21:26:23 -0400 Subject: [PATCH] fix(sandbox): re-verify response.getBodyBuffer against the id-resolved response, not the caller's bodyPath assertResponseBodyPathReadOwnership only confirmed a supplied bodyPath belonged to *some* persisted response, so a caller supplying a real response id alongside a different, real response's bodyPath still read that other response's body. When an id is supplied, re-load the response server-side and read only its own bodyPath, ignoring the caller's; fall back to the existing bodyPath-ownership check when no id is available (the pre-persistence response-hook call site). Co-Authored-By: Claude Fable 5 --- .../templating-worker-database.test.ts | 54 ++++++++++++++++++- .../src/main/templating-worker-database.ts | 14 ++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database.test.ts index f8fe955b97..5037ee2028 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database.test.ts @@ -23,7 +23,7 @@ vi.mock('insomnia-data', () => ({ workspace: { getById: vi.fn() }, oAuth2Token: { getByParentId: vi.fn() }, cookieJar: { getOrCreateForParentId: vi.fn() }, - response: { getLatestForRequestId: vi.fn() }, + response: { getLatestForRequestId: vi.fn(), getById: vi.fn(), getByBodyPath: vi.fn() }, helpers: { getResponseBodyBuffer: vi.fn() }, settings: { get: vi.fn() }, }, @@ -257,6 +257,58 @@ describe('runPluginTagInSandbox — util.render escape', () => { }); }); +describe('response.getBodyBuffer reads only the id-resolved response body when an id is supplied', () => { + const runTag = (runBody: string) => + runPluginTagInSandbox( + `module.exports.templateTags = [{ name: 't', run: async function (context) { ${runBody} } }];`, + { + args: [], + pluginName: 'p', + tagName: 't', + context: { meta: {}, renderPurpose: 'send' as const, context: {} as any }, + }, + ); + + beforeEach(() => { + (services.helpers.getResponseBodyBuffer as any).mockImplementation(async (resp: any) => `read:${resp?.bodyPath}`); + }); + + it('ignores a bodyPath belonging to a different response once an id is supplied', async () => { + (services.response.getById as any).mockResolvedValue({ _id: 'r1', bodyPath: '/app/r1/body', bodyCompression: null }); + (services.response.getByBodyPath as any).mockResolvedValue({ _id: 'r2', parentId: 'req2', bodyPath: '/app/r2/body' }); + const result = await runTag( + "return await context.util.models.response.getBodyBuffer({ _id: 'r1', bodyPath: '/app/r2/body' });", + ); + expect(services.response.getById).toHaveBeenCalledWith('r1'); + expect(result).toBe('read:/app/r1/body'); + }); + + it('returns the read-failure value (never touches disk) when the supplied id is unknown', async () => { + (services.response.getById as any).mockResolvedValue(null); + (services.helpers.getResponseBodyBuffer as any).mockClear(); + const result = await runTag( + "return await context.util.models.response.getBodyBuffer({ _id: 'unknown', bodyPath: '/app/r2/body' }, 'FAIL');", + ); + expect(result).toBe('FAIL'); + expect(services.helpers.getResponseBodyBuffer).not.toHaveBeenCalled(); + }); + + it('falls back to bodyPath-ownership verification when no id is supplied (e.g. the pre-persistence hook path)', async () => { + (services.response.getByBodyPath as any).mockResolvedValue({ _id: 'r2', parentId: 'req2', bodyPath: '/app/r2/body' }); + const result = await runTag( + "return await context.util.models.response.getBodyBuffer({ bodyPath: '/app/r2/body' });", + ); + expect(result).toBe('read:/app/r2/body'); + }); + + it('rejects a bodyPath that belongs to no known response when no id is supplied', async () => { + (services.response.getByBodyPath as any).mockResolvedValue(null); + await expect( + runTag("return await context.util.models.response.getBodyBuffer({ bodyPath: '/outside/body' });"), + ).rejects.toThrow(/does not belong to any known response/); + }); +}); + describe('cloudCredential.update reloads by id and strips identity fields from the patch', () => { const CREDS = ['render', 'models.read', 'util', 'crypto', 'credentials']; const BASELINE = ['render', 'models.read', 'util', 'crypto']; diff --git a/packages/insomnia/src/main/templating-worker-database.ts b/packages/insomnia/src/main/templating-worker-database.ts index b4bbb0c0d3..f32415c9d0 100644 --- a/packages/insomnia/src/main/templating-worker-database.ts +++ b/packages/insomnia/src/main/templating-worker-database.ts @@ -667,9 +667,21 @@ export const pluginToMainAPI: Record P return await services.response.getLatestForRequestId(body.requestId, body.environmentId); }, 'response.getBodyBuffer': async (body: { - response?: { bodyPath?: string; bodyCompression?: any }; + response?: { _id?: string; bodyPath?: string; bodyCompression?: any }; readFailureValue?: string; }) => { + const id = body.response?._id; + if (id) { + // Stronger path: re-load the response by id and read only its own server-owned bodyPath, + // ignoring whatever bodyPath the caller supplied alongside the id. + const real = await services.response.getById(String(id)); + if (!real) { + return body.readFailureValue ?? ''; + } + return await services.helpers.getResponseBodyBuffer(real, body.readFailureValue); + } + // No id available (e.g. a response hook running before its response is persisted) — fall back to + // verifying the supplied bodyPath belongs to some already-persisted response. await assertResponseBodyPathReadOwnership(body.response?.bodyPath); return await services.helpers.getResponseBodyBuffer(body.response, body.readFailureValue); },