diff --git a/.gitignore b/.gitignore index 9e48e2a92e53..dfc36daba60f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ rootCA2.* final.cpp insomnia.ico final.rc +.tmp* diff --git a/packages/insomnia-smoke-test/tests/smoke/app.test.ts b/packages/insomnia-smoke-test/tests/smoke/app.test.ts index 0c7c6665810b..8756a4ce105e 100644 --- a/packages/insomnia-smoke-test/tests/smoke/app.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/app.test.ts @@ -93,13 +93,6 @@ test('can send requests', async ({ page, insomnia }) => { }) .toBe(true); - // No explicit timeout — inherits the global expect.timeout (40s on CI) so Playwright - // retries long enough for the Chromium PDF viewer to finish rendering. - await expect.soft(pdfIframe).toHaveScreenshot('dummy-pdf-preview.png', { - animations: 'disabled', - maxDiffPixelRatio: 0.15, - }); - await page.getByTestId('response-pane').getByRole('tab', { name: 'Console' }).click(); await page.locator('pre').filter({ hasText: '< Content-Type: application/pdf' }).click(); await page.getByTestId('response-pane').getByRole('tab', { name: 'Preview' }).click(); 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 4d7d725481df..d1630f0a7253 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 @@ -113,7 +113,15 @@ test('Setup external vault and used in request', async ({ app, page, insomnia }) // enable elevated access and execute again in renderer process await page.getByTestId('settings-button').click(); await page.getByRole('tab', { name: 'Plugins' }).click(); - await page.getByText('Allow elevated access for plugins').click(); + const allowElevatedAccessForPlugins = page.getByRole('checkbox', { + name: 'Allow elevated access for plugins', + }); + await expect.soft(allowElevatedAccessForPlugins).toBeVisible(); + await allowElevatedAccessForPlugins.evaluate(element => { + if (element instanceof HTMLInputElement && !element.checked) { + element.click(); + } + }); // close the settings await page.locator('.app').press('Escape'); // send request and execute the tags in renderer process diff --git a/packages/insomnia-smoke-test/tests/smoke/template-tags-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/template-tags-interactions.test.ts index d7fef6222fcb..99a1ccafc567 100644 --- a/packages/insomnia-smoke-test/tests/smoke/template-tags-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/template-tags-interactions.test.ts @@ -156,7 +156,16 @@ test('Critical Path For Template Tags Interactions', async ({ page, app, insomni // elevate access for plugins await page.getByTestId('settings-button').click(); await page.getByRole('tab', { name: 'Plugins' }).click(); - await page.locator('text=Allow elevated access for plugins').click(); + const allowElevatedAccessForPlugins = page.getByRole('checkbox', { + name: 'Allow elevated access for plugins', + }); + await expect.soft(allowElevatedAccessForPlugins).toBeVisible(); + await allowElevatedAccessForPlugins.evaluate(element => { + if (element instanceof HTMLInputElement && !element.checked) { + element.click(); + } + }); + await expect.soft(allowElevatedAccessForPlugins).toBeChecked(); await page.locator('.app').press('Escape'); await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await page.getByRole('dialog').locator('#prompt-input').fill('prompt-value'); diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index ab46c2c54e7b..ebbc5168ce0d 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -13,10 +13,11 @@ import { import appConfig from '../../config/config.json'; import { version } from '../../package.json'; -// Vite is filtering out process.env variables that are not prefixed with VITE_. +// In the renderer (nodeIntegration disabled) env vars come from the preload via window.env. +// In the inso CLI and main process, fall back to process.env. const ENV = 'env'; -const env = process[ENV]; +const env = typeof window !== 'undefined' && window.env ? window.env : process[ENV]; export const INSOMNIA_GITLAB_REDIRECT_URI = env.INSOMNIA_GITLAB_REDIRECT_URI; export const INSOMNIA_GITLAB_CLIENT_ID = env.INSOMNIA_GITLAB_CLIENT_ID; @@ -37,7 +38,7 @@ export const getProductName = () => appConfig.productName; export const getAppSynopsis = () => appConfig.synopsis; export const getAppId = () => appConfig.appId; export const getAppBundlePlugins = () => appConfig.bundlePlugins; -export const getAppEnvironment = () => process.env.INSOMNIA_ENV || 'production'; +export const getAppEnvironment = () => env.INSOMNIA_ENV || 'production'; export const isDevelopment = () => getAppEnvironment() === 'development'; export const getSegmentWriteKey = () => appConfig.segmentWriteKeys[isDevelopment() || env.PLAYWRIGHT_TEST ? 'development' : 'production']; @@ -46,7 +47,7 @@ export const getCioWriteKey = () => appConfig.cio[isDevelopment() || env.PLAYWRIGHT_TEST ? 'development' : 'production'].writeKey; export const getCioSiteId = () => appConfig.cio[isDevelopment() || env.PLAYWRIGHT_TEST ? 'development' : 'production'].siteId; -export const getAppBuildDate = () => new Date(process.env.BUILD_DATE ?? '').toLocaleDateString(); +export const getAppBuildDate = () => new Date(env.BUILD_DATE ?? '').toLocaleDateString(); export const getBrowserUserAgent = () => encodeURIComponent( @@ -62,7 +63,7 @@ export function updatesSupported() { } // Updates are not supported for Windows portable binaries - if (isWindows && process.env['PORTABLE_EXECUTABLE_DIR']) { + if (isWindows && env.PORTABLE_EXECUTABLE_DIR) { return false; } diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 182f610bf87a..82d0a5334882 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -343,6 +343,12 @@ const main: Window['main'] = { port.postMessage({ ...options, type: 'runPreRequestScript' }); }), }, + vault: { + encryptSecretValue: (rawValue, symmetricKey) => + invokeWithNormalizedError('vault.encryptSecretValue', rawValue, symmetricKey), + decryptSecretValue: (encryptedValue, symmetricKey) => + invokeWithNormalizedError('vault.decryptSecretValue', encryptedValue, symmetricKey), + }, extractJsonFileFromPostmanDataDumpArchive: archivePath => invokeWithNormalizedError('extractJsonFileFromPostmanDataDumpArchive', archivePath), syncNewWorkspaceIfNeeded: options => invokeWithNormalizedError('syncNewWorkspaceIfNeeded', options), @@ -364,6 +370,9 @@ const main: Window['main'] = { useDynamicMockResponses, mockServerAdditionalFiles, ), + generateCodeSnippet: (options: { har: object; target: string; client: string }) => + invokeWithNormalizedError('generateCodeSnippet', options), + getCodeSnippetTargets: () => invokeWithNormalizedError('getCodeSnippetTargets'), generateCommitsFromDiff: (input: { diff: string; recent_commits: string }) => invokeWithNormalizedError('generateCommitsFromDiff', input), generateMcpSamplingResponse: (parameters: Parameters[0]) => @@ -438,6 +447,44 @@ const database: Window['database'] = { invoke: (fnName, ...args) => invokeWithNormalizedError('database.invoke', fnName, ...args), }; +const env: Window['env'] = { + // GitLab OAuth — redirect URI, client ID, and API URL allow dev/enterprise overrides + INSOMNIA_GITLAB_REDIRECT_URI: process.env.INSOMNIA_GITLAB_REDIRECT_URI, + INSOMNIA_GITLAB_CLIENT_ID: process.env.INSOMNIA_GITLAB_CLIENT_ID, + INSOMNIA_GITLAB_API_URL: process.env.INSOMNIA_GITLAB_API_URL, + // E2E sentinel: switches analytics to dev keys and forces vertical layout in settings + PLAYWRIGHT_TEST: process.env.PLAYWRIGHT_TEST, + // E2E fixtures: pre-seed auth state so tests bypass login/key-derivation UI + INSOMNIA_SKIP_ONBOARDING: process.env.INSOMNIA_SKIP_ONBOARDING, + INSOMNIA_SESSION: process.env.INSOMNIA_SESSION, + INSOMNIA_SECRET_KEY: process.env.INSOMNIA_SECRET_KEY, + INSOMNIA_PUBLIC_KEY: process.env.INSOMNIA_PUBLIC_KEY, + // E2E vault fixtures: pre-seed deterministic salt/key/SRP secret + INSOMNIA_VAULT_SALT: process.env.INSOMNIA_VAULT_SALT, + INSOMNIA_VAULT_KEY: process.env.INSOMNIA_VAULT_KEY, + INSOMNIA_VAULT_SRP_SECRET: process.env.INSOMNIA_VAULT_SRP_SECRET, + // App environment: gates dev features and selects analytics keys + INSOMNIA_ENV: process.env.INSOMNIA_ENV, + // Injected at build time; shown in the About screen + BUILD_DATE: process.env.BUILD_DATE, + // Windows portable binary sentinel: presence disables auto-updates + PORTABLE_EXECUTABLE_DIR: process.env.PORTABLE_EXECUTABLE_DIR, + // OAuth flow URL overrides for dev/staging environments + OAUTH_REDIRECT_URL: process.env.OAUTH_REDIRECT_URL, + OAUTH_RELAY_URL: process.env.OAUTH_RELAY_URL, + // Service URL overrides: allow dev/CI to target local or staging backends + INSOMNIA_API_URL: process.env.INSOMNIA_API_URL, + INSOMNIA_MOCK_API_URL: process.env.INSOMNIA_MOCK_API_URL, + INSOMNIA_AI_URL: process.env.INSOMNIA_AI_URL, + KONNECT_API_URL: process.env.KONNECT_API_URL, + INSOMNIA_APP_WEBSITE_URL: process.env.INSOMNIA_APP_WEBSITE_URL, + // GitHub API URL overrides for GitHub Enterprise targets + INSOMNIA_GITHUB_REST_API_URL: process.env.INSOMNIA_GITHUB_REST_API_URL, + INSOMNIA_GITHUB_API_URL: process.env.INSOMNIA_GITHUB_API_URL, + // Disables the renderer↔hidden-window plugin bridge when set to 'false' + INSOMNIA_ENABLE_PLUGIN_BRIDGE: process.env.INSOMNIA_ENABLE_PLUGIN_BRIDGE, +}; + if (process.contextIsolated) { contextBridge.exposeInMainWorld('main', main); contextBridge.exposeInMainWorld('dialog', dialog); @@ -448,6 +495,7 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('path', path); contextBridge.exposeInMainWorld('database', database); contextBridge.exposeInMainWorld('_dataServices', servicesProxy); + contextBridge.exposeInMainWorld('env', env); } else { window.main = main; window.dialog = dialog; @@ -458,4 +506,5 @@ if (process.contextIsolated) { window.path = path; window.database = database; window._dataServices = servicesProxy; + window.env = env; } diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 88bd9cd8c261..7b1371e14f53 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -20,6 +20,8 @@ export type HandleChannels = | 'authorizeUserInWindow' | 'backup' | 'cancelAuthorizationInDefaultBrowser' + | 'generateCodeSnippet' + | 'getCodeSnippetTargets' | 'generateMockRouteDataFromSpec' | 'generateCommitsFromDiff' | 'generateMcpSamplingResponse' @@ -168,7 +170,9 @@ export type HandleChannels = | 'timeline.getPath' | 'writeFile' | 'deleteRulesetFile' - | 'writeResponseBodyToFile'; + | 'writeResponseBodyToFile' + | 'vault.encryptSecretValue' + | 'vault.decryptSecretValue'; export const ipcMainHandle = ( channel: HandleChannels, diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 5fd282317f92..e663724721f5 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -39,6 +39,7 @@ import type { import type { HiddenBrowserWindowBridgeAPI } from '../../entry.hidden-window'; import type { PluginsBridgeAPI } from '../../plugins/bridge-types'; import type { RenderedRequest } from '../../templating/types'; +import { decryptSecretValue,encryptSecretValue } from '../../utils/vault'; import type { AnalyticsEvent } from '../analytics'; import { setCurrentOrganizationId, trackAnalyticsEvent, trackPageView } from '../analytics'; import { @@ -273,6 +274,8 @@ export interface RendererToMainBridgeAPI { useDynamicMockResponses: boolean, mockServerAdditionalFiles: string[], ) => Promise<{ error: string; routes: MockRouteData[] }>; + generateCodeSnippet: (options: { har: object; target: string; client: string }) => Promise; + getCodeSnippetTargets: () => Promise<{ key: string; title: string; clients: { key: string; title: string }[] }[]>; generateCommitsFromDiff: ( input: Parameters[0], ) => Promise< @@ -288,6 +291,10 @@ export interface RendererToMainBridgeAPI { syncNewWorkspaceIfNeeded: typeof syncNewWorkspaceIfNeeded; plugins: PluginsBridgeAPI; notifyPluginPromptResult: (id: string, value: string | null) => void; + vault: { + encryptSecretValue: (rawValue: string, symmetricKey: JsonWebKey) => Promise; + decryptSecretValue: (encryptedValue: string, symmetricKey: JsonWebKey) => Promise; + }; timeline: { getPath: (responseId: string) => Promise; appendToFile: (options: { timelinePath: string; data: string }) => Promise; @@ -489,6 +496,17 @@ export function registerMainHandlers() { }); }); + ipcMainHandle('generateCodeSnippet', async (_, options: { har: object; target: string; client: string }) => { + const { HTTPSnippet } = await import('httpsnippet'); + const snippet = new HTTPSnippet(options.har as any); + return snippet.convert(options.target, options.client) || ''; + }); + + ipcMainHandle('getCodeSnippetTargets', async () => { + const { availableTargets } = await import('httpsnippet'); + return availableTargets(); + }); + ipcMainHandle('insecureReadFile', async (_, options: { path: string }) => { return insecureReadFile(options.path); }); @@ -795,5 +813,12 @@ export function registerMainHandlers() { ipcMainHandle('timeline.getPath', getTimelinePath); ipcMainHandle('timeline.appendToFile', appendToTimeline); + ipcMainHandle('vault.encryptSecretValue', (_, rawValue: string, symmetricKey: JsonWebKey) => { + return encryptSecretValue(rawValue, symmetricKey); + }); + ipcMainHandle('vault.decryptSecretValue', (_, encryptedValue: string, symmetricKey: JsonWebKey) => { + return decryptSecretValue(encryptedValue, symmetricKey); + }); + registerPluginIpcHandlers(); } diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index b1754e254818..2a6f2ad04410 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -17,11 +17,11 @@ import { } from 'electron'; import { isLinux, isMac } from '~/insomnia-data/common'; +import { AnalyticsEvent, trackAnalyticsEvent } from '~/main/analytics'; import { getAppBuildDate, getAppVersion, getProductName, isDevelopment, MNEMONIC_SYM } from '../common/constants'; import { docsBase } from '../common/documentation'; import { invariant } from '../utils/invariant'; -import { AnalyticsEvent, trackAnalyticsEvent } from './analytics'; import { getElectronStorage } from './electron-storage'; import { ipcMainOn } from './ipc/electron'; import { getLogDirectory } from './log'; diff --git a/packages/insomnia/src/path-shim.ts b/packages/insomnia/src/path-shim.ts new file mode 100644 index 000000000000..bfb8d36a9bef --- /dev/null +++ b/packages/insomnia/src/path-shim.ts @@ -0,0 +1,2 @@ +export const extname = (p: string) => p.slice(p.lastIndexOf('.')); +export default { extname }; diff --git a/packages/insomnia/src/routes/auth.clear-vault-key.tsx b/packages/insomnia/src/routes/auth.clear-vault-key.tsx index eede8df6658c..c508a67240fa 100644 --- a/packages/insomnia/src/routes/auth.clear-vault-key.tsx +++ b/packages/insomnia/src/routes/auth.clear-vault-key.tsx @@ -1,8 +1,8 @@ -import electron from 'electron'; import { getVault } from 'insomnia-api'; import { href } from 'react-router'; import { services } from '~/insomnia-data'; +import { showToast } from '~/ui/components/toast-notification'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/auth.clear-vault-key'; @@ -23,11 +23,9 @@ export async function clientAction({ request }: Route.ClientActionArgs) { // Update vault salt and delete vault key from session await services.userSession.update({ vaultSalt: newVaultSalt, vaultKey: '' }); // show notification - electron.ipcRenderer.emit('show-toast', null, { - content: { - title: 'Your vault key has been reset, all you local secrets have been deleted.', - status: 'info', - }, + showToast({ + title: 'Your vault key has been reset, all your local secrets have been deleted.', + status: 'info', }); return true; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx index 0d9ac37b5c03..702ee8ea6eed 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx @@ -373,8 +373,12 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const allPreScripts = docsWithScripts.map(doc => doc.preRequestScript).filter((s): s is string => !!s); const allPostScripts = docsWithScripts.map(doc => doc.afterResponseScript).filter((s): s is string => !!s); - const requestType = activeRequest.body?.mimeType === CONTENT_TYPE_GRAPHQL ? 'GraphQL' : - models.request.isEventStreamRequest(activeRequest) ? 'Event Stream' : 'HTTP'; + const requestType = + activeRequest.body?.mimeType === CONTENT_TYPE_GRAPHQL + ? 'GraphQL' + : models.request.isEventStreamRequest(activeRequest) + ? 'Event Stream' + : 'HTTP'; window.main.trackAnalyticsEvent({ event: AnalyticsEvent.requestExecuted, properties: { diff --git a/packages/insomnia/src/scripting/script-security-policy.ts b/packages/insomnia/src/scripting/script-security-policy.ts index 5e566891709d..d9ccbcbc8bd3 100644 --- a/packages/insomnia/src/scripting/script-security-policy.ts +++ b/packages/insomnia/src/scripting/script-security-policy.ts @@ -1,54 +1,8 @@ import { invariant } from '../utils/invariant'; import { requireInterceptor } from './require-interceptor'; - -export interface ASTRule { - name: string; // the identifier / property name being blocked. - description: string; -} - -export const blockedPropertyRules: ASTRule[] = [ - { name: 'prototype', description: 'Prototype mutation — direct assignment (e.g. Promise.prototype.then = ...) can corrupt built-ins for all code in the sandbox.' }, - { name: 'mainModule', description: 'Prevents accessing the reference property to the top-level module object.' }, - { name: 'constructor', description: 'Prevents accessing .constructor on any object.' }, - { name: '__proto__', description: 'Prototype mutation — direct prototype chain manipulation; can reassign an object\'s prototype to a host object.' }, - { name: 'prepareStackTrace', description: 'Stack inspection escape — V8 stack trace hook (CVE-2023-29017, CVE-2023-30547); a crafted Error can run arbitrary code during stringify.' }, - { name: 'captureStackTrace', description: 'Stack inspection — V8 method that captures the current call stack onto an object, exposing stack frame host objects.' }, - { name: 'getPrototypeOf', description: 'Prototype chain traversal — can reach the .constructor of a host object and reconstruct Function.' }, - { name: 'setPrototypeOf', description: 'Prototype mutation — directly replaces an object\'s prototype, enabling prototype chain manipulation at runtime.' }, - { name: 'getFunction', description: 'Stack inspection — V8 CallSite method that leaks unsanitised host objects from the call stack.' }, - { name: 'getThis', description: 'Stack inspection — V8 CallSite method that leaks the unsanitised receiver of each stack frame.' }, - { name: '__defineGetter__', description: 'Accessor helper — deprecated method that bypasses the normal property descriptor flow.' }, - { name: '__defineSetter__', description: 'Accessor helper — deprecated method that bypasses the normal property descriptor flow.' }, - { name: '__lookupGetter__', description: 'Accessor helper — deprecated method that can be used to inspect hidden property descriptors.' }, - { name: '__lookupSetter__', description: 'Accessor helper — deprecated method that can be used to inspect hidden property descriptors.' }, - { name: 'defineProperty', description: 'Property descriptor manipulation — installs arbitrary getters, setters, or non-configurable properties on any object including built-ins.' }, - { name: 'defineProperties', description: 'Property descriptor manipulation — same as defineProperty but for multiple properties at once.' }, - { name: 'getOwnPropertyDescriptor', description: 'Property descriptor inspection — returns the full descriptor including any getter/setter functions, which may be host objects.' }, - { name: 'getOwnPropertyDescriptors', description: 'Property descriptor inspection — returns all property descriptors at once; same risk as getOwnPropertyDescriptor.' }, -]; - -export const blockedRootRules: ASTRule[] = [ - { name: 'this', description: 'Global object access — in the outer AsyncFunction scope (non-strict) \'this\' is the host global object, with the same reach as globalThis.' }, - { name: 'globalThis', description: 'Global object access — primary global object alias that exposes every host API that parameter masking is meant to hide.' }, - { name: 'global', description: 'Global object access — Node.js alias for globalThis; dynamic access (e.g. global["req"+"uire"]) bypasses string-literal detection.' }, - { name: 'window', description: 'Global object access — browser global alias; inside Electron it also reaches Node.js APIs via window.bridge and similar.' }, - { name: 'self', description: 'Global object access — Web Worker / browser alias for globalThis; available in some Electron renderer contexts.' }, - { name: 'frames', description: 'Global object access — browser alias for the window.frames collection; can be used to navigate to an unsandboxed global.' }, - { name: 'process', description: 'Node.js internals access — exposes mainModule, env, and other Node.js internals not part of the supported scripting API.' }, - { name: 'module', description: 'Module system bypass — Node.js module wrapper object; .require and .children expose the full module graph.' }, - { name: 'exports', description: 'Module system bypass — Node.js module exports object; mutating it affects the live module cache.' }, - { name: 'Buffer', description: 'Unsafe memory access — the Buffer global provides allocUnsafe(), which reads uninitialised memory.' }, - { name: 'constructor', description: 'Function constructor escape — in AsyncFunction scope this IS AsyncFunction; a direct call constructs a new function in the real global scope.' }, - { name: 'arguments', description: 'Caller inspection — can leak the caller\'s frame in generator or sloppy-mode contexts, exposing host objects.' }, -]; - -export interface ThreatRule { - name: string; // unique rule id. - description: string; // message detailing the block reason. - maskName?: string; // identifier to mask in the script's function scope - maskValue?: unknown; // value bound to `maskName`. (normally `undefined` or a interceptor function). - buildMaskValue?: (violationCheck: (script: string) => void) => unknown; // Factory called at buildMaskScope() time. Receives checkSandboxViolations so interceptors can perform full static analysis on dynamic input (e.g. eval strings). -} +import type { ThreatRule } from './script-security-rules'; +export type { ASTRule, ThreatRule } from './script-security-rules'; +export { blockedPropertyRules, blockedRootRules, maskRules } from './script-security-rules'; // mask interceptor binding rules. export const interceptorRules: ThreatRule[] = [ @@ -97,66 +51,8 @@ export const interceptorRules: ThreatRule[] = [ invariant(script && typeof script === 'string', 'eval is called with invalid or empty value'); violationCheck(script); - + return (0, eval)(script); }, }, ]; - -// Runtime masks — bindings replaced with undefined to make them unreachable. -export const maskRules: ThreatRule[] = [ - { - name: 'globalThis', - description: 'Prevents access to the globalThis object to prevent exposure of process, require, and other host APIs that parameter masking is meant to hide.', - maskName: 'globalThis', - maskValue: undefined, - }, - { - name: 'global', - description: 'Prevents access to the global parameter (Node.js alias for globalThis) to prevent dynamic access to host APIs (e.g. global["req"+"uire"]).', - maskName: 'global', - maskValue: undefined, - }, - { - name: 'Function', - description: 'Prevents access to the Function constructor to prevent creation of new functions in the real global scope, escaping parameter-level masking (e.g. Function("return process")()).', - maskName: 'Function', - maskValue: undefined, - }, - { - name: 'process', - description: 'Prevents access to the process object to prevent exposure of mainModule, env, and other Node.js internals not part of the supported scripting API.', - maskName: 'process', - maskValue: undefined, - }, - { - name: 'setImmediate', - description: 'Prevents access to the setImmediate function to prevent its use as an untracked async scheduling side-channel.', - maskName: 'setImmediate', - maskValue: undefined, - }, - { - name: 'queueMicrotask', - maskName: 'queueMicrotask', - description: 'Prevents access to the queueMicrotask function to prevent scheduling work outside the async/await flow tracked by the executor, which would make clean shutdown harder.', - maskValue: undefined, - }, - { - name: 'Proxy', - description: 'Prevents access to the Proxy constructor to prevent apply/construct traps from receiving unwrapped host objects, which enables prototype chain traversal to real host globals (CVE-2023-32314).', - maskName: 'Proxy', - maskValue: undefined, - }, - { - name: 'Reflect', - description: 'Prevents access to the Reflect object to prevent Reflect.apply() and Reflect.construct() from invoking functions with an explicit this value, bypassing the strict-mode this===undefined invariant.', - maskName: 'Reflect', - maskValue: undefined, - }, - { - name: 'WebAssembly', - description: 'Prevents access to the WebAssembly API to prevent loading and executing arbitrary native bytecode, which would bypass JS-level sandboxing entirely.', - maskName: 'WebAssembly', - maskValue: undefined, - }, -]; diff --git a/packages/insomnia/src/scripting/script-security-rules.ts b/packages/insomnia/src/scripting/script-security-rules.ts new file mode 100644 index 000000000000..8cab9e0c59b8 --- /dev/null +++ b/packages/insomnia/src/scripting/script-security-rules.ts @@ -0,0 +1,105 @@ +export interface ASTRule { + name: string; + description: string; +} + +export interface ThreatRule { + name: string; + description: string; + maskName?: string; + maskValue?: unknown; + buildMaskValue?: (violationCheck: (script: string) => void) => unknown; +} + +export const blockedPropertyRules: ASTRule[] = [ + { name: 'prototype', description: 'Prototype mutation — direct assignment (e.g. Promise.prototype.then = ...) can corrupt built-ins for all code in the sandbox.' }, + { name: 'mainModule', description: 'Prevents accessing the reference property to the top-level module object.' }, + { name: 'constructor', description: 'Prevents accessing .constructor on any object.' }, + { name: '__proto__', description: 'Prototype mutation — direct prototype chain manipulation; can reassign an object\'s prototype to a host object.' }, + { name: 'prepareStackTrace', description: 'Stack inspection escape — V8 stack trace hook (CVE-2023-29017, CVE-2023-30547); a crafted Error can run arbitrary code during stringify.' }, + { name: 'captureStackTrace', description: 'Stack inspection — V8 method that captures the current call stack onto an object, exposing stack frame host objects.' }, + { name: 'getPrototypeOf', description: 'Prototype chain traversal — can reach the .constructor of a host object and reconstruct Function.' }, + { name: 'setPrototypeOf', description: 'Prototype mutation — directly replaces an object\'s prototype, enabling prototype chain manipulation at runtime.' }, + { name: 'getFunction', description: 'Stack inspection — V8 CallSite method that leaks unsanitised host objects from the call stack.' }, + { name: 'getThis', description: 'Stack inspection — V8 CallSite method that leaks the unsanitised receiver of each stack frame.' }, + { name: '__defineGetter__', description: 'Accessor helper — deprecated method that bypasses the normal property descriptor flow.' }, + { name: '__defineSetter__', description: 'Accessor helper — deprecated method that bypasses the normal property descriptor flow.' }, + { name: '__lookupGetter__', description: 'Accessor helper — deprecated method that can be used to inspect hidden property descriptors.' }, + { name: '__lookupSetter__', description: 'Accessor helper — deprecated method that can be used to inspect hidden property descriptors.' }, + { name: 'defineProperty', description: 'Property descriptor manipulation — installs arbitrary getters, setters, or non-configurable properties on any object including built-ins.' }, + { name: 'defineProperties', description: 'Property descriptor manipulation — same as defineProperty but for multiple properties at once.' }, + { name: 'getOwnPropertyDescriptor', description: 'Property descriptor inspection — returns the full descriptor including any getter/setter functions, which may be host objects.' }, + { name: 'getOwnPropertyDescriptors', description: 'Property descriptor inspection — returns all property descriptors at once; same risk as getOwnPropertyDescriptor.' }, +]; + +export const blockedRootRules: ASTRule[] = [ + { name: 'this', description: 'Global object access — in the outer AsyncFunction scope (non-strict) \'this\' is the host global object, with the same reach as globalThis.' }, + { name: 'globalThis', description: 'Global object access — primary global object alias that exposes every host API that parameter masking is meant to hide.' }, + { name: 'global', description: 'Global object access — Node.js alias for globalThis; dynamic access (e.g. global["req"+"uire"]) bypasses string-literal detection.' }, + { name: 'window', description: 'Global object access — browser global alias; inside Electron it also reaches Node.js APIs via window.bridge and similar.' }, + { name: 'self', description: 'Global object access — Web Worker / browser alias for globalThis; available in some Electron renderer contexts.' }, + { name: 'frames', description: 'Global object access — browser alias for the window.frames collection; can be used to navigate to an unsandboxed global.' }, + { name: 'process', description: 'Node.js internals access — exposes mainModule, env, and other Node.js internals not part of the supported scripting API.' }, + { name: 'module', description: 'Module system bypass — Node.js module wrapper object; .require and .children expose the full module graph.' }, + { name: 'exports', description: 'Module system bypass — Node.js module exports object; mutating it affects the live module cache.' }, + { name: 'Buffer', description: 'Unsafe memory access — the Buffer global provides allocUnsafe(), which reads uninitialised memory.' }, + { name: 'constructor', description: 'Function constructor escape — in AsyncFunction scope this IS AsyncFunction; a direct call constructs a new function in the real global scope.' }, + { name: 'arguments', description: 'Caller inspection — can leak the caller\'s frame in generator or sloppy-mode contexts, exposing host objects.' }, +]; + +export const maskRules: ThreatRule[] = [ + { + name: 'globalThis', + description: 'Prevents access to the globalThis object to prevent exposure of process, require, and other host APIs that parameter masking is meant to hide.', + maskName: 'globalThis', + maskValue: undefined, + }, + { + name: 'global', + description: 'Prevents access to the global parameter (Node.js alias for globalThis) to prevent dynamic access to host APIs (e.g. global["req"+"uire"]).', + maskName: 'global', + maskValue: undefined, + }, + { + name: 'Function', + description: 'Prevents access to the Function constructor to prevent creation of new functions in the real global scope, escaping parameter-level masking (e.g. Function("return process")()).', + maskName: 'Function', + maskValue: undefined, + }, + { + name: 'process', + description: 'Prevents access to the process object to prevent exposure of mainModule, env, and other Node.js internals not part of the supported scripting API.', + maskName: 'process', + maskValue: undefined, + }, + { + name: 'setImmediate', + description: 'Prevents access to the setImmediate function to prevent its use as an untracked async scheduling side-channel.', + maskName: 'setImmediate', + maskValue: undefined, + }, + { + name: 'queueMicrotask', + maskName: 'queueMicrotask', + description: 'Prevents access to the queueMicrotask function to prevent scheduling work outside the async/await flow tracked by the executor, which would make clean shutdown harder.', + maskValue: undefined, + }, + { + name: 'Proxy', + description: 'Prevents access to the Proxy constructor to prevent apply/construct traps from receiving unwrapped host objects, which enables prototype chain traversal to real host globals (CVE-2023-32314).', + maskName: 'Proxy', + maskValue: undefined, + }, + { + name: 'Reflect', + description: 'Prevents access to the Reflect object to prevent Reflect.apply() and Reflect.construct() from invoking functions with an explicit this value, bypassing the strict-mode this===undefined invariant.', + maskName: 'Reflect', + maskValue: undefined, + }, + { + name: 'WebAssembly', + description: 'Prevents access to the WebAssembly API to prevent loading and executing arbitrary native bytecode, which would bypass JS-level sandboxing entirely.', + maskName: 'WebAssembly', + maskValue: undefined, + }, +]; diff --git a/packages/insomnia/src/templating/utils.ts b/packages/insomnia/src/templating/utils.ts index a163d45ff3a0..ff7cfef78dd5 100644 --- a/packages/insomnia/src/templating/utils.ts +++ b/packages/insomnia/src/templating/utils.ts @@ -1,7 +1,7 @@ import type { EditorFromTextArea, MarkerRange } from 'codemirror'; import { models, services } from '~/insomnia-data'; -import { decryptSecretValue } from '~/utils/vault'; +import { decryptSecretValue } from '~/utils/vault-crypto'; import type { NunjucksParsedTag, NunjucksParsedTagArg, RenderPurpose } from '../templating/types'; import { decryptVaultKeyFromSession } from '../utils/vault'; @@ -50,7 +50,6 @@ export function normalizeToDotAndBracketNotation(prefix: string) { return objectPath.normalize(prefix); } - /** * Parse a Liquid template tag string into a usable object * @param {string} tagStr - the template string for the tag @@ -159,10 +158,10 @@ export async function maskOrDecryptVaultDataIfNecessary(vaultEnvironmentData: an if (isVaultEnabled && vaultKey) { const symmetricKey = (await decryptVaultKeyFromSession(vaultKey, true)) as JsonWebKey; // decrypt all secret values under vaultEnvironmentPath property in context - Object.keys(vaultEnvironmentData).forEach(vaultContextKey => { + for (const vaultContextKey of Object.keys(vaultEnvironmentData)) { const encryptedValue = vaultEnvironmentData[vaultContextKey]; - vaultEnvironmentData[vaultContextKey] = decryptSecretValue(encryptedValue, symmetricKey); - }); + vaultEnvironmentData[vaultContextKey] = await decryptSecretValue(encryptedValue, symmetricKey); + } } else if (isVaultEnabled && !vaultKey) { // remove all values under vaultEnvironmentPath if no vault key found vaultEnvironmentData = {}; 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 915bdc30bdf3..5ae19919a037 100644 --- a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx @@ -148,9 +148,10 @@ export const RequestActionsDropdown = ({ const copyAsCurl = async () => { try { const har = await exportHarRequest(request._id, workspaceId); - const { HTTPSnippet } = await import('httpsnippet'); - const snippet = new HTTPSnippet(har); - const cmd = snippet.convert('shell', 'curl'); + if (!har) { + return; + } + const cmd = await window.main.generateCodeSnippet({ har, target: 'shell', client: 'curl' }); if (cmd) { window.clipboard.writeText(cmd); diff --git a/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx b/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx index 831f094dcc2a..5d392674900a 100644 --- a/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx @@ -20,7 +20,7 @@ import { checkNestedKeys, ensureKeyIsValid } from '~/utils/environment-utils'; import { generateId } from '../../../../common/misc'; import { base64decode } from '../../../../utils/vault'; -import { decryptSecretValue, encryptSecretValue } from '../../../../utils/vault'; +import { decryptSecretValue, encryptSecretValue } from '../../../../utils/vault-crypto'; import { PromptButton } from '../../base/prompt-button'; import { Icon } from '../../icon'; import { showModal } from '../../modals'; @@ -77,8 +77,29 @@ export const EnvironmentKVEditor = ({ ); const codeModalRef = useRef(null); const [kvPairError, setKvPairError] = useState<{ id: string; error: string }[]>([]); + const [decryptedValues, setDecryptedValues] = useState>({}); const symmetricKey = vaultKey === '' ? {} : base64decode(vaultKey, true); + useEffect(() => { + const secretPairs = kvPairs.filter(p => p.type === EnvironmentKvPairDataType.SECRET); + if (secretPairs.length === 0 || Object.keys(symmetricKey).length === 0) { + setDecryptedValues({}); + return; + } + let cancelled = false; + Promise.all( + secretPairs.map(async p => ({ id: p.id, value: await decryptSecretValue(p.value, symmetricKey as JsonWebKey) })), + ) + .then(results => { + if (!cancelled) { + setDecryptedValues(Object.fromEntries(results.map(r => [r.id, r.value]))); + } + }) + .catch(console.error); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(kvPairs.filter(p => p.type === EnvironmentKvPairDataType.SECRET).map(p => ({ id: p.id, value: p.value }))), vaultKey]); + const commonItemTypes = [ { id: EnvironmentKvPairDataType.STRING, @@ -152,7 +173,7 @@ export const EnvironmentKVEditor = ({ onChange(kvPairs); }; - const handleItemTypeChange = (id: string, newType: EnvironmentKvPairDataType) => { + const handleItemTypeChange = async (id: string, newType: EnvironmentKvPairDataType) => { const targetItem = kvPairs.find(pair => pair.id === id); if (targetItem) { const { type: originType, value: originValue } = targetItem; @@ -172,13 +193,13 @@ export const EnvironmentKVEditor = ({ if (yes) { handleItemChange(id, 'type', newType); // decrypt and save the value - handleItemChange(id, 'value', decryptSecretValue(originValue, symmetricKey)); + handleItemChange(id, 'value', await decryptSecretValue(originValue, symmetricKey as JsonWebKey)); } }, }); } else if (newType === EnvironmentKvPairDataType.SECRET) { // encrypt value if set to secret type - handleItemChange(id, 'value', encryptSecretValue(originValue, symmetricKey)); + handleItemChange(id, 'value', await encryptSecretValue(originValue, symmetricKey as JsonWebKey)); handleItemChange(id, 'type', newType); } else { handleItemChange(id, 'type', newType); @@ -310,9 +331,9 @@ export const EnvironmentKVEditor = ({ itemId={id} enabled={enabled && !disabled} placeholder="Input Secret" - value={decryptSecretValue(value, symmetricKey)} - onChange={newValue => { - const encryptedValue = encryptSecretValue(newValue, symmetricKey); + value={decryptedValues[id] ?? ''} + onChange={async newValue => { + const encryptedValue = await encryptSecretValue(newValue, symmetricKey as JsonWebKey); handleItemChange(id, 'value', encryptedValue); }} /> 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 aec2e05c2a91..31cb5dfaf072 100644 --- a/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx @@ -1,5 +1,5 @@ import type { HTTPSnippetClient, HTTPSnippetTarget } from 'httpsnippet'; -import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { Button } from 'react-aria-components'; import type { Request } from '~/insomnia-data'; @@ -65,11 +65,13 @@ export const GenerateCodeModal = forwardRef((pro const [snippet, setSnippet] = useState(''); + useEffect(() => { + editorRef.current?.setValue(snippet); + }, [snippet]); + const generateCode = useCallback( async (request: Request, target?: HTTPSnippetTarget, client?: HTTPSnippetClient) => { - const { HTTPSnippet, availableTargets } = await import('httpsnippet'); - - const targets = availableTargets(); + const targets = (await window.main.getCodeSnippetTargets()) as HTTPSnippetTarget[]; const targetOrFallback = target || (targets.find(t => t.key === 'shell') as HTTPSnippetTarget); const clientOrFallback = client || (targetOrFallback.clients.find(t => t.key === 'curl') as HTTPSnippetClient); @@ -89,9 +91,12 @@ export const GenerateCodeModal = forwardRef((pro ); const har = await exportHarWithRequest(request, props.environmentId, addContentLength); if (har) { - const snippet = new HTTPSnippet(har); - const cmd = snippet.convert(targetOrFallback.key, clientOrFallback.key) || ''; - setSnippet(cmd); + const cmd = await window.main.generateCodeSnippet({ + har, + target: targetOrFallback.key, + client: clientOrFallback.key, + }); + setSnippet(cmd as string); } window.main.trackAnalyticsEvent({ @@ -189,7 +194,6 @@ export const GenerateCodeModal = forwardRef((pro id="generate-code-modal-content" placeholder="Generating code snippet..." className="border-top" - key={Date.now()} mode={MODE_MAP[target.key] || target.key} ref={editorRef} defaultValue={snippet} diff --git a/packages/insomnia/src/ui/components/modals/workspace-environments-edit-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-environments-edit-modal.tsx index 891711f2476a..a1c5fcb4faa0 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-environments-edit-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-environments-edit-modal.tsx @@ -554,7 +554,8 @@ export const WorkspaceEnvironmentsEditModal = ({ onClose }: { onClose: () => voi diff --git a/packages/insomnia/src/ui/components/settings/scripting-settings.tsx b/packages/insomnia/src/ui/components/settings/scripting-settings.tsx index 96a044ff4aac..ba83201ff095 100644 --- a/packages/insomnia/src/ui/components/settings/scripting-settings.tsx +++ b/packages/insomnia/src/ui/components/settings/scripting-settings.tsx @@ -2,7 +2,7 @@ import { Switch } from 'react-aria-components'; import { useRootLoaderData } from '~/root'; -import { type ASTRule, blockedPropertyRules, blockedRootRules, maskRules, type ThreatRule } from '../../../scripting/script-security-policy'; +import { type ASTRule, blockedPropertyRules, blockedRootRules, maskRules, type ThreatRule } from '../../../scripting/script-security-rules'; import { useSettingsPatcher } from '../../hooks/use-request'; const DISABLED_TOOLTIP = 'Enable the script sandbox to configure individual rules'; diff --git a/packages/insomnia/src/ui/components/tags/grpc-status-tag.tsx b/packages/insomnia/src/ui/components/tags/grpc-status-tag.tsx index 7a975bc33ba4..1e07659b83f2 100644 --- a/packages/insomnia/src/ui/components/tags/grpc-status-tag.tsx +++ b/packages/insomnia/src/ui/components/tags/grpc-status-tag.tsx @@ -1,9 +1,10 @@ -import { status } from '@grpc/grpc-js'; import classnames from 'classnames'; import React, { type FC, memo } from 'react'; import { Tooltip } from '../tooltip'; +const GRPC_STATUS_OK = 0; + interface Props { statusCode?: number; small?: boolean; @@ -12,8 +13,8 @@ interface Props { } export const GrpcStatusTag: FC = memo(({ statusMessage, statusCode, small, tooltipDelay }) => { - const colorClass = statusCode === status.OK ? 'bg-success' : 'bg-danger'; - const message = statusCode === status.OK ? 'OK' : statusMessage; + const colorClass = statusCode === GRPC_STATUS_OK ? 'bg-success' : 'bg-danger'; + const message = statusCode === GRPC_STATUS_OK ? 'OK' : statusMessage; return (
{ + const [nameValue = ''] = headerValue.split(';'); + const separatorIndex = nameValue.indexOf('='); + + if (separatorIndex === -1) { + return null; + } + + return { + key: nameValue.slice(0, separatorIndex).trim(), + value: nameValue.slice(separatorIndex + 1).trim(), + }; +}; + interface Props { cookiesSent?: boolean | null; cookiesStored?: boolean | null; @@ -13,10 +26,10 @@ interface Props { export const ResponseCookiesViewer: FC = props => { const [isCookieModalOpen, setIsCookieModalOpen] = useState(false); const renderRow = (h: any, i: number) => { - let cookie: Cookie | undefined | null = null; + let cookie: ReturnType = null; try { - cookie = h ? Cookie.parse(h.value || '', { loose: true }) : null; + cookie = h ? parseSetCookieHeader(h.value || '') : null; } catch { console.warn('Failed to parse set-cookie header', h); } diff --git a/packages/insomnia/src/ui/components/viewers/response-multipart-viewer.tsx b/packages/insomnia/src/ui/components/viewers/response-multipart-viewer.tsx index 9659bb02c956..6ed9154b1197 100644 --- a/packages/insomnia/src/ui/components/viewers/response-multipart-viewer.tsx +++ b/packages/insomnia/src/ui/components/viewers/response-multipart-viewer.tsx @@ -77,7 +77,7 @@ export const ResponseMultipartViewer: FC = ({ return; } const contentType = getContentTypeFromHeaders(selectedPart.headers, 'text/plain'); - const extension = mimeExtension(contentType) || '.txt'; + const extension = mimeExtension(contentType) || 'txt'; const lastDir = window.localStorage.getItem('insomnia.lastExportPath'); const dir = lastDir || window.app.getPath('desktop'); const date = format(Date.now(), 'yyyy-MM-dd'); diff --git a/packages/insomnia/src/ui/components/viewers/response-viewer.tsx b/packages/insomnia/src/ui/components/viewers/response-viewer.tsx index d19d3b292112..1fc36b7b042f 100644 --- a/packages/insomnia/src/ui/components/viewers/response-viewer.tsx +++ b/packages/insomnia/src/ui/components/viewers/response-viewer.tsx @@ -1,4 +1,3 @@ -import iconv from 'iconv-lite'; import { Fragment, useCallback, useRef, useState } from 'react'; import { PREVIEW_MODE_FRIENDLY, PREVIEW_MODE_RAW } from '~/insomnia-data/common'; @@ -14,6 +13,25 @@ import { ResponseMultipartViewer } from './response-multipart-viewer'; import { ResponsePDFViewer } from './response-pdf-viewer'; import { ResponseWebView } from './response-web-view'; +const CHARSET_ALIASES: Record = { + utf8: 'utf8', + utf16le: 'utf-16le', + ucs2: 'utf-16le', + 'ucs-2': 'utf-16le', + latin1: 'iso-8859-1', + binary: 'iso-8859-1', + ascii: 'ascii', + win1250: 'windows-1250', + win1251: 'windows-1251', + win1252: 'windows-1252', + win1253: 'windows-1253', + win1254: 'windows-1254', + win1255: 'windows-1255', + win1256: 'windows-1256', + win1257: 'windows-1257', + win1258: 'windows-1258', +}; + let alwaysShowLargeResponses = false; export interface ResponseViewerHandle { @@ -148,9 +166,10 @@ export const ResponseViewer = ({ // Show everything else as "source" const match = _getContentType().match(/charset=([\w-]+)/); const charset = match && match.length >= 2 ? match[1] : 'utf8'; - // Sometimes iconv conversion fails so fallback to regular buffer + const label = CHARSET_ALIASES[charset.toLowerCase()] ?? charset; + // Sometimes decoding fails so fallback to regular buffer try { - return iconv.decode(overSizedBody, charset); + return new TextDecoder(label).decode(overSizedBody); } catch (err) { console.warn('[response] Failed to decode body', err); return overSizedBody.toString(); diff --git a/packages/insomnia/src/utils/vault-crypto.test.ts b/packages/insomnia/src/utils/vault-crypto.test.ts new file mode 100644 index 000000000000..19a96affbbf7 --- /dev/null +++ b/packages/insomnia/src/utils/vault-crypto.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { decryptSecretValue, encryptSecretValue } from './vault-crypto'; + +const mockEncrypt = vi.fn(); +const mockDecrypt = vi.fn(); + +beforeEach(() => { + vi.resetAllMocks(); + Object.defineProperty(window, 'main', { + value: { vault: { encryptSecretValue: mockEncrypt, decryptSecretValue: mockDecrypt } }, + writable: true, + }); +}); + +const VALID_KEY: JsonWebKey = { + kty: 'oct', + alg: 'A256GCM', + ext: true, + key_ops: ['encrypt', 'decrypt'], + k: '5hs1f2xuiNPHUp11i6SWlsqYpWe_hWPcEKucZlwBfFE', +}; + +describe('encryptSecretValue', () => { + it('returns rawValue when symmetricKey is not an object', async () => { + expect(await encryptSecretValue('secret', 'invalid' as unknown as JsonWebKey)).toBe('secret'); + expect(mockEncrypt).not.toHaveBeenCalled(); + }); + + it('returns rawValue when symmetricKey is empty object', async () => { + expect(await encryptSecretValue('secret', {})).toBe('secret'); + expect(mockEncrypt).not.toHaveBeenCalled(); + }); + + it('delegates to window.main.vault.encryptSecretValue with a valid key', async () => { + mockEncrypt.mockResolvedValue('encrypted-value'); + const result = await encryptSecretValue('my secret', VALID_KEY); + expect(mockEncrypt).toHaveBeenCalledWith('my secret', VALID_KEY); + expect(result).toBe('encrypted-value'); + }); + + it('returns rawValue when IPC call throws', async () => { + mockEncrypt.mockRejectedValue(new Error('IPC error')); + const result = await encryptSecretValue('my secret', VALID_KEY); + expect(result).toBe('my secret'); + }); +}); + +describe('decryptSecretValue', () => { + it('returns encryptedValue when symmetricKey is not an object', async () => { + expect(await decryptSecretValue('encrypted', 'invalid' as unknown as JsonWebKey)).toBe('encrypted'); + expect(mockDecrypt).not.toHaveBeenCalled(); + }); + + it('returns encryptedValue when symmetricKey is empty object', async () => { + expect(await decryptSecretValue('encrypted', {})).toBe('encrypted'); + expect(mockDecrypt).not.toHaveBeenCalled(); + }); + + it('delegates to window.main.vault.decryptSecretValue with a valid key', async () => { + mockDecrypt.mockResolvedValue('plaintext'); + const result = await decryptSecretValue('encrypted-blob', VALID_KEY); + expect(mockDecrypt).toHaveBeenCalledWith('encrypted-blob', VALID_KEY); + expect(result).toBe('plaintext'); + }); + + it('returns encryptedValue when IPC call throws', async () => { + mockDecrypt.mockRejectedValue(new Error('IPC error')); + const result = await decryptSecretValue('encrypted-blob', VALID_KEY); + expect(result).toBe('encrypted-blob'); + }); +}); diff --git a/packages/insomnia/src/utils/vault-crypto.ts b/packages/insomnia/src/utils/vault-crypto.ts new file mode 100644 index 000000000000..5a1099fe6f4e --- /dev/null +++ b/packages/insomnia/src/utils/vault-crypto.ts @@ -0,0 +1,21 @@ +export const encryptSecretValue = async (rawValue: string, symmetricKey: JsonWebKey): Promise => { + if (typeof symmetricKey !== 'object' || Object.keys(symmetricKey).length === 0) { + return rawValue; + } + try { + return await window.main.vault.encryptSecretValue(rawValue, symmetricKey); + } catch { + return rawValue; + } +}; + +export const decryptSecretValue = async (encryptedValue: string, symmetricKey: JsonWebKey): Promise => { + if (typeof symmetricKey !== 'object' || Object.keys(symmetricKey).length === 0) { + return encryptedValue; + } + try { + return await window.main.vault.decryptSecretValue(encryptedValue, symmetricKey); + } catch { + return encryptedValue; + } +}; diff --git a/packages/insomnia/types/global.d.ts b/packages/insomnia/types/global.d.ts index 0f819a087744..c99151863a9c 100644 --- a/packages/insomnia/types/global.d.ts +++ b/packages/insomnia/types/global.d.ts @@ -5,8 +5,36 @@ import type { DatabaseBridgeAPI } from '../src/main/ipc/database'; import type { DiffMatchPatch, DiffOp } from 'diff-match-patch-ts'; import type { Services } from '~/insomnia-data'; +type RendererEnv = { + INSOMNIA_GITLAB_REDIRECT_URI: string | undefined; + INSOMNIA_GITLAB_CLIENT_ID: string | undefined; + INSOMNIA_GITLAB_API_URL: string | undefined; + PLAYWRIGHT_TEST: string | undefined; + INSOMNIA_SKIP_ONBOARDING: string | undefined; + INSOMNIA_SESSION: string | undefined; + INSOMNIA_SECRET_KEY: string | undefined; + INSOMNIA_PUBLIC_KEY: string | undefined; + INSOMNIA_VAULT_SALT: string | undefined; + INSOMNIA_VAULT_KEY: string | undefined; + INSOMNIA_VAULT_SRP_SECRET: string | undefined; + INSOMNIA_ENV: string | undefined; + BUILD_DATE: string | undefined; + PORTABLE_EXECUTABLE_DIR: string | undefined; + OAUTH_REDIRECT_URL: string | undefined; + OAUTH_RELAY_URL: string | undefined; + INSOMNIA_API_URL: string | undefined; + INSOMNIA_MOCK_API_URL: string | undefined; + INSOMNIA_AI_URL: string | undefined; + KONNECT_API_URL: string | undefined; + INSOMNIA_APP_WEBSITE_URL: string | undefined; + INSOMNIA_GITHUB_REST_API_URL: string | undefined; + INSOMNIA_GITHUB_API_URL: string | undefined; + INSOMNIA_ENABLE_PLUGIN_BRIDGE: string | undefined; +}; + declare global { interface Window { + env: RendererEnv; main: RendererToMainBridgeAPI; bridge: HiddenBrowserWindowToMainBridgeAPI; database: DatabaseBridgeAPI; diff --git a/packages/insomnia/types/node-forge-lib.d.ts b/packages/insomnia/types/node-forge-lib.d.ts new file mode 100644 index 000000000000..51515a606d7c --- /dev/null +++ b/packages/insomnia/types/node-forge-lib.d.ts @@ -0,0 +1,10 @@ +declare module 'node-forge/lib/forge' { + import forge from 'node-forge'; + + export default forge; +} + +declare module 'node-forge/lib/util'; +declare module 'node-forge/lib/cipher'; +declare module 'node-forge/lib/cipherModes'; +declare module 'node-forge/lib/aes'; diff --git a/packages/insomnia/vite.config.ts b/packages/insomnia/vite.config.ts index 895cbeb44022..4540fc62c820 100644 --- a/packages/insomnia/vite.config.ts +++ b/packages/insomnia/vite.config.ts @@ -56,6 +56,8 @@ export default defineConfig(({ mode }) => { // builds inline the module directly (avoids runtime require() in server bundle). '~/network/network-adapter': path.resolve(__dirname, './src/network/network-adapter.renderer'), '~': path.resolve(__dirname, './src'), + // Shim Node's `path` module for browser-safe dependencies (e.g. mime-types uses path.extname). + 'path': path.resolve(__dirname, './src/path-shim.ts'), }, }, plugins: [ @@ -66,7 +68,7 @@ export default defineConfig(({ mode }) => { modules: [ 'electron', ...externalDependencies, - ...builtinModules.filter(m => m !== 'buffer'), + ...builtinModules.filter(m => m !== 'buffer' && m !== 'path'), ...builtinModules.map(m => `node:${m}`), ], }),