diff --git a/package-lock.json b/package-lock.json index 3f24b5f98ea..c339e6abcc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1706,6 +1706,19 @@ "node": ">= 20.16.0" } }, + "node_modules/@getinsomnia/srp-js": { + "version": "1.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/@getinsomnia/srp-js/-/srp-js-1.0.0-alpha.1.tgz", + "integrity": "sha512-Svgv1ifeZnGJyofOKXfpLiGYD8zRSGYMUQjZVfckHuFFYmjSSyy17yzYkI0MVcSByG+x5jAlFkJCflOrKn3wQw==", + "dev": true, + "dependencies": { + "@types/node-forge": "^1.3.1", + "blakejs": "^1.2.1", + "jsbn": "^1.1.0", + "node-forge": "^1.3.1", + "tweetnacl": "^1.0.3" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.0.tgz", @@ -22947,6 +22960,7 @@ "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.0", "@getinsomnia/api-client": "0.0.4", + "@getinsomnia/srp-js": "1.0.0-alpha.1", "@sentry/electron": "^5.1.0", "@stoplight/spectral-core": "^1.18.3", "@stoplight/spectral-formats": "^1.6.0", diff --git a/packages/insomnia-sdk/src/objects/environments.ts b/packages/insomnia-sdk/src/objects/environments.ts index a071163e324..40b8d7b083b 100644 --- a/packages/insomnia-sdk/src/objects/environments.ts +++ b/packages/insomnia-sdk/src/objects/environments.ts @@ -122,3 +122,38 @@ export class Variables { return this.localVars.toObject(); }; } + +export class Vault extends Environment { + + constructor(name: string, jsonObject: object | undefined, enableVaultInScripts: boolean) { + super(name, jsonObject); + return new Proxy(this, { + // throw error on get or set method call if enableVaultInScripts is false + get: (target, prop, receiver) => { + if (!enableVaultInScripts) { + throw new Error('Vault is disabled in script'); + } + return Reflect.get(target, prop, receiver); + }, + set: (target, prop, value, receiver) => { + if (!enableVaultInScripts) { + throw new Error('Vault is disabled in script'); + } + return Reflect.set(target, prop, value, receiver); + }, + }); + } + + unset = () => { + throw new Error('Vault can not be unset in script'); + }; + + clear = () => { + throw new Error('Vault can not be cleared in script'); + }; + + set = () => { + throw new Error('Vault can not be set in script'); + }; + +} diff --git a/packages/insomnia-sdk/src/objects/insomnia.ts b/packages/insomnia-sdk/src/objects/insomnia.ts index 8767c101abf..e758e20af2c 100644 --- a/packages/insomnia-sdk/src/objects/insomnia.ts +++ b/packages/insomnia-sdk/src/objects/insomnia.ts @@ -6,7 +6,7 @@ import { filterClientCertificates } from 'insomnia/src/network/certificate'; import { toPreRequestAuth } from './auth'; import { CookieObject } from './cookies'; -import { Environment, Variables } from './environments'; +import { Environment, Variables, Vault } from './environments'; import { Execution } from './execution'; import type { RequestContext } from './interfaces'; import { transformToSdkProxyOptions } from './proxy-configs'; @@ -28,6 +28,7 @@ export class InsomniaObject { public info: RequestInfo; public response?: ScriptResponse; public execution: Execution; + public vault?: Vault; private clientCertificates: ClientCertificate[]; private _expect = expect; @@ -55,6 +56,7 @@ export class InsomniaObject { requestInfo: RequestInfo; execution: Execution; response?: ScriptResponse; + vault?: Vault; }, ) { this.globals = rawObj.globals; @@ -66,6 +68,7 @@ export class InsomniaObject { this.cookies = rawObj.cookies; this.response = rawObj.response; this.execution = rawObj.execution; + this.vault = rawObj.vault; this.info = rawObj.requestInfo; this.request = rawObj.request; @@ -150,6 +153,9 @@ export async function initInsomniaObject( new Environment(rawObj.iterationData.name, rawObj.iterationData.data) : new Environment('iterationData', {}); const localVariables = rawObj.transientVariables ? new Environment(rawObj.transientVariables.name, rawObj.transientVariables.data) : new Environment('transientVariables', {}); + const enableVaultInScripts = rawObj.settings?.enableVaultInScripts || false; + const vault = rawObj.vault ? + new Vault('vault', rawObj.vault, enableVaultInScripts) : new Vault('vault', {}, enableVaultInScripts); const cookies = new CookieObject(rawObj.cookieJar); // TODO: update follows when post-request script and iterationData are introduced const requestInfo = new RequestInfo({ @@ -233,6 +239,7 @@ export async function initInsomniaObject( environment, baseEnvironment, iterationData, + vault, variables, request, settings: rawObj.settings, diff --git a/packages/insomnia-sdk/src/objects/interfaces.ts b/packages/insomnia-sdk/src/objects/interfaces.ts index b91ba4a51be..2ece93b481e 100644 --- a/packages/insomnia-sdk/src/objects/interfaces.ts +++ b/packages/insomnia-sdk/src/objects/interfaces.ts @@ -18,6 +18,7 @@ export interface RequestContext { timelinePath: string; environment: IEnvironment; baseEnvironment: IEnvironment; + vault?: IEnvironment; collectionVariables?: object; globals?: object; iterationData?: Omit; diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 4759bb57e9a..99e4852a961 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -93,6 +93,7 @@ "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.0", "@getinsomnia/api-client": "0.0.4", + "@getinsomnia/srp-js": "1.0.0-alpha.1", "@sentry/electron": "^5.1.0", "@stoplight/spectral-core": "^1.18.3", "@stoplight/spectral-formats": "^1.6.0", diff --git a/packages/insomnia/src/account/session.ts b/packages/insomnia/src/account/session.ts index eec1423d900..45b32358a3f 100644 --- a/packages/insomnia/src/account/session.ts +++ b/packages/insomnia/src/account/session.ts @@ -192,6 +192,8 @@ async function _unsetSessionData() { symmetricKey: {} as JsonWebKey, publicKey: {} as JsonWebKey, encPrivateKey: {} as crypt.AESMessage, + vaultSalt: '', + vaultKey: '', }); } diff --git a/packages/insomnia/src/common/export.tsx b/packages/insomnia/src/common/export.tsx index 85e9af90d8e..3c4eee88339 100644 --- a/packages/insomnia/src/common/export.tsx +++ b/packages/insomnia/src/common/export.tsx @@ -7,7 +7,7 @@ import YAML from 'yaml'; import { isApiSpec } from '../models/api-spec'; import { isCookieJar } from '../models/cookie-jar'; -import { type Environment, isEnvironment } from '../models/environment'; +import { type Environment, isEnvironment, maskVaultEnvironmentData } from '../models/environment'; import { isGrpcRequest } from '../models/grpc-request'; import * as requestOperations from '../models/helpers/request-operations'; import { type BaseModel, environment } from '../models/index'; @@ -493,7 +493,7 @@ export const exportMockServerToFile = async (workspace: Workspace) => { }); }; -const exportGlobalEnvironment = async (workspace: Workspace, selectedFormat: 'json' | 'yaml') => { +const exportGlobalEnvironment = async (workspace: Workspace, selectedFormat: 'json' | 'yaml', shouldExportPrivateEnvironments: boolean) => { const data: Insomnia4Data = { _type: 'export', __export_format: EXPORT_FORMAT, @@ -503,11 +503,16 @@ const exportGlobalEnvironment = async (workspace: Workspace, selectedFormat: 'js }; const baseEnvironment = await models.environment.getOrCreateForParentId(workspace._id); - const subEnvironments = await models.environment.findByParentId(baseEnvironment._id); + const subEnvironments = (await models.environment.findByParentId(baseEnvironment._id)) + .filter(subEnv => shouldExportPrivateEnvironments || !subEnv.isPrivate); data.resources.push({ ...workspace, _type: 'workspace' }); data.resources.push({ ...baseEnvironment, _type: 'environment' }); - subEnvironments.map(environment => data.resources.push({ ...environment, _type: 'environment' })); + subEnvironments.map(environment => { + // mask vault environment varibale if necessary + const maskedEnvironment = maskVaultEnvironmentData(environment); + data.resources.push({ ...maskedEnvironment, _type: 'environment' }); + }); if (selectedFormat === 'yaml') { return YAML.stringify(data); @@ -529,6 +534,15 @@ export const exportGlobalEnvironmentToFile = async (workspace: Workspace) => { invariant(selectedFormat, 'expected selected format to be defined'); invariant(selectedFormat === 'json' || selectedFormat === 'yaml', 'unexpected selected format'); window.localStorage.setItem('insomnia.lastExportFormat', selectedFormat); + // Modal to confirm whether to export private environment or not if necessary + const baseEnvironment = await models.environment.getOrCreateForParentId(workspace._id); + const subEnvironments = await models.environment.findByParentId(baseEnvironment._id); + const showPrivateEnvironmentPrompt = subEnvironments.some(subEnv => subEnv.isPrivate); + let shouldExportPrivateEnvironments = false; + if (showPrivateEnvironmentPrompt) { + shouldExportPrivateEnvironments = await showExportPrivateEnvironmentsModal(); + } + const fileName = await showSaveExportedFileDialog({ exportedFileNamePrefix: workspace.name, selectedFormat, @@ -536,8 +550,9 @@ export const exportGlobalEnvironmentToFile = async (workspace: Workspace) => { if (!fileName) { return; } + try { - const stringifiedExport = await exportGlobalEnvironment(workspace, selectedFormat); + const stringifiedExport = await exportGlobalEnvironment(workspace, selectedFormat, shouldExportPrivateEnvironments); writeExportedFileToFileSystem(fileName, stringifiedExport, err => err && console.warn('Export failed', err)); window.main.trackSegmentEvent({ event: SegmentEvent.dataExport, properties: { type: selectedFormat, scope: 'environment' } }); } catch (err) { diff --git a/packages/insomnia/src/common/render.ts b/packages/insomnia/src/common/render.ts index 76920f571f8..9742396a9f7 100644 --- a/packages/insomnia/src/common/render.ts +++ b/packages/insomnia/src/common/render.ts @@ -3,7 +3,7 @@ import orderedJSON from 'json-order'; import * as models from '../models'; import type { CookieJar } from '../models/cookie-jar'; -import type { Environment, UserUploadEnvironment } from '../models/environment'; +import { type Environment, type UserUploadEnvironment, vaultEnvironmentPath, vaultEnvironmentRuntimePath } from '../models/environment'; import type { GrpcRequest, GrpcRequestBody } from '../models/grpc-request'; import { isProject, type Project } from '../models/project'; import { PATH_PARAMETER_REGEX, type Request } from '../models/request'; @@ -18,7 +18,7 @@ import { database as db } from './database'; export const KEEP_ON_ERROR = 'keep'; export const THROW_ON_ERROR = 'throw'; -export type RenderPurpose = 'send' | 'general' | 'no-render'; +export type RenderPurpose = 'send' | 'general' | 'preview' | 'script' | 'no-render'; export const RENDER_PURPOSE_SEND: RenderPurpose = 'send'; export const RENDER_PURPOSE_GENERAL: RenderPurpose = 'general'; export const RENDER_PURPOSE_NO_RENDER: RenderPurpose = 'no-render'; @@ -212,7 +212,23 @@ export async function buildRenderContext( } // Render the context with itself to fill in the rest. - const finalRenderContext = renderContext; + const finalRenderContext = await templatingUtils.maskOrDecryptContextIfNecessary(renderContext as Record & BaseRenderContext); + // Merge all vault environments under vaultEnvironmentPath to vaultEnvironmentRuntimePath which is more human readable. + // This will also keep all legacy environment variables defined under the vaultEnvironmentRuntimePath. + if (finalRenderContext[vaultEnvironmentPath]) { + if (finalRenderContext[vaultEnvironmentRuntimePath] && typeof finalRenderContext[vaultEnvironmentRuntimePath] !== 'object') { + const errorMsg = `${vaultEnvironmentRuntimePath} is a reserved key for insomnia vault, please rename your environment with vault as key.`; + const newError = new templating.RenderError(errorMsg); + newError.type = 'render'; + newError.message = errorMsg; + throw newError; + } + finalRenderContext[vaultEnvironmentRuntimePath] = { + ...finalRenderContext[vaultEnvironmentPath], + ...finalRenderContext[vaultEnvironmentRuntimePath], + }; + delete finalRenderContext[vaultEnvironmentPath]; + }; const keys = _getOrderedEnvironmentKeys(finalRenderContext); @@ -372,7 +388,7 @@ interface BaseRenderContextOptions { ignoreUndefinedEnvVariable?: boolean; } -interface RenderContextOptions extends BaseRenderContextOptions, Partial> { +export interface RenderContextOptions extends BaseRenderContextOptions, Partial> { ancestors?: RenderContextAncestor[]; } export async function getRenderContext( @@ -533,7 +549,7 @@ export async function getRenderContext( interface BaseRenderContext { getMeta: () => {}; getKeysContext: () => {}; - getPurpose: () => string | undefined; + getPurpose: () => RenderPurpose | undefined; getExtraInfo: (key: string) => string | null; getEnvironmentId: () => string | undefined; getGlobalEnvironmentId: () => string | undefined; diff --git a/packages/insomnia/src/common/settings.ts b/packages/insomnia/src/common/settings.ts index 5ea6537f851..0c251303bd8 100644 --- a/packages/insomnia/src/common/settings.ts +++ b/packages/insomnia/src/common/settings.ts @@ -145,4 +145,7 @@ export interface Settings { useBulkParametersEditor: boolean; validateAuthSSL: boolean; validateSSL: boolean; + // vault related settings + saveVaultKeyLocally: boolean; + enableVaultInScripts: boolean; } diff --git a/packages/insomnia/src/main.development.ts b/packages/insomnia/src/main.development.ts index 6a3d0358a1c..61f72da36a3 100644 --- a/packages/insomnia/src/main.development.ts +++ b/packages/insomnia/src/main.development.ts @@ -15,6 +15,7 @@ import { backupIfNewerVersionAvailable } from './main/backup'; import { ipcMainOn, ipcMainOnce, registerElectronHandlers } from './main/ipc/electron'; import { registergRPCHandlers } from './main/ipc/grpc'; import { registerMainHandlers } from './main/ipc/main'; +import { registerSecretStorageHandlers } from './main/ipc/secret-storage'; import { registerCurlHandlers } from './main/network/curl'; import { registerWebSocketHandlers } from './main/network/websocket'; import { watchProxySettings } from './main/proxy'; @@ -64,6 +65,7 @@ app.on('ready', async () => { registergRPCHandlers(); registerWebSocketHandlers(); registerCurlHandlers(); + registerSecretStorageHandlers(); /** * There's no option that prevents Electron from fetching spellcheck dictionaries from Chromium's CDN and passing a non-resolving URL is the only known way to prevent it from fetching. diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 206433aa79c..30b2650c4e2 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -28,7 +28,12 @@ export type HandleChannels = | 'webSocket.open' | 'webSocket.readyState' | 'writeFile' - | 'extractJsonFileFromPostmanDataDumpArchive'; + | 'extractJsonFileFromPostmanDataDumpArchive' + | 'secretStorage.setSecret' + | 'secretStorage.getSecret' + | 'secretStorage.deleteSecret' + | 'secretStorage.encryptString' + | 'secretStorage.decryptString'; export const ipcMainHandle = ( channel: HandleChannels, @@ -162,6 +167,7 @@ export function registerElectronHandlers() { .sort((a, b) => fnOrString(a.templateTag.displayName).localeCompare(fnOrString(b.templateTag.displayName))) .map(l => { const actions = l.templateTag.args?.[0]; + const needsEnterprisePlan = l.templateTag.needsEnterprisePlan || false; const additionalArgs = l.templateTag.args?.slice(1); const hasSubmenu = actions?.options?.length; return { @@ -170,7 +176,8 @@ export function registerElectronHandlers() { { click: () => { const tag = `{% ${l.templateTag.name} ${l.templateTag.args?.map(getTemplateValue).join(', ')} %}`; - event.sender.send('context-menu-command', { key, tag }); + const displayName = l.templateTag.displayName; + event.sender.send('context-menu-command', { key, tag, needsEnterprisePlan, displayName }); }, } : { @@ -178,8 +185,9 @@ export function registerElectronHandlers() { label: fnOrString(action.displayName), click: () => { const additionalTagFields = additionalArgs.length ? ', ' + additionalArgs.map(getTemplateValue).join(', ') : ''; + const displayName = action.displayName; const tag = `{% ${l.templateTag.name} '${action.value}'${additionalTagFields} %}`; - event.sender.send('context-menu-command', { key, tag }); + event.sender.send('context-menu-command', { key, tag, needsEnterprisePlan, displayName }); }, })), }), diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 216a90bd6e3..0c7dcbb23de 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -17,6 +17,7 @@ import type { WebSocketBridgeAPI } from '../network/websocket'; import { ipcMainHandle, ipcMainOn, ipcMainOnce, type RendererOnChannels } from './electron'; import extractPostmanDataDumpHandler from './extractPostmanDataDump'; import type { gRPCBridgeAPI } from './grpc'; +import type { secretStorageBridgeAPI } from './secret-storage'; export interface RendererToMainBridgeAPI { loginStateChange: () => void; @@ -37,6 +38,7 @@ export interface RendererToMainBridgeAPI { webSocket: WebSocketBridgeAPI; grpc: gRPCBridgeAPI; curl: CurlBridgeAPI; + secretStorage: secretStorageBridgeAPI; trackSegmentEvent: (options: { event: string; properties?: Record }) => void; trackPageView: (options: { name: string }) => void; showContextMenu: (options: { key: string; nunjucksTag?: { template: string; range: MarkerRange } }) => void; diff --git a/packages/insomnia/src/main/ipc/secret-storage.ts b/packages/insomnia/src/main/ipc/secret-storage.ts new file mode 100644 index 00000000000..67908aa1eec --- /dev/null +++ b/packages/insomnia/src/main/ipc/secret-storage.ts @@ -0,0 +1,77 @@ +import { safeStorage } from 'electron'; + +import LocalStorage from '../local-storage'; +import { initLocalStorage } from '../window-utils'; +import { ipcMainHandle } from './electron'; + +export interface secretStorageBridgeAPI { + setSecret: typeof setSecret; + getSecret: typeof getSecret; + deleteSecret: typeof deleteSecret; + encryptString: (raw: string) => Promise; + decryptString: (cipherText: string) => Promise; +} + +export function registerSecretStorageHandlers() { + ipcMainHandle('secretStorage.setSecret', (_, key, secret) => setSecret(key, secret)); + ipcMainHandle('secretStorage.getSecret', (_, key) => getSecret(key)); + ipcMainHandle('secretStorage.deleteSecret', (_, key) => deleteSecret(key)); + ipcMainHandle('secretStorage.encryptString', (_, raw) => encryptString(raw)); + ipcMainHandle('secretStorage.decryptString', (_, raw) => decryptString(raw)); +} + +let localStorage: LocalStorage | null = null; + +const getLocalStorage = () => { + if (!localStorage) { + localStorage = initLocalStorage(); + } + return localStorage; +}; + +const setSecret = async (key: string, secret: string) => { + try { + const secretStorage = getLocalStorage(); + const encrypted = encryptString(secret); + secretStorage.setItem(key, encrypted); + } catch (error) { + console.error(`Can not save secret ${error.toString()}`); + return Promise.reject(error); + } +}; + +const getSecret = async (key: string) => { + try { + const secretStorage = getLocalStorage(); + const encrypted = secretStorage.getItem(key, ''); + return encrypted === '' ? null : decryptString(encrypted); + } catch (error) { + console.error(`Can not get secret ${error.toString()}`); + return Promise.reject(null); + } +}; + +const deleteSecret = async (key: string) => { + try { + const secretStorage = getLocalStorage(); + secretStorage.deleteItem(key); + } catch (error) { + console.error(`Can not delele secret ${error.toString()}`); + return Promise.reject(error); + } +}; + +const encryptString = (raw: string) => { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.encryptString(raw).toString('hex'); + } + return raw; +}; + +const decryptString = (cipherText: string) => { + const buffer = Buffer.from(cipherText, 'hex'); + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.decryptString(buffer); + } + return cipherText; +}; diff --git a/packages/insomnia/src/main/local-storage.ts b/packages/insomnia/src/main/local-storage.ts index 00514a19229..905f3871072 100644 --- a/packages/insomnia/src/main/local-storage.ts +++ b/packages/insomnia/src/main/local-storage.ts @@ -44,6 +44,21 @@ class LocalStorage { } } + deleteItem(key: string) { + clearTimeout(this._timeouts[key]); + delete this._buffer[key]; + + const path = this._getKeyPath(key); + + try { + fs.unlinkSync(path); + } catch (error) { + if (error.code !== 'ENOENT') { + console.error(`[localstorage] Failed to delete item from LocalStorage: ${error}`); + } + } + } + _flush() { const keys = Object.keys(this._buffer); diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index 18e3c37a137..8841451f656 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -50,7 +50,7 @@ interface Bounds { } export function init() { - initLocalStorage(); + localStorage = initLocalStorage(); } const stopAndWaitForHiddenBrowserWindow = async (runningHiddenBrowserWindow: BrowserWindow) => { return await new Promise(resolve => { @@ -804,9 +804,10 @@ export const setZoom = (transformer: (current: number) => number) => () => { localStorage?.setItem('zoomFactor', actual); }; -function initLocalStorage() { +export function initLocalStorage() { const localStoragePath = path.join(process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'), 'localStorage'); - localStorage = new LocalStorage(localStoragePath); + const localStorage = new LocalStorage(localStoragePath); + return localStorage; } export function createWindowsAndReturnMain({ firstLaunch }: { firstLaunch?: boolean } = {}) { diff --git a/packages/insomnia/src/models/environment.ts b/packages/insomnia/src/models/environment.ts index 478d8068732..d59cb1d7e13 100644 --- a/packages/insomnia/src/models/environment.ts +++ b/packages/insomnia/src/models/environment.ts @@ -1,14 +1,23 @@ import * as crypto from 'crypto'; import orderedJSON from 'json-order'; +import * as crypt from '../account/crypt'; import { JSON_ORDER_SEPARATOR } from '../common/constants'; import { database as db } from '../common/database'; import { generateId } from '../common/misc'; -import { type BaseModel } from './index'; +import { base64decode, base64encode } from '../utils/vault'; +import { type BaseModel, project, workspace } from './index'; +import type { Project } from './project'; +import type { Workspace } from './workspace'; export const name = 'Environment'; export const type = 'Environment'; export const prefix = 'env'; +// vault environment path when saved in environment data +export const vaultEnvironmentPath = '__insomnia_vault'; +// vault environment path when used in runtime rendering +export const vaultEnvironmentRuntimePath = 'vault'; +export const vaultEnvironmentMaskValue = '••••••'; export const canDuplicate = true; export const canSync = true; // for those keys do not need to add in model init method @@ -35,7 +44,8 @@ export enum EnvironmentType { }; export enum EnvironmentKvPairDataType { JSON = 'json', - STRING = 'str' + STRING = 'str', + SECRET = 'secret', } export interface EnvironmentKvPairData { id: string; @@ -53,14 +63,27 @@ export function getKVPairFromData(data: Record, dataPropertyOrder: const kvPair: EnvironmentKvPairData[] = []; Object.keys(ordered).forEach(key => { const val = ordered[key]; - const isValObject = val && typeof val === 'object' && data !== null; - kvPair.push({ - id: generateId('envPair'), - name: key, - value: isValObject ? JSON.stringify(val) : String(val), - type: isValObject ? EnvironmentKvPairDataType.JSON : EnvironmentKvPairDataType.STRING, - enabled: true, - }); + // get all secret items from vaultEnvironmentPath + if (key === vaultEnvironmentPath && typeof val === 'object') { + Object.keys(val).forEach(secretKey => { + kvPair.push({ + id: generateId('envPair'), + name: secretKey, + value: val[secretKey], + type: EnvironmentKvPairDataType.SECRET, + enabled: true, + }); + }); + } else { + const isValidObject = val && typeof val === 'object' && data !== null; + kvPair.push({ + id: generateId('envPair'), + name: key, + value: isValidObject ? JSON.stringify(val) : String(val), + type: isValidObject ? EnvironmentKvPairDataType.JSON : EnvironmentKvPairDataType.STRING, + enabled: true, + }); + }; }); return kvPair; } @@ -70,7 +93,15 @@ export function getDataFromKVPair(kvPair: EnvironmentKvPairData[]) { kvPair.forEach(pair => { const { name, value, type, enabled } = pair; if (enabled) { - data[name] = type === EnvironmentKvPairDataType.JSON ? JSON.parse(value) : value; + if (type === EnvironmentKvPairDataType.SECRET) { + if (!data[vaultEnvironmentPath]) { + // create object storing all secret items + data[vaultEnvironmentPath] = {}; + }; + data[vaultEnvironmentPath][name] = value; + } else { + data[name] = type === EnvironmentKvPairDataType.JSON ? JSON.parse(value) : value; + } } }); return { @@ -79,6 +110,82 @@ export function getDataFromKVPair(kvPair: EnvironmentKvPairData[]) { }; } +// mask vault environment varibale if necessary +export const maskVaultEnvironmentData = (environment: Environment) => { + if (environment.isPrivate) { + const { data, kvPairData } = environment; + const shouldMask = kvPairData?.some(pair => pair.type === EnvironmentKvPairDataType.SECRET); + if (shouldMask) { + kvPairData?.forEach(pair => { + const { type } = pair; + if (type === EnvironmentKvPairDataType.SECRET) { + pair.value = vaultEnvironmentMaskValue; + } + }); + Object.keys(data[vaultEnvironmentPath]).forEach(vaultKey => { + data[vaultEnvironmentPath][vaultKey] = vaultEnvironmentMaskValue; + }); + } + }; + return environment; +}; + +export const encryptSecretValue = (rawValue: string, symmetricKey: JsonWebKey) => { + if (typeof symmetricKey !== 'object' || Object.keys(symmetricKey).length === 0) { + // invalid symmetricKey + return rawValue; + } + const encryptResult = crypt.encryptAES(symmetricKey, rawValue); + const encryptedValue = base64encode(encryptResult); + return encryptedValue; +}; + +export const decryptSecretValue = (encryptedValue: string, symmetricKey: JsonWebKey) => { + if (typeof symmetricKey !== 'object' || Object.keys(symmetricKey).length === 0) { + // invalid symmetricKey + return encryptedValue; + } + try { + const jsonWebKey = base64decode(encryptedValue, true); + return crypt.decryptAES(symmetricKey, jsonWebKey); + } catch (error) { + // return origin value if failed to decrypt + return encryptedValue; + } +}; + +// remove all secret items when user reset vault key +export const removeAllSecrets = async (orgnizationIds: string[]) => { + const allProjects = await db.find(project.type, { + parentId: { $in: orgnizationIds }, + }); + const allProjectIds = allProjects.map(project => project._id); + const allGlobalEnvironmentWorkspaces = await db.find(workspace.type, { + parentId: { $in: allProjectIds }, + scope: workspace.WorkspaceScopeKeys.environment, + }); + const allGlobalBaseEnvironments = await db.find(type, { + parentId: { + $in: allGlobalEnvironmentWorkspaces.map(w => w._id), + }, + }); + const allGlobalSubEnvironments = await db.find(type, { + parentId: { + $in: allGlobalBaseEnvironments.map(e => e._id), + }, + }); + const allGlobalEnvironments = allGlobalBaseEnvironments.concat(allGlobalSubEnvironments); + const allGloablPrivateEnvironments = allGlobalEnvironments.filter(env => env.isPrivate); + allGloablPrivateEnvironments.forEach(async privateEnv => { + const { kvPairData, data } = privateEnv; + if (vaultEnvironmentPath in data) { + const { [vaultEnvironmentPath]: secretData, ...restData } = data; + const filteredKvPairData = kvPairData?.filter(kvPair => kvPair.type !== EnvironmentKvPairDataType.SECRET); + await update(privateEnv, { data: restData, kvPairData: filteredKvPairData }); + } + }); +}; + export const isEnvironment = (model: Pick): model is Environment => ( model.type === type ); diff --git a/packages/insomnia/src/models/settings.ts b/packages/insomnia/src/models/settings.ts index 68686ad93c0..6df814e37d0 100644 --- a/packages/insomnia/src/models/settings.ts +++ b/packages/insomnia/src/models/settings.ts @@ -70,6 +70,9 @@ export function init(): BaseSettings { useBulkParametersEditor: false, validateAuthSSL: true, validateSSL: true, + vaultSecretCacheDuration: 30, + saveVaultKeyLocally: true, + enableVaultInScripts: false, }; } diff --git a/packages/insomnia/src/models/user-session.ts b/packages/insomnia/src/models/user-session.ts index fed9628ee87..b252f317202 100644 --- a/packages/insomnia/src/models/user-session.ts +++ b/packages/insomnia/src/models/user-session.ts @@ -11,6 +11,8 @@ export interface BaseUserSession { symmetricKey: JsonWebKey; publicKey: JsonWebKey; encPrivateKey: AESMessage; + vaultSalt?: string; + vaultKey?: string; }; export interface HashedUserSession { @@ -34,6 +36,8 @@ export function init(): BaseUserSession { symmetricKey: {} as JsonWebKey, publicKey: {} as JsonWebKey, encPrivateKey: {} as AESMessage, + vaultKey: '', + vaultSalt: '', }; } diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index b3ba22fa987..f16d31233dc 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -22,7 +22,7 @@ import * as models from '../models'; import type { CaCertificate } from '../models/ca-certificate'; import type { ClientCertificate } from '../models/client-certificate'; import type { Cookie, CookieJar } from '../models/cookie-jar'; -import type { Environment, UserUploadEnvironment } from '../models/environment'; +import { type Environment, type UserUploadEnvironment, vaultEnvironmentPath } from '../models/environment'; import type { MockRoute } from '../models/mock-route'; import type { MockServer } from '../models/mock-server'; import { isProject, type Project } from '../models/project'; @@ -33,6 +33,7 @@ import type { WebSocketRequest } from '../models/websocket-request'; import { isWorkspace, type Workspace } from '../models/workspace'; import * as pluginContexts from '../plugins/context/index'; import * as plugins from '../plugins/index'; +import { maskOrDecryptContextIfNecessary } from '../templating/utils'; import { defaultSendActionRuntime, type SendActionRuntime } from '../ui/routes/request'; import { invariant } from '../utils/invariant'; import { serializeNDJSON } from '../utils/ndjson'; @@ -347,6 +348,15 @@ export const tryToExecuteScript = async (context: RequestAndContextAndOptionalRe .filter(doc => isRequest(doc) || isRequestGroup(doc) || isWorkspace(doc) || isProject(doc)) .reverse() .map(doc => doc.name); + let vault = undefined; + if (globals && vaultEnvironmentPath in globals.data && settings.enableVaultInScripts) { + // decrypt and set vault in insomnia sdk if necessary + await maskOrDecryptContextIfNecessary({ + ...globals.data, + getPurpose: () => 'script', + }); + vault = globals.data[vaultEnvironmentPath]; + } try { const fn = process.type === 'renderer' ? runScriptConcurrently : cancellableRunScript; @@ -377,6 +387,7 @@ export const tryToExecuteScript = async (context: RequestAndContextAndOptionalRe iteration, }, response, + vault, globals: globals?.data || undefined, iterationData: userUploadEnvironment ? { name: userUploadEnvironment.name, diff --git a/packages/insomnia/src/preload.ts b/packages/insomnia/src/preload.ts index fc994eb4346..7da13cd274c 100644 --- a/packages/insomnia/src/preload.ts +++ b/packages/insomnia/src/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { gRPCBridgeAPI } from './main/ipc/grpc'; +import type { secretStorageBridgeAPI } from './main/ipc/secret-storage'; import type { CurlBridgeAPI } from './main/network/curl'; import type { WebSocketBridgeAPI } from './main/network/websocket'; import { invariant } from './utils/invariant'; @@ -40,6 +41,15 @@ const grpc: gRPCBridgeAPI = { loadMethods: options => ipcRenderer.invoke('grpc.loadMethods', options), loadMethodsFromReflection: options => ipcRenderer.invoke('grpc.loadMethodsFromReflection', options), }; + +const secretStorage: secretStorageBridgeAPI = { + setSecret: (key, secret) => ipcRenderer.invoke('secretStorage.setSecret', key, secret), + getSecret: key => ipcRenderer.invoke('secretStorage.getSecret', key), + deleteSecret: key => ipcRenderer.invoke('secretStorage.deleteSecret', key), + encryptString: raw => ipcRenderer.invoke('secretStorage.encryptString', raw), + decryptString: cipherText => ipcRenderer.invoke('secretStorage.decryptString', cipherText), +}; + const main: Window['main'] = { startExecution: options => ipcRenderer.send('startExecution', options), addExecutionStep: options => ipcRenderer.send('addExecutionStep', options), @@ -67,6 +77,7 @@ const main: Window['main'] = { webSocket, grpc, curl, + secretStorage, trackSegmentEvent: options => ipcRenderer.send('trackSegmentEvent', options), trackPageView: options => ipcRenderer.send('trackPageView', options), showContextMenu: options => ipcRenderer.send('show-context-menu', options), diff --git a/packages/insomnia/src/templating/extensions/index.ts b/packages/insomnia/src/templating/extensions/index.ts index a3928ba5dee..3edd5fc4c95 100644 --- a/packages/insomnia/src/templating/extensions/index.ts +++ b/packages/insomnia/src/templating/extensions/index.ts @@ -99,6 +99,7 @@ export interface PluginTemplateTag { args: NunjucksParsedTagArg[]; name: string; displayName: DisplayName; + needsEnterprisePlan?: boolean; disablePreview?: (args: any[]) => boolean; description: string; actions?: NunjucksActionTag[]; diff --git a/packages/insomnia/src/templating/utils.ts b/packages/insomnia/src/templating/utils.ts index b6a06ef878e..2c59cd2b7e0 100644 --- a/packages/insomnia/src/templating/utils.ts +++ b/packages/insomnia/src/templating/utils.ts @@ -1,6 +1,10 @@ import type { EditorFromTextArea, MarkerRange } from 'codemirror'; import _ from 'lodash'; +import type { RenderPurpose } from '../common/render'; +import { type BaseModel, userSession } from '../models'; +import { decryptSecretValue, vaultEnvironmentMaskValue, vaultEnvironmentPath } from '../models/environment'; +import { decryptVaultKeyFromSession } from '../utils/vault'; import type { DisplayName, PluginArgumentEnumOption, PluginTemplateTagActionContext } from './extensions'; import objectPath from './third_party/objectPath'; @@ -15,12 +19,14 @@ export interface NunjucksParsedTagArg { displayName?: DisplayName; quotedBy?: '"' | "'"; validate?: (value: any) => string; + modelFilter?: (model: BaseModel, tagArg: NunjucksParsedTagArg[]) => boolean; hide?: (arg0: NunjucksParsedTagArg[]) => boolean; model?: string; options?: PluginArgumentEnumOption[]; itemTypes?: ('file' | 'directory')[]; extensions?: string[]; description?: string; + requireSubForm?: boolean; } export interface NunjucksActionTag { @@ -301,6 +307,42 @@ export function extractUndefinedVariableKey(text: string = '', templatingContext return missingVariables; } +export async function maskOrDecryptContextIfNecessary(context: Record & { getPurpose: () => RenderPurpose | undefined }) { + // all secret variables are under vaultEnvironmentPath property in context + const vaultEnvironmentData = context[vaultEnvironmentPath]; + const renderPurpose = typeof context.getPurpose === 'function' && context.getPurpose(); + /** + * Decrypt secrets when renderPurpose is one of the following: + * - preview: render the template in variable editor to do the live preview + * - send: render the template when sending requests + * - script: render the template in pre-request or after-response script + */ + const shouldDecrypt = renderPurpose === 'preview' || renderPurpose === 'send' || renderPurpose === 'script'; + if (typeof vaultEnvironmentData === 'object') { + if (shouldDecrypt) { + const { vaultKey, vaultSalt } = await userSession.getOrCreate(); + const isVaultEnabled = !!vaultSalt; + if (isVaultEnabled && vaultKey) { + const symmetricKey = await decryptVaultKeyFromSession(vaultKey, true) as JsonWebKey; + // decrypt all secert values under vaultEnvironmentPath property in context + Object.keys(vaultEnvironmentData).forEach(vaultContextKey => { + const encryptedValue = vaultEnvironmentData[vaultContextKey]; + vaultEnvironmentData[vaultContextKey] = decryptSecretValue(encryptedValue, symmetricKey); + }); + } else if (isVaultEnabled && !vaultKey) { + // remove all values under vaultEnvironmentPath if no vault key found + context[vaultEnvironmentPath] = {}; + } + } else { + // mask all secert values under vaultEnvironmentPath property in context + Object.keys(vaultEnvironmentData).forEach(vaultContextKey => { + vaultEnvironmentData[vaultContextKey] = vaultEnvironmentMaskValue; + }); + } + } + return context; +} + export function extractNunjucksTagFromCoords( coordinates: { left: number; top: number }, cm: React.MutableRefObject diff --git a/packages/insomnia/src/ui/components/base/copy-button.tsx b/packages/insomnia/src/ui/components/base/copy-button.tsx index d5187c9d9bd..51126c0c142 100644 --- a/packages/insomnia/src/ui/components/base/copy-button.tsx +++ b/packages/insomnia/src/ui/components/base/copy-button.tsx @@ -1,21 +1,25 @@ -import React, { type FC, useCallback, useState } from 'react'; +import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; import { useInterval } from 'react-use'; import { Button, type ButtonProps } from '../themed-button'; +export interface CopyBtnHanlde { + copy: () => void; +} interface Props extends ButtonProps { confirmMessage?: string; content: string; title?: string; } -export const CopyButton: FC = ({ - children, - confirmMessage, - content, - title, - ...buttonProps -}) => { +export const CopyButton = forwardRef((props, ref) => { + const { + children, + confirmMessage, + content, + title, + ...buttonProps + } = props; const [showConfirmation, setshowConfirmation] = useState(false); const onClick = useCallback(async (event: React.MouseEvent) => { event.preventDefault(); @@ -31,6 +35,15 @@ export const CopyButton: FC = ({ setshowConfirmation(false); }, 2000); + useImperativeHandle(ref, () => ({ + copy: () => { + if (content) { + window.clipboard.writeText(content); + setshowConfirmation(true); + } + }, + }), [content]); + const confirm = typeof confirmMessage === 'string' ? confirmMessage : 'Copied'; return ( ); -}; +}); diff --git a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx index aa95273a93a..3fb1f885e12 100644 --- a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx @@ -22,12 +22,14 @@ import { jsonPrettify } from '../../../utils/prettify/json'; import { queryXPath } from '../../../utils/xpath/query'; import { useGatedNunjucks } from '../../context/nunjucks/use-gated-nunjucks'; import { useEditorRefresh } from '../../hooks/use-editor-refresh'; +import { usePlanData } from '../../hooks/use-plan'; import { useRootLoaderData } from '../../routes/root'; import { Icon } from '../icon'; import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from '../keydown-binder'; import { FilterHelpModal } from '../modals/filter-help-modal'; import { showModal } from '../modals/index'; import { NunjucksModal } from '../modals/nunjucks-modal'; +import { UpgradeModal } from '../modals/upgrade-modal'; import { isKeyCombinationInRegistry } from '../settings/shortcuts'; import { normalizeIrregularWhitespace } from './normalizeIrregularWhitespace'; const TAB_SIZE = 4; @@ -188,6 +190,7 @@ export const CodeEditor = memo(forwardRef(({ const { settings, } = useRootLoaderData(); + const { isOwner, isEnterprisePlan } = usePlanData(); const indentSize = settings.editorIndentSize; const indentWithTabs = shouldIndentWithTabs({ mode, indentWithTabs: settings.editorIndentWithTabs }); const indentChars = indentWithTabs ? '\t' : new Array((indentSize || TAB_SIZE) + 1).join(' '); @@ -546,8 +549,17 @@ export const CodeEditor = memo(forwardRef(({ } }; useEffect(() => { - const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag }) => { + const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag, needsEnterprisePlan, displayName }) => { if (id === key) { + if (needsEnterprisePlan && !isEnterprisePlan) { + // show modal if current user is not an enteprise user and the command is an enterprise feature + showModal(UpgradeModal, { + newPlan: 'enterprise', + featureName: displayName, + isOwner, + }); + return; + } if (nunjucksTag) { const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions; switch (type) { @@ -578,7 +590,7 @@ export const CodeEditor = memo(forwardRef(({ return () => { unsubscribe(); }; - }, [id]); + }, [id, isEnterprisePlan, isOwner]); useEffect(() => tryToSetOption('hintOptions', hintOptions), [hintOptions]); useEffect(() => tryToSetOption('info', infoOptions), [infoOptions]); useEffect(() => tryToSetOption('jump', jumpOptions), [jumpOptions]); diff --git a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx index 5ffdf985c83..f64cb4c3732 100644 --- a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx @@ -13,9 +13,11 @@ import { getTagDefinitions } from '../../../templating/index'; import { extractNunjucksTagFromCoords, type NunjucksParsedTag, type nunjucksTagContextMenuOptions } from '../../../templating/utils'; import { useNunjucks } from '../../context/nunjucks/use-nunjucks'; import { useEditorRefresh } from '../../hooks/use-editor-refresh'; +import { usePlanData } from '../../hooks/use-plan'; import { useRootLoaderData } from '../../routes/root'; import { showModal } from '../modals'; import { NunjucksModal } from '../modals/nunjucks-modal'; +import { UpgradeModal } from '../modals/upgrade-modal'; import { isKeyCombinationInRegistry } from '../settings/shortcuts'; export interface OneLineEditorProps { defaultValue: string; @@ -57,6 +59,7 @@ export const OneLineEditor = forwardRef const { settings, } = useRootLoaderData(); + const { isOwner, isEnterprisePlan } = usePlanData(); const { handleRender, handleGetRenderContext } = useNunjucks(); const initEditor = useCallback(() => { @@ -257,7 +260,16 @@ export const OneLineEditor = forwardRef }, [onChange]); useEffect(() => { - const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag }) => { + const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag, needsEnterprisePlan, displayName }) => { + if (needsEnterprisePlan && !isEnterprisePlan) { + // show modal if current user is not an enteprise user and the command is an enterprise feature + showModal(UpgradeModal, { + newPlan: 'enterprise', + featureName: displayName, + isOwner, + }); + return; + } if (id === key) { if (nunjucksTag) { const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions; @@ -288,7 +300,7 @@ export const OneLineEditor = forwardRef return () => { unsubscribe(); }; - }, [id]); + }, [id, isEnterprisePlan, isOwner]); useImperativeHandle(ref, () => ({ selectAll: () => codeMirror.current?.setSelection({ line: 0, ch: 0 }, { line: codeMirror.current.lineCount(), ch: 0 }), 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 641a30c6486..867d0aa7070 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 @@ -2,19 +2,25 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button, type ButtonProps, DropIndicator, ListBox, ListBoxItem, Menu, MenuItem, MenuTrigger, Popover, Toolbar, useDragAndDrop } from 'react-aria-components'; import { generateId } from '../../../../common/misc'; -import { type EnvironmentKvPairData, EnvironmentKvPairDataType } from '../../../../models/environment'; +import { decryptSecretValue, encryptSecretValue, type EnvironmentKvPairData, EnvironmentKvPairDataType } from '../../../../models/environment'; +import { base64decode } from '../../../../utils/vault'; import { PromptButton } from '../../base/prompt-button'; import { OneLineEditor } from '../../codemirror/one-line-editor'; import { Icon } from '../../icon'; +import { showModal } from '../../modals'; +import { AskModal } from '../../modals/ask-modal'; import { CodePromptModal, type CodePromptModalHandle } from '../../modals/code-prompt-modal'; import { Tooltip } from '../../tooltip'; import { checkNestedKeys, ensureKeyIsValid } from '../environment-utils'; +import { PasswordInput } from './password-input'; interface EditorProps { data: EnvironmentKvPairData[]; onChange: (newPair: EnvironmentKvPairData[]) => void; + vaultKey?: string; + isPrivate?: boolean; } -const cellCommonStyle = 'h-full px-2 flex items-center'; +const cellCommonStyle = 'h-full px-2 flex items-center'; const createNewPair = (enabled: boolean = true): EnvironmentKvPairData => ({ id: generateId('envPair'), @@ -38,15 +44,29 @@ const ItemButton = (props: ButtonProps & { tabIndex?: number }) => { return + + ); +}; diff --git a/packages/insomnia/src/ui/components/editors/environment-utils.tsx b/packages/insomnia/src/ui/components/editors/environment-utils.tsx index fe3180d4544..c809c07bda4 100644 --- a/packages/insomnia/src/ui/components/editors/environment-utils.tsx +++ b/packages/insomnia/src/ui/components/editors/environment-utils.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { type Environment, type EnvironmentKvPairData, EnvironmentType, getKVPairFromData } from '../../../models/environment'; +import { type Environment, type EnvironmentKvPairData, EnvironmentType, getKVPairFromData, vaultEnvironmentPath, vaultEnvironmentRuntimePath } from '../../../models/environment'; import { NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME } from '../../../templating'; import { showModal } from '../modals'; import { AlertModal } from '../modals/alert-modal'; @@ -19,6 +19,14 @@ export const ensureKeyIsValid = (key: string, isRoot: boolean): string | null => return `"${NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME}" is a reserved key`; } + if (key === vaultEnvironmentPath && isRoot) { + return `"${vaultEnvironmentPath}" is a reserved key`; + } + + if (key === vaultEnvironmentRuntimePath && isRoot) { + return `"${vaultEnvironmentRuntimePath}" is a reserved key`; + } + return null; }; diff --git a/packages/insomnia/src/ui/components/editors/request-script-editor.tsx b/packages/insomnia/src/ui/components/editors/request-script-editor.tsx index a0e06b9fd78..9f79a125e94 100644 --- a/packages/insomnia/src/ui/components/editors/request-script-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/request-script-editor.tsx @@ -1,5 +1,5 @@ import type { Snippet } from 'codemirror'; -import { CookieObject, Environment, Execution, InsomniaObject, Request as ScriptRequest, RequestInfo, Url, Variables } from 'insomnia-sdk'; +import { CookieObject, Environment, Execution, InsomniaObject, Request as ScriptRequest, RequestInfo, Url, Variables, Vault } from 'insomnia-sdk'; import React, { type FC, useRef } from 'react'; import { Button, Collection, Header, Menu, MenuItem, MenuTrigger, Popover, Section, Toolbar } from 'react-aria-components'; @@ -515,6 +515,7 @@ export const RequestScriptEditor: FC = ({ iterationDataVars: new Environment('data', {}), localVars: new Environment('data', {}), }), + vault: settings.enableVaultInScripts ? new Vault('vault', {}, settings.enableVaultInScripts) : undefined, request: new ScriptRequest({ url: new Url('http://placeholder.com'), }), diff --git a/packages/insomnia/src/ui/components/modals/input-valut-key-modal.tsx b/packages/insomnia/src/ui/components/modals/input-valut-key-modal.tsx new file mode 100644 index 00000000000..f2a360c3aa0 --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/input-valut-key-modal.tsx @@ -0,0 +1,182 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Dialog, Heading, Input, Modal, ModalOverlay } from 'react-aria-components'; +import { useFetcher, useRouteLoaderData } from 'react-router-dom'; + +import { removeAllSecrets } from '../../../models/environment'; +import type { OrganizationLoaderData } from '../../routes/organization'; +import { useRootLoaderData } from '../../routes/root'; +import { Icon } from '../icon'; +import { VaultKeyDisplayInput } from '../settings/vault-key-panel'; +import { showModal } from '.'; +import { AskModal } from './ask-modal'; + +export interface InputVaultKeyModalProps { + onClose: (vaultKey?: string) => void; + allowClose?: boolean; +} + +export const InputVaultKeyModal = (props: InputVaultKeyModalProps) => { + const { onClose, allowClose = true } = props; + const { userSession } = useRootLoaderData(); + const [vaultKey, setVaultKey] = useState(''); + const [error, setError] = useState(''); + const [resetDone, setResetDone] = useState(false); + const resetVaultKeyFetcher = useFetcher(); + const validateVaultKeyFetcher = useFetcher(); + const { organizations } = useRouteLoaderData('/organization') as OrganizationLoaderData; + const isLoading = resetVaultKeyFetcher.state !== 'idle' || validateVaultKeyFetcher.state !== 'idle'; + + useEffect(() => { + // close modal and return new vault key after reset + if (resetVaultKeyFetcher.data && !resetVaultKeyFetcher.data.error && resetVaultKeyFetcher.state === 'idle') { + const newVaultKey = resetVaultKeyFetcher.data; + setVaultKey(newVaultKey); + setResetDone(true); + }; + }, [resetVaultKeyFetcher.data, resetVaultKeyFetcher.state]); + + useEffect(() => { + if (resetVaultKeyFetcher?.data?.error && resetVaultKeyFetcher.state === 'idle') { + setError(resetVaultKeyFetcher.data.error); + } + }, [resetVaultKeyFetcher.data, resetVaultKeyFetcher.state]); + + useEffect(() => { + (async () => { + // close modal and return user input vault key if srp validation success + if (validateVaultKeyFetcher.data && !validateVaultKeyFetcher.data.error && validateVaultKeyFetcher.state === 'idle') { + onClose(validateVaultKeyFetcher.data.vaultKey); + }; + })(); + }, [validateVaultKeyFetcher.data, validateVaultKeyFetcher.state, onClose, userSession]); + + useEffect(() => { + if (validateVaultKeyFetcher?.data?.error && validateVaultKeyFetcher.state === 'idle') { + setError(validateVaultKeyFetcher.data.error); + } + }, [validateVaultKeyFetcher.data, validateVaultKeyFetcher.state]); + + const handleValidateVaultKey = () => { + setError(''); + validateVaultKeyFetcher.submit( + { + vaultKey, saveVaultKey: true, + }, + { + action: '/auth/validateVaultKey', + method: 'POST', + encType: 'application/json', + }); + }; + + const resetVaultKey = () => { + showModal(AskModal, { + title: 'Reset Vault Key', + message: 'Are you sure you sure to reset vault key? This will clear all secrets in private environment among all devices.', + yesText: 'Yes', + noText: 'No', + onDone: async (yes: boolean) => { + if (yes) { + // clear all local secrets first + await removeAllSecrets(organizations.map(org => org.id)); + resetVaultKeyFetcher.submit('', { + action: '/auth/resetVaultKey', + method: 'POST', + }); + } + }, + }); + }; + + return ( + { + !isOpen && onClose(); + }} + className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex items-start justify-center bg-black/30" + > + { + !isOpen && onClose(); + }} + > + + {({ close }) => ( +
+
+ + {resetDone ? 'Reset Vault Key' : 'Enter Vault Key'} + + {allowClose && + + } +
+ {!resetDone ? + ( + <> +
+ + setVaultKey(e.target.value)} + /> +
+ {error && +

{error}

+ } +
+
+ Forget Vault Key? + +
+ +
+ + ) : + ( + <> +
Please save or download the vault key which will be needed when you login again.
+ +
+ +
+ + ) + } +
+ )} +
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx b/packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx index 12c87949709..ff472ff6e4c 100644 --- a/packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx @@ -88,6 +88,7 @@ export const NunjucksModal = forwardRef event.preventDefault(); modalRef.current?.hide(); }} + className='px-2' >{editor} diff --git a/packages/insomnia/src/ui/components/modals/upgrade-modal.tsx b/packages/insomnia/src/ui/components/modals/upgrade-modal.tsx new file mode 100644 index 00000000000..f9a4cc023a2 --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/upgrade-modal.tsx @@ -0,0 +1,61 @@ +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; + +import { getAppWebsiteBaseURL } from '../../../common/constants'; +import type { PersonalPlanType } from '../../routes/organization'; +import { type ModalProps } from '../base/modal'; +import { AskModal, type AskModalHandle, type AskModalOptions } from './ask-modal'; + +type UpgradePlanType = Exclude; +export interface UpgradeModalOptions extends Partial { + newPlan: UpgradePlanType; + featureName: string; + isOwner: boolean; +} +export interface UpgradeModalHandle { + show: (options: UpgradeModalOptions) => void; + hide: () => void; +} +export const UpgradeModal = forwardRef((_, ref) => { + const modalRef = useRef(null); + useImperativeHandle(ref, () => ({ + hide: () => { + modalRef.current?.hide(); + }, + show: (options: UpgradeModalOptions) => { + const { + newPlan, + featureName, + title = 'Upgrade Plan', + yesText = 'Upgrade', + noText = 'Cancel', + color = 'surpirse', + isOwner, + } = options; + const planDetail = newPlan === 'team' ? 'Team plan or above' : 'Enterprise plan'; + const upgradeDetail = isOwner ? 'please upgrade your plan.' : 'please contact the organization owner to upgrade the plan.'; + const message = `${featureName} is only enbaled for ${planDetail}, ${upgradeDetail}`; + const onDone = async (isYes: boolean) => { + if (isYes) { + window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/subscription/update?plan=team`); + } + }; + if (isOwner) { + modalRef.current?.show({ + title, message, yesText, noText, color, + ...(isOwner && { onDone }), + }); + } else { + modalRef.current?.show({ + title, message, + }); + } + }, + }), []); + + return ( + + ); +}); +UpgradeModal.displayName = 'UpgradeModal'; 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 ae94d19c00d..b123f3ce38d 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 @@ -5,7 +5,7 @@ import { useFetcher, useParams, useRouteLoaderData } from 'react-router-dom'; import { docsAfterResponseScript, docsTemplateTags } from '../../../common/documentation'; import { debounce } from '../../../common/misc'; -import { type Environment, type EnvironmentKvPairData, EnvironmentType, getDataFromKVPair } from '../../../models/environment'; +import { type Environment, type EnvironmentKvPairData, EnvironmentKvPairDataType, EnvironmentType, getDataFromKVPair } from '../../../models/environment'; import { isRemoteProject } from '../../../models/project'; import { responseTagRegex } from '../../../templating/utils'; import { useOrganizationPermissions } from '../../hooks/use-organization-features'; @@ -51,6 +51,8 @@ export const WorkspaceEnvironmentsEditModal = ({ onClose }: { } return false; }, [selectedEnvironment]); + // Do not allowed to switch to json environment if contains secret item + const allowSwitchEnvironment = !selectedEnvironment?.kvPairData?.some(d => d.type === EnvironmentKvPairDataType.SECRET); const environmentActionsList: { id: string; @@ -438,7 +440,7 @@ export const WorkspaceEnvironmentsEditModal = ({ onClose }: { /> )} - {selectedEnvironment && ( + {selectedEnvironment && allowSwitchEnvironment && ( { const toggleSwitchEnvironmentType = (newEnvironmentType: EnvironmentType, kvPairData: EnvironmentKvPairData[]) => { diff --git a/packages/insomnia/src/ui/components/password-input.tsx b/packages/insomnia/src/ui/components/password-input.tsx new file mode 100644 index 00000000000..31217eac459 --- /dev/null +++ b/packages/insomnia/src/ui/components/password-input.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; + +export interface PasswordInputProps { + value: string; + label?: string; + placeholder?: string; + className?: string; + onChange: (value: string) => void; + onShowPassword?: (value: string) => void; + onHidePassword?: (value: string) => void; +}; + +export const PasswordInput = (props: PasswordInputProps) => { + const { value, className, placeholder, onShowPassword, onHidePassword, onChange } = props; + const [isHidden, setHidden] = useState(true); + + const handleShowHidePassword = () => { + if (isHidden && onShowPassword) { + onShowPassword(value); + } else if (!isHidden && onHidePassword) { + onHidePassword(value); + } + setHidden(prevState => !prevState); + }; + + return ( +
+
+ onChange(event.target.value)} + placeholder={placeholder} + type={isHidden ? 'password' : 'text'} + /> + +
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/request-url-bar.tsx b/packages/insomnia/src/ui/components/request-url-bar.tsx index cd9ac6fd9ea..cd041beeb9a 100644 --- a/packages/insomnia/src/ui/components/request-url-bar.tsx +++ b/packages/insomnia/src/ui/components/request-url-bar.tsx @@ -5,6 +5,7 @@ import { useInterval } from 'react-use'; import { database as db } from '../../common/database'; import * as models from '../../models'; +import { vaultEnvironmentPath } from '../../models/environment'; import type { Request } from '../../models/request'; import { isEventStreamRequest, isGraphqlSubscriptionRequest } from '../../models/request'; import { isRequestGroup, type RequestGroup } from '../../models/request-group'; @@ -25,6 +26,7 @@ import { MethodDropdown } from './dropdowns/method-dropdown'; import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from './keydown-binder'; import { GenerateCodeModal } from './modals/generate-code-modal'; import { showAlert, showModal, showPrompt } from './modals/index'; +import { InputVaultKeyModal } from './modals/input-valut-key-modal'; import { VariableMissingErrorModal } from './modals/variable-missing-error-modal'; interface Props { @@ -44,7 +46,10 @@ export const RequestUrlBar = forwardRef(({ onPaste, }, ref) => { const [searchParams, setSearchParams] = useSearchParams(); + const { userSession } = useRootLoaderData(); + const { vaultKey } = userSession; const [showEnvVariableMissingModal, setShowEnvVariableMissingModal] = useState(false); + const [showInputVaultKeyModal, setShowInputVaultKeyModal] = useState(false); const [undefinedEnvironmentVariables, setUndefinedEnvironmentVariables] = useState(''); const undefinedEnvironmentVariableList = undefinedEnvironmentVariables?.split(','); if (searchParams.has('error')) { @@ -380,7 +385,29 @@ export const RequestUrlBar = forwardRef(({ })} + {!vaultKey && undefinedEnvironmentVariableList.some(variableName => variableName.startsWith(`${vaultEnvironmentPath}.`)) && +
+

These are secret environment variables. However, the required vault key has not been provided yet.

+ +
+ {undefinedEnvironmentVariableList?.filter(variableName => variableName.startsWith(`${vaultEnvironmentPath}.`)).map(item => { + return
{item}
; + })} +
+
+ } + {showInputVaultKeyModal && + setShowInputVaultKeyModal(false)} /> + } ); }); diff --git a/packages/insomnia/src/ui/components/settings/general.tsx b/packages/insomnia/src/ui/components/settings/general.tsx index 7a4503209da..268ec0e581b 100644 --- a/packages/insomnia/src/ui/components/settings/general.tsx +++ b/packages/insomnia/src/ui/components/settings/general.tsx @@ -20,12 +20,14 @@ import { BooleanSetting } from './boolean-setting'; import { EnumSetting } from './enum-setting'; import { NumberSetting } from './number-setting'; import { TextSetting } from './text-setting'; +import { VaultKeyPanel } from './vault-key-panel'; export const General: FC = () => { const { settings, userSession, } = useRootLoaderData(); + const isLoggedIn = Boolean(userSession.id); return ( @@ -268,6 +270,7 @@ export const General: FC = () => { help="If checked, validates SSL certificates during authentication flows." /> + {isLoggedIn && } {updatesSupported() && ( diff --git a/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx b/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx new file mode 100644 index 00000000000..606218574e8 --- /dev/null +++ b/packages/insomnia/src/ui/components/settings/vault-key-panel.tsx @@ -0,0 +1,207 @@ +import fs from 'fs'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from 'react-aria-components'; +import { useFetcher } from 'react-router-dom'; + +import { getProductName } from '../../../common/constants'; +import { decryptVaultKeyFromSession, deleteVaultKeyFromStorage, saveVaultKeyIfNecessary } from '../../../utils/vault'; +import { useRootLoaderData } from '../../routes/root'; +import { type CopyBtnHanlde, CopyButton } from '../base/copy-button'; +import { HelpTooltip } from '../help-tooltip'; +import { Icon } from '../icon'; +import { showError, showModal } from '../modals'; +import { AskModal } from '../modals/ask-modal'; +import { InputVaultKeyModal } from '../modals/input-valut-key-modal'; +import { BooleanSetting } from './boolean-setting'; + +export const VaultKeyDisplayInput = ({ vaultKey }: { vaultKey: string }) => { + const copyBtnRef = useRef(null); + + const donwloadVaultKey = async () => { + const { canceled, filePath: outputPath } = await window.dialog.showSaveDialog({ + title: 'Download Vault Key', + buttonLabel: 'Save', + defaultPath: `${getProductName()}-vault-key-${Date.now()}.txt`, + }); + + if (canceled || !outputPath) { + return; + } + + const to = fs.createWriteStream(outputPath); + + to.on('error', err => { + console.warn('Failed to save vault key', err); + }); + + to.write(vaultKey); + to.end(); + }; + + return ( +
+
copyBtnRef.current?.copy()}>{vaultKey}
+ + + + +
+ + ); +}; + +export const VaultKeyPanel = () => { + const { userSession, settings } = useRootLoaderData(); + const { saveVaultKeyLocally } = settings; + const [isGenerating, setGenerating] = useState(false); + const [vaultKeyValue, setVaultKeyValue] = useState(''); + const [showInputVaultKeyModal, setShowModal] = useState(false); + const { accountId, vaultKey, vaultSalt } = userSession; + const vaultKeyFetcher = useFetcher(); + const vaultSaltFetcher = useFetcher(); + const vaultSaltExists = typeof vaultSalt === 'string' && vaultSalt.length > 0; + const vaultKeyExists = typeof vaultKey === 'string' && vaultKey.length > 0; + + const showVaultKey = useCallback(async () => { + if (vaultKey) { + // decrypt vault key saved in user session + const decryptedVaultKey = await decryptVaultKeyFromSession(vaultKey, false); + setVaultKeyValue(decryptedVaultKey); + } + }, [vaultKey]); + + useEffect(() => { + if (vaultKeyExists) { + showVaultKey(); + } + }, [showVaultKey, vaultKeyExists]); + + useEffect(() => { + if (vaultKeyFetcher.data && !vaultKeyFetcher.data.error && vaultKeyFetcher.state === 'idle') { + setGenerating(false); + setVaultKeyValue(vaultKeyFetcher.data); + }; + }, [vaultKeyFetcher.data, vaultKeyFetcher.state]); + + useEffect(() => { + if (vaultKeyFetcher.data && vaultKeyFetcher.data.error && vaultKeyFetcher.state === 'idle') { + setGenerating(false); + // user has created vault key in another device; + if (vaultKeyFetcher.data.error.toLowerCase().includes('conflict')) { + // get vault salt from server + vaultSaltFetcher.submit('', { + action: '/auth/updateVaultSalt', + method: 'POST', + }); + showModal(AskModal, { + title: 'Vault Key Already Exists', + message: 'You have generated the vault key in other device. Please input your vault key', + yesText: 'OK', + noText: 'Cancel', + }); + } else { + showError({ + title: 'Can not generate vault key', + message: vaultKeyFetcher.data.error, + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- vaultSaltFetcher should only be triggered once + }, [vaultKeyFetcher.data, vaultKeyFetcher.state]); + + const generateVaultKey = async () => { + setGenerating(true); + vaultKeyFetcher.submit('', { + action: '/auth/createVaultKey', + method: 'POST', + }); + }; + + const handleModalClose = (newVaultKey?: string) => { + if (newVaultKey) { + setVaultKeyValue(newVaultKey); + }; + setShowModal(false); + }; + + useEffect(() => { + // save or delete vault key to keychain + if (saveVaultKeyLocally) { + if (vaultKeyValue.length > 0) { + saveVaultKeyIfNecessary(accountId, vaultKeyValue); + }; + } else { + deleteVaultKeyFromStorage(accountId); + }; + }, [saveVaultKeyLocally, accountId, vaultKeyValue]); + + return ( +
+ {/* Show Gen Vault button when vault salt does not exist */} + {!vaultSaltExists && +
+ +
+ } + {vaultSaltExists && vaultKeyExists && vaultKeyValue !== '' && + <> +
+
+ Vault Key + The vault key will be needed when you login again. +
+ +
+
+ +
+
+ +
+ + } + {/* User has not input vault key after re-login */} + {vaultSaltExists && !vaultKeyExists && +
+ +
+ } + {showInputVaultKeyModal && + + } +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/templating/tag-editor.tsx b/packages/insomnia/src/ui/components/templating/tag-editor.tsx index cf9896f787b..9999d5535b0 100644 --- a/packages/insomnia/src/ui/components/templating/tag-editor.tsx +++ b/packages/insomnia/src/ui/components/templating/tag-editor.tsx @@ -77,7 +77,7 @@ export const TagEditor: FC = props => { error: '', variables: [], }); - const { handleRender, handleGetRenderContext } = useNunjucks(); + const { handleRender, handleGetRenderContext } = useNunjucks({ renderContext: { purpose: 'preview' } }); const refreshModels = useCallback(async () => { setState(state => ({ ...state, loadingDocs: true })); @@ -371,6 +371,12 @@ export const TagEditor: FC = props => { extensions={argDefinition.extensions} />); } else if (argDefinition.type === 'model') { + const modelName = typeof argDefinition.model === 'string' ? argDefinition.model : 'unknown'; + let targetDoc = state.allDocs[modelName]; + const modelFilterFunc = argDefinition.modelFilter; + if (modelFilterFunc && typeof modelFilterFunc === 'function') { + targetDoc = targetDoc.filter(doc => modelFilterFunc(doc, activeTagData.args)); + } argInput = state.loadingDocs ? ( - {state.allDocs[typeof argDefinition.model === 'string' ? argDefinition.model : 'unknown']?.map((doc: any) => { + {targetDoc?.map((doc: any) => { let namePrefix: string | null = null; // Show parent folder with name if it's a request if (isRequest(doc)) { diff --git a/packages/insomnia/src/ui/components/templating/variable-editor.tsx b/packages/insomnia/src/ui/components/templating/variable-editor.tsx index b8e2381a9c3..c577e1ce9ec 100644 --- a/packages/insomnia/src/ui/components/templating/variable-editor.tsx +++ b/packages/insomnia/src/ui/components/templating/variable-editor.tsx @@ -1,5 +1,8 @@ -import React, { type FC, useEffect, useState } from 'react'; +import React, { type FC, useEffect, useMemo, useState } from 'react'; +import type { RenderPurpose } from '../../../common/render'; +import { vaultEnvironmentMaskValue, vaultEnvironmentRuntimePath } from '../../../models/environment'; +import { NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME } from '../../../templating'; import { useNunjucks } from '../../context/nunjucks/use-nunjucks'; interface Props { @@ -8,11 +11,19 @@ interface Props { } export const VariableEditor: FC = ({ onChange, defaultValue }) => { - const { handleRender, handleGetRenderContext } = useNunjucks(); + const [purpose, setPurpose] = useState(''); + const useNunjuckOptions = useMemo(() => { + const renderContext = purpose === '' ? {} : { purpose }; + return { renderContext }; + }, [purpose]); + const { handleRender, handleGetRenderContext } = useNunjucks(useNunjuckOptions); const [selected, setSelected] = useState(defaultValue); const [options, setOptions] = useState<{ name: string; value: any }[]>([]); const [preview, setPreview] = useState(''); const [error, setError] = useState(''); + const isVaultVariable = selected + && selected.replace('{{', '').replace('}}', '').trim().startsWith(`${NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME}.${vaultEnvironmentRuntimePath}`) + && preview === vaultEnvironmentMaskValue; useEffect(() => { let isMounted = true; @@ -69,6 +80,22 @@ export const VariableEditor: FC = ({ onChange, defaultValue }) => { )}
+ {isVaultVariable && + + }