fix: address Copilot review comments on PR #9992

- vault-crypto: replace forge-in-renderer with IPC bridge (main process
  retains forge; renderer calls window.main.vault.{en,de}cryptSecretValue)
- mime.ts: expand lookup table to 48 entries (webp, wasm, mp4, docx, xlsx,
  fonts, audio/video, etc.) and fix remaining mime-types import in send route
- response-viewer: move charset alias map to module level; normalise iconv-lite
  alias names (utf8, latin1, win1252, …) to WHATWG labels for TextDecoder
- auth.clear-vault-key: fix typo "all you local" → "all your local"
This commit is contained in:
jackkav
2026-06-01 03:46:06 +02:00
parent fe86fd4102
commit d6fd92e7ff
11 changed files with 163 additions and 174 deletions

View File

@@ -1,18 +1,57 @@
const extensionToMimeType: Record<string, string> = {
// 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<string, string> = {

View File

@@ -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),

View File

@@ -170,7 +170,9 @@ export type HandleChannels =
| 'timeline.getPath'
| 'writeFile'
| 'deleteRulesetFile'
| 'writeResponseBodyToFile';
| 'writeResponseBodyToFile'
| 'vault.encryptSecretValue'
| 'vault.decryptSecretValue';
export const ipcMainHandle = (
channel: HandleChannels,

View File

@@ -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<string>;
decryptSecretValue: (encryptedValue: string, symmetricKey: JsonWebKey) => Promise<string>;
};
timeline: {
getPath: (responseId: string) => Promise<string>;
appendToFile: (options: { timelinePath: string; data: string }) => Promise<void>;
@@ -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();
}

View File

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

View File

@@ -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';

View File

@@ -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 = {};

View File

@@ -77,8 +77,26 @@ export const EnvironmentKVEditor = ({
);
const codeModalRef = useRef<CodePromptModalHandle>(null);
const [kvPairError, setKvPairError] = useState<{ id: string; error: string }[]>([]);
const [decryptedValues, setDecryptedValues] = useState<Record<string, string>>({});
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);
}}
/>

View File

@@ -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<string, string> = {
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();

View File

@@ -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');
});
});

View File

@@ -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<string> => {
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<string> => {
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;
}
};