From e19227124527b443441fb7cbed2ef7e57beb61ab Mon Sep 17 00:00:00 2001 From: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:51:16 +0800 Subject: [PATCH 1/3] feat: confidential value policy and render engine (#10509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(phase3-engine): implement confidential value policy and render engine Introduce getConfidentialValuePolicy() as the single decision point for reveal/mask across all render surfaces (Phase 3.0). Extend RenderPurpose with 'codegen' and add forceReveal/getSettings() to BaseRenderContext. Update buildRenderContext() to track per-key ConfidentialValueKind via EnvironmentRenderLayer so normal-confidential KV pairs are masked before entering renderSubContext() — preview cannot pollute subsequent send (Phase 3.1). Wire maskOrDecryptVaultDataIfNecessary() through the same policy so SECRET reveal/mask follows hideSecretValuesInPreviewAndConsole. Add explicit purpose:'codegen' to exportHarWithRequest/exportHarRequest IPC path so Generate Code always masks confidential values independent of the user setting (Phase 3.4 IPC layer). Co-Authored-By: Claude Sonnet 4.6 (1M context) * refactor(phase3-engine): remove codegen purpose — generate-code callers use preview codegen and preview now have identical policy semantics (both controlled by hideSecretValuesInPreviewAndConsole; forceReveal only applies at call-site for preview). Keeping a separate purpose added complexity with no behavioural benefit. Remove 'codegen' from RenderPurpose, drop the codegen branch from getConfidentialValuePolicy, and restore shouldMaskExternalVaultTag to intercept only 'preview'. Generate Code / Copy as cURL callers will pass purpose:'preview' instead (updated in the UI branch). Co-Authored-By: Claude Sonnet 4.6 (1M context) * refactor(phase3-engine): extract buildEnvironmentRenderLayers from buildRenderContext Pure extract-function: the environment layer construction is unchanged, just moved into its own exported function so downstream stacked PRs (T9/T10 confidential-value prefill) can reuse the same layer enumeration instead of re-implementing it. buildRenderContext's behavior is unchanged and remains covered by render.test.ts. Co-Authored-By: Claude Sonnet 5 * fix(phase3-engine): stop buildEnvironmentRenderLayers mutating the ancestors array for (const doc of (ancestors || []).reverse()) reversed the caller's array in place. Now that the layer construction is extracted into a standalone function, downstream callers (e.g. a T9/T10 prefill pass that enumerates the same layers before the real render pass) can receive the same ancestors array and call it twice, flipping the folder precedence order on the second call. Copy before reversing so the caller's array is left untouched. Co-Authored-By: Claude Sonnet 5 * update * fix: comment --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- .../insomnia/src/common/__tests__/har.test.ts | 66 +++ .../src/common/__tests__/render.test.ts | 298 +++++++++++++- packages/insomnia/src/common/render.ts | 383 ++++++++++++------ .../render-context-serialization.test.ts | 22 +- .../confidential-value-policy.test.ts | 82 ++++ .../templating/confidential-value-policy.ts | 75 ++++ .../templating/mask-or-decrypt-vault-data.ts | 18 +- .../insomnia/src/common/templating/types.ts | 6 + packages/insomnia/src/entry.preload.ts | 6 +- packages/insomnia/src/main/har.ts | 10 +- packages/insomnia/src/main/ipc/main.ts | 12 +- 11 files changed, 829 insertions(+), 149 deletions(-) create mode 100644 packages/insomnia/src/common/templating/confidential-value-policy.test.ts create mode 100644 packages/insomnia/src/common/templating/confidential-value-policy.ts diff --git a/packages/insomnia/src/common/__tests__/har.test.ts b/packages/insomnia/src/common/__tests__/har.test.ts index 22ea08687e..2cf258df1f 100644 --- a/packages/insomnia/src/common/__tests__/har.test.ts +++ b/packages/insomnia/src/common/__tests__/har.test.ts @@ -27,6 +27,7 @@ vi.mock('~/common/runtime', () => ({ import type { Cookie, Request, Response } from 'insomnia-data'; import { models, services } from 'insomnia-data'; +import { EnvironmentKvPairDataType } from 'insomnia-data'; import { database as db } from '../../common/database'; import { getRenderedRequestAndContext } from '../../common/render'; @@ -441,6 +442,71 @@ describe('export', () => { }); }); + describe('Generate Code vs explicit export separation', () => { + it('exportHarWithRequest with purpose=preview masks confidential env values when setting is ON (default)', async () => { + const workspace = await services.workspace.create(); + const baseEnv = await services.environment.getOrCreateForParentId(workspace._id); + await services.environment.update(baseEnv, { + data: { token: 'real-secret' }, + kvPairData: [ + { + id: 'pair_token', + name: 'token', + value: 'real-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ], + }); + const request: Request = { + ...models.request.init(), + _id: 'req_codegen', + modified: 1, + created: 1, + parentId: workspace._id, + type: models.request.type, + headers: [{ name: 'Authorization', value: 'Bearer {{ _.token }}' }], + method: 'GET', + url: 'https://api.example.com/', + }; + const { context } = await getRenderedRequestAndContext({ request, environment: baseEnv._id, purpose: 'preview' }); + expect((context as any).token).toBe(models.environment.vaultEnvironmentMaskValue); + }); + + it('exportHar (explicit export) uses undefined purpose — confidential env values are masked (same as SECRET, not affected by setting)', async () => { + const workspace = await services.workspace.create({ _id: 'wrk_export_sep' }); + const baseEnv = await services.environment.getOrCreateForParentId(workspace._id); + await services.environment.update(baseEnv, { + data: { token: 'real-secret' }, + kvPairData: [ + { + id: 'pair_token', + name: 'token', + value: 'real-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ], + }); + const request: Request = { + ...models.request.init(), + _id: 'req_export_sep', + modified: 1, + created: 1, + parentId: workspace._id, + type: models.request.type, + method: 'GET', + url: 'https://api.example.com/', + headers: [], + }; + // explicit export passes no purpose → fail-closed 'mask' → same behaviour as SECRET + const { context } = await getRenderedRequestAndContext({ request, environment: baseEnv._id }); + expect((context as any).token).toBe(models.environment.vaultEnvironmentMaskValue); + }); + }); + describe('exportHarWithRequest()', () => { it('renders does it correctly', async () => { const workspace = await services.workspace.create(); diff --git a/packages/insomnia/src/common/__tests__/render.test.ts b/packages/insomnia/src/common/__tests__/render.test.ts index 94de012655..090064e30e 100644 --- a/packages/insomnia/src/common/__tests__/render.test.ts +++ b/packages/insomnia/src/common/__tests__/render.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { createBuilder } from '@develohpanda/fluent-builder'; import type { Environment, Workspace } from 'insomnia-data'; -import { models, services } from 'insomnia-data'; +import { EnvironmentKvPairDataType, models, services } from 'insomnia-data'; import { beforeEach, describe, expect, it } from 'vitest'; import { environmentModelSchema, requestGroupModelSchema } from '../../sync/__schemas__/model-schemas'; @@ -30,6 +30,16 @@ const stubContextGetters = { getGlobalEnvironmentId: expect.any(Function), getProjectId: expect.any(Function), }; +const makeBaseContext = ( + purpose: 'preview' | 'send' | 'no-render' | 'script', + hideSecretValuesInPreviewAndConsole = true, + forceReveal?: boolean, +) => + ({ + getMeta: () => ({}), + getPurpose: () => purpose, + getSettings: () => ({ dataFolders: [], hideSecretValuesInPreviewAndConsole, forceReveal }), + }) as any; describe('render tests', () => { beforeEach(async () => { @@ -103,6 +113,273 @@ describe('render tests', () => { }); describe('buildRenderContext()', () => { + it('preserves legacy vault masking for no-render', async () => { + const rootEnvironment = envBuilder + .data({ [models.environment.vaultEnvironmentPath]: { token: 'encrypted-value' } }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'encrypted-value', + type: EnvironmentKvPairDataType.SECRET, + enabled: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('no-render'), + }); + + expect(context.vault.token).toBe(models.environment.vaultEnvironmentMaskValue); + }); + + it('masks confidential values in preview while preserving JSON shape and derived variables', async () => { + const rootEnvironment = envBuilder + .data({ + token: 'top-secret', + config: { token: 'nested-secret', enabled: true, retries: 3, empty: null, values: ['secret'] }, + authorization: 'Bearer {{ token }}', + }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + { + id: 'envPair_config', + name: 'config', + value: '{}', + type: EnvironmentKvPairDataType.JSON, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(context.token).toBe(models.environment.vaultEnvironmentMaskValue); + expect(context.config).toEqual({ + token: models.environment.vaultEnvironmentMaskValue, + enabled: models.environment.vaultEnvironmentMaskValue, + retries: models.environment.vaultEnvironmentMaskValue, + empty: models.environment.vaultEnvironmentMaskValue, + values: [models.environment.vaultEnvironmentMaskValue], + }); + expect(context.authorization).toBe(`Bearer ${models.environment.vaultEnvironmentMaskValue}`); + }); + + it('uses the last enabled KV row and higher-priority public layers to clear confidentiality', async () => { + const rootEnvironment = envBuilder + .data({ token: 'root-secret' }) + .kvPairData([ + { + id: 'envPair_confidential', + name: 'token', + value: 'root-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + { + id: 'envPair_public', + name: 'token', + value: 'root-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: false, + }, + ]) + .build(); + const subEnvironment = envBuilder.data({ token: 'sub-public' }).build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + subEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(context.token).toBe('sub-public'); + }); + + it('masks a higher-priority confidential value that overrides a public value', async () => { + const rootEnvironment = envBuilder.data({ token: 'root-public' }).build(); + const subEnvironment = envBuilder + .data({ token: 'sub-secret' }) + .kvPairData([ + { + id: 'envPair_sub', + name: 'token', + value: 'sub-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + subEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(context.token).toBe(models.environment.vaultEnvironmentMaskValue); + }); + + it('reveals confidential values in preview when the local setting is disabled', async () => { + const rootEnvironment = envBuilder + .data({ token: 'top-secret' }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview', false), + }); + + expect(context.token).toBe('top-secret'); + }); + + it('ignores disabled confidential rows', async () => { + const rootEnvironment = envBuilder + .data({ token: 'visible' }) + .kvPairData([ + { + id: 'envPair_disabled', + name: 'token', + value: 'visible', + type: EnvironmentKvPairDataType.STRING, + enabled: false, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(context.token).toBe('visible'); + }); + + it('script purpose reveals confidential values', async () => { + const rootEnvironment = envBuilder + .data({ token: 'top-secret' }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('script'), + }); + + expect(context.token).toBe('top-secret'); + }); + + it('forceReveal in preview bypasses hideSecretValuesInPreviewAndConsole', async () => { + const rootEnvironment = envBuilder + .data({ token: 'top-secret' }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview', true, true), + }); + + expect(context.token).toBe('top-secret'); + }); + + it('does not mutate vault data object when preview masking runs', async () => { + const rootEnvironment = envBuilder + .data({ [models.environment.vaultEnvironmentPath]: { token: 'encrypted-value' } }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'encrypted-value', + type: EnvironmentKvPairDataType.SECRET, + enabled: true, + }, + ]) + .build(); + + await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(rootEnvironment.data[models.environment.vaultEnvironmentPath].token).toBe('encrypted-value'); + }); + + it('does not mutate environment data when preview masking precedes a send render', async () => { + const rootEnvironment = envBuilder + .data({ token: 'top-secret' }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const previewContext = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview'), + }); + const sendContext = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('send'), + }); + + expect(previewContext.token).toBe(models.environment.vaultEnvironmentMaskValue); + expect(sendContext.token).toBe('top-secret'); + expect(rootEnvironment.data.token).toBe('top-secret'); + expect(rootEnvironment.kvPairData?.[0].value).toBe('top-secret'); + }); + it('cascades properly', async () => { const ancestors = [ reqGroupBuilder.environment({ foo: 'parent', ancestor: true }).build(), @@ -129,6 +406,25 @@ describe('render tests', () => { }); }); + it('does not mutate the ancestors array passed by the caller', async () => { + const ancestors = [ + reqGroupBuilder.environment({ foo: 'parent', ancestor: true }).build(), + reqGroupBuilder.environment({ foo: 'grandparent', ancestor: true }).build(), + ]; + const originalOrder = [...ancestors]; + + // Calling buildRenderContext twice with the same array (e.g. a prefill + // pass followed by the real render pass) must yield the same result + // both times. A previous bug reversed `ancestors` in place, so the + // second call would see an already-reversed array and flip precedence. + const firstContext = await renderUtils.buildRenderContext({ baseContext: stubBaseContext, ancestors }); + const secondContext = await renderUtils.buildRenderContext({ baseContext: stubBaseContext, ancestors }); + + expect(ancestors).toEqual(originalOrder); + expect(firstContext.foo).toBe('parent'); + expect(secondContext.foo).toBe('parent'); + }); + it('rendered recursive should not infinite loop', async () => { const ancestors = [reqGroupBuilder.environment({ recursive: '{{ recursive }}/hello' }).build()]; diff --git a/packages/insomnia/src/common/render.ts b/packages/insomnia/src/common/render.ts index 2495d134dd..d0a8ca26a0 100644 --- a/packages/insomnia/src/common/render.ts +++ b/packages/insomnia/src/common/render.ts @@ -1,6 +1,7 @@ import clone from 'clone'; import type { Environment, + EnvironmentKvPairData, GrpcRequest, GrpcRequestBody, McpRequest, @@ -11,9 +12,10 @@ import type { WebSocketRequest, Workspace, } from 'insomnia-data'; -import { models, services } from 'insomnia-data'; +import { EnvironmentKvPairDataType, models, services } from 'insomnia-data'; import orderedJSON from 'json-order'; +import { type ConfidentialValueKind, getConfidentialValuePolicy } from '~/common/templating/confidential-value-policy'; import { NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME } from '~/common/templating/constants'; import { maskOrDecryptVaultDataIfNecessary } from '~/common/templating/mask-or-decrypt-vault-data'; import { RenderError } from '~/common/templating/render-error'; @@ -74,6 +76,204 @@ function getKeySource( } } +interface EnvironmentRenderLayer { + data: Record; + kvPairData?: EnvironmentKvPairData[]; + source: 'global' | 'collection' | 'folder' | 'iteration' | 'transient'; + // Per-layer source attribution for the "Template tags show variable source + // and value" setting; layers that define no variables of their own carry none. + meta?: VariableSourceMeta; +} + +// Builds a name → ConfidentialValueKind lookup for one environment layer. +// SECRET rows are intentionally excluded: they are handled separately by +// maskOrDecryptVaultDataIfNecessary and must not be double-processed here. +// Disabled rows are excluded to match getDataFromKVPair semantics — they +// do not appear in `data` either. +// If the same name appears more than once, the last enabled non-SECRET row +// wins (consistent with getDataFromKVPair last-wins behaviour). +function getConfidentialityByKey(kvPairData?: EnvironmentKvPairData[]) { + const confidentialityByKey = new Map(); + + for (const pair of kvPairData || []) { + if (!pair.enabled || pair.type === EnvironmentKvPairDataType.SECRET) { + continue; + } + confidentialityByKey.set(pair.name, pair.isConfidential ? 'normal' : undefined); + } + + return confidentialityByKey; +} + +function maskConfidentialValue(value: any): any { + if (Array.isArray(value)) { + return value.map(maskConfidentialValue); + } + + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, maskConfidentialValue(child)])); + } + + return models.environment.vaultEnvironmentMaskValue; +} + +export function buildEnvironmentRenderLayers({ + ancestors, + rootEnvironment, + subEnvironment, + rootGlobalEnvironment, + subGlobalEnvironment, + userUploadEnvironment, + transientVariables, + workspaceId, +}: { + ancestors?: RenderContextAncestor[]; + rootEnvironment?: Environment; + subEnvironment?: Environment; + rootGlobalEnvironment?: Environment | null; + subGlobalEnvironment?: Environment | null; + userUploadEnvironment?: UserUploadEnvironment; + transientVariables?: Environment; + // Collection and folder layers attribute their source to this workspace. + workspaceId?: string; +}): EnvironmentRenderLayer[] { + const environmentLayers: EnvironmentRenderLayer[] = []; + + if (rootGlobalEnvironment) { + const ordered = orderedJSON.order( + rootGlobalEnvironment.data, + rootGlobalEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + kvPairData: rootGlobalEnvironment.kvPairData, + source: 'global', + meta: { + label: formatSourceLabel('Project Environment', rootGlobalEnvironment.name), + workspaceId: rootGlobalEnvironment.parentId, + environmentId: rootGlobalEnvironment._id, + }, + }); + } + + if (subGlobalEnvironment) { + const ordered = orderedJSON.order( + subGlobalEnvironment.data, + subGlobalEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + kvPairData: subGlobalEnvironment.kvPairData, + source: 'global', + meta: { + label: formatSourceLabel('Project Environment', subGlobalEnvironment.name), + workspaceId: rootGlobalEnvironment?.parentId, + environmentId: subGlobalEnvironment._id, + }, + }); + } + + // Get root environment keys in correct order + // Then get sub environment keys in correct order + // Then get ancestor (folder) environment keys in correct order + if (rootEnvironment) { + const ordered = orderedJSON.order( + rootEnvironment.data, + rootEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + kvPairData: rootEnvironment.kvPairData, + source: 'collection', + meta: { + label: formatSourceLabel('Collection Environment', rootEnvironment.name), + workspaceId: workspaceId, + environmentId: rootEnvironment._id, + }, + }); + } + + if (subEnvironment) { + const ordered = orderedJSON.order( + subEnvironment.data, + subEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + kvPairData: subEnvironment.kvPairData, + source: 'collection', + // The active sub environment can be the base environment itself; only a + // distinct sub environment carries its own source attribution. + meta: + subEnvironment._id !== rootEnvironment?._id + ? { + label: formatSourceLabel('Collection Environment', subEnvironment.name), + workspaceId: workspaceId, + environmentId: subEnvironment._id, + } + : undefined, + }); + } + + for (const doc of [...(ancestors || [])].reverse()) { + const ancestor: any = doc; + const { environment, environmentPropertyOrder, kvPairData } = ancestor; + + if (typeof environment === 'object' && environment !== null) { + const ordered = orderedJSON.order(environment, environmentPropertyOrder ?? null, JSON_ORDER_SEPARATOR); + environmentLayers.push({ + data: ordered, + kvPairData, + source: 'folder', + // Only folder environments carry source metadata; the request and workspace + // ancestors define no variables of their own. + meta: + isRequestGroup(ancestor) && 'name' in ancestor + ? { + label: formatSourceLabel('Folder Environment', ancestor.name), + workspaceId: workspaceId, + requestGroupId: ancestor._id, + } + : undefined, + }); + } + } + + // user upload env in collection runner has highest priority except local variables + if (userUploadEnvironment) { + const ordered = orderedJSON.order( + userUploadEnvironment.data, + userUploadEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + source: 'iteration', + meta: { label: formatSourceLabel('Upload Data', userUploadEnvironment.name) }, + }); + } + + // script local variables (insomnia.variable.set) has highest priority + if (transientVariables) { + const ordered = orderedJSON.order( + transientVariables.data, + transientVariables.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + source: 'transient', + meta: { label: formatSourceLabel('Script Variables', transientVariables.name) }, + }); + } + + return environmentLayers; +} + export async function buildRenderContext({ ancestors, rootEnvironment, @@ -97,122 +297,16 @@ export async function buildRenderContext({ // follows the same merge order and winners as the values. const keySource: Record = {}; const keySourceMeta: Record = {}; - const workspaceId = baseContext.getMeta().workspaceId; - const envObjects: { data: Record; meta?: VariableSourceMeta }[] = []; - - if (rootGlobalEnvironment) { - const ordered = orderedJSON.order( - rootGlobalEnvironment.data, - rootGlobalEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ - data: ordered, - meta: { - label: formatSourceLabel('Project Environment', rootGlobalEnvironment.name), - workspaceId: rootGlobalEnvironment.parentId, - environmentId: rootGlobalEnvironment._id, - }, - }); - } - - if (subGlobalEnvironment) { - const ordered = orderedJSON.order( - subGlobalEnvironment.data, - subGlobalEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ - data: ordered, - meta: { - label: formatSourceLabel('Project Environment', subGlobalEnvironment.name), - workspaceId: rootGlobalEnvironment?.parentId, - environmentId: subGlobalEnvironment._id, - }, - }); - } - - // Get root environment keys in correct order - // Then get sub environment keys in correct order - // Then get ancestor (folder) environment keys in correct order - if (rootEnvironment) { - const ordered = orderedJSON.order( - rootEnvironment.data, - rootEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ - data: ordered, - meta: { - label: formatSourceLabel('Collection Environment', rootEnvironment.name), - workspaceId: workspaceId, - environmentId: rootEnvironment._id, - }, - }); - } - - if (subEnvironment) { - const ordered = orderedJSON.order( - subEnvironment.data, - subEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ - data: ordered, - // The active sub environment can be the base environment itself; only a - // distinct sub environment carries its own source attribution. - meta: - subEnvironment._id !== rootEnvironment?._id - ? { - label: formatSourceLabel('Collection Environment', subEnvironment.name), - workspaceId: workspaceId, - environmentId: subEnvironment._id, - } - : undefined, - }); - } - - for (const doc of (ancestors || []).reverse()) { - const ancestor: any = doc; - const { environment, environmentPropertyOrder } = ancestor; - - if (typeof environment === 'object' && environment !== null) { - const ordered = orderedJSON.order(environment, environmentPropertyOrder ?? null, JSON_ORDER_SEPARATOR); - // Only folder environments carry source metadata; the request and workspace - // ancestors define no variables of their own. - envObjects.push({ - data: ordered, - meta: - isRequestGroup(ancestor) && 'name' in ancestor - ? { - label: formatSourceLabel('Folder Environment', ancestor.name), - workspaceId: workspaceId, - requestGroupId: ancestor._id, - } - : undefined, - }); - } - } - - // user upload env in collection runner has highest priority except local variables - if (userUploadEnvironment) { - const ordered = orderedJSON.order( - userUploadEnvironment.data, - userUploadEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ data: ordered, meta: { label: formatSourceLabel('Upload Data', userUploadEnvironment.name) } }); - } - - // script local variables (insomnia.variable.set) has highest priority - if (transientVariables) { - const ordered = orderedJSON.order( - transientVariables.data, - transientVariables.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ data: ordered, meta: { label: formatSourceLabel('Script Variables', transientVariables.name) } }); - } + const environmentLayers = buildEnvironmentRenderLayers({ + ancestors, + rootEnvironment, + subEnvironment, + rootGlobalEnvironment, + subGlobalEnvironment, + userUploadEnvironment, + transientVariables, + workspaceId: baseContext.getMeta().workspaceId, + }); // At this point, environments is a list of environments ordered // from top-most parent to bottom-most child, and they keys in each environment @@ -271,9 +365,43 @@ export async function buildRenderContext({ } let finalRenderContext = { ...renderContext }; - for (const { data } of envObjects) { - // For every environment render the Objects - finalRenderContext = await renderSubContext(data, finalRenderContext); + // Cross-layer confidentiality accumulator. Updated on every key as layers are + // merged in priority order (low → high). When a higher-priority layer defines + // the same key, its confidentiality verdict overwrites the previous one — including + // setting it to undefined when the higher-priority layer has no kvPairData entry + // for that key (e.g. iteration/transient layers), which intentionally clears the + // confidential flag so a public override is not masked. + const confidentialityByKey = new Map(); + const hideSecretValues = renderContext?.getSettings?.().hideSecretValuesInPreviewAndConsole; + const forceReveal = renderContext?.getSettings?.().forceReveal; + // Hoisted above both loops: purpose/hideSecretValues/forceReveal are invariant for the + // entire buildRenderContext call. undefined purpose hits the fail-closed 'mask' branch, + // so file-export paths (explicit HAR, etc.) always mask confidential values — consistent + // with SECRET behaviour and independent of the hideSecretValuesInPreviewAndConsole setting. + const purpose = renderContext?.getPurpose?.(); + const confidentialPolicy = getConfidentialValuePolicy({ purpose, hideSecretValues, forceReveal }); + + for (const environmentLayer of environmentLayers) { + // Per-layer lookup built from kvPairData metadata (not from data values). + const layerConfidentialityByKey = getConfidentialityByKey(environmentLayer.kvPairData); + // Build a masked copy of this layer's data before merging into the context. + // Masking must happen here — after this point the values enter renderSubContext + // where they may be rendered into other variables, and we must never let a + // masked value be subsequently over-written with the real one from a cache or + // re-render of the same context. + const renderableData: Record = {}; + + for (const [key, value] of Object.entries(environmentLayer.data)) { + // Overwrite the accumulated confidentiality with this layer's verdict. + // layerConfidentialityByKey.get(key) returns undefined for keys that have + // no kvPairData (e.g. raw-JSON layers, iteration data), which is the + // correct "public" signal and clears any confidentiality from lower layers. + confidentialityByKey.set(key, layerConfidentialityByKey.get(key)); + const confidentiality = confidentialityByKey.get(key); + renderableData[key] = confidentiality && confidentialPolicy === 'mask' ? maskConfidentialValue(value) : value; + } + + finalRenderContext = await renderSubContext(renderableData, finalRenderContext); } // Key source attribution, registered in the same order the runtime merges the @@ -285,7 +413,7 @@ export async function buildRenderContext({ // there for `_.vault.key` chips; the reserved key itself is never exposed. const inKey = NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME; - for (const { data, meta } of envObjects) { + for (const { data, meta } of environmentLayers) { if (!meta) { continue; } @@ -301,7 +429,7 @@ export async function buildRenderContext({ } } - for (const { data, meta } of envObjects) { + for (const { data, meta } of environmentLayers) { if (!meta) { continue; } @@ -311,7 +439,9 @@ export async function buildRenderContext({ const vaultEnvironmentData = await maskOrDecryptVaultDataIfNecessary( finalRenderContext[models.environment.vaultEnvironmentPath], - renderContext.getPurpose(), + purpose, + hideSecretValues, + forceReveal, ); if (vaultEnvironmentData) { // avoid add undefined data to render context @@ -498,6 +628,7 @@ export async function getRenderContext({ ancestors: _ancestors, purpose, extraInfo, + forceReveal, }: RenderContextOptions): Promise { const ancestors = _ancestors || (await getRenderContextAncestors(request)); @@ -553,7 +684,11 @@ export async function getRenderContext({ getGlobalEnvironmentId: () => subGlobalEnvironment?._id || rootGlobalEnvironment?._id, // It is possible for a project to not exist because this code path can be reached via Inso which has no concept of a project. getProjectId: () => project?._id, - getSettings: () => ({ dataFolders: settings.dataFolders }), + getSettings: () => ({ + dataFolders: settings.dataFolders, + hideSecretValuesInPreviewAndConsole: settings.hideSecretValuesInPreviewAndConsole, + forceReveal: forceReveal, + }), }; // Generate the context we need to render diff --git a/packages/insomnia/src/common/templating/__tests__/render-context-serialization.test.ts b/packages/insomnia/src/common/templating/__tests__/render-context-serialization.test.ts index 348dc9dbdf..2c8f4e7d4d 100644 --- a/packages/insomnia/src/common/templating/__tests__/render-context-serialization.test.ts +++ b/packages/insomnia/src/common/templating/__tests__/render-context-serialization.test.ts @@ -15,7 +15,7 @@ const makeContext = () => ({ getKeysContext: () => ({ keyContext: {} }), getProjectId: () => 'proj_1', getPurpose: () => 'send', - getSettings: () => ({ dataFolders: [] }), + getSettings: () => ({ dataFolders: [], hideSecretValuesInPreviewAndConsole: true }), }); describe('serializeRenderContext', () => { @@ -41,7 +41,7 @@ describe('serializeRenderContext', () => { keysContext: { keyContext: {} }, projectId: 'proj_1', purpose: 'send', - settings: { dataFolders: [] }, + settings: { dataFolders: [], hideSecretValuesInPreviewAndConsole: true }, }); }); }); @@ -54,8 +54,24 @@ describe('deserializeRenderContext', () => { expect(context.getMeta()).toEqual({ requestId: 'req_1', workspaceId: 'wrk_1' }); expect(context.getProjectId()).toBe('proj_1'); expect(context.getEnvironmentId()).toBe('env_1'); - expect(context.getSettings()).toEqual({ dataFolders: [] }); + expect(context.getSettings()).toEqual({ dataFolders: [], hideSecretValuesInPreviewAndConsole: true }); // environment data survives the round-trip expect(context.foo).toBe('bar'); }); + + it('preserves forceReveal in settings across the worker boundary', () => { + const ctx = { + ...makeContext(), + getSettings: () => ({ dataFolders: [], hideSecretValuesInPreviewAndConsole: true, forceReveal: true }), + }; + const roundTripped = structuredClone(serializeRenderContext(ctx)); + const context = deserializeRenderContext(roundTripped); + + expect(context.getSettings()).toEqual({ + dataFolders: [], + hideSecretValuesInPreviewAndConsole: true, + forceReveal: true, + }); + }); + }); diff --git a/packages/insomnia/src/common/templating/confidential-value-policy.test.ts b/packages/insomnia/src/common/templating/confidential-value-policy.test.ts new file mode 100644 index 0000000000..e07dd4689c --- /dev/null +++ b/packages/insomnia/src/common/templating/confidential-value-policy.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { type ConfidentialValuePolicy, getConfidentialValuePolicy, shouldMaskExternalVaultTag } from './confidential-value-policy'; +import type { RenderPurpose } from './types'; + +const purposes: (RenderPurpose | undefined)[] = [ + undefined, + 'general', + 'send', + 'preview', + 'script', + 'no-render', +]; +const settings = [true, false, undefined]; + +function expectedPolicy( + purpose: RenderPurpose | undefined, + hideSecretValues: boolean | undefined, +): ConfidentialValuePolicy { + if (purpose === 'send' || purpose === 'script') { + return 'reveal'; + } + + if (purpose === 'preview') { + return hideSecretValues === false ? 'reveal' : 'mask'; + } + + return 'mask'; +} + +describe('getConfidentialValuePolicy', () => { + it.each(purposes.flatMap(purpose => settings.map(hideSecretValues => ({ purpose, hideSecretValues }))))( + 'purpose=$purpose hideSecretValues=$hideSecretValues follows the policy matrix', + ({ purpose, hideSecretValues }) => { + expect(getConfidentialValuePolicy({ purpose, hideSecretValues })).toBe( + expectedPolicy(purpose, hideSecretValues), + ); + }, + ); + + it('forceReveal only applies to preview — fallback surfaces ignore it', () => { + expect(getConfidentialValuePolicy({ purpose: 'preview', forceReveal: true })).toBe('reveal'); + expect(getConfidentialValuePolicy({ purpose: undefined, forceReveal: true })).toBe('mask'); + expect(getConfidentialValuePolicy({ purpose: 'general', forceReveal: true })).toBe('mask'); + }); +}); + +describe('shouldMaskExternalVaultTag', () => { + const PLUGIN = '@kong/insomnia-plugin-external-vault'; + const TAG = 'vault'; + + it('returns false for non-vault plugin', () => { + expect(shouldMaskExternalVaultTag({ pluginName: 'other-plugin', tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + }); + + it('returns false for non-vault tag name', () => { + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: 'other-tag', renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + }); + + it('returns false for non-preview purposes — plugin run() handles its own placeholder', () => { + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'send', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'script', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'general', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + }); + + it('returns true for preview when setting is ON (default-closed)', () => { + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(true); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: {} })).toBe(true); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview' })).toBe(true); + }); + + it('returns false for preview when setting is OFF — reveal real value', () => { + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: false } })).toBe(false); + }); + + it('returns false for preview when forceReveal is true — eye-button reveal bypasses setting', () => { + expect( + shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true, forceReveal: true } }), + ).toBe(false); + }); +}); diff --git a/packages/insomnia/src/common/templating/confidential-value-policy.ts b/packages/insomnia/src/common/templating/confidential-value-policy.ts new file mode 100644 index 0000000000..fad6ba8eca --- /dev/null +++ b/packages/insomnia/src/common/templating/confidential-value-policy.ts @@ -0,0 +1,75 @@ +import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants'; + +import type { RenderPurpose } from './types'; + +export type ConfidentialValueKind = 'normal' | 'secret' | 'external-vault'; +export type ConfidentialValuePolicy = + // Use the real plaintext value (send, script, or preview with setting OFF). + | 'reveal' + // Replace the value with •••••• before it reaches any display surface or log (preview with setting ON). + | 'mask'; + +// The string displayed in place of a masked confidential value across all surfaces. +export const CONFIDENTIAL_MASK_VALUE = '••••••'; + +// Tag name for the bundled external vault plugin, used to intercept tag execution +// at the adapter boundary before any provider fetch occurs. +const BUNDLED_EXTERNAL_VAULT_TAG = 'vault'; + +export function getConfidentialValuePolicy({ + purpose, + hideSecretValues, + forceReveal, +}: { + purpose?: RenderPurpose; + hideSecretValues?: boolean; + forceReveal?: boolean; +}): ConfidentialValuePolicy { + if (purpose === 'send' || purpose === 'script') { + return 'reveal'; + } + + if (purpose === 'preview') { + // forceReveal is set by the VariableEditor eye-button; it only applies to the preview + // surface so that codegen and fallback surfaces remain unaffected. + return forceReveal || hideSecretValues === false ? 'reveal' : 'mask'; + } + + // Fail-closed: any surface that does not explicitly declare its purpose is masked. + // All known display surfaces (preview, send, script) are explicit above. + return 'mask'; +} + +/** + * Returns true when the external vault tag's run() should be skipped and CONFIDENTIAL_MASK_VALUE + * returned instead, avoiding any provider fetch. + * + * Only intercepts the 'preview' purpose. Generate Code and Copy as cURL callers use + * purpose:'preview' so they are covered here. For 'general', undefined, and other purposes + * the plugin's own run() handles its behavior. + */ +export function shouldMaskExternalVaultTag({ + pluginName, + tagName, + renderPurpose, + settings, +}: { + pluginName: string; + tagName?: string; + renderPurpose?: RenderPurpose; + settings?: { hideSecretValuesInPreviewAndConsole?: boolean; forceReveal?: boolean }; +}): boolean { + if (pluginName !== EXTERNAL_VAULT_PLUGIN_NAME || tagName !== BUNDLED_EXTERNAL_VAULT_TAG) { + return false; + } + if (renderPurpose !== 'preview') { + return false; + } + return ( + getConfidentialValuePolicy({ + purpose: renderPurpose, + hideSecretValues: settings?.hideSecretValuesInPreviewAndConsole, + forceReveal: settings?.forceReveal, + }) === 'mask' + ); +} diff --git a/packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts b/packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts index 4c7d2e2bad..bb5aeb24b5 100644 --- a/packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts +++ b/packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts @@ -1,5 +1,6 @@ import { models, services } from 'insomnia-data'; +import { getConfidentialValuePolicy } from '~/common/templating/confidential-value-policy'; import type { RenderPurpose } from '~/common/templating/types'; import { decryptVaultKeyFromSession } from '~/common/utils/vault'; import { getRuntime } from '~/runtimes'; @@ -11,14 +12,15 @@ import { getRuntime } from '~/runtimes'; * * Given an object the result is always a fresh copy; a non-object input is returned as is. */ -export async function maskOrDecryptVaultDataIfNecessary(vaultEnvironmentData: any, renderPurpose?: RenderPurpose) { - /** - * Decrypt secrets when renderPurpose is one of the following: - * - preview: render the template in variable editor to do the live preview - * - send: render the template when sending requests - * - script: render the template in pre-request or after-response script - */ - const shouldDecrypt = renderPurpose === 'preview' || renderPurpose === 'send' || renderPurpose === 'script'; +export async function maskOrDecryptVaultDataIfNecessary( + vaultEnvironmentData: any, + renderPurpose?: RenderPurpose, + hideSecretValues?: boolean, + forceReveal?: boolean, +) { + // getConfidentialValuePolicy is the single decision point for reveal/mask across all render surfaces + const shouldDecrypt = + getConfidentialValuePolicy({ purpose: renderPurpose, hideSecretValues, forceReveal }) === 'reveal'; if (typeof vaultEnvironmentData !== 'object' || vaultEnvironmentData === null) { return vaultEnvironmentData; } diff --git a/packages/insomnia/src/common/templating/types.ts b/packages/insomnia/src/common/templating/types.ts index 0b5acd1a39..e41cb85488 100644 --- a/packages/insomnia/src/common/templating/types.ts +++ b/packages/insomnia/src/common/templating/types.ts @@ -15,6 +15,7 @@ import type { Response, ResponseHeader, Services, + Settings, SocketIORequest, UserUploadEnvironment, WebSocketRequest, @@ -63,6 +64,7 @@ export interface SerializedCookie { hostOnly?: boolean; } +// TODO: check if we still need no-render, since we do not have any entry points that require it export type RenderPurpose = 'send' | 'general' | 'preview' | 'script' | 'no-render'; export type PluginToMainAPIPaths = | 'readFile' @@ -139,6 +141,9 @@ export interface BaseRenderContextOptions { purpose?: RenderPurpose; extraInfo?: { requestChain: string[] }; ignoreUndefinedEnvVariable?: boolean; + // When true the user has explicitly clicked reveal in the VariableEditor; + // this bypasses hideSecretValuesInPreviewAndConsole for the current render only. + forceReveal?: boolean; } export type RenderContextAncestor = | Request @@ -282,6 +287,7 @@ export interface BaseRenderContext { keyContextMeta?: Record; }; getPurpose: () => RenderPurpose | undefined; + getSettings: () => Pick & { forceReveal?: boolean }; getExtraInfo: () => { requestChain: string[] } | undefined; getEnvironmentId: () => string | undefined; getGlobalEnvironmentId: () => string | undefined; diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index a732c25f23..e852e85321 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -10,7 +10,7 @@ import type { RunTemplateTagActionArgs, } from '~/common/plugins/bridge-types'; import type { GenerateMcpSamplingResponseFunction } from '~/common/plugins/types'; -import type { RenderedRequest } from '~/common/templating/types'; +import type { RenderedRequest, RenderPurpose } from '~/common/templating/types'; import { invariant } from '~/common/utils/invariant'; import { invokeWithNormalizedError } from '~/main/ipc/invoke'; import type { LLMBackend, LLMConfig, LLMConfigServiceAPI } from '~/main/llm-config-service'; @@ -425,9 +425,9 @@ const main: Window['main'] = { generateCodeSnippet: (options: { har: object; target: string; client: string }) => invokeWithNormalizedError('generateCodeSnippet', options), getCodeSnippetTargets: () => invokeWithNormalizedError('getCodeSnippetTargets'), - exportHarWithRequest: (options: { requestId: string; environmentId?: string; addContentLength?: boolean }) => + exportHarWithRequest: (options: { requestId: string; environmentId?: string; addContentLength?: boolean; purpose?: RenderPurpose }) => invokeWithNormalizedError('exportHarWithRequest', options), - exportHarRequest: (options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean }) => + exportHarRequest: (options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean; purpose?: RenderPurpose }) => invokeWithNormalizedError('exportHarRequest', options), exportHarCurrentRequest: (options: { requestId: string; responseId: string }) => invokeWithNormalizedError('exportHarCurrentRequest', options), diff --git a/packages/insomnia/src/main/har.ts b/packages/insomnia/src/main/har.ts index c99c7ad57c..72e51a2a68 100644 --- a/packages/insomnia/src/main/har.ts +++ b/packages/insomnia/src/main/har.ts @@ -4,7 +4,7 @@ import { models, services } from 'insomnia-data'; import { Cookie as ToughCookie } from 'tough-cookie'; import { RenderError } from '~/common/templating/render-error'; -import type { RenderedRequest } from '~/common/templating/types'; +import type { RenderedRequest, RenderPurpose } from '~/common/templating/types'; import { parseGraphQLReqeustBody } from '~/common/utils/graph-ql'; import { smartEncodeUrl } from '~/common/utils/url/querystring'; import { getAuthHeader } from '~/main/network/get-auth-header'; @@ -218,7 +218,7 @@ export async function exportHarResponse(response?: Response) { return harResponse; } -export async function exportHarRequest(requestId: string, environmentOrWorkspaceId: string, addContentLength = false) { +export async function exportHarRequest(requestId: string, environmentOrWorkspaceId: string, addContentLength = false, purpose?: RenderPurpose) { const request = await services.request.getById(requestId); let environmentId: string; if (models.environment.isEnvironmentId(environmentOrWorkspaceId)) { @@ -239,12 +239,12 @@ export async function exportHarRequest(requestId: string, environmentOrWorkspace return null; } - return exportHarWithRequest(request, environmentId, addContentLength); + return exportHarWithRequest(request, environmentId, addContentLength, purpose); } -export async function exportHarWithRequest(request: Request, environmentId?: string, addContentLength = false) { +export async function exportHarWithRequest(request: Request, environmentId?: string, addContentLength = false, purpose?: RenderPurpose) { try { - const renderResult = await getRenderedRequestAndContext({ request, environment: environmentId }); + const renderResult = await getRenderedRequestAndContext({ request, environment: environmentId, purpose }); const renderedRequest = await getRuntime().network.applyRequestHooks(renderResult.request, renderResult.context); parseGraphQLReqeustBody(renderedRequest); return exportHarWithRenderedRequest(renderedRequest, addContentLength); diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 5a3169d34e..411fcf047b 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -32,7 +32,7 @@ import type { MockRouteData, ModelConfig, } from '~/common/plugins/types'; -import type { RenderedRequest } from '~/common/templating/types'; +import type { RenderedRequest, RenderPurpose } from '~/common/templating/types'; import { bundleSpectralRuleset } from '~/main/bundle-spectral-ruleset'; import { initializeWorkspaceBackendProject, syncNewWorkspaceIfNeeded } from '~/main/cloud-sync/initialization'; import type { SyncBridgeAPI } from '~/main/cloud-sync/ipc'; @@ -320,11 +320,13 @@ export interface RendererToMainBridgeAPI { requestId: string; environmentId?: string; addContentLength?: boolean; + purpose?: RenderPurpose; }) => Promise; exportHarRequest: (options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean; + purpose?: RenderPurpose; }) => Promise; exportHarCurrentRequest: (options: { requestId: string; responseId: string }) => Promise; exportRequestsHAR: (options: { requests: any[]; includePrivateDocs?: boolean }) => Promise; @@ -567,19 +569,19 @@ export function registerMainHandlers() { ipcMainHandle( 'exportHarWithRequest', - async (_, options: { requestId: string; environmentId?: string; addContentLength?: boolean }) => { + async (_, options: { requestId: string; environmentId?: string; addContentLength?: boolean; purpose?: RenderPurpose }) => { const request = await services.request.getById(options.requestId); if (!request) { throw new Error(`Request ${options.requestId} not found`); } - return exportHarWithRequest(request, options.environmentId, options.addContentLength); + return exportHarWithRequest(request, options.environmentId, options.addContentLength, options.purpose); }, ); ipcMainHandle( 'exportHarRequest', - async (_, options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean }) => { - return exportHarRequest(options.requestId, options.environmentOrWorkspaceId, options.addContentLength); + async (_, options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean; purpose?: RenderPurpose }) => { + return exportHarRequest(options.requestId, options.environmentOrWorkspaceId, options.addContentLength, options.purpose); }, ); From 03076765745b4742fcd88d205f498d301dee1aff Mon Sep 17 00:00:00 2001 From: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:05:29 +0800 Subject: [PATCH 2/3] feat: apply confidential policy to preview surfaces (#10510) * feat(phase3-ui): apply confidential policy to all preview surfaces Wire purpose:'preview' explicitly into RenderedText, RenderedQueryString, CookiesModal, CodeEditor, and OneLineEditor so they respond to the hideSecretValuesInPreviewAndConsole setting (Phase 3.2). Refactor VariableEditor to use isRevealed state with forceReveal instead of toggling purpose, making the eye button available for all variable types (normal confidential, SECRET, external vault). Short-circuit external vault tag run() in both liquid-extension.ts and liquid-extension-worker.ts via shouldMaskExternalVaultTag() so no provider fetch occurs when the policy says mask (Phase 3.3). Add isRevealed/forceReveal to TagEditor for consistent reveal UX. Wire purpose:'codegen' in request-actions-dropdown and generate-code-modal callers to complete the codegen/export split (Phase 3.4 UI callers). Co-Authored-By: Claude Sonnet 4.6 (1M context) * refactor(phase3-ui): generate-code callers use purpose:preview instead of codegen Now that codegen purpose has been removed from RenderPurpose, switch both Generate Code modal and Copy as cURL (request-actions-dropdown) to pass purpose:'preview'. Behaviour is identical: setting ON masks confidential values, setting OFF reveals them. Co-Authored-By: Claude Sonnet 4.6 (1M context) * update * fix: process in worker * fix: smoke test * fix lint --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- .../smoke/external-vault-integration.test.ts | 10 ++-- packages/insomnia/src/common/constants.ts | 4 +- .../templating/liquid-extension-worker.ts | 8 +++ .../src/templating/liquid-extension.ts | 8 +++ .../.client/codemirror/code-editor.tsx | 8 ++- .../.client/codemirror/one-line-editor.tsx | 8 ++- .../dropdowns/request-actions-dropdown.tsx | 1 + .../ui/components/modals/cookies-modal.tsx | 4 +- .../components/modals/generate-code-modal.tsx | 1 + .../ui/components/rendered-query-string.tsx | 4 +- .../src/ui/components/rendered-text.tsx | 4 +- .../ui/components/templating/tag-editor.tsx | 25 +++++++++- .../components/templating/variable-editor.tsx | 50 +++++++------------ .../src/ui/context/nunjucks/use-nunjucks.ts | 2 +- .../src/ui/hooks/use-editor-refresh.ts | 14 ++++-- 15 files changed, 99 insertions(+), 52 deletions(-) diff --git a/packages/insomnia-smoke-test/tests/smoke/external-vault-integration.test.ts b/packages/insomnia-smoke-test/tests/smoke/external-vault-integration.test.ts index 9a79fb543f..ce882eaaa3 100644 --- a/packages/insomnia-smoke-test/tests/smoke/external-vault-integration.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/external-vault-integration.test.ts @@ -64,12 +64,7 @@ test('Setup external vault and used in request', async ({ app, page, insomnia }) await page.locator('#close-add-cloud-credential-modal').click(); // close the settings - await page.locator('.app').press('Escape'); - // dismiss any remaining modal overlay before interacting with the sidebar - await page - .locator('[data-close-modal="true"]') - .click() - .catch(() => {}); + await page.getByRole('button', { name: 'Modal Close Button' }).click(); // used in request await insomnia.navigationSidebar.clickRequestOrFolder('External Vault Tag'); @@ -91,18 +86,21 @@ test('Setup external vault and used in request', async ({ app, page, insomnia }) // test aws vault tag await page.locator(`[data-template^="${externalVaultTestCases.aws.tagPrefix}"]`).click(); await page.getByLabel('Credential For Vault Service').selectOption(awsCredentialName); + await page.getByRole('dialog').getByRole('button', { name: 'Reveal value' }).click(); const previewText = page.getByRole('dialog').getByLabel('Live Preview'); await expect.soft(previewText).toHaveText(externalVaultTestCases.aws.expectedResult); await page.getByRole('button', { name: 'Done' }).click(); // test gcp vault tag await page.locator(`[data-template^="${externalVaultTestCases.gcp.tagPrefix}"]`).click(); await page.getByLabel('Credential For Vault Service').selectOption(gcpCredentialName); + await page.getByRole('dialog').getByRole('button', { name: 'Reveal value' }).click(); const gcpPreviewText = page.getByRole('dialog').getByLabel('Live Preview'); await expect.soft(gcpPreviewText).toHaveText(externalVaultTestCases.gcp.expectedResult); await page.getByRole('button', { name: 'Done' }).click(); // test hashicorp vault tag await page.locator(`[data-template^="${externalVaultTestCases.hashicorp.tagPrefix}"]`).click(); await page.getByLabel('Credential For Vault Service').selectOption(hashicorpCredentialName); + await page.getByRole('dialog').getByRole('button', { name: 'Reveal value' }).click(); const hashicorpPreviewText = page.getByRole('dialog').getByLabel('Live Preview'); await expect.soft(hashicorpPreviewText).toHaveText(externalVaultTestCases.hashicorp.expectedResult); await page.getByRole('button', { name: 'Done' }).click(); diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index e649e9b11f..40fb9613ca 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -17,8 +17,8 @@ import { version } from '../../package.json'; // In the inso CLI and main process, fall back to process.env. const ENV = 'env'; -// eslint-disable-next-line no-restricted-globals -- isomorphic: guarded by `typeof window`. Renderer reads env from the preload (`window.env`); main process, UtilityProcess and the inso CLI fall back to process.env. -const env = typeof window !== 'undefined' && window.env ? window.env : process[ENV]; +// eslint-disable-next-line no-restricted-globals -- isomorphic: guarded by `typeof window`/`typeof process`. Renderer reads env from the preload (`window.env`); main process, UtilityProcess and the inso CLI fall back to process.env; dedicated Web Workers (e.g. the templating worker) have neither, so fall back to `{}` there instead of throwing. +const env = typeof window !== 'undefined' && window.env ? window.env : typeof process !== 'undefined' ? process[ENV] : {}; export const INSOMNIA_GITLAB_REDIRECT_URI = env.INSOMNIA_GITLAB_REDIRECT_URI; export const INSOMNIA_GITLAB_CLIENT_ID = env.INSOMNIA_GITLAB_CLIENT_ID; diff --git a/packages/insomnia/src/common/templating/liquid-extension-worker.ts b/packages/insomnia/src/common/templating/liquid-extension-worker.ts index 614542051c..ec279cc002 100644 --- a/packages/insomnia/src/common/templating/liquid-extension-worker.ts +++ b/packages/insomnia/src/common/templating/liquid-extension-worker.ts @@ -5,6 +5,7 @@ import { Tag } from 'liquidjs'; import type { Plugin } from '~/common/plugins/types'; import packageJson from '../../../package.json'; +import { CONFIDENTIAL_MASK_VALUE, shouldMaskExternalVaultTag } from './confidential-value-policy'; import { resolveArg } from './resolve-arg'; import { tokenizeArgs } from './tokenize-args'; import type { @@ -182,6 +183,13 @@ export function createLiquidTagWorker( }, }; + // Short-circuit external vault before any provider fetch when the policy says mask. + const renderSettings = renderContext.getSettings?.(); + if (shouldMaskExternalVaultTag({ pluginName: plugin.name, tagName: ext.name, renderPurpose, settings: renderSettings })) { + emitter.write(CONFIDENTIAL_MASK_VALUE); + return; + } + const result = await Promise.resolve(ext.run(helperContext, ...args)); emitter.write(result == null ? '' : String(result)); } diff --git a/packages/insomnia/src/templating/liquid-extension.ts b/packages/insomnia/src/templating/liquid-extension.ts index 982fa48fcb..91f1b0fc0d 100644 --- a/packages/insomnia/src/templating/liquid-extension.ts +++ b/packages/insomnia/src/templating/liquid-extension.ts @@ -10,6 +10,7 @@ import { Tag } from 'liquidjs'; import { jarFromCookies } from '~/common/cookies'; import type { Plugin } from '~/common/plugins/types'; +import { CONFIDENTIAL_MASK_VALUE, shouldMaskExternalVaultTag } from '~/common/templating/confidential-value-policy'; import { resolveArg } from '~/common/templating/resolve-arg'; import type { BaseRenderContext, PluginTemplateTag, PluginTemplateTagContext } from '~/common/templating/types'; import { decodeEncoding, tokenizeArgs } from '~/common/templating/utils'; @@ -104,6 +105,13 @@ export function createLiquidTag( }, }; + // Short-circuit external vault before any provider fetch when the policy says mask. + const renderSettings = renderContext.getSettings?.(); + if (shouldMaskExternalVaultTag({ pluginName: plugin.name, tagName: ext.name, renderPurpose, settings: renderSettings })) { + emitter.write(CONFIDENTIAL_MASK_VALUE); + return; + } + const result = await Promise.resolve(ext.run(helperContext, ...args)); emitter.write(result == null ? '' : String(result)); } diff --git a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx index 0aa84f800e..9d80726480 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx @@ -36,7 +36,7 @@ import { showModal } from '~/ui/components/modals/index'; import { NunjucksModal } from '~/ui/components/modals/nunjucks-modal'; import { UpgradeModal } from '~/ui/components/modals/upgrade-modal'; import { isKeyCombinationInRegistry } from '~/ui/components/settings/shortcuts'; -import { useNunjucks } from '~/ui/context/nunjucks/use-nunjucks'; +import { useNunjucks, type UseNunjucksOptions } from '~/ui/context/nunjucks/use-nunjucks'; import { useEditorRefresh } from '~/ui/hooks/use-editor-refresh'; import { useOpenVariableSource } from '~/ui/hooks/use-open-variable-source'; import { usePlanData } from '~/ui/hooks/use-plan'; @@ -49,6 +49,10 @@ import { queryXPath } from '~/ui/utils/xpath/query'; import { getCachedEditorState, setCachedEditorState } from './editor-state-cache'; import { hideVariableSourceTooltip, isVariableSourceTooltipInside } from './extensions/variable-source-tooltip'; import { normalizeIrregularWhitespace } from './normalize-irregular-whitespace'; + +// Stable options object so handleRender reference stays constant across re-renders. +// A new object per render would cause initEditor to re-run on every render. +const PREVIEW_NUNJUCKS_OPTIONS: UseNunjucksOptions = { renderContext: { purpose: 'preview' } }; const TAB_SIZE = 4; const MAX_SIZE_FOR_LINTING = 1_000_000; // Around 1MB const LONG_LINE_THRESHOLD = 10_000; // Collapse lines longer than 10,000 characters @@ -293,7 +297,7 @@ export const CodeEditor = memo( }), [indentChars], ); - const { handleRender, handleGetRenderContext } = useNunjucks(); + const { handleRender, handleGetRenderContext } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS); const openVariableSource = useOpenVariableSource(); const isNunjucksEnabled = enableNunjucks && handleRender; const shouldTruncateLongLines = !!readOnly && !!truncateLongLines; diff --git a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx index 77989c55a8..9bb240c513 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx @@ -31,7 +31,7 @@ import { NunjucksModal } from '~/ui/components/modals/nunjucks-modal'; import { UpgradeModal } from '~/ui/components/modals/upgrade-modal'; import { isKeyCombinationInRegistry } from '~/ui/components/settings/shortcuts'; import { Tooltip } from '~/ui/components/tooltip'; -import { useNunjucks } from '~/ui/context/nunjucks/use-nunjucks'; +import { useNunjucks, type UseNunjucksOptions } from '~/ui/context/nunjucks/use-nunjucks'; import { useEditorRefresh } from '~/ui/hooks/use-editor-refresh'; import { useOpenVariableSource } from '~/ui/hooks/use-open-variable-source'; import { usePlanData } from '~/ui/hooks/use-plan'; @@ -42,6 +42,10 @@ import { getTagDefinitions } from '~/ui/templating/renderer-safe'; import { getCachedEditorState, setCachedEditorState } from './editor-state-cache'; import { hideVariableSourceTooltip, isVariableSourceTooltipInside } from './extensions/variable-source-tooltip'; +// Stable options object so handleRender reference stays constant across re-renders. +// A new object per render would cause initEditor to re-run on every render. +const PREVIEW_NUNJUCKS_OPTIONS: UseNunjucksOptions = { renderContext: { purpose: 'preview' } }; + // Replace the editor's entire value while PRESERVING undo/redo history and the // cursor. Unlike cm.setValue(), which clears history, replaceRange records the // change as a normal, undoable edit. No-ops when the value is unchanged so we @@ -114,7 +118,7 @@ export const OneLineEditor = forwardRef ); const { settings } = useRootLoaderData()!; const { isOwner, isEnterprisePlan } = usePlanData(); - const { handleRender, handleGetRenderContext } = useNunjucks(); + const { handleRender, handleGetRenderContext } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS); const openVariableSource = useOpenVariableSource(); const isPasswordEditor = type?.toLowerCase() === 'password'; diff --git a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx index 1c2a6df9b0..bb60a95ee3 100644 --- a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx @@ -161,6 +161,7 @@ export const RequestActionsDropdown = ({ const har = await window.main.exportHarRequest({ requestId: request._id, environmentOrWorkspaceId: workspaceId, + purpose: 'preview', }); if (!har) { return; diff --git a/packages/insomnia/src/ui/components/modals/cookies-modal.tsx b/packages/insomnia/src/ui/components/modals/cookies-modal.tsx index d52ecf986b..8db977e7aa 100644 --- a/packages/insomnia/src/ui/components/modals/cookies-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/cookies-modal.tsx @@ -48,8 +48,10 @@ interface Props { setIsOpen: (isOpen: boolean) => void; } +const PREVIEW_NUNJUCKS_OPTIONS = { renderContext: { purpose: 'preview' as const } }; + export const CookiesModal = ({ setIsOpen }: Props) => { - const { handleRender } = useNunjucks(); + const { handleRender } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS); const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; diff --git a/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx b/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx index 901c7da8c4..cb843d35dd 100644 --- a/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx @@ -92,6 +92,7 @@ export const GenerateCodeModal = forwardRef((pro requestId: request._id, environmentId: props.environmentId, addContentLength, + purpose: 'preview', }); if (har) { const cmd = await window.main.generateCodeSnippet({ diff --git a/packages/insomnia/src/ui/components/rendered-query-string.tsx b/packages/insomnia/src/ui/components/rendered-query-string.tsx index 6b1fc00a13..e2d953bf0c 100644 --- a/packages/insomnia/src/ui/components/rendered-query-string.tsx +++ b/packages/insomnia/src/ui/components/rendered-query-string.tsx @@ -61,10 +61,12 @@ async function getQueryParamsFromAuth( const MAX_URL_LENGTH = 10 * 1024; +const PREVIEW_NUNJUCKS_OPTIONS = { renderContext: { purpose: 'preview' as const } }; + export const RenderedQueryString: FC = ({ request }) => { const [previewString, setPreviewString] = useState(defaultPreview); const [tooLong, setTooLong] = useState(false); - const { handleRender } = useNunjucks(); + const { handleRender } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS); useEffect(() => { const fn = async () => { diff --git a/packages/insomnia/src/ui/components/rendered-text.tsx b/packages/insomnia/src/ui/components/rendered-text.tsx index ec7f3eeba2..7e869dc810 100644 --- a/packages/insomnia/src/ui/components/rendered-text.tsx +++ b/packages/insomnia/src/ui/components/rendered-text.tsx @@ -4,6 +4,8 @@ import type { HandleRender } from '~/common/templating/types'; import { useNunjucks } from '../context/nunjucks/use-nunjucks'; +const PREVIEW_NUNJUCKS_OPTIONS = { renderContext: { purpose: 'preview' as const } }; + interface Props { children: string; render: HandleRender; @@ -71,7 +73,7 @@ class RenderedTextInternal extends PureComponent { } export const RenderedText: FC> = props => { - const { handleRender } = useNunjucks({ requestId: props.requestId }); + const { handleRender } = useNunjucks({ ...PREVIEW_NUNJUCKS_OPTIONS, requestId: props.requestId }); return ; }; diff --git a/packages/insomnia/src/ui/components/templating/tag-editor.tsx b/packages/insomnia/src/ui/components/templating/tag-editor.tsx index 091c06a167..8d9c48e865 100644 --- a/packages/insomnia/src/ui/components/templating/tag-editor.tsx +++ b/packages/insomnia/src/ui/components/templating/tag-editor.tsx @@ -2,7 +2,7 @@ import classnames from 'classnames'; import clone from 'clone'; import type { BaseModel, CloudProviderCredential, PluginData, Request, RequestGroup, Workspace } from 'insomnia-data'; import { models, services } from 'insomnia-data'; -import React, { type FC, useCallback, useEffect, useRef, useState } from 'react'; +import React, { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Link } from 'react-aria-components'; import * as reactUse from 'react-use'; @@ -87,7 +87,12 @@ export const TagEditor: FC = props => { variables: [], vaultPluginData: [], }); - const { handleRender, handleGetRenderContext } = useNunjucks({ renderContext: { purpose: 'preview' } }); + const [isRevealed, setIsRevealed] = useState(false); + const nunjucksOptions = useMemo( + () => ({ renderContext: { purpose: 'preview' as const, forceReveal: isRevealed } }), + [isRevealed], + ); + const { handleRender, handleGetRenderContext } = useNunjucks(nunjucksOptions); const isEditorMountedRef = useRef(true); useEffect(() => { @@ -150,6 +155,14 @@ export const TagEditor: FC = props => { refreshModels(); }, [refreshModels]); + useEffect(() => { + const { tagDefinitions, activeTagDefinition, activeTagData } = state; + if (activeTagData) { + update(tagDefinitions, activeTagDefinition, activeTagData, true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRevealed]); + async function updateArg( argValue: string | number | boolean, argIndex: number, @@ -720,6 +733,14 @@ export const TagEditor: FC = props => { })} /> +