diff --git a/packages/insomnia/src/common/mime.ts b/packages/insomnia/src/common/mime.ts index 26ec8eb3c2..d188efa8c2 100644 --- a/packages/insomnia/src/common/mime.ts +++ b/packages/insomnia/src/common/mime.ts @@ -1,18 +1,57 @@ const extensionToMimeType: Record = { + // text + css: 'text/css', csv: 'text/csv', - gif: 'image/gif', + htm: 'text/html', html: 'text/html', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', js: 'application/javascript', json: 'application/json', - pdf: 'application/pdf', - png: 'image/png', - svg: 'image/svg+xml', + jsonld: 'application/ld+json', + md: 'text/markdown', + mjs: 'application/javascript', txt: 'text/plain', xml: 'application/xml', yaml: 'application/yaml', yml: 'application/yaml', + // image + bmp: 'image/bmp', + gif: 'image/gif', + ico: 'image/x-icon', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + svg: 'image/svg+xml', + tif: 'image/tiff', + tiff: 'image/tiff', + webp: 'image/webp', + // audio/video + aac: 'audio/aac', + flac: 'audio/flac', + m4a: 'audio/mp4', + mp3: 'audio/mpeg', + mp4: 'video/mp4', + ogg: 'audio/ogg', + opus: 'audio/opus', + wav: 'audio/wav', + webm: 'video/webm', + // document / office + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + pdf: 'application/pdf', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + // archive / binary + gz: 'application/gzip', + tar: 'application/x-tar', + wasm: 'application/wasm', + zip: 'application/zip', + // font + otf: 'font/otf', + ttf: 'font/ttf', + woff: 'font/woff', + woff2: 'font/woff2', }; const mimeTypeToExtension: Record = { diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 7dc6726ce9..82d0a53348 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), diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 618f64d2e3..7b1371e14f 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -170,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 a6f35ecb8e..e663724721 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 { @@ -290,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; @@ -808,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/routes/auth.clear-vault-key.tsx b/packages/insomnia/src/routes/auth.clear-vault-key.tsx index 32f5b7ea59..c508a67240 100644 --- a/packages/insomnia/src/routes/auth.clear-vault-key.tsx +++ b/packages/insomnia/src/routes/auth.clear-vault-key.tsx @@ -24,7 +24,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) { await services.userSession.update({ vaultSalt: newVaultSalt, vaultKey: '' }); // show notification showToast({ - title: 'Your vault key has been reset, all you local secrets have been deleted.', + 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 0d9ac37b5c..6bb1606393 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 @@ -1,5 +1,5 @@ import contentDisposition from 'content-disposition'; -import { extension as mimeExtension } from 'mime-types'; +import { mimeTypeExtension as mimeExtension } from '~/common/mime'; import { href, redirect } from 'react-router'; import { v4 as uuidv4 } from 'uuid'; diff --git a/packages/insomnia/src/templating/utils.ts b/packages/insomnia/src/templating/utils.ts index 63ce50a780..ff7cfef78d 100644 --- a/packages/insomnia/src/templating/utils.ts +++ b/packages/insomnia/src/templating/utils.ts @@ -158,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/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 187f96ec26..739d85fadb 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 @@ -77,8 +77,26 @@ 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) { + 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]))); + } + }); + 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 +170,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 +190,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 +328,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/viewers/response-viewer.tsx b/packages/insomnia/src/ui/components/viewers/response-viewer.tsx index d0e9f0803d..1fc36b7b04 100644 --- a/packages/insomnia/src/ui/components/viewers/response-viewer.tsx +++ b/packages/insomnia/src/ui/components/viewers/response-viewer.tsx @@ -13,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 { @@ -147,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'; + const label = CHARSET_ALIASES[charset.toLowerCase()] ?? charset; // Sometimes decoding fails so fallback to regular buffer try { - return new TextDecoder(charset).decode(overSizedBody); + 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 index 052b159d6d..19a96affbb 100644 --- a/packages/insomnia/src/utils/vault-crypto.test.ts +++ b/packages/insomnia/src/utils/vault-crypto.test.ts @@ -1,9 +1,20 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { decryptSecretValue, encryptSecretValue } from './vault-crypto'; -const TEST_AES_KEY: JsonWebKey = { +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, @@ -12,82 +23,51 @@ const TEST_AES_KEY: JsonWebKey = { }; describe('encryptSecretValue', () => { - it('returns rawValue when symmetricKey is not an object', () => { - expect(encryptSecretValue('secret', 'invalid' as unknown as JsonWebKey)).toBe('secret'); + 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', () => { - expect(encryptSecretValue('secret', {})).toBe('secret'); + it('returns rawValue when symmetricKey is empty object', async () => { + expect(await encryptSecretValue('secret', {})).toBe('secret'); + expect(mockEncrypt).not.toHaveBeenCalled(); }); - it('encrypts the value with a valid key', () => { - const encrypted = encryptSecretValue('my secret', TEST_AES_KEY); - expect(typeof encrypted).toBe('string'); - expect(encrypted).not.toBe('my secret'); + 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 original value when encryption fails', () => { - // Use an invalid key format - const invalidKey = { kty: 'oct', k: 'invalid' }; - const encrypted = encryptSecretValue('my secret', invalidKey as unknown as JsonWebKey); - expect(encrypted).toBe('my secret'); + 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', () => { - expect(decryptSecretValue('encrypted', 'invalid' as unknown as JsonWebKey)).toBe('encrypted'); + 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', () => { - expect(decryptSecretValue('encrypted', {})).toBe('encrypted'); + it('returns encryptedValue when symmetricKey is empty object', async () => { + expect(await decryptSecretValue('encrypted', {})).toBe('encrypted'); + expect(mockDecrypt).not.toHaveBeenCalled(); }); - it('round-trips encrypt then decrypt', () => { - const plaintext = 'my secret value'; - const encrypted = encryptSecretValue(plaintext, TEST_AES_KEY); - const decrypted = decryptSecretValue(encrypted, TEST_AES_KEY); - expect(decrypted).toBe(plaintext); + 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 original value when decryption fails', () => { - // Use an invalid encrypted value - const encrypted = encryptSecretValue('my secret', TEST_AES_KEY); - // Try to decrypt with wrong key - const wrongKey = { - kty: 'oct', - alg: 'A256GCM', - k: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - }; - const result = decryptSecretValue(encrypted, wrongKey); - expect(result).toBe(encrypted); - }); - - it('handles special characters in plaintext', () => { - const plaintext = 'special chars: !@#$%^&*()_+-=[]{}|;:,.<>?/~`'; - const encrypted = encryptSecretValue(plaintext, TEST_AES_KEY); - const decrypted = decryptSecretValue(encrypted, TEST_AES_KEY); - expect(decrypted).toBe(plaintext); - }); - - it('handles unicode characters in plaintext', () => { - const plaintext = 'unicode: 你好世界 🚀 مرحبا العالم'; - const encrypted = encryptSecretValue(plaintext, TEST_AES_KEY); - const decrypted = decryptSecretValue(encrypted, TEST_AES_KEY); - expect(decrypted).toBe(plaintext); - }); - - it('handles empty string', () => { - const plaintext = ''; - const encrypted = encryptSecretValue(plaintext, TEST_AES_KEY); - const decrypted = decryptSecretValue(encrypted, TEST_AES_KEY); - expect(decrypted).toBe(plaintext); - }); - - it('handles large plaintext', () => { - const plaintext = 'x'.repeat(10_000); - const encrypted = encryptSecretValue(plaintext, TEST_AES_KEY); - const decrypted = decryptSecretValue(encrypted, TEST_AES_KEY); - expect(decrypted).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 index f0464d1462..5a1099fe6f 100644 --- a/packages/insomnia/src/utils/vault-crypto.ts +++ b/packages/insomnia/src/utils/vault-crypto.ts @@ -1,109 +1,21 @@ -import 'node-forge/lib/util'; -import 'node-forge/lib/cipher'; -import 'node-forge/lib/cipherModes'; -import 'node-forge/lib/aes'; - -import forge from 'node-forge/lib/forge'; - -import type { AESMessage } from '../account/crypt'; - -const base64encode = (input: string | object) => { - const inputStr = typeof input === 'string' ? input : JSON.stringify(input); - const binary = atob(btoa(unescape(encodeURIComponent(inputStr)))); - return btoa(binary); -}; - -const base64decode = (base64Str: string, toObject = false) => { - try { - const decodedStr = decodeURIComponent(escape(atob(base64Str))); - if (toObject) { - return JSON.parse(decodedStr); - } - return decodedStr; - } catch { - console.error(`failed to base64 decode string ${base64Str}`); - } - return base64Str; -}; - -const b64UrlToHex = (value: string) => { - const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); - return forge.util.bytesToHex(atob(base64)); -}; - -const getKeyBytes = (symmetricKey: JsonWebKey) => forge.util.hexToBytes(b64UrlToHex(symmetricKey.k || '')); - -const getRandomIv = () => { - const iv = new Uint8Array(12); - globalThis.crypto.getRandomValues(iv); - return String.fromCodePoint(...iv); -}; - -// Bind cipher methods to avoid direct pattern detection while preserving call semantics. -// The renderer-safe vault-crypto is used only for environment secret encryption and -// uses a random IV per encryption, so IV reuse vulnerabilities don't apply here. -// Using createCipheriv/createDecipheriv would require IV derivation logic not worth the complexity. -const createForgeCipher = forge.cipher.createCipher.bind(forge.cipher); -const createForgeDecipher = forge.cipher.createDecipher.bind(forge.cipher); - -const encryptAES = (symmetricKey: JsonWebKey, plaintext: string): AESMessage => { - const cipher = createForgeCipher('AES-GCM', getKeyBytes(symmetricKey)); - const iv = getRandomIv(); - const encodedPlaintext = encodeURIComponent(plaintext); - cipher.start({ - iv, - tagLength: 128, - }); - cipher.update(forge.util.createBuffer(encodedPlaintext)); - cipher.finish(); - return { - iv: forge.util.bytesToHex(iv), - t: forge.util.bytesToHex(cipher.mode.tag.bytes()), - ad: '', - d: forge.util.bytesToHex(cipher.output.bytes()), - }; -}; - -const decryptAES = (symmetricKey: JsonWebKey, encryptedValue: AESMessage) => { - const decipher = createForgeDecipher('AES-GCM', getKeyBytes(symmetricKey)); - decipher.start({ - iv: forge.util.hexToBytes(encryptedValue.iv), - tagLength: encryptedValue.t.length * 4, - tag: forge.util.createBuffer(forge.util.hexToBytes(encryptedValue.t)), - additionalData: forge.util.hexToBytes(encryptedValue.ad), - }); - decipher.update(forge.util.createBuffer(forge.util.hexToBytes(encryptedValue.d))); - if (!decipher.finish()) { - throw new Error('Failed to decrypt data'); - } - return decodeURIComponent(decipher.output.toString()); -}; - -export const encryptSecretValue = (rawValue: string, symmetricKey: JsonWebKey) => { +export const encryptSecretValue = async (rawValue: string, symmetricKey: JsonWebKey): Promise => { if (typeof symmetricKey !== 'object' || Object.keys(symmetricKey).length === 0) { - // invalid symmetricKey return rawValue; } try { - const encryptResult = encryptAES(symmetricKey, rawValue); - const encryptedValue = base64encode(encryptResult); - return encryptedValue; + return await window.main.vault.encryptSecretValue(rawValue, symmetricKey); } catch { - // return original value if encryption fails return rawValue; } }; -export const decryptSecretValue = (encryptedValue: string, symmetricKey: JsonWebKey) => { +export const decryptSecretValue = async (encryptedValue: string, symmetricKey: JsonWebKey): Promise => { if (typeof symmetricKey !== 'object' || Object.keys(symmetricKey).length === 0) { - // invalid symmetricKey return encryptedValue; } try { - const jsonWebKey = base64decode(encryptedValue, true) as AESMessage; - return decryptAES(symmetricKey, jsonWebKey); + return await window.main.vault.decryptSecretValue(encryptedValue, symmetricKey); } catch { - // return origin value if failed to decrypt return encryptedValue; } };