From 90c9f7fc502c61625f3f0f1cddf50376f759212d Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Mon, 4 May 2026 13:19:32 -0400 Subject: [PATCH 01/75] feat: adds functionality to lint specs with a user uploaded spectral ruleset file [INS-2338] Co-authored-by: Copilot --- packages/insomnia-inso/src/cli.ts | 13 +++- .../src/common/select-file-or-folder.ts | 39 ++++++---- packages/insomnia/src/entry.preload.ts | 1 + packages/insomnia/src/main/ipc/electron.ts | 6 +- packages/insomnia/src/main/ipc/main.ts | 19 ++++- ...$projectId.workspace.$workspaceId.spec.tsx | 78 ++++++++++++++----- 6 files changed, 114 insertions(+), 42 deletions(-) diff --git a/packages/insomnia-inso/src/cli.ts b/packages/insomnia-inso/src/cli.ts index 1fc2f8e8cf0f..6535f53d4d6f 100644 --- a/packages/insomnia-inso/src/cli.ts +++ b/packages/insomnia-inso/src/cli.ts @@ -886,7 +886,11 @@ export const go = (args?: string[]) => { ) .command('spec [identifier]') .description('Lint an API Specification, identifier can be an API Spec id or a file path') - .action(async identifier => { + .option( + '-r, --ruleset ', + 'path to a Spectral ruleset file, overrides default OAS ruleset and any ruleset in the API Spec folder', + ) + .action(async (identifier, cmd: { ruleset?: string }) => { const options = await mergeOptionsAndInit({}); // Assert identifier is a file @@ -899,11 +903,16 @@ export const go = (args?: string[]) => { const pathToSearch = ''; let specContent: string | undefined; let rulesetFileName: string | undefined; + if (cmd.ruleset) { + rulesetFileName = getAbsoluteFilePath({ workingDir: options.workingDir, file: cmd.ruleset }); + } if (isIdentifierAFile) { // try load as a file logger.trace(`Linting specification file from identifier: \`${identifierAsAbsPath}\``); specContent = await fs.promises.readFile(identifierAsAbsPath, 'utf8'); - rulesetFileName = await getRuleSetFileFromFolderByFilename(identifierAsAbsPath); + if (!rulesetFileName) { + rulesetFileName = await getRuleSetFileFromFolderByFilename(identifierAsAbsPath); + } if (!specContent) { logger.fatal(`Specification content not found using path: ${identifier} in ${identifierAsAbsPath}`); return process.exit(1); diff --git a/packages/insomnia/src/common/select-file-or-folder.ts b/packages/insomnia/src/common/select-file-or-folder.ts index d839e954f8e7..87d7168f8fe0 100644 --- a/packages/insomnia/src/common/select-file-or-folder.ts +++ b/packages/insomnia/src/common/select-file-or-folder.ts @@ -1,6 +1,7 @@ interface Options { itemTypes?: ('file' | 'directory')[]; extensions?: string[]; + showHiddenFiles?: boolean; } interface FileSelection { @@ -8,7 +9,7 @@ interface FileSelection { canceled: boolean; } -export const selectFileOrFolder = async ({ itemTypes, extensions }: Options) => { +export const selectFileOrFolder = async ({ itemTypes, extensions, showHiddenFiles }: Options) => { // If no types are selected then default to just files and not directories const types = itemTypes || ['file']; let title = 'Select '; @@ -25,24 +26,30 @@ export const selectFileOrFolder = async ({ itemTypes, extensions }: Options) => title += ' Directory'; } + const properties: Electron.OpenDialogOptions['properties'] = types.map(type => { + switch (type) { + case 'file': { + return 'openFile'; + } + + case 'directory': { + return 'openDirectory'; + } + + default: { + throw new Error(`unrecognized item type: "${type}"`); + } + } + }); + + if (showHiddenFiles) { + properties.push('showHiddenFiles'); + } + const { canceled, filePaths } = await window.dialog.showOpenDialog({ title, buttonLabel: 'Select', - properties: types.map(type => { - switch (type) { - case 'file': { - return 'openFile'; - } - - case 'directory': { - return 'openDirectory'; - } - - default: { - throw new Error(`unrecognized item type: "${type}"`); - } - } - }), + properties, filters: [ { extensions: extensions?.length ? extensions : ['*'], diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index a4319e6c75e0..d6d0dd863018 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -278,6 +278,7 @@ const main: Window['main'] = { ipcRenderer.on(channel, listener); return () => ipcRenderer.removeListener(channel, listener); }, + watchRulesetFile: options => ipcRenderer.send('watchRulesetFile', options), webSocket, socketIO, mcp, diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 6dd510089db3..f5550a2736e9 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -202,7 +202,8 @@ export type MainOnChannels = | 'sync.cancelConflict' | 'sync.resolveConflict' | 'mcp.sendMCPRequest' - | 'writeText'; + | 'writeText' + | 'watchRulesetFile'; export type RendererOnChannels = | 'contextMenuCommand' @@ -228,7 +229,8 @@ export type RendererOnChannels = | 'hide-oauth-authorization-modal' | 'mcp-auth-confirmation' | 'git.db-synced' - | 'git.file-problems-changed'; + | 'git.file-problems-changed' + | 'ruleset.file-changed'; export const ipcMainOn = ( channel: MainOnChannels, diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index e82053b75e45..07950510252e 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -158,7 +158,11 @@ export interface RendererToMainBridgeAPI { bodyCompression?: 'zip' | null; }) => Promise; getAuthHeader: (renderedRequest: RenderedRequest, url: string) => Promise; - getOAuth2Token: (requestId: string, authentication: AuthTypeOAuth2, forceRefresh?: boolean) => Promise; + getOAuth2Token: ( + requestId: string, + authentication: AuthTypeOAuth2, + forceRefresh?: boolean, + ) => Promise; secureReadFile: (options: { path: string }) => Promise; insecureReadFile: (options: { path: string }) => Promise; insecureReadFileWithEncoding: (options: { @@ -199,6 +203,7 @@ export interface RendererToMainBridgeAPI { documentContent: string; rulesetPath: string; }) => Promise<{ diagnostics?: ISpectralDiagnostic[]; error?: string; cancelled?: boolean }>; + watchRulesetFile: (options: { rulesetPath: string }) => void; database: { caCertificate: { create: (options: { parentId: string; path: string }) => Promise; @@ -355,6 +360,18 @@ export function registerMainHandlers() { process.postMessage({ documentContent, rulesetPath }); }); }); + + ipcMainOn('watchRulesetFile', (event, options: { rulesetPath: string }) => { + try { + fs.watch(options.rulesetPath, eventType => { + if (eventType === 'change') { + event.sender.send('ruleset.file-changed', { rulesetPath: options.rulesetPath }); + } + }); + } catch (err) { + console.error('Failed to watch ruleset file:', err); + } + }); ipcMainHandle('insecureReadFile', async (_, options: { path: string }) => { return insecureReadFile(options.path); }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 30d20c9a9a50..023f0d72eaf8 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -60,6 +60,7 @@ import { useGitVCSVersion } from '~/ui/hooks/use-vcs-version'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec'; +import { selectFileOrFolder } from '~/common/select-file-or-folder'; export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId, projectId, workspaceId } = params; @@ -83,13 +84,12 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { } const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId); + const isGitProject = models.project.isGitProject(project); - const gitRepositoryId = models.project.isGitProject(project) - ? project.gitRepositoryId - : workspaceMeta?.gitRepositoryId; + const gitRepositoryId = isGitProject ? project.gitRepositoryId : workspaceMeta?.gitRepositoryId; // we don't run the lint here because it is expensive and slows first render too much // TODO: add this in once we run this loader outside the renderer - const rulesetPath = gitRepositoryId + const gitSyncRulesetPath = gitRepositoryId ? window.path.join(window.app.getPath('userData'), `version-control/git/${gitRepositoryId}/.spectral.yaml`) : ''; @@ -101,7 +101,8 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { return { apiSpec, - rulesetPath, + gitSyncRulesetPath, + isGitProject, parsedSpec, }; } @@ -180,7 +181,7 @@ const Component = ({ params }: Route.ComponentProps) => { const { isGenerateMockServersWithAIEnabled } = useAIFeatureStatus(); - const { apiSpec, rulesetPath, parsedSpec } = useLoaderData(); + const { apiSpec, gitSyncRulesetPath, isGitProject, parsedSpec } = useLoaderData(); const [lintMessages, setLintMessages] = useState([]); @@ -189,6 +190,7 @@ const Component = ({ params }: Route.ComponentProps) => { const generateRequestCollectionFetcher = useSpecGenerateRequestCollectionActionFetcher(); const [isLintPaneOpen, setIsLintPaneOpen] = useState(false); const [isSpecPaneOpen, setIsSpecPaneOpen] = useState(Boolean(parsedSpec)); + const [rulesetPath, setRulesetPath] = useState(gitSyncRulesetPath); const { components, info, servers, paths } = parsedSpec || {}; const { requestBodies, responses, parameters, headers, schemas, securitySchemes } = components || {}; @@ -245,6 +247,18 @@ const Component = ({ params }: Route.ComponentProps) => { editor.current?.tryToSetOption('lint', { ...lintOptions }); }, [rulesetPath]); + useEffect(() => { + if (rulesetPath) { + window.main.watchRulesetFile({ rulesetPath }); + } + + return window.main.on('ruleset.file-changed', () => { + console.log('file changed...'); + registerCodeMirrorLint(rulesetPath); + editor.current?.tryToSetOption('lint', { ...lintOptions }); + }); + }, [rulesetPath]); + reactUse.useUnmount(() => { // delete the helper to avoid it run multiple times when user enter the page next time CodeMirror.registerHelper('lint', 'openapi', () => {}); @@ -388,6 +402,20 @@ const Component = ({ params }: Route.ComponentProps) => { updateApiSpec({ organizationId, projectId, workspaceId, contents }); }; + const handleSelectSpectralFile = async () => { + const { filePath, canceled } = await selectFileOrFolder({ + itemTypes: ['file'], + extensions: ['yaml', 'yml'], + showHiddenFiles: true, + }); + + if (canceled || !filePath) { + return; + } + + setRulesetPath(filePath); + }; + const specActionList: SpecActionItem[] = [ { id: 'generate-request-collection', @@ -1017,9 +1045,10 @@ const Component = ({ params }: Route.ComponentProps) => { >
- { ) : ( -

Using default OAS ruleset.

- To use a custom ruleset add a .spectral.yaml file to the - root of your git repository + Using default OAS ruleset. Click to upload a custom ruleset yaml file. + {isGitProject && ( + + {' '} + Alternatively, add a .spectral.yaml file to the root of + your git repository. + + )}

)}
+ {lintErrors.length > 0 && (
- {lintErrors.length}
)} {lintWarnings.length > 0 && (
- {lintWarnings.length}
)} {apiSpec.contents && (
{lintMessages.length === 0 && } - {lintMessages.length === 0 ? 'No lint problems' : 'Lint problems detected'} + {lintMessages.length === 0 ? ( + 'No lint problems' + ) : ( + + )}
)} - - {lintMessages.length > 0 && ( - - )} {isLintPaneOpen && ( Date: Mon, 4 May 2026 13:26:37 -0400 Subject: [PATCH 02/75] chore: update var name Co-authored-by: Copilot --- ...roject.$projectId.workspace.$workspaceId.spec.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 51d4c745f0d7..10c204dfe60a 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -84,9 +84,9 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { } const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId); - const isGitProject = models.project.isConnectedGitProject(project); + const isConnectedGitProject = models.project.isConnectedGitProject(project); - const gitRepositoryId = models.project.isConnectedGitProject(project) + const gitRepositoryId = isConnectedGitProject ? models.project.getEffectiveRepoId(project) : workspaceMeta?.gitRepositoryId; // we don't run the lint here because it is expensive and slows first render too much @@ -104,7 +104,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { return { apiSpec, gitSyncRulesetPath, - isGitProject, + isConnectedGitProject, parsedSpec, }; } @@ -183,7 +183,7 @@ const Component = ({ params }: Route.ComponentProps) => { const { isGenerateMockServersWithAIEnabled } = useAIFeatureStatus(); - const { apiSpec, gitSyncRulesetPath, isGitProject, parsedSpec } = useLoaderData(); + const { apiSpec, gitSyncRulesetPath, isConnectedGitProject, parsedSpec } = useLoaderData(); const [lintMessages, setLintMessages] = useState([]); @@ -1067,11 +1067,11 @@ const Component = ({ params }: Route.ComponentProps) => {

Using default OAS ruleset. Click to upload a custom ruleset yaml file. - {isGitProject && ( + {isConnectedGitProject && ( {' '} Alternatively, add a .spectral.yaml file to the root of - your git repository. + your connected git repository. )}

From 7092d46cf514589b61586e011913bb4272929653 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Mon, 4 May 2026 13:27:46 -0400 Subject: [PATCH 03/75] chore: remove log --- ...nizationId.project.$projectId.workspace.$workspaceId.spec.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 10c204dfe60a..df24ff120093 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -255,7 +255,6 @@ const Component = ({ params }: Route.ComponentProps) => { } return window.main.on('ruleset.file-changed', () => { - console.log('file changed...'); registerCodeMirrorLint(rulesetPath); editor.current?.tryToSetOption('lint', { ...lintOptions }); }); From f3079058d83424ff27fcb074532ec191b3ac9dcb Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Mon, 4 May 2026 16:02:37 -0400 Subject: [PATCH 04/75] feat: adds logic to persist rulesetFilePath --- .../insomnia/src/insomnia-data/src/models/api-spec.ts | 3 +++ ...nId.project.$projectId.workspace.$workspaceId.spec.tsx | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts index 6caca7133d3e..a2fde1ab4c90 100644 --- a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts +++ b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts @@ -11,10 +11,13 @@ export const canDuplicate = true; export const canSync = true; +export const optionalKeys = ['rulesetFilePath']; + export interface BaseApiSpec { fileName: string; contentType: 'json' | 'yaml'; contents: string; + rulesetFilePath?: string; } export type ApiSpec = BaseModel & BaseApiSpec; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index df24ff120093..500f12a66996 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -192,7 +192,7 @@ const Component = ({ params }: Route.ComponentProps) => { const generateRequestCollectionFetcher = useSpecGenerateRequestCollectionActionFetcher(); const [isLintPaneOpen, setIsLintPaneOpen] = useState(false); const [isSpecPaneOpen, setIsSpecPaneOpen] = useState(Boolean(parsedSpec)); - const [rulesetPath, setRulesetPath] = useState(gitSyncRulesetPath); + const [rulesetPath, setRulesetPath] = useState(''); const { components, info, servers, paths } = parsedSpec || {}; const { requestBodies, responses, parameters, headers, schemas, securitySchemes } = components || {}; @@ -260,6 +260,10 @@ const Component = ({ params }: Route.ComponentProps) => { }); }, [rulesetPath]); + useEffect(() => { + setRulesetPath(apiSpec.rulesetFilePath || gitSyncRulesetPath || ''); + }, [apiSpec, gitSyncRulesetPath]); + reactUse.useUnmount(() => { // delete the helper to avoid it run multiple times when user enter the page next time CodeMirror.registerHelper('lint', 'openapi', () => {}); @@ -413,7 +417,7 @@ const Component = ({ params }: Route.ComponentProps) => { if (canceled || !filePath) { return; } - + await services.apiSpec.update(apiSpec, { rulesetFilePath: filePath }); setRulesetPath(filePath); }; From f327dead2f4e52b24830a55ea8390264b187e559 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Mon, 4 May 2026 17:01:14 -0400 Subject: [PATCH 05/75] feat: adds logic to remove a uploaded ruleset file and use default OAS ruleset --- ...$projectId.workspace.$workspaceId.spec.tsx | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 500f12a66996..64cefdb2d03a 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -45,7 +45,7 @@ import { WorkspaceSyncDropdown } from '~/ui/components/dropdowns/workspace-sync- import { EnvironmentPicker } from '~/ui/components/environment-picker'; import { Icon } from '~/ui/components/icon'; import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder'; -import { showError } from '~/ui/components/modals'; +import { showError, showModal } from '~/ui/components/modals'; import { CookiesModal } from '~/ui/components/modals/cookies-modal'; import { NewWorkspaceModal } from '~/ui/components/modals/new-workspace-modal'; import { CertificatesModal } from '~/ui/components/modals/workspace-certificates-modal'; @@ -61,6 +61,7 @@ import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec'; import { selectFileOrFolder } from '~/common/select-file-or-folder'; +import { AskModal } from '~/ui/components/modals/ask-modal'; export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId, projectId, workspaceId } = params; @@ -421,6 +422,23 @@ const Component = ({ params }: Route.ComponentProps) => { setRulesetPath(filePath); }; + const handleUnselectSpectralFile = async () => { + showModal(AskModal, { + title: 'Remove Ruleset File', + message: + 'Are you sure you want to remove the custom ruleset file for this? This will disable all custom linting rules and use the default Spectral ruleset.', + yesText: 'Remove', + color: 'danger', + noText: 'Cancel', + onDone: async (confirmed: boolean) => { + if (confirmed) { + setRulesetPath(gitSyncRulesetPath || ''); + await services.apiSpec.update(apiSpec, { rulesetFilePath: '' }); + } + }, + }); + }; + const specActionList: SpecActionItem[] = [ { id: 'generate-request-collection', @@ -1083,6 +1101,20 @@ const Component = ({ params }: Route.ComponentProps) => { + {apiSpec.rulesetFilePath === rulesetPath && ( + + + +

Clear custom ruleset and use default OAS ruleset

+
+
+ )} {lintErrors.length > 0 && (
From de66df472603a313a7b8e0b63f80fc23089ba729 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Tue, 5 May 2026 10:09:25 -0400 Subject: [PATCH 06/75] feat: adds logic to clean up old ruleset file watcher --- packages/insomnia/src/main/ipc/main.ts | 7 +++++-- ...onId.project.$projectId.workspace.$workspaceId.spec.tsx | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 07950510252e..b9d4174edb69 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -70,6 +70,7 @@ import type { gRPCBridgeAPI } from './grpc'; import type { secretStorageBridgeAPI } from './secret-storage'; let lintProcess: Electron.UtilityProcess | null = null; +let rulesetFileWatcher: fs.FSWatcher | null = null; export const openInBrowser = (href: string) => { const { protocol } = new URL(href); @@ -363,8 +364,9 @@ export function registerMainHandlers() { ipcMainOn('watchRulesetFile', (event, options: { rulesetPath: string }) => { try { - fs.watch(options.rulesetPath, eventType => { - if (eventType === 'change') { + rulesetFileWatcher?.close(); + rulesetFileWatcher = fs.watch(options.rulesetPath, eventType => { + if (eventType === 'change' && !event.sender.isDestroyed()) { event.sender.send('ruleset.file-changed', { rulesetPath: options.rulesetPath }); } }); @@ -372,6 +374,7 @@ export function registerMainHandlers() { console.error('Failed to watch ruleset file:', err); } }); + ipcMainHandle('insecureReadFile', async (_, options: { path: string }) => { return insecureReadFile(options.path); }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 64cefdb2d03a..f8ab8d5fd668 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -251,9 +251,9 @@ const Component = ({ params }: Route.ComponentProps) => { }, [rulesetPath]); useEffect(() => { - if (rulesetPath) { - window.main.watchRulesetFile({ rulesetPath }); - } + if (!rulesetPath) return; + + window.main.watchRulesetFile({ rulesetPath }); return window.main.on('ruleset.file-changed', () => { registerCodeMirrorLint(rulesetPath); From 0f035c5b061fbc51d08cc7e691f92bd925aaf9ff Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Tue, 5 May 2026 11:28:14 -0400 Subject: [PATCH 07/75] chore: adds comment for testing --- ...izationId.project.$projectId.workspace.$workspaceId.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index f8ab8d5fd668..4d33deec26c5 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -257,7 +257,7 @@ const Component = ({ params }: Route.ComponentProps) => { return window.main.on('ruleset.file-changed', () => { registerCodeMirrorLint(rulesetPath); - editor.current?.tryToSetOption('lint', { ...lintOptions }); + editor.current?.tryToSetOption('lint', { ...lintOptions }); // do we even need this? }); }, [rulesetPath]); From a8793794a2214797e9c3f1011c8f1aebbc7f2880 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Tue, 5 May 2026 12:12:41 -0400 Subject: [PATCH 08/75] chore: adds comment for clarity --- packages/insomnia/src/main/ipc/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index b9d4174edb69..c12b3c5459e8 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -364,7 +364,7 @@ export function registerMainHandlers() { ipcMainOn('watchRulesetFile', (event, options: { rulesetPath: string }) => { try { - rulesetFileWatcher?.close(); + rulesetFileWatcher?.close(); // we should only have one watcher at a time, so close any existing watcher before creating a new one rulesetFileWatcher = fs.watch(options.rulesetPath, eventType => { if (eventType === 'change' && !event.sender.isDestroyed()) { event.sender.send('ruleset.file-changed', { rulesetPath: options.rulesetPath }); From 4146159ac8accc283d052d9b7b0ce53ddabc661f Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Wed, 6 May 2026 11:45:16 -0400 Subject: [PATCH 09/75] feat: adds logic to enable cunstom lint rules for cloud/git sync projects Co-authored-by: Copilot --- packages/insomnia/src/entry.preload.ts | 1 + .../src/insomnia-data/src/models/api-spec.ts | 4 +- packages/insomnia/src/main/ipc/electron.ts | 1 + packages/insomnia/src/main/ipc/main.ts | 8 +++ ...$projectId.workspace.$workspaceId.spec.tsx | 49 ++++++++++++++++--- 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index d6d0dd863018..0737bc5c8a1c 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -258,6 +258,7 @@ const main: Window['main'] = { curlRequest: options => invokeWithNormalizedError('curlRequest', options), cancelCurlRequest: options => ipcRenderer.send('cancelCurlRequest', options), writeFile: options => invokeWithNormalizedError('writeFile', options), + deleteFile: options => invokeWithNormalizedError('deleteFile', options), writeResponseBodyToFile: options => invokeWithNormalizedError('writeResponseBodyToFile', options), getAuthHeader: (renderedRequest: RenderedRequest, url: string): Promise => invokeWithNormalizedError('getAuthHeader', renderedRequest, url), diff --git a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts index a2fde1ab4c90..62d36c364871 100644 --- a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts +++ b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts @@ -11,13 +11,13 @@ export const canDuplicate = true; export const canSync = true; -export const optionalKeys = ['rulesetFilePath']; +export const optionalKeys = ['rulesetContent']; export interface BaseApiSpec { fileName: string; contentType: 'json' | 'yaml'; contents: string; - rulesetFilePath?: string; + rulesetContent?: string; // This is the content of the spectral ruleset file for linting API specs. It is stored in the DB to support cloud sync, but also written to disk for spectral to use when linting. } export type ApiSpec = BaseModel & BaseApiSpec; diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index f5550a2736e9..1e8897c7f527 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -148,6 +148,7 @@ export type HandleChannels = | 'webSocket.open' | 'webSocket.readyState' | 'writeFile' + | 'deleteFile' | 'writeResponseBodyToFile'; export const ipcMainHandle = ( diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index c12b3c5459e8..568f226ff19a 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -153,6 +153,7 @@ export interface RendererToMainBridgeAPI { parseImport: typeof convert; multipartBufferToArray: (options: { bodyBuffer: Buffer; contentType: string }) => Promise; writeFile: (options: { path: string; content: string | Buffer }) => Promise; + deleteFile: (options: { path: string }) => Promise; writeResponseBodyToFile: (options: { sourcePath: string; destinationPath: string; @@ -321,6 +322,13 @@ export function registerMainHandlers() { throw new Error(err); } }); + ipcMainHandle('deleteFile', async (_, options: { path: string }) => { + try { + await fs.promises.unlink(options.path); + } catch (err) { + throw new Error(err); + } + }); ipcMainHandle('writeResponseBodyToFile', writeResponseBodyToFile); ipcMainHandle('getAuthHeader', (_, renderedRequest: RenderedRequest, url: string) => { return getAuthHeaderInMain(renderedRequest, url); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 4d33deec26c5..16b91dc90708 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -195,6 +195,17 @@ const Component = ({ params }: Route.ComponentProps) => { const [isSpecPaneOpen, setIsSpecPaneOpen] = useState(Boolean(parsedSpec)); const [rulesetPath, setRulesetPath] = useState(''); + // This determines what path to write the ruleset content to when the user selects a custom ruleset file or when the rulesetContent in the DB changes. + // For git sync projects, this will be the git working directory so that it appears in the staging modal and can be committed. + // For cloud/local projects, this will be a per-workspace path — rulesetContent in the DB handles syncing for these projects. + // Spectral requires a file path on disk to lint with a ruleset. Ref: lint-process.mjs. + const rulesetWritePath = useMemo( + () => + gitSyncRulesetPath || + window.path.join(window.app.getPath('userData'), `workspaces/${workspaceId}/.spectral.yaml`), + [gitSyncRulesetPath, workspaceId], + ); + const { components, info, servers, paths } = parsedSpec || {}; const { requestBodies, responses, parameters, headers, schemas, securitySchemes } = components || {}; @@ -262,8 +273,20 @@ const Component = ({ params }: Route.ComponentProps) => { }, [rulesetPath]); useEffect(() => { - setRulesetPath(apiSpec.rulesetFilePath || gitSyncRulesetPath || ''); - }, [apiSpec, gitSyncRulesetPath]); + const syncRulesetToDisk = async () => { + if (apiSpec.rulesetContent) { + // Write the stored content to the correct path for this project type and use it for linting. + await window.main.writeFile({ path: rulesetWritePath, content: apiSpec.rulesetContent }); + setRulesetPath(rulesetWritePath); + } else { + // No ruleset content — for git sync, fall back to any .spectral.yaml already present in the repo (e.g. committed by another user). + // For cloud/local projects, default to OAS ruleset. + setRulesetPath(gitSyncRulesetPath || ''); + } + }; + + syncRulesetToDisk(); + }, [apiSpec.rulesetContent, rulesetWritePath, gitSyncRulesetPath]); reactUse.useUnmount(() => { // delete the helper to avoid it run multiple times when user enter the page next time @@ -411,15 +434,24 @@ const Component = ({ params }: Route.ComponentProps) => { const handleSelectSpectralFile = async () => { const { filePath, canceled } = await selectFileOrFolder({ itemTypes: ['file'], - extensions: ['yaml', 'yml'], + extensions: ['yaml'], showHiddenFiles: true, }); if (canceled || !filePath) { return; } - await services.apiSpec.update(apiSpec, { rulesetFilePath: filePath }); - setRulesetPath(filePath); + + if (window.path.basename(filePath) !== '.spectral.yaml') { + showError({ title: 'Invalid File', message: 'Please select a file named .spectral.yaml' }); + return; + } + + const content = await window.main.insecureReadFile({ path: filePath }); + + await window.main.writeFile({ path: rulesetWritePath, content }); + await services.apiSpec.update(apiSpec, { rulesetContent: content }); + setRulesetPath(rulesetWritePath); }; const handleUnselectSpectralFile = async () => { @@ -432,8 +464,9 @@ const Component = ({ params }: Route.ComponentProps) => { noText: 'Cancel', onDone: async (confirmed: boolean) => { if (confirmed) { - setRulesetPath(gitSyncRulesetPath || ''); - await services.apiSpec.update(apiSpec, { rulesetFilePath: '' }); + await window.main.deleteFile({ path: rulesetWritePath }); + await services.apiSpec.update(apiSpec, { rulesetContent: '' }); + setRulesetPath(''); } }, }); @@ -1101,7 +1134,7 @@ const Component = ({ params }: Route.ComponentProps) => {
- {apiSpec.rulesetFilePath === rulesetPath && ( + {!!apiSpec.rulesetContent && ( + )} + : + { className="max-h-[85vh] max-w-xs overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden" >
- {rulesetPath ? ( + {selectedRulesetPath ? (

Using ruleset from

- {rulesetPath} + {selectedRulesetPath}
) : ( @@ -1156,7 +1213,7 @@ const Component = ({ params }: Route.ComponentProps) => {
- {!!rulesetPath && ( + {!!selectedRulesetPath && ( - )} - - )} +
+ {lintErrors.length > 0 && ( +
+ +
+ )} + {lintWarnings.length > 0 && ( +
+ +
+ )} + {apiSpec.contents && ( +
+ {lintMessages.length === 0 && } + {lintMessages.length === 0 ? ( + 'No lint problems' + ) : ( + + )} +
+ )} +
{isLintPaneOpen && ( Date: Wed, 13 May 2026 17:20:10 -0400 Subject: [PATCH 23/75] chore: adds some comments --- .../insomnia/src/common/safe-ref-resolver.ts | 1 + .../src/common/spectral-ruleset-validator.ts | 173 ++++++++++-------- 2 files changed, 96 insertions(+), 78 deletions(-) diff --git a/packages/insomnia/src/common/safe-ref-resolver.ts b/packages/insomnia/src/common/safe-ref-resolver.ts index ec409ba6ae4f..ac38c790a737 100644 --- a/packages/insomnia/src/common/safe-ref-resolver.ts +++ b/packages/insomnia/src/common/safe-ref-resolver.ts @@ -2,6 +2,7 @@ import { Resolver } from '@stoplight/spectral-ref-resolver'; import { isPrivateOrLoopbackHost } from './spectral-ruleset-validator'; +// Protect against SSRF attacks in spec $ref resolution function isSafeRefUrl(href: string): boolean { let url: URL; try { diff --git a/packages/insomnia/src/common/spectral-ruleset-validator.ts b/packages/insomnia/src/common/spectral-ruleset-validator.ts index 565c8b6bb087..5da60726a663 100644 --- a/packages/insomnia/src/common/spectral-ruleset-validator.ts +++ b/packages/insomnia/src/common/spectral-ruleset-validator.ts @@ -1,16 +1,17 @@ import ipaddr from 'ipaddr.js'; import YAML from 'yaml'; +import path from 'path'; export type SpectralRulesetValidationResult = { isValid: true } | { isValid: false; error: string }; -// Top-level keys we support. Everything else within Spectral (notably `functions`, `aliases`, -// `overrides`, `parserOptions`, top-level `documentationUrl`) is rejected by -// default. +// Top-level keys we support. We reject everything else for the time being. +// When adding new top-level properties, consider how they might be abused and how to mitigate. const ALLOWED_TOP_LEVEL_PROPERTIES = ['rules', 'extends']; // These are the only built-in Spectral identities we allow in the extends property. const ALLOWED_EXTENDS_IDENTIFIERS = ['spectral:oas', 'spectral:asyncapi', 'spectral:arazzo']; +// These are the only built-in Spectral functions we allow in ruleset "then" clauses const ALLOWED_BUILTIN_FUNCTIONS = [ 'alphabetical', 'casing', @@ -28,54 +29,67 @@ const ALLOWED_BUILTIN_FUNCTIONS = [ 'xor', ]; +// For security reasons we do not allow rulesets to contain certain tokens that could be used for JavaScript prototype pollution when used in certain Spectral properties (e.g. "field"). const PROTOTYPE_POLLUTION_TOKENS = ['__proto__', 'prototype', 'constructor']; +// For security reasons we only allow extends URLs with certain safe schemes and hosts. const SAFE_URL_SCHEMES = ['https:']; -export function validateSpectralRuleset(content: string): SpectralRulesetValidationResult { - if (typeof content !== 'string' || content.trim() === '') { - return fail('Ruleset file is empty.'); - } +function isFilePath(value: string): boolean { + return value.startsWith('./') || value.startsWith('../') || path.isAbsolute(value); +} - let parsed: unknown; - try { - parsed = YAML.parse(content); - } catch (err) { - return fail(`Ruleset is not valid YAML or JSON`); +// Given our support for remote extends, we need to protect against the possibility of SSRF attacks. We block any hostname that is a loopback or private network address, as well as "localhost". +// Note: The logic in this function is duplicated in the main process's Spectral linting handler (lint-process.mjs) to protect against SSRF via $ref resolution in extends files. +// If logic is changed here, mirror it there. +export function isPrivateOrLoopbackHost(hostname: string): boolean { + if (hostname === 'localhost' || hostname.endsWith('.localhost')) { + return true; } - - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return fail('Ruleset must be an object at the top level.'); + const host = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; + if (!ipaddr.isValid(host)) { + return false; } + return ipaddr.process(host).range() !== 'unicast'; +} - const ruleset = parsed as Record; - const keys = Object.keys(ruleset); - if (keys.length === 0) { - return fail('Ruleset must declare at least one of: rules, extends.'); +function containsPrototypePollution(value: string): boolean { + return PROTOTYPE_POLLUTION_TOKENS.some(token => value.includes(token)); +} + +function isSafeUrl(value: string): boolean { + try { + return SAFE_URL_SCHEMES.includes(new URL(value).protocol); + } catch { + return false; } +} - const disallowed = keys.filter(key => !ALLOWED_TOP_LEVEL_PROPERTIES.includes(key)); - if (disallowed.length > 0) { - return fail( - `Ruleset contains unsupported top-level keys: ${disallowed.join(', ')}. Only "rules" and "extends" are allowed.`, - ); +function toArray(value: T | T[] | undefined): T[] { + if (value === undefined) { + return []; } + return Array.isArray(value) ? value : [value]; +} - if ('extends' in ruleset) { - const extendsError = validateExtends(ruleset.extends); - if (extendsError) { - return fail(extendsError); - } +function fail(error: string): SpectralRulesetValidationResult { + return { isValid: false, error }; +} + +function validateThen(ruleName: string, then: Record): string | null { + // We do not allow javascript prototype pollution via the "field" property + if (typeof then.field === 'string' && (containsPrototypePollution(then.field) || /[.\[\]]/.test(then.field))) { + return `Rule "${ruleName}" has a "field" containing a disallowed token or traversal syntax.`; } - if ('rules' in ruleset) { - const rulesError = validateRules(ruleset.rules); - if (rulesError) { - return fail(rulesError); + // only Spectral's documented built-in functions are reachable. + if (then.function !== undefined) { + if (typeof then.function !== 'string' || !ALLOWED_BUILTIN_FUNCTIONS.includes(then.function)) { + return `Rule "${ruleName}" uses function "${String(then.function)}" which is not an allowed Spectral built-in.`; } } - return { isValid: true }; + return null; } function validateExtends(value: unknown): string | null { @@ -83,18 +97,24 @@ function validateExtends(value: unknown): string | null { if (typeof entry !== 'string') { return '"extends" entries must be strings.'; } - if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry)) { + + // allow built in identifier and local file paths without further validation + if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry) || isFilePath(entry)) { continue; } + + // validate remote URLs let url: URL; try { url = new URL(entry); } catch { return `"extends" entry "${entry}" is not a recognized Spectral identifier or a valid URL.`; } + if (!SAFE_URL_SCHEMES.includes(url.protocol)) { return `"extends" entry "${entry}" must use https (got "${url.protocol}").`; } + if (!url.hostname || isPrivateOrLoopbackHost(url.hostname.toLocaleLowerCase())) { return `"extends" entry "${entry}" targets a disallowed host`; } @@ -102,35 +122,25 @@ function validateExtends(value: unknown): string | null { return null; } -// Note: The logic in this function is duplicated in the main process's Spectral linting handler (lint-process.mjs) to protect against SSRF via $ref resolution in extends files. -// If logic is changed here, mirror it there. -export function isPrivateOrLoopbackHost(hostname: string): boolean { - if (hostname === 'localhost' || hostname.endsWith('.localhost')) { - return true; - } - const host = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; - if (!ipaddr.isValid(host)) { - return false; - } - return ipaddr.process(host).range() !== 'unicast'; -} - function validateRules(value: unknown): string | null { if (value === null || typeof value !== 'object' || Array.isArray(value)) { return '"rules" must be an object.'; } for (const [ruleName, rule] of Object.entries(value as Record)) { + // allow shorthand rule definitions (boolean or severity string) + if (rule === true || rule === false || typeof rule === 'string') { + continue; + } + // protect against Javascript prototype pollution if (PROTOTYPE_POLLUTION_TOKENS.includes(ruleName)) { return `Rule name "${ruleName}" is not allowed.`; } - if (rule === true || rule === false || typeof rule === 'string') { - continue; - } if (rule === null || typeof rule !== 'object') { return `Rule "${ruleName}" must be an object, boolean, or severity string.`; } + const ruleError = validateRuleBody(ruleName, rule as Record); if (ruleError) { return ruleError; @@ -163,41 +173,48 @@ function validateRuleBody(ruleName: string, rule: Record): stri return null; } -function validateThen(ruleName: string, then: Record): string | null { - // We do not allow javascript prototype pollution via the "field" property - if (typeof then.field === 'string' && (containsPrototypePollution(then.field) || /[.\[\]]/.test(then.field))) { - return `Rule "${ruleName}" has a "field" containing a disallowed token or traversal syntax.`; +export function validateSpectralRuleset(content: string): SpectralRulesetValidationResult { + if (typeof content !== 'string' || content.trim() === '') { + return fail('Ruleset file is empty.'); } - // only Spectral's documented built-in functions are reachable. - if (then.function !== undefined) { - if (typeof then.function !== 'string' || !ALLOWED_BUILTIN_FUNCTIONS.includes(then.function)) { - return `Rule "${ruleName}" uses function "${String(then.function)}" which is not an allowed Spectral built-in.`; - } + let parsed: unknown; + try { + parsed = YAML.parse(content); + } catch (err) { + return fail(`Ruleset is not valid YAML or JSON`); } - return null; -} + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return fail('Ruleset must be an object at the top level.'); + } -function containsPrototypePollution(value: string): boolean { - return PROTOTYPE_POLLUTION_TOKENS.some(token => value.includes(token)); -} + const ruleset = parsed as Record; + const keys = Object.keys(ruleset); + if (keys.length === 0) { + return fail('Ruleset must declare at least one of: rules, extends.'); + } -function isSafeUrl(value: string): boolean { - try { - return SAFE_URL_SCHEMES.includes(new URL(value).protocol); - } catch { - return false; + const disallowed = keys.filter(key => !ALLOWED_TOP_LEVEL_PROPERTIES.includes(key)); + if (disallowed.length > 0) { + return fail( + `Ruleset contains unsupported top-level keys: ${disallowed.join(', ')}. Only "rules" and "extends" are allowed.`, + ); } -} -function toArray(value: T | T[] | undefined): T[] { - if (value === undefined) { - return []; + if ('extends' in ruleset) { + const extendsError = validateExtends(ruleset.extends); + if (extendsError) { + return fail(extendsError); + } } - return Array.isArray(value) ? value : [value]; -} -function fail(error: string): SpectralRulesetValidationResult { - return { isValid: false, error }; + if ('rules' in ruleset) { + const rulesError = validateRules(ruleset.rules); + if (rulesError) { + return fail(rulesError); + } + } + + return { isValid: true }; } From bbe15a0a80161e51472aee2b5ad098c4b5c701c5 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Wed, 13 May 2026 23:34:15 -0400 Subject: [PATCH 24/75] chore: change function names/clean up --- .../src/common/spectral-ruleset-validator.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/insomnia/src/common/spectral-ruleset-validator.ts b/packages/insomnia/src/common/spectral-ruleset-validator.ts index 5da60726a663..02002ef61a61 100644 --- a/packages/insomnia/src/common/spectral-ruleset-validator.ts +++ b/packages/insomnia/src/common/spectral-ruleset-validator.ts @@ -35,10 +35,17 @@ const PROTOTYPE_POLLUTION_TOKENS = ['__proto__', 'prototype', 'constructor']; // For security reasons we only allow extends URLs with certain safe schemes and hosts. const SAFE_URL_SCHEMES = ['https:']; -function isFilePath(value: string): boolean { +export function isLocalFilePath(value: string): boolean { return value.startsWith('./') || value.startsWith('../') || path.isAbsolute(value); } +export function toArray(value: T | T[] | undefined): T[] { + if (value === undefined) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + // Given our support for remote extends, we need to protect against the possibility of SSRF attacks. We block any hostname that is a loopback or private network address, as well as "localhost". // Note: The logic in this function is duplicated in the main process's Spectral linting handler (lint-process.mjs) to protect against SSRF via $ref resolution in extends files. // If logic is changed here, mirror it there. @@ -65,13 +72,6 @@ function isSafeUrl(value: string): boolean { } } -function toArray(value: T | T[] | undefined): T[] { - if (value === undefined) { - return []; - } - return Array.isArray(value) ? value : [value]; -} - function fail(error: string): SpectralRulesetValidationResult { return { isValid: false, error }; } @@ -99,7 +99,7 @@ function validateExtends(value: unknown): string | null { } // allow built in identifier and local file paths without further validation - if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry) || isFilePath(entry)) { + if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry) || isLocalFilePath(entry)) { continue; } From 7b146845c3de926d2b3a5e8213546b28a8755aab Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Wed, 13 May 2026 23:50:02 -0400 Subject: [PATCH 25/75] feat: adds logic to flatten extended rulesets into inline prior to writing to disk --- packages/insomnia/src/entry.preload.ts | 1 + .../src/main/bundle-spectral-ruleset.ts | 91 +++++++++++++++++++ packages/insomnia/src/main/ipc/electron.ts | 1 + packages/insomnia/src/main/ipc/main.ts | 16 +++- ...$projectId.workspace.$workspaceId.spec.tsx | 19 ++-- 5 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 packages/insomnia/src/main/bundle-spectral-ruleset.ts diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index bbb4147cc067..14c627f3df11 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -276,6 +276,7 @@ const main: Window['main'] = { readDir: options => invokeWithNormalizedError('readDir', options), readOrCreateDataDir: options => invokeWithNormalizedError('readOrCreateDataDir', options), lintSpec: options => invokeWithNormalizedError('lintSpec', options), + bundleSpectralRuleset: options => invokeWithNormalizedError('bundleSpectralRuleset', options), on: (channel, listener) => { ipcRenderer.on(channel, listener); return () => ipcRenderer.removeListener(channel, listener); diff --git a/packages/insomnia/src/main/bundle-spectral-ruleset.ts b/packages/insomnia/src/main/bundle-spectral-ruleset.ts new file mode 100644 index 000000000000..a15a14d5c7ab --- /dev/null +++ b/packages/insomnia/src/main/bundle-spectral-ruleset.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import YAML from 'yaml'; + +import { isLocalFilePath, toArray } from '~/common/spectral-ruleset-validator'; + +// Maximum depth of nested extends to follow when bundling. Guards against pathological or cyclical rulesets. +const MAX_EXTENDS_DEPTH = 5; + +const ALLOWED_EXTENSIONS = ['.yaml', '.yml']; + +// we only allow rulesets that contain extends and/or rules for the time being +interface Ruleset { + extends?: string[]; + rules?: Record; +} + +// Prevents the below +// - Excessively deep nesting of extends (e.g. A extends B extends C extends D extends E extends F) +// - Cycles in extends (e.g. A extends B extends A) +// - Extends that point to non-YAML files (e.g. A extends B.txt) +function assertAllowed(absolute: string, visited: Set, depth: number): void { + if (depth > MAX_EXTENDS_DEPTH) { + throw new Error(`"extends" nested too deeply (max ${MAX_EXTENDS_DEPTH}) at ${absolute}`); + } + if (visited.has(absolute)) { + throw new Error(`"extends" cycle detected at ${absolute}`); + } + if (!ALLOWED_EXTENSIONS.includes(path.extname(absolute).toLowerCase())) { + throw new Error(`"extends" target must be a .yaml or .yml file: ${absolute}`); + } +} + +// reads and parses a ruleset file +async function readRuleset(absolute: string): Promise> { + const raw = await fs.promises.readFile(absolute, { encoding: 'utf-8' }); + const parsed = YAML.parse(raw); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Ruleset at ${absolute} must be an object at the top level.`); + } + return parsed as Record; +} + +function pushUnique(list: T[], value: T): void { + if (!list.includes(value)) { + list.push(value); + } +} + +// Recursively flattens local-file "extends" entries, returning a singular ruleset whose "extends" +// contains only built-in spectral identifiers and remote URLs. Rules are merged such that the parent overrides +// its extends, and among multiple extends entries the later ones override earlier. (ref: https://docs.stoplight.io/docs/spectral/83527ef2dd8c0-extending-rulesets) +async function flattenRuleset(filePath: string, visited: Set, depth: number): Promise { + const absolute = path.resolve(filePath); + assertAllowed(absolute, visited, depth); + + const ruleset = await readRuleset(absolute); + const baseDir = path.dirname(absolute); + const nextVisited = new Set(visited).add(absolute); + + const inheritedRules: Record = {}; + const remainingExtends: string[] = []; + + for (const entry of toArray(ruleset.extends)) { + if (typeof entry !== 'string') { + throw new TypeError(`"extends" entries must be strings (in ${absolute}).`); + } + + if (!isLocalFilePath(entry)) { + pushUnique(remainingExtends, entry); + continue; + } + + const child = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1); + child.extends?.forEach(childEntry => pushUnique(remainingExtends, childEntry)); + Object.assign(inheritedRules, child.rules ?? {}); + } + + const mergedRules = { ...inheritedRules, ...((ruleset.rules ?? {}) as Record) }; + + return { + ...(remainingExtends.length > 0 && { extends: remainingExtends }), + ...(Object.keys(mergedRules).length > 0 && { rules: mergedRules }), + }; +} + +export async function bundleSpectralRuleset(sourcePath: string): Promise { + const flattened = await flattenRuleset(sourcePath, new Set(), 0); + return YAML.stringify(flattened); +} diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index b25e8803f8c1..1df5c944b828 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -89,6 +89,7 @@ export type HandleChannels = | 'insecureReadFileWithEncoding' | 'installPlugin' | 'lintSpec' + | 'bundleSpectralRuleset' | 'llm.clearActiveBackend' | 'llm.getActiveBackend' | 'llm.getAIFeatureEnabled' diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index d3614013cbe1..cef69608ceb9 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -23,6 +23,7 @@ import { cannotAccessPathError } from '~/common/misc'; import { validateSpectralRuleset } from '~/common/spectral-ruleset-validator'; import type { AuthTypeOAuth2, OAuth2Token, RequestHeader, Services } from '~/insomnia-data'; import { services } from '~/insomnia-data'; +import { bundleSpectralRuleset } from '~/main/bundle-spectral-ruleset'; import { initializeWorkspaceBackendProject, syncNewWorkspaceIfNeeded } from '~/main/cloud-sync/initialization'; import type { SyncBridgeAPI } from '~/main/cloud-sync/ipc'; import { convert } from '~/main/importers/convert'; @@ -205,6 +206,7 @@ export interface RendererToMainBridgeAPI { documentContent: string; rulesetPath: string; }) => Promise<{ diagnostics?: ISpectralDiagnostic[]; error?: string; cancelled?: boolean }>; + bundleSpectralRuleset: (options: { sourcePath: string }) => Promise<{ content?: string; error?: string }>; database: { caCertificate: { create: (options: { parentId: string; path: string }) => Promise; @@ -344,13 +346,25 @@ export function registerMainHandlers() { ipcMainHandle('getOAuth2Token', (_, requestId: string, authentication: AuthTypeOAuth2, forceRefresh?: boolean) => { return getOAuth2TokenInMain(requestId, authentication, forceRefresh); }); + ipcMainHandle('bundleSpectralRuleset', async (_, options: { sourcePath: string }) => { + try { + const content = await bundleSpectralRuleset(options.sourcePath); + const validation = validateSpectralRuleset(content); + if (!validation.isValid) { + return { error: `Invalid Spectral ruleset: ${validation.error}` }; + } + return { content }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }); ipcMainHandle('lintSpec', async (_, options: { documentContent: string; rulesetPath: string }) => { const { documentContent, rulesetPath } = options; //defensive validation for ruleset file before spawning the spectral lint worker if (rulesetPath) { try { - const rulesetContent = await fs.promises.readFile(rulesetPath, 'utf8'); + const rulesetContent = await fs.promises.readFile(rulesetPath, { encoding: 'utf-8' }); const validation = validateSpectralRuleset(rulesetContent); if (!validation.isValid) { return { error: `Invalid Spectral ruleset: ${validation.error}` }; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index b6238ae40fdf..3262b3eeb1f6 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -30,8 +30,8 @@ import YAML from 'yaml'; import { parseApiSpec } from '~/common/api-specs'; import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; -import { validateSpectralRuleset } from '~/common/spectral-ruleset-validator'; import { debounce, isNotNullOrUndefined } from '~/common/misc'; +import { selectFileOrFolder } from '~/common/select-file-or-folder'; import { models, services } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; @@ -48,6 +48,7 @@ import { EnvironmentPicker } from '~/ui/components/environment-picker'; import { Icon } from '~/ui/components/icon'; import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder'; import { showError, showModal } from '~/ui/components/modals'; +import { AskModal } from '~/ui/components/modals/ask-modal'; import { CookiesModal } from '~/ui/components/modals/cookies-modal'; import { NewWorkspaceModal } from '~/ui/components/modals/new-workspace-modal'; import { CertificatesModal } from '~/ui/components/modals/workspace-certificates-modal'; @@ -62,8 +63,6 @@ import { useGitVCSVersion } from '~/ui/hooks/use-vcs-version'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec'; -import { selectFileOrFolder } from '~/common/select-file-or-folder'; -import { AskModal } from '~/ui/components/modals/ask-modal'; export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId, projectId, workspaceId } = params; @@ -456,18 +455,12 @@ const Component = ({ params }: Route.ComponentProps) => { return; } - const raw = await window.main.insecureReadFile({ path: filePath }); - const content = raw - .split('\n') - .map(line => line.trimEnd()) - .join('\n') - .trim(); - - const validation = validateSpectralRuleset(content); - if (!validation.isValid) { + // We have to flatten the rules within each extends local path because we can only have one ruleset file on disk for Spectral to consume and to sync to cloud/git projects. + const { content, error } = await window.main.bundleSpectralRuleset({ sourcePath: filePath }); + if (error || !content) { showError({ title: 'Invalid Spectral Ruleset', - message: validation.error, + message: error ?? 'Failed to bundle ruleset.', }); return; } From fe7d72579a474aff0ea313ca31df918f36c4520b Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 00:08:49 -0400 Subject: [PATCH 26/75] chore: update comment --- packages/insomnia/src/main/bundle-spectral-ruleset.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/insomnia/src/main/bundle-spectral-ruleset.ts b/packages/insomnia/src/main/bundle-spectral-ruleset.ts index a15a14d5c7ab..7ba8e9934ed4 100644 --- a/packages/insomnia/src/main/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/main/bundle-spectral-ruleset.ts @@ -48,7 +48,7 @@ function pushUnique(list: T[], value: T): void { } } -// Recursively flattens local-file "extends" entries, returning a singular ruleset whose "extends" +// Recursively resolves local-file "extends" entries, returning a singular ruleset whose "extends" // contains only built-in spectral identifiers and remote URLs. Rules are merged such that the parent overrides // its extends, and among multiple extends entries the later ones override earlier. (ref: https://docs.stoplight.io/docs/spectral/83527ef2dd8c0-extending-rulesets) async function flattenRuleset(filePath: string, visited: Set, depth: number): Promise { From 90ae4285840565320b05b2f4a5938ac222d7e1e7 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 10:17:34 -0400 Subject: [PATCH 27/75] chore: clean up --- .../src/commands/lint-specification.ts | 19 +------------------ packages/insomnia/src/main/ipc/main.ts | 5 +++-- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/insomnia-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index 124fb706a6c4..5ca800203ec6 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -9,7 +9,6 @@ import { oas } from '@stoplight/spectral-rulesets'; import { DiagnosticSeverity } from '@stoplight/types'; import { safeRefResolver } from 'insomnia/src/common/safe-ref-resolver'; import { validateSpectralRuleset } from 'insomnia/src/common/spectral-ruleset-validator'; -import type { ISpectralDiagnostic } from '@stoplight/spectral-core'; import { InsoError } from '../errors'; import { logger } from '../logger'; @@ -53,23 +52,7 @@ export async function lintSpecification({ } spectral.setRuleset(ruleset as RulesetDefinition); - - const LINT_TIMEOUT_MS = 30_000; - let results: ISpectralDiagnostic[]; - - try { - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error(`Linting exceeded the ${LINT_TIMEOUT_MS / 1000}s time limit and was aborted.`)), - LINT_TIMEOUT_MS, - ), - ); - - results = await Promise.race([spectral.run(specContent), timeoutPromise]); - } catch (error) { - logger.fatal(error.message); - return { isValid: false }; - } + const results = await spectral.run(specContent); if (!results.length) { logger.log('No linting errors or warnings.'); diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index cef69608ceb9..bc5071b9e07f 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -386,14 +386,15 @@ export function registerMainHandlers() { let process: UtilityProcess | null = lintProcess!; + // defends against ReDoS via pattern function regex. We terminate the lintProcess worker if it exceeds a reasonable time limit (30s) so it does not pin a CPU core indefinitely. const LINT_WORKER_TIMEOUT_MS = 30_000; const timeoutHandle = setTimeout(() => { if (process) { - console.warn(`[lint-process] exceeded ${LINT_WORKER_TIMEOUT_MS}ms wall-clock limit; terminating.`); + console.warn(`[lint-process] exceeded ${LINT_WORKER_TIMEOUT_MS / 1000}s limit; terminating.`); process.kill(); process = null; resolve({ - error: `Linting exceeded the ${LINT_WORKER_TIMEOUT_MS / 1000}s time limit and was terminated. The ruleset or specification may contain a catastrophic regex or deeply nested schema.`, + error: `Linting exceeded the ${LINT_WORKER_TIMEOUT_MS / 1000}s time limit and was terminated. The ruleset or specification may contain a deeply nested schema.`, }); } }, LINT_WORKER_TIMEOUT_MS); From 55c58fb1099f67366c1e96f8c65d2f48a349b2ab Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 10:18:50 -0400 Subject: [PATCH 28/75] chore: update comment --- packages/insomnia-inso/src/commands/lint-specification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/insomnia-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index 5ca800203ec6..896e4e7e1031 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -41,7 +41,7 @@ export async function lintSpecification({ const rulesetContent = await fs.promises.readFile(rulesetFileName, 'utf8'); const validation = validateSpectralRuleset(rulesetContent); if (!validation.isValid) { - logger.fatal(`Invalid Spectral ruleset at ${rulesetFileName}: ${validation.error}`); + logger.fatal(`Invalid Spectral ruleset: ${validation.error}`); return { isValid: false }; } ruleset = await bundleAndLoadRuleset(rulesetFileName, { fs }); From 19430eac7a1af3928a468786099117414829875f Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 10:26:01 -0400 Subject: [PATCH 29/75] chore: more comments --- packages/insomnia/src/common/safe-ref-resolver.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/insomnia/src/common/safe-ref-resolver.ts b/packages/insomnia/src/common/safe-ref-resolver.ts index ac38c790a737..683c1907433e 100644 --- a/packages/insomnia/src/common/safe-ref-resolver.ts +++ b/packages/insomnia/src/common/safe-ref-resolver.ts @@ -2,7 +2,8 @@ import { Resolver } from '@stoplight/spectral-ref-resolver'; import { isPrivateOrLoopbackHost } from './spectral-ruleset-validator'; -// Protect against SSRF attacks in spec $ref resolution +// Protect against SSRF attacks in spec $ref resolution. +// Note: This is duplicated in lint-process.mjs. Remember to mirror changes there as well. function isSafeRefUrl(href: string): boolean { let url: URL; try { From 5a4f6367356fcceec9a05b5e9595728412577a93 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 11:04:35 -0400 Subject: [PATCH 30/75] chore: remove comment --- ...izationId.project.$projectId.workspace.$workspaceId.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 3262b3eeb1f6..ee93943028fb 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -275,7 +275,7 @@ const Component = ({ params }: Route.ComponentProps) => { registerCodeMirrorLint(selectedRulesetPath); // when first time into document editor, the lint helper register later than codemirror init, we need to trigger lint through execute setOption editor.current?.tryToSetOption('lint', { ...lintOptions }); - }, [selectedRulesetPath, rulesetContent]); //removed rulesetVersion here, removed apiSpec.rulesetContent + }, [selectedRulesetPath, rulesetContent]); useEffect(() => { if (lintErrors.length > 0 || lintWarnings.length > 0) { From 7bc5dc6d0ff4e0a0f2b21b2ab0f8874d43d47907 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 14:59:20 -0400 Subject: [PATCH 31/75] refactor: clean up code to make it more readable --- .../src/main/bundle-spectral-ruleset.ts | 79 ++++++++++++------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/packages/insomnia/src/main/bundle-spectral-ruleset.ts b/packages/insomnia/src/main/bundle-spectral-ruleset.ts index 7ba8e9934ed4..22884ea01c80 100644 --- a/packages/insomnia/src/main/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/main/bundle-spectral-ruleset.ts @@ -5,16 +5,18 @@ import YAML from 'yaml'; import { isLocalFilePath, toArray } from '~/common/spectral-ruleset-validator'; -// Maximum depth of nested extends to follow when bundling. Guards against pathological or cyclical rulesets. +// Maximum depth of nested extends to follow when bundling. Guards against deep nesting and cycles. const MAX_EXTENDS_DEPTH = 5; const ALLOWED_EXTENSIONS = ['.yaml', '.yml']; -// we only allow rulesets that contain extends and/or rules for the time being -interface Ruleset { +// `extends` is the only key we touch by name in this file: local paths get resolved away and +// remote/built-in entries are carried through. Every other top-level key — `rules`, `aliases`, +// `parserOptions`, anything we may add later — flows through the generic `mergeInto` step. +// The validator that runs after bundling decides which keys are actually allowed. +type Ruleset = Record & { extends?: string[]; - rules?: Record; -} +}; // Prevents the below // - Excessively deep nesting of extends (e.g. A extends B extends C extends D extends E extends F) @@ -32,19 +34,28 @@ function assertAllowed(absolute: string, visited: Set, depth: number): v } } -// reads and parses a ruleset file -async function readRuleset(absolute: string): Promise> { - const raw = await fs.promises.readFile(absolute, { encoding: 'utf-8' }); +// Reads and parses a ruleset file +async function readRuleset(absolute: string): Promise { + const raw = await fs.promises.readFile(absolute, { encoding: 'utf8' }); const parsed = YAML.parse(raw); if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`Ruleset at ${absolute} must be an object at the top level.`); } - return parsed as Record; + return parsed as Ruleset; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); } -function pushUnique(list: T[], value: T): void { - if (!list.includes(value)) { - list.push(value); +// One level deep merge for top-level spectral keys. +// Object values are merged shallowly (e.g. rules) with "source" taking precedence over "target". +// Non-object values (e.g. extends ) are overridden by "source" if they exist, otherwise left as-is from "target". +function mergeInto(target: Ruleset, source: Ruleset): void { + for (const key of Object.keys(source)) { + const sourceVal = source[key]; + const targetVal = target[key]; + target[key] = isPlainObject(targetVal) && isPlainObject(sourceVal) ? { ...targetVal, ...sourceVal } : sourceVal; } } @@ -59,33 +70,41 @@ async function flattenRuleset(filePath: string, visited: Set, depth: num const baseDir = path.dirname(absolute); const nextVisited = new Set(visited).add(absolute); - const inheritedRules: Record = {}; - const remainingExtends: string[] = []; + const flattenedRuleset: Ruleset = {}; // Flattended ruleset containing all rules within this file path and its local extends + const remainingExtends: string[] = []; // non local file paths — built-in identifiers and remote URLs; deduped at return + // Resolve 'extends' first: recurse on local file paths and merge each flattened child into 'flattenedRuleset', + // Collect any non-local entries (built-in identifiers, https URLs) for the final 'extends'. for (const entry of toArray(ruleset.extends)) { - if (typeof entry !== 'string') { - throw new TypeError(`"extends" entries must be strings (in ${absolute}).`); - } - + // Keep built-in rulesets and remote URLs. if (!isLocalFilePath(entry)) { - pushUnique(remainingExtends, entry); + remainingExtends.push(entry); continue; } + // flatten local rulesets + const childRuleset = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1); + if (childRuleset.extends) { + remainingExtends.push(...childRuleset.extends); + } - const child = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1); - child.extends?.forEach(childEntry => pushUnique(remainingExtends, childEntry)); - Object.assign(inheritedRules, child.rules ?? {}); + mergeInto(flattenedRuleset, childRuleset); // merge child's rules and other keys into the flattenedRuleset, with child taking precedence over parent } - const mergedRules = { ...inheritedRules, ...((ruleset.rules ?? {}) as Record) }; - - return { - ...(remainingExtends.length > 0 && { extends: remainingExtends }), - ...(Object.keys(mergedRules).length > 0 && { rules: mergedRules }), - }; + // Now layer the parent ruleset over the inherited values (parent wins on collisions). + // We exclude the parent's 'extends' from this step — its local paths have already been + // resolved in the loop above, and the final value lives in 'remainingExtends'. + const parentOverrides: Ruleset = { ...ruleset }; + delete parentOverrides.extends; + mergeInto(flattenedRuleset, parentOverrides); + + // The parent's own 'extends' is already represented in 'remainingExtends' (resolved or carried). + // Remove duplicates while preserving order, and return the final flattened ruleset with a consolidated 'extends' array containing only built-in identifiers and remote URLs. + const uniqueExtends = [...new Set(remainingExtends)]; + delete flattenedRuleset.extends; + return uniqueExtends.length > 0 ? { extends: uniqueExtends, ...flattenedRuleset } : flattenedRuleset; } export async function bundleSpectralRuleset(sourcePath: string): Promise { - const flattened = await flattenRuleset(sourcePath, new Set(), 0); - return YAML.stringify(flattened); + const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0); + return YAML.stringify(flattenedRuleset); } From d2156925a8222bc47b525a83b725d2b3448a2112 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 14:59:37 -0400 Subject: [PATCH 32/75] feat: address double writes when uploading --- ...nId.project.$projectId.workspace.$workspaceId.spec.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index ee93943028fb..b872e7ec41d5 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -289,8 +289,12 @@ const Component = ({ params }: Route.ComponentProps) => { // Git-sync: file is already on disk at gitSyncRulesetPath. setSelectedRulesetPath(rulesetContent ? gitSyncRulesetPath : ''); } else if (rulesetContent) { - // Cloud/local: write rulesetContent to disk and update selectedRulesetPath. - await window.main.writeFile({ path: rulesetWritePath, content: rulesetContent }); + // Cloud sync: ensure rulesetContent is on disk so collaborators are able to pull changes. + // Note: We need this check because handleSelectSpectralFile also writes to disk which would trigger this useEffect again for an uploader and write to disk again unnecessarily. + const existing = await window.main.insecureReadFile({ path: rulesetWritePath }); + if (existing !== rulesetContent) { + await window.main.writeFile({ path: rulesetWritePath, content: rulesetContent }); + } setSelectedRulesetPath(rulesetWritePath); } else { await window.main.deleteFile({ path: rulesetWritePath }); From b8f3c81d57287388967792fb051649580b4fb688 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 15:34:10 -0400 Subject: [PATCH 33/75] chore: update comments/error messages --- packages/insomnia/src/common/safe-ref-resolver.ts | 2 +- packages/insomnia/src/common/spectral-ruleset-validator.ts | 6 +++--- packages/insomnia/src/main/lint-process.mjs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/insomnia/src/common/safe-ref-resolver.ts b/packages/insomnia/src/common/safe-ref-resolver.ts index 683c1907433e..8fb3fe69bc4f 100644 --- a/packages/insomnia/src/common/safe-ref-resolver.ts +++ b/packages/insomnia/src/common/safe-ref-resolver.ts @@ -21,7 +21,7 @@ const safeHttpResolver = { async resolve(ref: { href: () => string }): Promise { const href = ref.href(); if (!isSafeRefUrl(href)) { - throw new Error(`Refused to resolve $ref "${href}" — only https URLs to public unicast hosts are allowed.`); + throw new Error(`Refused to resolve $ref "${href}" — only https URLs to public hosts are allowed.`); } const response = await fetch(href); if (!response.ok) { diff --git a/packages/insomnia/src/common/spectral-ruleset-validator.ts b/packages/insomnia/src/common/spectral-ruleset-validator.ts index 02002ef61a61..64dfbd7c7924 100644 --- a/packages/insomnia/src/common/spectral-ruleset-validator.ts +++ b/packages/insomnia/src/common/spectral-ruleset-validator.ts @@ -47,7 +47,7 @@ export function toArray(value: T | T[] | undefined): T[] { } // Given our support for remote extends, we need to protect against the possibility of SSRF attacks. We block any hostname that is a loopback or private network address, as well as "localhost". -// Note: The logic in this function is duplicated in the main process's Spectral linting handler (lint-process.mjs) to protect against SSRF via $ref resolution in extends files. +// Note: The logic in this function is duplicated in the main process's Spectral linting handler (lint-process.mjs) to protect against SSRF via $ref resolution in spec files. // If logic is changed here, mirror it there. export function isPrivateOrLoopbackHost(hostname: string): boolean { if (hostname === 'localhost' || hostname.endsWith('.localhost')) { @@ -79,13 +79,13 @@ function fail(error: string): SpectralRulesetValidationResult { function validateThen(ruleName: string, then: Record): string | null { // We do not allow javascript prototype pollution via the "field" property if (typeof then.field === 'string' && (containsPrototypePollution(then.field) || /[.\[\]]/.test(then.field))) { - return `Rule "${ruleName}" has a "field" containing a disallowed token or traversal syntax.`; + return `Rule "${ruleName}" has an invalid "field" value "${then.field}". The "field" must be a plain property name. It cannot contain ".", "[", or "]", or use reserved names like __proto__, prototype, or constructor.`; } // only Spectral's documented built-in functions are reachable. if (then.function !== undefined) { if (typeof then.function !== 'string' || !ALLOWED_BUILTIN_FUNCTIONS.includes(then.function)) { - return `Rule "${ruleName}" uses function "${String(then.function)}" which is not an allowed Spectral built-in.`; + return `Rule "${ruleName}" uses function "${String(then.function)}" which is not an allowed Spectral built-in function.`; } } diff --git a/packages/insomnia/src/main/lint-process.mjs b/packages/insomnia/src/main/lint-process.mjs index 0a4087b731af..9d30cea232b5 100644 --- a/packages/insomnia/src/main/lint-process.mjs +++ b/packages/insomnia/src/main/lint-process.mjs @@ -41,7 +41,7 @@ const safeHttpResolver = { async resolve(ref) { const href = ref.href(); if (!isSafeRefUrl(href)) { - throw new Error(`Refused to resolve $ref "${href}" — only https URLs to public unicast hosts are allowed.`); + throw new Error(`Refused to resolve $ref "${href}" — only https URLs to public hosts are allowed.`); } const response = await fetch(href); if (!response.ok) { From c832d4b06df286420f5c4b027ee24ccacbeecbea Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 17:00:11 -0400 Subject: [PATCH 34/75] chore: update comment --- packages/insomnia/src/common/spectral-ruleset-validator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/insomnia/src/common/spectral-ruleset-validator.ts b/packages/insomnia/src/common/spectral-ruleset-validator.ts index 64dfbd7c7924..40e79f6645fe 100644 --- a/packages/insomnia/src/common/spectral-ruleset-validator.ts +++ b/packages/insomnia/src/common/spectral-ruleset-validator.ts @@ -77,7 +77,7 @@ function fail(error: string): SpectralRulesetValidationResult { } function validateThen(ruleName: string, then: Record): string | null { - // We do not allow javascript prototype pollution via the "field" property + // We do not allow javascript prototype pollution via the "field" property as well as square brackets/dot notation that could traverse beyond a single property level. if (typeof then.field === 'string' && (containsPrototypePollution(then.field) || /[.\[\]]/.test(then.field))) { return `Rule "${ruleName}" has an invalid "field" value "${then.field}". The "field" must be a plain property name. It cannot contain ".", "[", or "]", or use reserved names like __proto__, prototype, or constructor.`; } From 3df5ca5c21c2a5e12c0da6f6451e67e45b612f9e Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 17:00:26 -0400 Subject: [PATCH 35/75] test: adds unit tests for spectral ruleset validator --- .../spectral-ruleset-validator.test.ts | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts diff --git a/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts b/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts new file mode 100644 index 000000000000..30afb5a4e4f1 --- /dev/null +++ b/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts @@ -0,0 +1,356 @@ +/* eslint-disable unicorn/no-useless-undefined */ +// `toArray(undefined)` is an explicit call to exercise the undefined-input code path. +import { describe, expect, it } from 'vitest'; + +import { + isLocalFilePath, + isPrivateOrLoopbackHost, + toArray, + validateSpectralRuleset, +} from '../spectral-ruleset-validator'; + +const expectInvalid = (content: string, errorContains?: string | RegExp): string => { + const result = validateSpectralRuleset(content); + expect(result.isValid).toBe(false); + if (!result.isValid && errorContains) { + expect(result.error).toMatch(errorContains); + } + return result.isValid ? '' : result.error; +}; + +const expectValid = (content: string): void => { + expect(validateSpectralRuleset(content)).toEqual({ isValid: true }); +}; + +const ruleWith = (body: string): string => + `rules:\n my-rule:\n${body + .split('\n') + .map(l => (l ? ` ${l}` : l)) + .join('\n')}`; + +describe('isLocalFilePath()', () => { + it('returns true for explicit relative prefixes', () => { + expect(isLocalFilePath('./foo.yaml')).toBe(true); + expect(isLocalFilePath('../foo.yaml')).toBe(true); + expect(isLocalFilePath('../../shared/foo.yaml')).toBe(true); + }); + + it('returns true for POSIX absolute paths', () => { + expect(isLocalFilePath('/etc/spectral/rules.yaml')).toBe(true); + }); + + it('returns false for bare filenames', () => { + expect(isLocalFilePath('foo.yaml')).toBe(false); + }); + + it('returns false for URLs', () => { + expect(isLocalFilePath('https://example.com/rules.yaml')).toBe(false); + expect(isLocalFilePath('http://example.com/rules.yaml')).toBe(false); + }); + + it('returns false for built-in Spectral identifiers', () => { + expect(isLocalFilePath('spectral:oas')).toBe(false); + }); +}); + +describe('isPrivateOrLoopbackHost()', () => { + it('returns true for localhost variants', () => { + expect(isPrivateOrLoopbackHost('localhost')).toBe(true); + expect(isPrivateOrLoopbackHost('foo.localhost')).toBe(true); + }); + + it('returns true for loopback IPs (v4 and v6)', () => { + expect(isPrivateOrLoopbackHost('127.0.0.1')).toBe(true); + expect(isPrivateOrLoopbackHost('::1')).toBe(true); + }); + + it('returns true for RFC 1918 private ranges', () => { + expect(isPrivateOrLoopbackHost('10.0.0.1')).toBe(true); + expect(isPrivateOrLoopbackHost('172.16.0.1')).toBe(true); + expect(isPrivateOrLoopbackHost('192.168.1.1')).toBe(true); + }); + + it('returns true for link-local IPv4', () => { + expect(isPrivateOrLoopbackHost('169.254.0.1')).toBe(true); + }); + + it('returns true for bracketed IPv6 hostnames (as produced by new URL().hostname)', () => { + expect(isPrivateOrLoopbackHost('[::1]')).toBe(true); + }); + + it('returns false for public unicast IPs', () => { + expect(isPrivateOrLoopbackHost('8.8.8.8')).toBe(false); + expect(isPrivateOrLoopbackHost('1.1.1.1')).toBe(false); + }); + + it('returns false for non-IP hostnames (DNS resolution is handled elsewhere)', () => { + expect(isPrivateOrLoopbackHost('example.com')).toBe(false); + }); +}); + +describe('toArray()', () => { + it('returns [] for undefined', () => { + expect(toArray(undefined)).toEqual([]); + }); + + it('wraps a single value in an array', () => { + expect(toArray('a')).toEqual(['a']); + expect(toArray(0)).toEqual([0]); + }); + + it('returns arrays unchanged', () => { + expect(toArray(['a', 'b'])).toEqual(['a', 'b']); + expect(toArray([])).toEqual([]); + }); +}); + +describe('validateSpectralRuleset()', () => { + // Top-level shape + it('rejects empty string', () => { + expectInvalid('', /empty/i); + }); + + it('rejects whitespace-only content', () => { + expectInvalid(' \n \t\n', /empty/i); + }); + + it('rejects unparseable YAML', () => { + expectInvalid('rules: [unterminated', /yaml|json/i); + }); + + it('rejects YAML that parses to a non-object', () => { + expectInvalid('"just a string"', /object/i); + expectInvalid('- a\n- b\n', /object/i); + expectInvalid('null', /object/i); + }); + + it('rejects an empty object', () => { + expectInvalid('{}', /declare at least one/i); + }); + + it('rejects unsupported top-level keys', () => { + const error = expectInvalid('functions:\n - exec\n', /unsupported top-level/i); + expect(error).toContain('functions'); + }); + + it('accepts JSON input (YAML is a superset of JSON)', () => { + expectValid('{"extends": ["spectral:oas"]}'); + }); + + // extends — covers validateExtends() in full + it('accepts every built-in extends identifier', () => { + expectValid('extends:\n - spectral:oas\n - spectral:asyncapi\n - spectral:arazzo\n'); + }); + + it('accepts a bare-string extends identifier (single, not array)', () => { + expectValid('extends: spectral:oas\n'); + }); + + it('accepts relative file paths in extends', () => { + expectValid('extends:\n - ./rules.yaml\n'); + expectValid('extends:\n - ../shared/rules.yml\n'); + }); + + it('accepts absolute file paths in extends', () => { + expectValid('extends:\n - /tmp/rules.yaml\n'); + }); + + it('accepts https URLs to public hosts', () => { + expectValid('extends:\n - https://example.com/rules.yaml\n'); + }); + + it('rejects non-string extends entries', () => { + expectInvalid('extends:\n - 42\n', /must be strings/i); + }); + + it('rejects http URLs in extends', () => { + expectInvalid('extends:\n - http://example.com/rules.yaml\n', /must use https/i); + }); + + it('rejects extends URLs targeting localhost variants', () => { + expectInvalid('extends:\n - https://localhost/rules.yaml\n', /disallowed host/i); + expectInvalid('extends:\n - https://foo.localhost/rules.yaml\n', /disallowed host/i); + }); + + it('rejects extends URLs targeting loopback IPs (v4 and v6)', () => { + expectInvalid('extends:\n - https://127.0.0.1/rules.yaml\n', /disallowed host/i); + expectInvalid('extends:\n - "https://[::1]/rules.yaml"\n', /disallowed host/i); + }); + + it('rejects extends URLs targeting RFC 1918 private ranges', () => { + expectInvalid('extends:\n - https://10.0.0.1/rules.yaml\n', /disallowed host/i); + expectInvalid('extends:\n - https://192.168.1.1/rules.yaml\n', /disallowed host/i); + expectInvalid('extends:\n - https://172.16.0.1/rules.yaml\n', /disallowed host/i); + }); + + it('rejects extends strings that are neither identifiers nor paths nor valid URLs', () => { + expectInvalid('extends:\n - not-a-real-thing\n', /not a recognized|valid URL/i); + }); + + // rules + rule body + then — covers validateRules(), validateRuleBody(), validateThen() + it('rejects rules that is not an object', () => { + expectInvalid('rules:\n - foo\n', /"rules" must be an object/); + expectInvalid('rules: "string"\n', /"rules" must be an object/); + expectInvalid('rules: null\n', /"rules" must be an object/); + }); + + it('rejects prototype-pollution rule names with object bodies', () => { + // YAML produces an own property for these names, unlike a JS object literal. + expectInvalid('"rules":\n "__proto__":\n given: $\n then:\n function: truthy\n', /not allowed/i); + expectInvalid('rules:\n constructor:\n given: $\n then:\n function: truthy\n', /not allowed/i); + expectInvalid('rules:\n prototype:\n given: $\n then:\n function: truthy\n', /not allowed/i); + }); + + it('accepts shorthand boolean rule definitions', () => { + expectValid('rules:\n my-rule: true\n'); + expectValid('rules:\n my-rule: false\n'); + }); + + it('accepts shorthand severity-string rule definitions', () => { + expectValid('rules:\n my-rule: warn\n'); + expectValid('rules:\n my-rule: error\n'); + }); + + it('rejects rule bodies that are not objects, booleans, or severity strings', () => { + expectInvalid('rules:\n my-rule: 42\n', /must be an object, boolean, or severity string/i); + }); + + it('rejects given expressions containing each prototype-pollution token', () => { + expectInvalid(ruleWith('given: "$.__proto__.x"\nthen:\n function: truthy'), /disallowed token/i); + expectInvalid(ruleWith('given: "$.prototype.x"\nthen:\n function: truthy'), /disallowed token/i); + expectInvalid(ruleWith('given: "$.constructor.x"\nthen:\n function: truthy'), /disallowed token/i); + }); + + it('rejects when any entry of a given array is unsafe', () => { + expectInvalid(ruleWith('given:\n - $.paths[*]\n - $.__proto__\nthen:\n function: truthy'), /disallowed token/i); + }); + + it('accepts non-string given values (only strings are checked)', () => { + expectValid(ruleWith('given: 42\nthen:\n function: truthy')); + }); + + it('rejects rule documentationUrl with unsafe schemes', () => { + expectInvalid( + ruleWith('given: $\ndocumentationUrl: http://example.com\nthen:\n function: truthy'), + /documentationUrl/i, + ); + expectInvalid( + ruleWith('given: $\ndocumentationUrl: "ftp://example.com"\nthen:\n function: truthy'), + /documentationUrl/i, + ); + expectInvalid( + ruleWith('given: $\ndocumentationUrl: "javascript:alert(1)"\nthen:\n function: truthy'), + /documentationUrl/i, + ); + expectInvalid(ruleWith('given: $\ndocumentationUrl: "not a url"\nthen:\n function: truthy'), /documentationUrl/i); + }); + + it('accepts rule documentationUrl that is https', () => { + expectValid(ruleWith('given: $\ndocumentationUrl: https://example.com\nthen:\n function: truthy')); + }); + + it('skips non-string documentationUrl (the string check is the only gate)', () => { + expectValid(ruleWith('given: $\ndocumentationUrl: 42\nthen:\n function: truthy')); + }); + + it('rejects then.field containing prototype-pollution tokens', () => { + expectInvalid(ruleWith('given: $\nthen:\n field: __proto__\n function: truthy'), /field/i); + expectInvalid(ruleWith('given: $\nthen:\n field: prototype\n function: truthy'), /field/i); + expectInvalid(ruleWith('given: $\nthen:\n field: constructor\n function: truthy'), /field/i); + }); + + it('rejects then.field containing path traversal characters', () => { + expectInvalid(ruleWith('given: $\nthen:\n field: a.b\n function: truthy'), /field/i); + expectInvalid(ruleWith('given: $\nthen:\n field: "a[0]"\n function: truthy'), /field/i); + expectInvalid(ruleWith('given: $\nthen:\n field: "a]b"\n function: truthy'), /field/i); + }); + + it('accepts then.field that is a plain property name', () => { + expectValid(ruleWith('given: $\nthen:\n field: summary\n function: truthy')); + }); + + it('rejects then.function that is not a built-in', () => { + expectInvalid(ruleWith('given: $\nthen:\n function: exec'), /not an allowed/i); + expectInvalid(ruleWith('given: $\nthen:\n function: arbitrary'), /not an allowed/i); + }); + + it('rejects non-string then.function values', () => { + expectInvalid(ruleWith('given: $\nthen:\n function: 123'), /not an allowed/i); + }); + + it('accepts every documented built-in Spectral function', () => { + const builtins = [ + 'alphabetical', + 'casing', + 'defined', + 'enumeration', + 'falsy', + 'length', + 'pattern', + 'schema', + 'truthy', + 'typedEnum', + 'undefined', + 'unreferencedReusableObject', + 'or', + 'xor', + ]; + for (const fn of builtins) { + expectValid(ruleWith(`given: $\nthen:\n function: ${fn}`)); + } + }); + + it('iterates an array of then clauses and rejects any invalid entry', () => { + expectInvalid( + `rules: + my-rule: + given: $ + then: + - function: truthy + - function: exec +`, + /not an allowed/i, + ); + }); + + it('accepts an array of then clauses when all are valid', () => { + expectValid(` +rules: + my-rule: + given: $ + then: + - field: summary + function: truthy + - field: description + function: truthy +`); + }); + + it('skips non-object entries inside a then array', () => { + expectValid(` +rules: + my-rule: + given: $ + then: + - null + - function: truthy +`); + }); + + it('accepts a full ruleset combining extends, rules, and a documentationUrl', () => { + expectValid(` +extends: + - spectral:oas + - ./shared.yaml +rules: + my-rule: + description: My rule + given: $.paths[*] + severity: warn + documentationUrl: https://example.com/docs + then: + field: summary + function: truthy +`); + }); +}); From f12b146ef8d1973175273640d5fb85e9ad7336b5 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 14 May 2026 17:01:32 -0400 Subject: [PATCH 36/75] test: adds unit tests for spectral ruleset validator --- .../src/common/__tests__/spectral-ruleset-validator.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts b/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts index 30afb5a4e4f1..a9fc514a84cc 100644 --- a/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts +++ b/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable unicorn/no-useless-undefined */ -// `toArray(undefined)` is an explicit call to exercise the undefined-input code path. import { describe, expect, it } from 'vitest'; import { From 03c96b0ab85690c7af6ebfdddb92aa567398d0c3 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 15 May 2026 09:33:43 -0400 Subject: [PATCH 37/75] test: add unit tests --- .../__tests__/safe-ref-resolver.test.ts | 240 ++++++++++++++++++ .../spectral-ruleset-validator.test.ts | 3 +- 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 packages/insomnia/src/common/__tests__/safe-ref-resolver.test.ts diff --git a/packages/insomnia/src/common/__tests__/safe-ref-resolver.test.ts b/packages/insomnia/src/common/__tests__/safe-ref-resolver.test.ts new file mode 100644 index 000000000000..ac7e8f17580f --- /dev/null +++ b/packages/insomnia/src/common/__tests__/safe-ref-resolver.test.ts @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { safeRefResolver } from '../safe-ref-resolver'; + +function getHttpResolver() { + return (safeRefResolver as any).resolvers.http; +} + +describe('safeHttpResolver', () => { + const httpResolver = getHttpResolver(); + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('URL validation', () => { + it('rejects invalid URLs', async () => { + await expect( + httpResolver.resolve({ + href: () => 'not-a-url', + }), + ).rejects.toThrow('Failed to resolve $ref "not-a-url"'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects relative URLs', async () => { + await expect( + httpResolver.resolve({ + href: () => '/foo/bar.yaml', + }), + ).rejects.toThrow('Failed to resolve $ref "/foo/bar.yaml"'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects http URLs', async () => { + await expect( + httpResolver.resolve({ + href: () => 'http://example.com/schema.yaml', + }), + ).rejects.toThrow('only https URLs to public hosts are allowed'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects ftp URLs', async () => { + await expect( + httpResolver.resolve({ + href: () => 'ftp://example.com/schema.yaml', + }), + ).rejects.toThrow('only https URLs to public hosts are allowed'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects localhost', async () => { + await expect( + httpResolver.resolve({ + href: () => 'https://localhost/schema.yaml', + }), + ).rejects.toThrow('only https URLs to public hosts are allowed'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects loopback IPv4 addresses', async () => { + await expect( + httpResolver.resolve({ + href: () => 'https://127.0.0.1/schema.yaml', + }), + ).rejects.toThrow('only https URLs to public hosts are allowed'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects private IPv4 addresses', async () => { + const urls = [ + 'https://10.0.0.1/schema.yaml', + 'https://172.16.0.1/schema.yaml', + 'https://192.168.1.1/schema.yaml', + ]; + + for (const url of urls) { + await expect( + httpResolver.resolve({ + href: () => url, + }), + ).rejects.toThrow('only https URLs to public hosts are allowed'); + } + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects link-local IP addresses', async () => { + await expect( + httpResolver.resolve({ + href: () => 'https://169.254.169.254/latest/meta-data', + }), + ).rejects.toThrow('only https URLs to public hosts are allowed'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects IPv6 loopback addresses', async () => { + await expect( + httpResolver.resolve({ + href: () => 'https://[::1]/schema.yaml', + }), + ).rejects.toThrow('only https URLs to public hosts are allowed'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('allows public HTTPS URLs', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('openapi: 3.1.0'), + } as unknown as Response); + + const result = await httpResolver.resolve({ + href: () => 'https://example.com/schema.yaml', + }); + + expect(result).toBe('openapi: 3.1.0'); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith('https://example.com/schema.yaml'); + }); + + it('allows HTTPS URLs with ports', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('ok'), + } as unknown as Response); + + await expect( + httpResolver.resolve({ + href: () => 'https://example.com:8443/schema.yaml', + }), + ).resolves.toBe('ok'); + + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('allows HTTPS URLs with query strings', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('ok'), + } as unknown as Response); + + await expect( + httpResolver.resolve({ + href: () => 'https://example.com/schema.yaml?raw=1', + }), + ).resolves.toBe('ok'); + + expect(fetch).toHaveBeenCalledOnce(); + }); + }); + + describe('fetch handling', () => { + it('returns response text for successful fetches', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('test-content'), + } as unknown as Response); + + await expect( + httpResolver.resolve({ + href: () => 'https://example.com/test.yaml', + }), + ).resolves.toBe('test-content'); + }); + + it('throws on 404 responses', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + } as unknown as Response); + + await expect( + httpResolver.resolve({ + href: () => 'https://example.com/missing.yaml', + }), + ).rejects.toThrow('Failed to fetch $ref "https://example.com/missing.yaml": 404 Not Found'); + }); + + it('throws on 500 responses', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as unknown as Response); + + await expect( + httpResolver.resolve({ + href: () => 'https://example.com/error.yaml', + }), + ).rejects.toThrow('Failed to fetch $ref "https://example.com/error.yaml": 500 Internal Server Error'); + }); + + it('propagates fetch network errors', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('network failure')); + + await expect( + httpResolver.resolve({ + href: () => 'https://example.com/schema.yaml', + }), + ).rejects.toThrow('network failure'); + }); + + it('propagates response.text() failures', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockRejectedValue(new Error('failed reading body')), + } as unknown as Response); + + await expect( + httpResolver.resolve({ + href: () => 'https://example.com/schema.yaml', + }), + ).rejects.toThrow('failed reading body'); + }); + }); + + describe('resolver wiring', () => { + it('uses the same resolver for http and https keys', () => { + // @ts-expect-error internal access for test verification + const resolvers = safeRefResolver.resolvers; + + expect(resolvers.http).toBe(resolvers.https); + }); + }); +}); diff --git a/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts b/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts index a9fc514a84cc..bb15a6c4bf21 100644 --- a/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts +++ b/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts @@ -88,7 +88,8 @@ describe('isPrivateOrLoopbackHost()', () => { describe('toArray()', () => { it('returns [] for undefined', () => { - expect(toArray(undefined)).toEqual([]); + const value = undefined; + expect(toArray(value)).toEqual([]); }); it('wraps a single value in an array', () => { From c79f46531b75763d701286575279448a89cb26b5 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 15 May 2026 09:34:35 -0400 Subject: [PATCH 38/75] chore: cleanup code --- .../insomnia/src/common/safe-ref-resolver.ts | 2 +- .../src/common/spectral-ruleset-validator.ts | 19 ++++++++++++------- packages/insomnia/src/main/ipc/main.ts | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/insomnia/src/common/safe-ref-resolver.ts b/packages/insomnia/src/common/safe-ref-resolver.ts index 8fb3fe69bc4f..1ab9a775f64a 100644 --- a/packages/insomnia/src/common/safe-ref-resolver.ts +++ b/packages/insomnia/src/common/safe-ref-resolver.ts @@ -21,7 +21,7 @@ const safeHttpResolver = { async resolve(ref: { href: () => string }): Promise { const href = ref.href(); if (!isSafeRefUrl(href)) { - throw new Error(`Refused to resolve $ref "${href}" — only https URLs to public hosts are allowed.`); + throw new Error(`Failed to resolve $ref "${href}" — only https URLs to public hosts are allowed.`); } const response = await fetch(href); if (!response.ok) { diff --git a/packages/insomnia/src/common/spectral-ruleset-validator.ts b/packages/insomnia/src/common/spectral-ruleset-validator.ts index 40e79f6645fe..70ab5cad5881 100644 --- a/packages/insomnia/src/common/spectral-ruleset-validator.ts +++ b/packages/insomnia/src/common/spectral-ruleset-validator.ts @@ -1,6 +1,5 @@ import ipaddr from 'ipaddr.js'; import YAML from 'yaml'; -import path from 'path'; export type SpectralRulesetValidationResult = { isValid: true } | { isValid: false; error: string }; @@ -35,8 +34,13 @@ const PROTOTYPE_POLLUTION_TOKENS = ['__proto__', 'prototype', 'constructor']; // For security reasons we only allow extends URLs with certain safe schemes and hosts. const SAFE_URL_SCHEMES = ['https:']; +// Check if path is absolute file path (e.g. /foo/bar.yaml, C:\foo\bar.yaml, \\server\share\file.yaml) +function isAbsoluteFilePath(value: string): boolean { + return value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value); +} + export function isLocalFilePath(value: string): boolean { - return value.startsWith('./') || value.startsWith('../') || path.isAbsolute(value); + return value.startsWith('./') || value.startsWith('../') || isAbsoluteFilePath(value); } export function toArray(value: T | T[] | undefined): T[] { @@ -83,10 +87,11 @@ function validateThen(ruleName: string, then: Record): string | } // only Spectral's documented built-in functions are reachable. - if (then.function !== undefined) { - if (typeof then.function !== 'string' || !ALLOWED_BUILTIN_FUNCTIONS.includes(then.function)) { - return `Rule "${ruleName}" uses function "${String(then.function)}" which is not an allowed Spectral built-in function.`; - } + if ( + then.function !== undefined && + (typeof then.function !== 'string' || !ALLOWED_BUILTIN_FUNCTIONS.includes(then.function)) + ) { + return `Rule "${ruleName}" uses function "${String(then.function)}" which is not an allowed Spectral built-in function.`; } return null; @@ -181,7 +186,7 @@ export function validateSpectralRuleset(content: string): SpectralRulesetValidat let parsed: unknown; try { parsed = YAML.parse(content); - } catch (err) { + } catch { return fail(`Ruleset is not valid YAML or JSON`); } diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index bc5071b9e07f..765ab237c6d3 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -364,7 +364,7 @@ export function registerMainHandlers() { //defensive validation for ruleset file before spawning the spectral lint worker if (rulesetPath) { try { - const rulesetContent = await fs.promises.readFile(rulesetPath, { encoding: 'utf-8' }); + const rulesetContent = await fs.promises.readFile(rulesetPath, { encoding: 'utf8' }); const validation = validateSpectralRuleset(rulesetContent); if (!validation.isValid) { return { error: `Invalid Spectral ruleset: ${validation.error}` }; From ab64fbab0809c290bd52f8bd593817f25983d2eb Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 15 May 2026 10:29:16 -0400 Subject: [PATCH 39/75] chore: make comments much clearer --- .../src/main/bundle-spectral-ruleset.ts | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/insomnia/src/main/bundle-spectral-ruleset.ts b/packages/insomnia/src/main/bundle-spectral-ruleset.ts index 22884ea01c80..724586aeba9e 100644 --- a/packages/insomnia/src/main/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/main/bundle-spectral-ruleset.ts @@ -11,9 +11,9 @@ const MAX_EXTENDS_DEPTH = 5; const ALLOWED_EXTENSIONS = ['.yaml', '.yml']; // `extends` is the only key we touch by name in this file: local paths get resolved away and -// remote/built-in entries are carried through. Every other top-level key — `rules`, `aliases`, -// `parserOptions`, anything we may add later — flows through the generic `mergeInto` step. -// The validator that runs after bundling decides which keys are actually allowed. +// remote URLs and spectral identifier entries are carried through. Every other top-level key — 'rules', 'aliases', +// 'parserOptions', anything we may add later — flows through the generic 'mergeInto' step. +// The validator that runs after bundling (ref: spectral-ruleset-validator.ts) decides which keys are actually allowed and all of the constraints. type Ruleset = Record & { extends?: string[]; }; @@ -71,17 +71,25 @@ async function flattenRuleset(filePath: string, visited: Set, depth: num const nextVisited = new Set(visited).add(absolute); const flattenedRuleset: Ruleset = {}; // Flattended ruleset containing all rules within this file path and its local extends - const remainingExtends: string[] = []; // non local file paths — built-in identifiers and remote URLs; deduped at return - - // Resolve 'extends' first: recurse on local file paths and merge each flattened child into 'flattenedRuleset', - // Collect any non-local entries (built-in identifiers, https URLs) for the final 'extends'. + const remainingExtends: string[] = []; // non local file paths — built-in identifiers and remote URLs + + // Process everything listed in "extends". + // + // For local file paths: + // - recursively load and flatten them + // - merge their rules into the current result + // + // For non-local entries (built in identifiers / remote URLs): + // - keep them in a separate list + // - include them later in the final "extends" array for (const entry of toArray(ruleset.extends)) { - // Keep built-in rulesets and remote URLs. + // If this entry is NOT a local file path, + // keep it as-is for the final output. if (!isLocalFilePath(entry)) { remainingExtends.push(entry); continue; } - // flatten local rulesets + // Local file paths are recursively loaded and flattened. const childRuleset = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1); if (childRuleset.extends) { remainingExtends.push(...childRuleset.extends); @@ -90,15 +98,26 @@ async function flattenRuleset(filePath: string, visited: Set, depth: num mergeInto(flattenedRuleset, childRuleset); // merge child's rules and other keys into the flattenedRuleset, with child taking precedence over parent } - // Now layer the parent ruleset over the inherited values (parent wins on collisions). - // We exclude the parent's 'extends' from this step — its local paths have already been - // resolved in the loop above, and the final value lives in 'remainingExtends'. + // After all inherited rulesets are merged, + // apply the current file's own rules on top. + // + // If parent and child define the same rule, + // the parent value wins. + // + // Do NOT merge the parent's "extends" field here, + // because: + // - local file paths were already flattened above + // - non-local entries are already stored in "remainingExtends" const parentOverrides: Ruleset = { ...ruleset }; delete parentOverrides.extends; mergeInto(flattenedRuleset, parentOverrides); - // The parent's own 'extends' is already represented in 'remainingExtends' (resolved or carried). - // Remove duplicates while preserving order, and return the final flattened ruleset with a consolidated 'extends' array containing only built-in identifiers and remote URLs. + // At this point: + // - all local file-based "extends" have been flattened + // - only built-in spectral identifiers and remote URLs remain + // + // Remove duplicate entries while preserving order, + // then return the final flattened ruleset. const uniqueExtends = [...new Set(remainingExtends)]; delete flattenedRuleset.extends; return uniqueExtends.length > 0 ? { extends: uniqueExtends, ...flattenedRuleset } : flattenedRuleset; From b8a97cffcff544e247716c65e63dc7e8a74f418c Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 15 May 2026 12:56:14 -0400 Subject: [PATCH 40/75] feat: adds proper error messages when user attempts to upload a rule set with severity tuples in extends --- .../src/common/spectral-ruleset-validator.ts | 20 ++++++++++++------- .../src/main/bundle-spectral-ruleset.ts | 5 +++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/insomnia/src/common/spectral-ruleset-validator.ts b/packages/insomnia/src/common/spectral-ruleset-validator.ts index 70ab5cad5881..c361373f335b 100644 --- a/packages/insomnia/src/common/spectral-ruleset-validator.ts +++ b/packages/insomnia/src/common/spectral-ruleset-validator.ts @@ -44,10 +44,11 @@ export function isLocalFilePath(value: string): boolean { } export function toArray(value: T | T[] | undefined): T[] { + //no extends key in the ruleset if (value === undefined) { return []; } - return Array.isArray(value) ? value : [value]; + return Array.isArray(value) ? value : [value]; // handles both array and single value cases for extends in a given ruleset } // Given our support for remote extends, we need to protect against the possibility of SSRF attacks. We block any hostname that is a loopback or private network address, as well as "localhost". @@ -99,29 +100,34 @@ function validateThen(ruleName: string, then: Record): string | function validateExtends(value: unknown): string | null { for (const entry of toArray(value)) { - if (typeof entry !== 'string') { + if (Array.isArray(entry)) { + return `"extends" entry ${JSON.stringify(entry)} uses tuple format (e.g. [path, severity]) which is not supported. Use a plain string instead.`; + } + + const path = entry; + if (typeof path !== 'string') { return '"extends" entries must be strings.'; } // allow built in identifier and local file paths without further validation - if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry) || isLocalFilePath(entry)) { + if (ALLOWED_EXTENDS_IDENTIFIERS.includes(path) || isLocalFilePath(path)) { continue; } // validate remote URLs let url: URL; try { - url = new URL(entry); + url = new URL(path); } catch { - return `"extends" entry "${entry}" is not a recognized Spectral identifier or a valid URL.`; + return `"extends" entry "${path}" is not a recognized Spectral identifier or a valid URL.`; } if (!SAFE_URL_SCHEMES.includes(url.protocol)) { - return `"extends" entry "${entry}" must use https (got "${url.protocol}").`; + return `"extends" entry "${path}" must use https (got "${url.protocol}").`; } if (!url.hostname || isPrivateOrLoopbackHost(url.hostname.toLocaleLowerCase())) { - return `"extends" entry "${entry}" targets a disallowed host`; + return `"extends" entry "${path}" targets a disallowed host`; } } return null; diff --git a/packages/insomnia/src/main/bundle-spectral-ruleset.ts b/packages/insomnia/src/main/bundle-spectral-ruleset.ts index 724586aeba9e..86be5aa619e9 100644 --- a/packages/insomnia/src/main/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/main/bundle-spectral-ruleset.ts @@ -83,6 +83,11 @@ async function flattenRuleset(filePath: string, visited: Set, depth: num // - keep them in a separate list // - include them later in the final "extends" array for (const entry of toArray(ruleset.extends)) { + if (Array.isArray(entry)) { + throw new Error( + `Failed to process "extends" entry ${JSON.stringify(entry)}: tuple format (e.g. [path, severity]) is not supported. Use a plain string instead.`, + ); + } // If this entry is NOT a local file path, // keep it as-is for the final output. if (!isLocalFilePath(entry)) { From 199fc11328d05aae02e1d8a85adff79d7532d023 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 15 May 2026 15:16:39 -0400 Subject: [PATCH 41/75] feat: adds UI tweaks --- ...$projectId.workspace.$workspaceId.spec.tsx | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index b872e7ec41d5..3a8a8f3f1eaf 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -1168,18 +1168,22 @@ const Component = ({ params }: Route.ComponentProps) => {
-
- Ruleset - {!!selectedRulesetPath && ( - - )} - : +
+ + {selectedRulesetPath ? ( + <> + + + ) : ( + 'Default OAS Ruleset' + )} + +
- { ) : (

- Using default OAS ruleset. Click to upload a custom ruleset yaml file. + Using default OAS ruleset. Upload a custom Spectral ruleset. {isConnectedGitProject && ( {' '} From 7b02375db2b5bbcdc7420f5f7aa99c8ded4c1efa Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 15 May 2026 15:50:39 -0400 Subject: [PATCH 42/75] test: adds unit tests --- .../__tests__/bundle-spectral-ruleset.test.ts | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts diff --git a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts new file mode 100644 index 000000000000..a1caff9f458d --- /dev/null +++ b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts @@ -0,0 +1,239 @@ +import path from 'node:path'; + +import { beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest'; + +// Mock fs so no real files are needed. +vi.mock('node:fs', () => ({ + default: { + promises: { + readFile: vi.fn(), + }, + }, +})); + +import fs from 'node:fs'; + +import { bundleSpectralRuleset } from '../bundle-spectral-ruleset'; + +const mockReadFile = vi.mocked(fs.promises.readFile) as MockedFunction<(path: string) => Promise>; + +// Returns the absolute path that bundleSpectralRuleset will resolve for a given fake path. +function abs(fakePath: string) { + return path.resolve(fakePath); +} + +beforeEach(() => { + mockReadFile.mockReset(); +}); + +describe('bundleSpectralRuleset', () => { + it('returns a simple ruleset with no extends unchanged', async () => { + mockReadFile.mockResolvedValueOnce( + ` +rules: + my-rule: + given: "$.info" + severity: warn + then: + function: truthy +`, + ); + + const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); + expect(result).toContain('my-rule'); + expect(result).not.toContain('extends'); + }); + + it('passes through remote URL extends unchanged', async () => { + mockReadFile.mockResolvedValueOnce( + ` +extends: + - "https://example.com/ruleset.yaml" +rules: + my-rule: + given: "$.info" + severity: warn + then: + function: truthy +`, + ); + + const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); + expect(result).toContain('https://example.com/ruleset.yaml'); + expect(result).toContain('my-rule'); + }); + + it('passes through spectral built-in identifier extends unchanged', async () => { + mockReadFile.mockResolvedValueOnce( + ` +extends: "spectral:oas" +rules: + my-rule: + given: "$.info" + severity: warn + then: + function: truthy +`, + ); + + const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); + expect(result).toContain('spectral:oas'); + expect(result).toContain('my-rule'); + }); + + it('flattens a local extends entry, merging child rules into the parent', async () => { + const parentPath = '/fake/parent.yaml'; + const childPath = '/fake/child.yaml'; + + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === abs(parentPath)) { + return ` +extends: + - "./child.yaml" +rules: + parent-rule: + given: "$.info" + severity: warn + then: + function: truthy +`; + } + if (filePath === abs(childPath)) { + return ` +rules: + child-rule: + given: "$.paths" + severity: error + then: + function: truthy +`; + } + throw new Error(`Unexpected readFile call: ${filePath}`); + }); + + const result = await bundleSpectralRuleset(parentPath); + expect(result).toContain('parent-rule'); + expect(result).toContain('child-rule'); + expect(result).not.toContain('./child.yaml'); + }); + + it('parent rule overrides child rule with the same name', async () => { + const parentPath = '/fake/parent.yaml'; + const childPath = '/fake/child.yaml'; + + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === abs(parentPath)) { + return ` +extends: + - "./child.yaml" +rules: + shared-rule: + given: "$.info" + severity: warn + then: + function: truthy +`; + } + if (filePath === abs(childPath)) { + return ` +rules: + shared-rule: + given: "$.paths" + severity: error + then: + function: truthy +`; + } + throw new Error(`Unexpected readFile call: ${filePath}`); + }); + + const result = await bundleSpectralRuleset(parentPath); + // Parent's severity (warn) wins over child's (error). + expect(result).toContain('warn'); + expect(result).not.toContain('error'); + }); + + it('throws on a cycle in extends', async () => { + const aPath = '/fake/a.yaml'; + const bPath = '/fake/b.yaml'; + + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === abs(aPath)) { + return `extends:\n - "./b.yaml"\n`; + } + if (filePath === abs(bPath)) { + return `extends:\n - "./a.yaml"\n`; + } + throw new Error(`Unexpected readFile call: ${filePath}`); + }); + + await expect(bundleSpectralRuleset(aPath)).rejects.toThrow('"extends" cycle detected'); + }); + + it('throws when extends nesting exceeds max depth', async () => { + // 7 levels of nesting exceeds the max depth of 5, so this should throw an error. + const files: Record = {}; + for (let i = 0; i <= 6; i++) { + const next = i < 6 ? `extends:\n - "./depth${i + 1}.yaml"\n` : `rules: {}\n`; + files[abs(`/fake/depth${i}.yaml`)] = next; + } + + mockReadFile.mockImplementation(async (filePath) => { + if (files[filePath]) { + return files[filePath]; + } + throw new Error(`Unexpected readFile call: ${filePath}`); + }); + + await expect(bundleSpectralRuleset('/fake/depth0.yaml')).rejects.toThrow('"extends" nested too deeply'); + }); + + it('throws when extends points to a non-YAML file', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "./rules.txt"\n`); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow( + '"extends" target must be a .yaml or .yml file', + ); + }); + + it('throws when an extends entry uses tuple format', async () => { + mockReadFile.mockResolvedValueOnce( + ` +extends: + - - spectral:oas + - recommended +`, + ); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('tuple format'); + }); + + it('throws when the ruleset file is not a YAML object', async () => { + mockReadFile.mockResolvedValueOnce('- item1\n- item2\n'); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('must be an object at the top level'); + }); + + it('deduplicates remote extends from multiple child files', async () => { + const parentPath = '/fake/parent.yaml'; + const childAPath = '/fake/childA.yaml'; + const childBPath = '/fake/childB.yaml'; + + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === abs(parentPath)) { + return `extends:\n - "./childA.yaml"\n - "./childB.yaml"\n`; + } + if (filePath === abs(childAPath)) { + return `extends:\n - "spectral:oas"\n`; + } + if (filePath === abs(childBPath)) { + return `extends:\n - "spectral:oas"\n`; + } + throw new Error(`Unexpected readFile call: ${filePath}`); + }); + + const result = await bundleSpectralRuleset(parentPath); + const matches = (result.match(/spectral:oas/g) ?? []).length; + expect(matches).toBe(1); + }); +}); From f703d99e27d13bb03d089208300c55613739b7e3 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 15 May 2026 22:22:19 -0400 Subject: [PATCH 43/75] feat: adds proper logic to handle scenarios when file does not exist when syncing --- ...$projectId.workspace.$workspaceId.spec.tsx | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 3a8a8f3f1eaf..7b542a710a5a 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -272,10 +272,14 @@ const Component = ({ params }: Route.ComponentProps) => { }; useEffect(() => { - registerCodeMirrorLint(selectedRulesetPath); + // For git-sync projects the correct path is derivable synchronously from rulesetContent, + // so compute it here instead of relying on selectedRulesetPath state which may lag by one + // render cycle (syncRuleset effect runs after this one and hasn't called setSelectedRulesetPath yet). + const effectivePath = gitSyncRulesetPath ? (rulesetContent ? gitSyncRulesetPath : '') : selectedRulesetPath; + registerCodeMirrorLint(effectivePath); // when first time into document editor, the lint helper register later than codemirror init, we need to trigger lint through execute setOption editor.current?.tryToSetOption('lint', { ...lintOptions }); - }, [selectedRulesetPath, rulesetContent]); + }, [selectedRulesetPath, rulesetContent, gitSyncRulesetPath]); useEffect(() => { if (lintErrors.length > 0 || lintWarnings.length > 0) { @@ -289,14 +293,24 @@ const Component = ({ params }: Route.ComponentProps) => { // Git-sync: file is already on disk at gitSyncRulesetPath. setSelectedRulesetPath(rulesetContent ? gitSyncRulesetPath : ''); } else if (rulesetContent) { - // Cloud sync: ensure rulesetContent is on disk so collaborators are able to pull changes. - // Note: We need this check because handleSelectSpectralFile also writes to disk which would trigger this useEffect again for an uploader and write to disk again unnecessarily. - const existing = await window.main.insecureReadFile({ path: rulesetWritePath }); - if (existing !== rulesetContent) { - await window.main.writeFile({ path: rulesetWritePath, content: rulesetContent }); + // Cloud sync: ensure rulesetContent is on disk at at rulesWritePath + try { + const existing = await window.main.insecureReadFile({ path: rulesetWritePath }); + //file exists but there is new content, we should update the file with the new content + if (existing !== rulesetContent) { + await window.main.writeFile({ path: rulesetWritePath, content: rulesetContent }); + } + setSelectedRulesetPath(rulesetWritePath); + } catch (err) { + // File does not exist, we should create it with the rulesetContent + const isFileNotFound = err instanceof Error && err.message.includes('ENOENT'); + if (isFileNotFound) { + await window.main.writeFile({ path: rulesetWritePath, content: rulesetContent }); + setSelectedRulesetPath(rulesetWritePath); + } } - setSelectedRulesetPath(rulesetWritePath); } else { + // No ruleset content, ensure file is deleted await window.main.deleteFile({ path: rulesetWritePath }); setSelectedRulesetPath(''); } From 0c5e30beb0c6ec2183a9ca291b3128d4a0e800bf Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 15 May 2026 23:09:10 -0400 Subject: [PATCH 44/75] feat: adds proper logic to handle scenarios when file does not exist when syncing --- ...t.$projectId.workspace.$workspaceId.spec.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 7b542a710a5a..b47e879dbb5f 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -207,9 +207,7 @@ const Component = ({ params }: Route.ComponentProps) => { const gitVersion = useGitVCSVersion(); const [isLintPaneOpen, setIsLintPaneOpen] = useState(false); const [isSpecPaneOpen, setIsSpecPaneOpen] = useState(Boolean(parsedSpec)); - const [selectedRulesetPath, setSelectedRulesetPath] = useState( - gitSyncRulesetPath && rulesetContent ? gitSyncRulesetPath : '', - ); + const [selectedRulesetPath, setSelectedRulesetPath] = useState(''); // Spectral requires a file path on disk to lint with a ruleset. Ref: lint-process.mjs. // For git sync projects, write .spectral.yaml directly to the git working directory so it @@ -272,14 +270,10 @@ const Component = ({ params }: Route.ComponentProps) => { }; useEffect(() => { - // For git-sync projects the correct path is derivable synchronously from rulesetContent, - // so compute it here instead of relying on selectedRulesetPath state which may lag by one - // render cycle (syncRuleset effect runs after this one and hasn't called setSelectedRulesetPath yet). - const effectivePath = gitSyncRulesetPath ? (rulesetContent ? gitSyncRulesetPath : '') : selectedRulesetPath; - registerCodeMirrorLint(effectivePath); + registerCodeMirrorLint(selectedRulesetPath); // when first time into document editor, the lint helper register later than codemirror init, we need to trigger lint through execute setOption editor.current?.tryToSetOption('lint', { ...lintOptions }); - }, [selectedRulesetPath, rulesetContent, gitSyncRulesetPath]); + }, [selectedRulesetPath, rulesetContent]); useEffect(() => { if (lintErrors.length > 0 || lintWarnings.length > 0) { @@ -290,13 +284,12 @@ const Component = ({ params }: Route.ComponentProps) => { useEffect(() => { const syncRuleset = async () => { if (gitSyncRulesetPath) { - // Git-sync: file is already on disk at gitSyncRulesetPath. setSelectedRulesetPath(rulesetContent ? gitSyncRulesetPath : ''); } else if (rulesetContent) { - // Cloud sync: ensure rulesetContent is on disk at at rulesWritePath + // Cloud sync: ensure rulesetContent is on disk at rulesetWritePath try { const existing = await window.main.insecureReadFile({ path: rulesetWritePath }); - //file exists but there is new content, we should update the file with the new content + // file exists but there is new content, we should update the file with the new content if (existing !== rulesetContent) { await window.main.writeFile({ path: rulesetWritePath, content: rulesetContent }); } From ed4548e93fee489d26c8cc573261545910ac9c3a Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Tue, 19 May 2026 11:54:27 -0400 Subject: [PATCH 45/75] chore: sight clean up --- ...project.$projectId.workspace.$workspaceId.spec.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index b47e879dbb5f..6ab21b8505fb 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -99,8 +99,11 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { let rulesetContent = ''; if (gitSyncRulesetPath) { - const { content } = await window.main.insecureReadFileWithEncoding({ path: gitSyncRulesetPath }); - rulesetContent = content; + try { + rulesetContent = await window.main.insecureReadFile({ path: gitSyncRulesetPath }); + } catch { + // no .spectral.yaml on disk yet + } } else { rulesetContent = apiSpec.rulesetContent || ''; } @@ -1107,6 +1110,7 @@ const Component = ({ params }: Route.ComponentProps) => {

+
{rulesetContent && ( { )}
Date: Tue, 19 May 2026 12:13:04 -0400 Subject: [PATCH 46/75] chore: bring back old code --- ...ationId.project.$projectId.workspace.$workspaceId.spec.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 6ab21b8505fb..84cdf3629890 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -1110,7 +1110,6 @@ const Component = ({ params }: Route.ComponentProps) => { Date: Tue, 19 May 2026 13:23:25 -0400 Subject: [PATCH 47/75] feat: adds logic to migrate rulesets when changing project types --- ...ganizationId.project.$projectId.update.tsx | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx index a3680cd0bd6c..7f9ea8989d58 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx @@ -3,7 +3,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; import { projectLock } from '~/common/project'; -import type { WorkspaceMeta } from '~/insomnia-data'; +import type { ApiSpec, WorkspaceMeta } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new'; import { SegmentEvent } from '~/ui/analytics'; @@ -23,6 +23,35 @@ interface UpdateProjectInputData { selectedAuthorEmail?: string | null; } +async function migrateGitRulesetToApiSpecs(projectId: string, gitRepositoryId: string) { + const spectralPath = window.path.join( + window.app.getPath('userData'), + `version-control/git/${gitRepositoryId}/.spectral.yaml`, + ); + let content: string | undefined; + try { + content = await window.main.insecureReadFile({ path: spectralPath }); + } catch { + // no .spectral.yaml on disk — nothing to migrate + } + if (!content) { + return; + } + const designWorkspaces = (await services.workspace.findByParentId(projectId)).filter(w => w.scope === 'design'); + const apiSpecs = await database.find(models.apiSpec.type, { + parentId: { $in: designWorkspaces.map(w => w._id) }, + }); + const apiSpecMap = new Map(apiSpecs.map(spec => [spec.parentId, spec])); + const bufferId = await database.bufferChanges(); + for (const workspace of designWorkspaces) { + const apiSpec = apiSpecMap.get(workspace._id); + if (apiSpec) { + await services.apiSpec.update(apiSpec, { rulesetContent: content }); + } + } + await database.flushChanges(bufferId); +} + export async function clientAction({ request, params }: Route.ClientActionArgs) { const { name, @@ -168,7 +197,10 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) if (models.project.isConnectedGitProject(project)) { const gitRepository = await services.gitRepository.getById(models.project.getEffectiveRepoId(project) || ''); - gitRepository && (await services.gitRepository.remove(gitRepository)); + if (gitRepository) { + await migrateGitRulesetToApiSpecs(project._id, gitRepository._id); + await services.gitRepository.remove(gitRepository); + } } await services.project.update(project, { name, remoteId: newCloudProject.id, gitRepositoryId: null }); @@ -340,7 +372,10 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const effectiveId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; const gitRepository = effectiveId ? await services.gitRepository.getById(effectiveId) : null; - gitRepository && (await services.gitRepository.remove(gitRepository)); + if (gitRepository) { + await migrateGitRulesetToApiSpecs(project._id, gitRepository._id); + await services.gitRepository.remove(gitRepository); + } await services.project.update(project, { name, gitRepositoryId: null }); reportGitProjectCount(organizationId, sessionId); From b119e5aaacdc4210b887bdba347388017005aafc Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Wed, 20 May 2026 09:36:38 -0400 Subject: [PATCH 48/75] chore: update some styles --- ...nId.project.$projectId.workspace.$workspaceId.spec.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 84cdf3629890..2f91ca59867d 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -1176,9 +1176,9 @@ const Component = ({ params }: Route.ComponentProps) => { className={`flex ${isLintPaneOpen ? '' : 'h-(--line-height-sm)'} box-border flex-col divide-y divide-solid divide-(--hl-md) overflow-hidden`} >
- + -
+
{selectedRulesetPath ? ( <> @@ -1202,7 +1202,7 @@ const Component = ({ params }: Route.ComponentProps) => {
{selectedRulesetPath ? ( -

Using ruleset from

+

Using ruleset from

{selectedRulesetPath}
) : ( @@ -1223,7 +1223,7 @@ const Component = ({ params }: Route.ComponentProps) => { {!!selectedRulesetPath && ( - + From 9e3f07d44fa9ade654980a268a90bffec0946500 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Wed, 20 May 2026 11:05:04 -0400 Subject: [PATCH 49/75] chore: adds more styling changes --- ...$projectId.workspace.$workspaceId.spec.tsx | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 2f91ca59867d..6360bca206e4 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -1190,9 +1190,24 @@ const Component = ({ params }: Route.ComponentProps) => { 'Default OAS Ruleset' )} - + {selectedRulesetPath ? ( + + + +

Clear custom ruleset and use default OAS ruleset

+
+
+ ) : ( + + )}
{ {selectedRulesetPath ? (

Using ruleset from

- {selectedRulesetPath} + {selectedRulesetPath}
) : ( @@ -1222,20 +1237,6 @@ const Component = ({ params }: Route.ComponentProps) => {
- {!!selectedRulesetPath && ( - - - -

Clear custom ruleset and use default OAS ruleset

-
-
- )}
{lintErrors.length > 0 && ( From 615a6b22772dd3da13960897b07e773cef5da3dc Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Wed, 20 May 2026 11:13:26 -0400 Subject: [PATCH 50/75] chore: more styling updates --- ...roject.$projectId.workspace.$workspaceId.spec.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 6360bca206e4..d294d22613ff 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -1179,7 +1179,7 @@ const Component = ({ params }: Route.ComponentProps) => {
- + {selectedRulesetPath ? ( <> { ) : ( - )} From 4fe9b362a1c105985aa22b77d0d62307caf0b710 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Wed, 20 May 2026 14:23:47 -0400 Subject: [PATCH 51/75] feat: adds the ruleset to project scope instead of work space so git <> cloud can be compatible --- .../node-src/services/project-lint-ruleset.ts | 24 +++++++++++ .../src/insomnia-data/src/models/api-spec.ts | 3 -- .../insomnia-data/src/models/base-types.ts | 1 + .../src/insomnia-data/src/models/db-models.ts | 1 + .../src/models/project-lint-ruleset.ts | 26 ++++++++++++ .../src/insomnia-data/src/models/types.ts | 1 + ...ganizationId.project.$projectId.update.tsx | 41 ++----------------- ...tId.workspace.$workspaceId.spec.update.tsx | 29 ++++++------- 8 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts create mode 100644 packages/insomnia/src/insomnia-data/src/models/project-lint-ruleset.ts diff --git a/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts b/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts new file mode 100644 index 000000000000..d3294c7372f7 --- /dev/null +++ b/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts @@ -0,0 +1,24 @@ +import type { ProjectLintRuleset } from '~/insomnia-data'; +import { database as db, models } from '~/insomnia-data'; + +const { type } = models.projectLintRuleset; + +export function getByParentId(projectId: string) { + return db.findOne(type, { parentId: projectId }); +} + +export async function upsert(projectId: string, patch: Partial = {}) { + const spec = await db.findOne(type, { + parentId: projectId, + }); + + if (!spec) { + return db.docCreate(type, { ...patch, parentId: projectId }); + } + + return spec; +} + +export function remove(projectId: string) { + return db.removeWhere(type, { parentId: projectId }); +} diff --git a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts index 446e3103f2db..adc691fd231f 100644 --- a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts +++ b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts @@ -12,13 +12,10 @@ export const canDuplicate = true; export const canSync = true; -export const optionalKeys = ['rulesetContent']; - export interface BaseApiSpec { fileName: string; contentType: 'json' | 'yaml'; contents: string; - rulesetContent?: string; } export type ApiSpec = BaseModel & BaseApiSpec; diff --git a/packages/insomnia/src/insomnia-data/src/models/base-types.ts b/packages/insomnia/src/insomnia-data/src/models/base-types.ts index 253a48e4907e..732b19471589 100644 --- a/packages/insomnia/src/insomnia-data/src/models/base-types.ts +++ b/packages/insomnia/src/insomnia-data/src/models/base-types.ts @@ -14,6 +14,7 @@ export type AllTypes = | 'OAuth2Token' | 'PluginData' | 'Project' + | 'ProjectLintRuleset' | 'ProtoDirectory' | 'ProtoFile' | 'Request' diff --git a/packages/insomnia/src/insomnia-data/src/models/db-models.ts b/packages/insomnia/src/insomnia-data/src/models/db-models.ts index cfacae553382..00ebd5fe7792 100644 --- a/packages/insomnia/src/insomnia-data/src/models/db-models.ts +++ b/packages/insomnia/src/insomnia-data/src/models/db-models.ts @@ -41,3 +41,4 @@ export * as webSocketResponse from './websocket-response'; export * as webSocketRequestMeta from './websocket-request-meta'; export * as workspace from './workspace'; export * as workspaceMeta from './workspace-meta'; +export * as projectLintRuleset from './project-lint-ruleset'; diff --git a/packages/insomnia/src/insomnia-data/src/models/project-lint-ruleset.ts b/packages/insomnia/src/insomnia-data/src/models/project-lint-ruleset.ts new file mode 100644 index 000000000000..9b44b014ef33 --- /dev/null +++ b/packages/insomnia/src/insomnia-data/src/models/project-lint-ruleset.ts @@ -0,0 +1,26 @@ +import type { BaseModel } from './base-types'; + +export const name = 'ProjectLintRuleset'; + +export const type = 'ProjectLintRuleset'; + +export const prefix = 'plr'; + +export const canDuplicate = true; + +export const canSync = true; + +export interface BaseProjectLintRuleset { + rulesetContent: string; +} + +export type ProjectLintRuleset = BaseModel & BaseProjectLintRuleset; + +export const isProjectLintRuleset = (model: Pick): model is ProjectLintRuleset => + model.type === type; + +export function init(): BaseProjectLintRuleset { + return { + rulesetContent: '', + }; +} diff --git a/packages/insomnia/src/insomnia-data/src/models/types.ts b/packages/insomnia/src/insomnia-data/src/models/types.ts index c1ed45b3c9ea..aa186ee8c2b1 100644 --- a/packages/insomnia/src/insomnia-data/src/models/types.ts +++ b/packages/insomnia/src/insomnia-data/src/models/types.ts @@ -79,6 +79,7 @@ export type { RunnerResultPerRequestPerIteration, } from './runner-test-result'; export type { Project, LocalProject, RemoteProject, GitProject } from './project'; +export type { ProjectLintRuleset } from './project-lint-ruleset'; export type { Settings, ThemeSettings } from './settings'; export type { Stats } from './stats'; export type { UserSession } from './user-session'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx index e5feec283af6..d2a63e160071 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx @@ -3,7 +3,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; import { projectLock } from '~/common/project'; -import type { ApiSpec, WorkspaceMeta } from '~/insomnia-data'; +import type { WorkspaceMeta } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new'; import { AnalyticsEvent } from '~/ui/analytics'; @@ -23,35 +23,6 @@ interface UpdateProjectInputData { selectedAuthorEmail?: string | null; } -async function migrateGitRulesetToApiSpecs(projectId: string, gitRepositoryId: string) { - const spectralPath = window.path.join( - window.app.getPath('userData'), - `version-control/git/${gitRepositoryId}/.spectral.yaml`, - ); - let content: string | undefined; - try { - content = await window.main.insecureReadFile({ path: spectralPath }); - } catch { - // no .spectral.yaml on disk — nothing to migrate - } - if (!content) { - return; - } - const designWorkspaces = (await services.workspace.findByParentId(projectId)).filter(w => w.scope === 'design'); - const apiSpecs = await database.find(models.apiSpec.type, { - parentId: { $in: designWorkspaces.map(w => w._id) }, - }); - const apiSpecMap = new Map(apiSpecs.map(spec => [spec.parentId, spec])); - const bufferId = await database.bufferChanges(); - for (const workspace of designWorkspaces) { - const apiSpec = apiSpecMap.get(workspace._id); - if (apiSpec) { - await services.apiSpec.update(apiSpec, { rulesetContent: content }); - } - } - await database.flushChanges(bufferId); -} - export async function clientAction({ request, params }: Route.ClientActionArgs) { const { name, @@ -197,10 +168,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) if (models.project.isConnectedGitProject(project)) { const gitRepository = await services.gitRepository.getById(models.project.getEffectiveRepoId(project) || ''); - if (gitRepository) { - await migrateGitRulesetToApiSpecs(project._id, gitRepository._id); - await services.gitRepository.remove(gitRepository); - } + gitRepository && (await services.gitRepository.remove(gitRepository)); } await services.project.update(project, { name, remoteId: newCloudProject.id, gitRepositoryId: null }); @@ -372,10 +340,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const effectiveId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; const gitRepository = effectiveId ? await services.gitRepository.getById(effectiveId) : null; - if (gitRepository) { - await migrateGitRulesetToApiSpecs(project._id, gitRepository._id); - await services.gitRepository.remove(gitRepository); - } + gitRepository && (await services.gitRepository.remove(gitRepository)); await services.project.update(project, { name, gitRepositoryId: null }); reportGitProjectCount(organizationId, sessionId); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update.tsx index fac80c0f9aa9..74846c9bae19 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update.tsx @@ -10,21 +10,20 @@ import type { Route } from './+types/organization.$organizationId.project.$proje export async function clientAction({ request, params }: Route.ClientActionArgs) { const { workspaceId } = params; - const { contents, fromTemplate, rulesetContent } = (await request.json()) as { - contents?: string; - fromTemplate?: boolean; - rulesetContent?: string | null; - }; + const formData = await request.formData(); + const contents = formData.get('contents'); + const fromTemplate = Boolean(formData.get('fromTemplate')); + + invariant(typeof contents === 'string', 'Contents is required'); const apiSpec = await services.apiSpec.getByParentId(workspaceId); - invariant(apiSpec, 'API Spec not found'); + invariant(apiSpec, 'API Spec not found'); await database.update({ ...apiSpec, modified: Date.now(), created: fromTemplate ? Date.now() : apiSpec.created, - ...(contents !== undefined && { contents }), - ...(rulesetContent !== undefined && { rulesetContent: rulesetContent ?? undefined }), + contents, }); return null; @@ -38,14 +37,12 @@ export const useSpecUpdateActionFetcher = createFetcherSubmitHook( workspaceId, contents, fromTemplate = false, - rulesetContent, }: { organizationId: string; projectId: string; workspaceId: string; - contents?: string; + contents: string; fromTemplate?: boolean; - rulesetContent?: string | null; }) => { const url = href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/spec/update', { organizationId, @@ -53,11 +50,15 @@ export const useSpecUpdateActionFetcher = createFetcherSubmitHook( workspaceId, }); - return submit(JSON.stringify({ contents, fromTemplate, rulesetContent }), { + const formData = new FormData(); + formData.append('contents', contents); + if (fromTemplate) { + formData.append('fromTemplate', 'true'); + } + + return submit(formData, { action: url, method: 'POST', - encType: 'application/json', }); }, - clientAction, ); From a105c237013ce62ec70b6db2817578ac9ec36653 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Wed, 20 May 2026 17:34:38 -0400 Subject: [PATCH 52/75] feat: adds functionality to get syncing project scoped rulesets working on cloud projects --- .../node-src/database/database-nedb.ts | 5 ++ .../insomnia-data/node-src/services/index.ts | 2 + .../main/cloud-sync/pull-backend-project.ts | 5 ++ packages/insomnia/src/main/ipc/main.ts | 19 ++++++-- ...onId.project.$projectId.delete-ruleset.tsx | 31 ++++++++++++ ...onId.project.$projectId.update-ruleset.tsx | 47 +++++++++++++++++++ ...kspaceId.insomnia-sync.branch.checkout.tsx | 6 +-- ...orkspaceId.insomnia-sync.branch.create.tsx | 8 ++-- ...workspaceId.insomnia-sync.branch.merge.tsx | 6 +-- ...space.$workspaceId.insomnia-sync.fetch.tsx | 3 +- ...kspace.$workspaceId.insomnia-sync.pull.tsx | 4 +- ...ace.$workspaceId.insomnia-sync.restore.tsx | 6 +-- ...ce.$workspaceId.insomnia-sync.rollback.tsx | 6 +-- ...$projectId.workspace.$workspaceId.spec.tsx | 29 ++++++------ packages/insomnia/src/sync/ignore-keys.ts | 17 ++++++- .../sync/vcs/initialize-backend-project.ts | 23 ++++++--- packages/insomnia/src/ui/sync-utils.ts | 25 +++++++++- 17 files changed, 195 insertions(+), 47 deletions(-) create mode 100644 packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete-ruleset.tsx create mode 100644 packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update-ruleset.tsx diff --git a/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts b/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts index 8280dcb0793d..f4add65401ba 100644 --- a/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts +++ b/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts @@ -22,6 +22,7 @@ import type { GitRepository, IDatabase, Operation, + ProjectLintRuleset, Query, Workspace, WorkspaceMeta, @@ -278,6 +279,10 @@ export const createNedbDatabase = ( ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.Project.db'), }), + ProjectLintRuleset: new NeDB({ + ...defaultConfig, + filename: fsPath.join(dbPath, 'insomnia.ProjectLintRuleset.db'), + }), ProtoDirectory: new NeDB({ ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.ProtoDirectory.db'), diff --git a/packages/insomnia/src/insomnia-data/node-src/services/index.ts b/packages/insomnia/src/insomnia-data/node-src/services/index.ts index b53542b4f288..53c11eb0fb19 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/index.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/index.ts @@ -18,6 +18,7 @@ import * as oAuth2TokenService from './o-auth-2-token'; import * as organizationService from './organization'; import * as pluginDataService from './plugin-data'; import * as projectService from './project'; +import * as projectLintRulesetService from './project-lint-ruleset'; import * as protoDirectoryService from './proto-directory'; import * as protoFileService from './proto-file'; import * as requestService from './request'; @@ -72,6 +73,7 @@ export const servicesNodeImpl = { response: responseService, runnerTestResult: runnerTestResultService, project: projectService, + projectLintRuleset: projectLintRulesetService, settings: settingsService, stats: statsService, userSession: userSessionService, diff --git a/packages/insomnia/src/main/cloud-sync/pull-backend-project.ts b/packages/insomnia/src/main/cloud-sync/pull-backend-project.ts index 895ffb276e34..c89e941bb77d 100644 --- a/packages/insomnia/src/main/cloud-sync/pull-backend-project.ts +++ b/packages/insomnia/src/main/cloud-sync/pull-backend-project.ts @@ -59,6 +59,11 @@ export const pullBackendProject = async ({ vcs, backendProject, remoteProject }: doc.parentId = remoteProject._id; workspaceId = doc._id; } + // ProjectLintRuleset is parented to the project, whose _id is not stable across machines, + // so its parentId is normalized to null in sync transit. Re-parent it to the local project. + if (models.projectLintRuleset.isProjectLintRuleset(doc)) { + doc.parentId = remoteProject._id; + } const allModelType = models.types(); if (allModelType.includes(doc.type)) { await database.update(doc); diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 88420cb595d2..2885e9d584af 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -363,7 +363,8 @@ export function registerMainHandlers() { } }); ipcMainHandle('lintSpec', async (_, options: { documentContent: string; rulesetPath: string }) => { - const { documentContent, rulesetPath } = options; + const { documentContent } = options; + let { rulesetPath } = options; //defensive validation for ruleset file before spawning the spectral lint worker if (rulesetPath) { @@ -374,7 +375,12 @@ export function registerMainHandlers() { return { error: `Invalid Spectral ruleset: ${validation.error}` }; } } catch (err) { - return { error: `Failed to read ruleset file: ${err instanceof Error ? err.message : String(err)}` }; + // Fall back to the default OAS ruleset instead of erroring when a user deletes their custom ruleset + if (err && err.code === 'ENOENT') { + rulesetPath = ''; + } else { + return { error: `Failed to read ruleset file: ${err instanceof Error ? err.message : String(err)}` }; + } } } @@ -477,9 +483,12 @@ export function registerMainHandlers() { cancelCurlRequest(requestId); }); - ipcMainOn('trackAnalyticsEvent', (_, options: { event: AnalyticsEvent; properties?: Record }): void => { - trackAnalyticsEvent(options.event, options.properties); - }); + ipcMainOn( + 'trackAnalyticsEvent', + (_, options: { event: AnalyticsEvent; properties?: Record }): void => { + trackAnalyticsEvent(options.event, options.properties); + }, + ); ipcMainOn('trackPageView', (_, options: { name: string }): void => { trackPageView(options.name); }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete-ruleset.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete-ruleset.tsx new file mode 100644 index 000000000000..5e5199d4574e --- /dev/null +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete-ruleset.tsx @@ -0,0 +1,31 @@ +import { href } from 'react-router'; + +import { services } from '~/insomnia-data'; +import { invariant } from '~/utils/invariant'; +import { createFetcherSubmitHook } from '~/utils/router'; + +import type { Route } from './+types/organization.$organizationId.project.$projectId.delete-ruleset'; + +export async function clientAction({ params }: Route.ClientActionArgs) { + const { projectId } = params; + + const project = await services.project.get(projectId); + invariant(project, 'Project not found'); + + await services.projectLintRuleset.remove(projectId); + + return null; +} + +export const useDeleteProjectRulesetActionFetcher = createFetcherSubmitHook( + submit => + ({ organizationId, projectId }: { organizationId: string; projectId: string }) => { + return submit(null, { + action: href('/organization/:organizationId/project/:projectId/delete-ruleset', { + organizationId, + projectId, + }), + method: 'POST', + }); + }, +); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update-ruleset.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update-ruleset.tsx new file mode 100644 index 000000000000..7dda0046db06 --- /dev/null +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update-ruleset.tsx @@ -0,0 +1,47 @@ +import { href } from 'react-router'; + +import { services } from '~/insomnia-data'; +import { invariant } from '~/utils/invariant'; +import { createFetcherSubmitHook } from '~/utils/router'; + +import type { Route } from './+types/organization.$organizationId.project.$projectId.update-ruleset'; + +interface UpdateProjectRulesetInputData { + rulesetContent: string; +} + +export async function clientAction({ request, params }: Route.ClientActionArgs) { + const { projectId } = params; + + const project = await services.project.get(projectId); + invariant(project, 'Project not found'); + + const { rulesetContent } = (await request.json()) as UpdateProjectRulesetInputData; + invariant(typeof rulesetContent === 'string', 'Ruleset content is required'); + + await services.projectLintRuleset.upsert(projectId, { rulesetContent }); + + return null; +} + +export const useUpdateProjectRulesetActionFetcher = createFetcherSubmitHook( + submit => + ({ + organizationId, + projectId, + rulesetContent, + }: { + organizationId: string; + projectId: string; + rulesetContent: string; + }) => { + return submit(JSON.stringify({ rulesetContent }), { + action: href('/organization/:organizationId/project/:projectId/update-ruleset', { + organizationId, + projectId, + }), + method: 'POST', + encType: 'application/json', + }); + }, +); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.checkout.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.checkout.tsx index 4ddc7a85a07b..5cbbb3683b12 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.checkout.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.checkout.tsx @@ -3,7 +3,7 @@ import { href, redirect } from 'react-router'; import type { Operation } from '~/common/database'; import { database } from '~/common/database'; import { models, services } from '~/insomnia-data'; -import { getSyncItems, remoteCompareCache } from '~/ui/sync-utils'; +import { getSyncItems, remoteCompareCache, reparentSyncDelta } from '~/ui/sync-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -20,9 +20,9 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const { syncItems } = await getSyncItems({ workspaceId }); try { - const delta = await window.main.sync.checkout(syncItems, branch); + const delta = (await window.main.sync.checkout(syncItems, branch)) as Operation; // This is to synchronize the local database with the branch changes - await database.batchModifyDocs(delta as Operation); + await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); delete remoteCompareCache[workspaceId]; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error while checking out branch.'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.create.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.create.tsx index 463c84313c25..81cacf701c83 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.create.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.create.tsx @@ -2,14 +2,14 @@ import { href } from 'react-router'; import type { Operation } from '~/common/database'; import { database } from '~/common/database'; -import { getSyncItems, remoteCompareCache } from '~/ui/sync-utils'; +import { getSyncItems, remoteCompareCache, reparentSyncDelta } from '~/ui/sync-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.delete'; export async function clientAction({ request, params }: Route.ClientActionArgs) { - const { workspaceId } = params; + const { projectId, workspaceId } = params; const formData = await request.formData(); @@ -21,9 +21,9 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) try { await window.main.sync.fork(branchName); // Checkout new branch - const delta = await window.main.sync.checkout(syncItems, branchName); + const delta = (await window.main.sync.checkout(syncItems, branchName)) as Operation; // This is to synchronize the local database with the branch changes - await database.batchModifyDocs(delta as Operation); + await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); delete remoteCompareCache[workspaceId]; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error while merging branch.'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.merge.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.merge.tsx index 9f2d493c22fe..3e4525f98b2d 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.merge.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.merge.tsx @@ -3,14 +3,14 @@ import { href } from 'react-router'; import type { Operation } from '~/common/database'; import { database } from '~/common/database'; import { UserAbortResolveMergeConflictError } from '~/sync/vcs/errors'; -import { getSyncItems, remoteCompareCache } from '~/ui/sync-utils'; +import { getSyncItems, remoteCompareCache, reparentSyncDelta } from '~/ui/sync-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.merge'; export async function clientAction({ request, params }: Route.ClientActionArgs) { - const { workspaceId } = params; + const { projectId, workspaceId } = params; const formData = await request.formData(); const branch = formData.get('branch'); @@ -27,7 +27,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) } try { // This is to synchronize the local database with the branch changes - await database.batchModifyDocs(delta as Operation); + await database.batchModifyDocs(reparentSyncDelta(delta as Operation, projectId)); delete remoteCompareCache[workspaceId]; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error while merging branch.'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.fetch.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.fetch.tsx index 15184fff6f14..40db6f1f4bbb 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.fetch.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.fetch.tsx @@ -2,6 +2,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; import { services } from '~/insomnia-data'; +import { reparentSyncDelta } from '~/ui/sync-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -29,7 +30,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) }); // This is to synchronize the local database with the branch changes - await database.batchModifyDocs(delta); + await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); } catch (err) { await window.main.sync.checkout([], currentBranch); const errorMessage = err instanceof Error ? err.message : 'Unknown error while fetching remote branch.'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.pull.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.pull.tsx index f331b770f640..5dd3689c6862 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.pull.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.pull.tsx @@ -3,7 +3,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; import { services } from '~/insomnia-data'; import { AnalyticsEvent } from '~/ui/analytics'; -import { getSyncItems, remoteCompareCache, vcsEventProperties } from '~/ui/sync-utils'; +import { getSyncItems, remoteCompareCache, reparentSyncDelta, vcsEventProperties } from '~/ui/sync-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -29,7 +29,7 @@ export async function clientAction({ params }: Route.ClientActionArgs) { properties: vcsEventProperties('remote', 'pull'), }); // This is to synchronize the local database with the branch changes - await database.batchModifyDocs(delta); + await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); delete remoteCompareCache[workspaceId]; return { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.restore.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.restore.tsx index 2dcea4b1ebe7..51a15be3d7ee 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.restore.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.restore.tsx @@ -3,7 +3,7 @@ import { href, redirect } from 'react-router'; import type { Operation } from '~/common/database'; import { database } from '~/common/database'; import { models, services } from '~/insomnia-data'; -import { getSyncItems, remoteCompareCache } from '~/ui/sync-utils'; +import { getSyncItems, remoteCompareCache, reparentSyncDelta } from '~/ui/sync-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -17,9 +17,9 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) invariant(typeof id === 'string', 'Id is required'); try { const { syncItems } = await getSyncItems({ workspaceId }); - const delta = await window.main.sync.rollback(id, syncItems); + const delta = (await window.main.sync.rollback(id, syncItems)) as unknown as Operation; // This is to synchronize the local database with the branch changes - await database.batchModifyDocs(delta as unknown as Operation); + await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); delete remoteCompareCache[workspaceId]; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error while restoring changes.'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.rollback.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.rollback.tsx index de820ad7611c..0b6bff60cf16 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.rollback.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.rollback.tsx @@ -3,7 +3,7 @@ import { href, redirect } from 'react-router'; import type { Operation } from '~/common/database'; import { database } from '~/common/database'; import { models, services } from '~/insomnia-data'; -import { getSyncItems, remoteCompareCache } from '~/ui/sync-utils'; +import { getSyncItems, remoteCompareCache, reparentSyncDelta } from '~/ui/sync-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -14,9 +14,9 @@ export async function clientAction({ params }: Route.ClientActionArgs) { try { const { syncItems } = await getSyncItems({ workspaceId }); - const delta = await window.main.sync.rollbackToLatest(syncItems); + const delta = (await window.main.sync.rollbackToLatest(syncItems)) as unknown as Operation; // This is to synchronize the local database with the branch changes - await database.batchModifyDocs(delta as unknown as Operation); + await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); delete remoteCompareCache[workspaceId]; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error while rolling back changes.'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 950163c4a2ba..741246c1e886 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -63,6 +63,8 @@ import { useGitVCSVersion } from '~/ui/hooks/use-vcs-version'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec'; +import { useUpdateProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.update-ruleset'; +import { useDeleteProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.delete-ruleset'; export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId, projectId, workspaceId } = params; @@ -105,7 +107,8 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { // no .spectral.yaml on disk yet } } else { - rulesetContent = apiSpec.rulesetContent || ''; + const projectLintRuleset = await services.projectLintRuleset.getByParentId(projectId); + rulesetContent = projectLintRuleset?.rulesetContent || ''; } let parsedSpec: OpenAPIV3.Document | undefined; @@ -206,6 +209,8 @@ const Component = ({ params }: Route.ComponentProps) => { const editor = useRef(null); const { submit: updateApiSpec } = useSpecUpdateActionFetcher(); + const { submit: updateProjectRuleset } = useUpdateProjectRulesetActionFetcher(); + const { submit: deleteProjectRuleset } = useDeleteProjectRulesetActionFetcher(); const generateRequestCollectionFetcher = useSpecGenerateRequestCollectionActionFetcher(); const gitVersion = useGitVCSVersion(); const [isLintPaneOpen, setIsLintPaneOpen] = useState(false); @@ -215,12 +220,11 @@ const Component = ({ params }: Route.ComponentProps) => { // Spectral requires a file path on disk to lint with a ruleset. Ref: lint-process.mjs. // For git sync projects, write .spectral.yaml directly to the git working directory so it // appears in the staging modal and can be committed/pushed/pulled like any other file. - // For cloud/local projects, write to a per-workspace scratch path — rulesetContent in NeDB handles syncing. + // For cloud/local projects, write to a per-project scratch path — rulesetContent in NeDB handles syncing. const rulesetWritePath = useMemo( () => - gitSyncRulesetPath || - window.path.join(window.app.getPath('userData'), `workspaces/${workspaceId}/.spectral.yaml`), - [gitSyncRulesetPath, workspaceId], + gitSyncRulesetPath || window.path.join(window.app.getPath('userData'), `projects/${projectId}/.spectral.yaml`), + [gitSyncRulesetPath, projectId], ); const { components, info, servers, paths } = parsedSpec || {}; @@ -479,17 +483,13 @@ const Component = ({ params }: Route.ComponentProps) => { return; } + await updateProjectRuleset({ organizationId, projectId, rulesetContent: content }); await window.main.writeFile({ path: rulesetWritePath, content }); - // We do not write rulesetContent to NeDB for git sync projects because it would - // be serialized into insomnia.{workspaceId}.yaml, creating a duplicate representation. - // Instead, .spectral.yaml is written directly to the git working directory and - // tracked by git like any other file — committed, pushed, and pulled normally. if (gitSyncRulesetPath) { revalidator.revalidate(); - } else { - updateApiSpec({ organizationId, projectId, workspaceId, rulesetContent: content }); } + setSelectedRulesetPath(rulesetWritePath); }; @@ -503,9 +503,10 @@ const Component = ({ params }: Route.ComponentProps) => { noText: 'Cancel', onDone: async (confirmed: boolean) => { if (confirmed) { - if (!gitSyncRulesetPath) { - updateApiSpec({ organizationId, projectId, workspaceId, rulesetContent: null }); - } + await deleteProjectRuleset({ + organizationId, + projectId, + }); await window.main.deleteFile({ path: rulesetWritePath }); if (gitSyncRulesetPath) { revalidator.revalidate(); diff --git a/packages/insomnia/src/sync/ignore-keys.ts b/packages/insomnia/src/sync/ignore-keys.ts index b1bb8211e830..545911322668 100644 --- a/packages/insomnia/src/sync/ignore-keys.ts +++ b/packages/insomnia/src/sync/ignore-keys.ts @@ -1,4 +1,4 @@ -import type { BaseModel, Workspace } from '~/insomnia-data'; +import type { BaseModel, ProjectLintRuleset, Workspace } from '~/insomnia-data'; import { models } from '~/insomnia-data'; // Key for VCS to delete before computing changes @@ -16,6 +16,10 @@ const RESET_WORKSPACE_KEYS: ResetModelKeys = { parentId: null, }; +const RESET_PROJECT_LINT_RULESET_KEYS: ResetModelKeys = { + parentId: null, +}; + export const shouldIgnoreKey = (key: keyof T, doc: T) => { if (key === DELETE_KEY) { return true; @@ -25,6 +29,10 @@ export const shouldIgnoreKey = (key: keyof T, doc: T) => { return key in RESET_WORKSPACE_KEYS; } + if (models.projectLintRuleset.isProjectLintRuleset(doc)) { + return key in RESET_PROJECT_LINT_RULESET_KEYS; + } + return false; }; @@ -40,4 +48,11 @@ export const resetKeys = (doc: T) => { doc[key] = RESET_WORKSPACE_KEYS[key]; }); } + + if (models.projectLintRuleset.isProjectLintRuleset(doc)) { + (Object.keys(RESET_PROJECT_LINT_RULESET_KEYS) as (keyof typeof RESET_PROJECT_LINT_RULESET_KEYS)[]).forEach(key => { + // @ts-expect-error -- mapping unsoundness + doc[key] = RESET_PROJECT_LINT_RULESET_KEYS[key]; + }); + } }; diff --git a/packages/insomnia/src/sync/vcs/initialize-backend-project.ts b/packages/insomnia/src/sync/vcs/initialize-backend-project.ts index 4cdbcdfd67c4..f801bfd5d496 100644 --- a/packages/insomnia/src/sync/vcs/initialize-backend-project.ts +++ b/packages/insomnia/src/sync/vcs/initialize-backend-project.ts @@ -23,14 +23,23 @@ export const initializeLocalBackendProjectAndMarkForSync = async ({ // Create local project await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); + // The lint ruleset is project-scoped (shared by every design document in the project), + // so it is not a descendant of the workspace and must be added explicitly. + const projectLintRuleset = await services.projectLintRuleset.getByParentId(workspace.parentId); + // Everything unstaged - const candidates = (await database.getWithDescendants(workspace)).filter(models.canSync).map( - (doc: BaseModel): StatusCandidate => ({ - key: doc._id, - name: doc.name || '', - document: doc, - }), - ); + const candidates = [ + ...(await database.getWithDescendants(workspace)), + ...(projectLintRuleset ? [projectLintRuleset] : []), + ] + .filter(models.canSync) + .map( + (doc: BaseModel): StatusCandidate => ({ + key: doc._id, + name: doc.name || '', + document: doc, + }), + ); const status = await vcs.status(candidates); // Stage everything diff --git a/packages/insomnia/src/ui/sync-utils.ts b/packages/insomnia/src/ui/sync-utils.ts index da416e14287c..17e337716a7c 100644 --- a/packages/insomnia/src/ui/sync-utils.ts +++ b/packages/insomnia/src/ui/sync-utils.ts @@ -1,4 +1,4 @@ -import { database } from '~/common/database'; +import { database, type Operation } from '~/common/database'; import type { ApiSpec, Environment, @@ -6,6 +6,7 @@ import type { McpRequest, MockRoute, MockServer, + ProjectLintRuleset, Request, RequestGroup, SocketIORequest, @@ -45,6 +46,21 @@ export const remoteBranchesCache: Record = {}; export const remoteCompareCache: Record = {}; export const remoteBackendProjectsCache: Record = {}; +/** + * ProjectLintRuleset is parented to the project, whose _id is not stable across machines, + * so its parentId is normalized to null in sync transit. Re-parent any ProjectLintRuleset + * in a pulled delta to the local project before the delta is applied to the database. + */ +export function reparentSyncDelta(delta: Operation, projectId: string): Operation { + delta.upsert?.forEach(doc => { + if (doc.type === 'ProjectLintRuleset') { + doc.parentId = projectId; + } + }); + + return delta; +} + export async function getSyncItems({ workspaceId }: { workspaceId: string }) { const syncItemsList: ( | Workspace @@ -60,6 +76,7 @@ export async function getSyncItems({ workspaceId }: { workspaceId: string }) { | UnitTest | MockServer | MockRoute + | ProjectLintRuleset )[] = []; const activeWorkspace = await services.workspace.getById(workspaceId); invariant(activeWorkspace, 'Workspace could not be found'); @@ -122,6 +139,12 @@ export async function getSyncItems({ workspaceId }: { workspaceId: string }) { const subEnvironments = (await services.environment.findByParentId(baseEnvironment._id)).sort( (e1, e2) => e1.metaSortKey - e2.metaSortKey, ); + + const projectLintRuleset = await services.projectLintRuleset.getByParentId(activeWorkspace.parentId); + if (projectLintRuleset) { + syncItemsList.push(projectLintRuleset); + } + allRequests.map(r => syncItemsList.push(r)); tests.map(t => syncItemsList.push(t)); testSuites.map(t => syncItemsList.push(t)); From df72119259e18cfefc55d9a97cba00a0133eaf21 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 21 May 2026 01:14:26 -0400 Subject: [PATCH 53/75] feat: adds logic to sync git FS/DB for project ruleset --- .../node-src/services/project-lint-ruleset.ts | 6 +- .../src/sync/git/repo-file-watcher.ts | 80 +++++++++++++++++-- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts b/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts index d3294c7372f7..7e187c08901b 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts @@ -8,15 +8,15 @@ export function getByParentId(projectId: string) { } export async function upsert(projectId: string, patch: Partial = {}) { - const spec = await db.findOne(type, { + const existing = await db.findOne(type, { parentId: projectId, }); - if (!spec) { + if (!existing) { return db.docCreate(type, { ...patch, parentId: projectId }); } - return spec; + return db.docUpdate(existing, patch); } export function remove(projectId: string) { diff --git a/packages/insomnia/src/sync/git/repo-file-watcher.ts b/packages/insomnia/src/sync/git/repo-file-watcher.ts index 14a098ef0657..9ee28df6169f 100644 --- a/packages/insomnia/src/sync/git/repo-file-watcher.ts +++ b/packages/insomnia/src/sync/git/repo-file-watcher.ts @@ -51,6 +51,7 @@ import { database as db } from '../../common/database'; import { InsomniaFileTypeValues } from '../../common/import-v5-parser'; import { getInsomniaV5DataExport, tryImportV5Data } from '../../common/insomnia-v5'; import { SyncQueue } from './sync-queue'; +import YAML from 'yaml'; const POLL_INTERVAL_MS = 10_000; const DEBOUNCE_MS = 300; @@ -223,6 +224,7 @@ class RepoFileWatcher { } this.queue.enqueue(() => this.flushProjectWorkspacesToDisk()); + this.queue.enqueue(() => this.flushProjectLintRulesetToDisk()); await this.queue.waitUntilDone(); } @@ -359,6 +361,7 @@ class RepoFileWatcher { this.flushDebounce = setTimeout(() => { this.flushDebounce = null; this.queue.enqueue(() => this.flushProjectWorkspacesToDisk()); + this.queue.enqueue(() => this.flushProjectLintRulesetToDisk()); }, DEBOUNCE_MS); }); } @@ -431,6 +434,39 @@ class RepoFileWatcher { } } + private async flushProjectLintRulesetToDisk(): Promise { + if (this.stopped) { + return; + } + + const absPath = path.normalize(path.join(this.repoDir, '.spectral.yaml')); + const ruleset = await services.projectLintRuleset.getByParentId(this.projectId); + + try { + if (!ruleset) { + // Ruleset removed from the DB — remove the file if we were tracking it. + if (this.lastWrittenHash.has(absPath) || this.lastSyncMtime.has(absPath)) { + await fs.promises.rm(absPath, { force: true }); + this.lastWrittenHash.delete(absPath); + this.lastSyncMtime.delete(absPath); + } + return; + } + + const hash = contentHash(ruleset.rulesetContent); + if (this.lastWrittenHash.get(absPath) === hash) { + return; + } + + await fs.promises.writeFile(absPath, ruleset.rulesetContent, 'utf8'); + this.lastWrittenHash.set(absPath, hash); + const stat = await fs.promises.stat(absPath); + this.lastSyncMtime.set(absPath, stat.mtimeMs); + } catch (err) { + console.warn('[repo-file-watcher] Could not flush project lint ruleset to disk:', err); + } + } + // --------------------------------------------------------------------------- // FS → DB direction (inbound) // --------------------------------------------------------------------------- @@ -505,6 +541,27 @@ class RepoFileWatcher { this.debounceTimers.set(absPath, timer); } + private isSpectralRulesetPath(normalisedPath: string): boolean { + return ( + path.basename(normalisedPath) === '.spectral.yaml' && + path.normalize(path.dirname(normalisedPath)) === path.normalize(this.repoDir) + ); + } + + private isSpectralRulesetFile(normalisedPath: string, content: string): boolean { + if (!this.isSpectralRulesetPath(normalisedPath)) { + return false; + } + try { + const parsedContent = YAML.parse(content); + return ( + !!parsedContent && typeof parsedContent === 'object' && ('extends' in parsedContent || 'rules' in parsedContent) + ); + } catch { + return false; + } + } + /** * Read a YAML file from disk and import its documents into the DB. * @@ -528,6 +585,12 @@ class RepoFileWatcher { this.lastWrittenHash.set(normalised, result.hash); this.lastSyncMtime.set(normalised, result.mtimeMs); + if (this.isSpectralRulesetFile(normalised, result.content)) { + await services.projectLintRuleset.upsert(this.projectId, { rulesetContent: result.content }); + this.notifyRenderer(); + return; + } + const docs = this.parseAndValidate(absPath, normalised, result.content); if (!docs) { return; @@ -685,6 +748,16 @@ class RepoFileWatcher { return; } + // The lint ruleset file was deleted — remove the ProjectLintRuleset record. + if (this.isSpectralRulesetPath(normalised)) { + await services.projectLintRuleset.remove(this.projectId); + this.lastSyncMtime.delete(normalised); + this.lastWrittenHash.delete(normalised); + this.clearProblem(normalised); + this.notifyRenderer(); + return; + } + const relPath = this.toPosixRelPath(normalised); // Find the workspace whose gitFilePath matches this deleted file @@ -726,12 +799,7 @@ class RepoFileWatcher { return; } - console.warn( - '[repo-file-watcher] Failed to remove workspace file from disk:', - workspaceId, - normalisedPath, - err, - ); + console.warn('[repo-file-watcher] Failed to remove workspace file from disk:', workspaceId, normalisedPath, err); } } From ed24a5bc8e2830c2a56b2bc9739fa179ddbf58f4 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 21 May 2026 01:33:50 -0400 Subject: [PATCH 54/75] chore: refactor to use nedb as source of truth for rulesetContent for all projects. --- ...$projectId.workspace.$workspaceId.spec.tsx | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 741246c1e886..9d93b832aae0 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -99,17 +99,10 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { ? window.path.join(window.app.getPath('userData'), `version-control/git/${gitRepositoryId}/.spectral.yaml`) : ''; - let rulesetContent = ''; - if (gitSyncRulesetPath) { - try { - rulesetContent = await window.main.insecureReadFile({ path: gitSyncRulesetPath }); - } catch { - // no .spectral.yaml on disk yet - } - } else { - const projectLintRuleset = await services.projectLintRuleset.getByParentId(projectId); - rulesetContent = projectLintRuleset?.rulesetContent || ''; - } + // The ProjectLintRuleset record is the source of truth for both git and cloud projects. + // For git, the RepoFileWatcher keeps .spectral.yaml in sync with this record. + const projectLintRuleset = await services.projectLintRuleset.getByParentId(projectId); + const rulesetContent = projectLintRuleset?.rulesetContent || ''; let parsedSpec: OpenAPIV3.Document | undefined; @@ -218,13 +211,12 @@ const Component = ({ params }: Route.ComponentProps) => { const [selectedRulesetPath, setSelectedRulesetPath] = useState(''); // Spectral requires a file path on disk to lint with a ruleset. Ref: lint-process.mjs. - // For git sync projects, write .spectral.yaml directly to the git working directory so it - // appears in the staging modal and can be committed/pushed/pulled like any other file. - // For cloud/local projects, write to a per-project scratch path — rulesetContent in NeDB handles syncing. + // Cloud/local projects have no RepoFileWatcher, so rulesetContent from NeDB is mirrored + // to this per-project scratch path. Git projects lint against gitSyncRulesetPath, which + // the RepoFileWatcher keeps in sync with the record. const rulesetWritePath = useMemo( - () => - gitSyncRulesetPath || window.path.join(window.app.getPath('userData'), `projects/${projectId}/.spectral.yaml`), - [gitSyncRulesetPath, projectId], + () => window.path.join(window.app.getPath('userData'), `projects/${projectId}/.spectral.yaml`), + [projectId], ); const { components, info, servers, paths } = parsedSpec || {}; @@ -484,13 +476,16 @@ const Component = ({ params }: Route.ComponentProps) => { } await updateProjectRuleset({ organizationId, projectId, rulesetContent: content }); - await window.main.writeFile({ path: rulesetWritePath, content }); if (gitSyncRulesetPath) { + // git: the RepoFileWatcher mirrors the record to .spectral.yaml in the working dir revalidator.revalidate(); + } else { + // cloud/local: no watcher — mirror to the scratch path so Spectral can lint + await window.main.writeFile({ path: rulesetWritePath, content }); } - setSelectedRulesetPath(rulesetWritePath); + setSelectedRulesetPath(gitSyncRulesetPath || rulesetWritePath); }; const handleUnselectSpectralFile = async () => { @@ -507,9 +502,11 @@ const Component = ({ params }: Route.ComponentProps) => { organizationId, projectId, }); - await window.main.deleteFile({ path: rulesetWritePath }); if (gitSyncRulesetPath) { + // git: the RepoFileWatcher removes .spectral.yaml from the working dir revalidator.revalidate(); + } else { + await window.main.deleteFile({ path: rulesetWritePath }); } setSelectedRulesetPath(''); } From 23f77e2c8ce22c3d1f6609a1c961fce7073700a6 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 21 May 2026 01:49:44 -0400 Subject: [PATCH 55/75] feat: adds logic to mitigate against repo replacing cloud project ruleset when migrating from cloud -> git project types --- .../src/sync/git/repo-file-watcher.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/insomnia/src/sync/git/repo-file-watcher.ts b/packages/insomnia/src/sync/git/repo-file-watcher.ts index 9ee28df6169f..2fab376d88c7 100644 --- a/packages/insomnia/src/sync/git/repo-file-watcher.ts +++ b/packages/insomnia/src/sync/git/repo-file-watcher.ts @@ -152,6 +152,7 @@ class RepoFileWatcher { // requests on the old app during a downgrade), write fresh YAML to // disk BEFORE importing so those edits are not silently overwritten. await watcher.flushNewerDbWorkspacesToDisk(); + await watcher.flushNewerDbRulesetToDisk(); // 2. Import all YAML files into the DB so it reflects disk state. // This populates lastSyncMtime + lastWrittenHash as a side-effect, @@ -304,6 +305,50 @@ class RepoFileWatcher { ); } + /** + * If the DB's ProjectLintRuleset record was modified more recently than the + * on-disk `.spectral.yaml`, write the record to disk before the initial + * `importAllFiles` scan. + * + * Without this, converting a cloud/local project to Git against a repo that + * already contains a `.spectral.yaml` would let `importAllFiles` silently + * overwrite the user's existing ruleset with the repo's file. + * + * Mirrors {@link flushNewerDbWorkspacesToDisk}: if the file does not exist, + * nothing is written here — a later `flushProjectLintRulesetToDisk` seeds it. + */ + private async flushNewerDbRulesetToDisk(): Promise { + const ruleset = await services.projectLintRuleset.getByParentId(this.projectId); + if (!ruleset) { + return; + } + + const absPath = path.normalize(path.join(this.repoDir, '.spectral.yaml')); + + let fileMtime = 0; + try { + const stat = await fs.promises.stat(absPath); + fileMtime = stat.mtimeMs; + } catch { + // File doesn't exist yet — flushProjectLintRulesetToDisk will create it. + return; + } + + if (ruleset.modified <= fileMtime) { + return; // disk is up-to-date + } + + try { + await fs.promises.writeFile(absPath, ruleset.rulesetContent, 'utf8'); + const hash = contentHash(ruleset.rulesetContent); + this.lastWrittenHash.set(absPath, hash); + const newStat = await fs.promises.stat(absPath); + this.lastSyncMtime.set(absPath, newStat.mtimeMs); + } catch (err) { + console.warn('[repo-file-watcher] flushNewerDbRulesetToDisk error:', err); + } + } + /** * Import all YAML files in the repo directory into the DB. * From 4433ea5750faefbea9ae985d0be371382c987eaf Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 21 May 2026 13:42:38 -0400 Subject: [PATCH 56/75] chore: fixes imports order --- packages/insomnia/src/main/bundle-spectral-ruleset.ts | 2 +- ...ionId.project.$projectId.workspace.$workspaceId.spec.tsx | 6 +++--- packages/insomnia/src/sync/git/repo-file-watcher.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/insomnia/src/main/bundle-spectral-ruleset.ts b/packages/insomnia/src/main/bundle-spectral-ruleset.ts index 86be5aa619e9..05f1a25b4895 100644 --- a/packages/insomnia/src/main/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/main/bundle-spectral-ruleset.ts @@ -84,7 +84,7 @@ async function flattenRuleset(filePath: string, visited: Set, depth: num // - include them later in the final "extends" array for (const entry of toArray(ruleset.extends)) { if (Array.isArray(entry)) { - throw new Error( + throw new TypeError( `Failed to process "extends" entry ${JSON.stringify(entry)}: tuple format (e.g. [path, severity]) is not supported. Use a plain string instead.`, ); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 5daba85ecc6f..db82ff114631 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -29,8 +29,11 @@ import YAML from 'yaml'; import { parseApiSpec } from '~/common/api-specs'; import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; import { debounce } from '~/common/misc'; +import { selectFileOrFolder } from '~/common/select-file-or-folder'; import { models, services } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; +import { useDeleteProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.delete-ruleset'; +import { useUpdateProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.update-ruleset'; import { useWorkspaceLoaderData, WORKSPACE_CONTENT_WRAPPER, @@ -60,9 +63,6 @@ import { useGitVCSVersion } from '~/ui/hooks/use-vcs-version'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec'; -import { useUpdateProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.update-ruleset'; -import { useDeleteProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.delete-ruleset'; -import { selectFileOrFolder } from '~/common/select-file-or-folder'; export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId, projectId, workspaceId } = params; diff --git a/packages/insomnia/src/sync/git/repo-file-watcher.ts b/packages/insomnia/src/sync/git/repo-file-watcher.ts index 2fab376d88c7..aec64a778544 100644 --- a/packages/insomnia/src/sync/git/repo-file-watcher.ts +++ b/packages/insomnia/src/sync/git/repo-file-watcher.ts @@ -42,6 +42,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { BrowserWindow } from 'electron'; +import YAML from 'yaml'; import type { Workspace, WorkspaceMeta } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; @@ -51,7 +52,6 @@ import { database as db } from '../../common/database'; import { InsomniaFileTypeValues } from '../../common/import-v5-parser'; import { getInsomniaV5DataExport, tryImportV5Data } from '../../common/insomnia-v5'; import { SyncQueue } from './sync-queue'; -import YAML from 'yaml'; const POLL_INTERVAL_MS = 10_000; const DEBOUNCE_MS = 300; From dc44b9cd3a188950939603c7a0514ec09ea03c1c Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 21 May 2026 13:55:03 -0400 Subject: [PATCH 57/75] test: fixes e2e test --- packages/insomnia-smoke-test/tests/smoke/openapi.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/insomnia-smoke-test/tests/smoke/openapi.test.ts b/packages/insomnia-smoke-test/tests/smoke/openapi.test.ts index 390ee8371a48..3f540c9ac238 100644 --- a/packages/insomnia-smoke-test/tests/smoke/openapi.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/openapi.test.ts @@ -14,8 +14,5 @@ test('can render Spectral OpenAPI lint errors', async ({ page }) => { // Cause a lint error await page.locator('[data-testid="CodeEditor"] >> text=info').click(); page.keyboard.insertText(' !@#$%^&*('); - await page.getByText('Lint problems detected').click(); - - await page.getByLabel('Toggle lint panel').click(); await page.getByRole('option', { name: 'oas3-schema must have' }).click(); }); From 7748f8169067da14fd70c065906e9ffce885bbd5 Mon Sep 17 00:00:00 2001 From: Alison Sabuwala Date: Thu, 21 May 2026 14:57:50 -0400 Subject: [PATCH 58/75] fix: resolve aikido security suggestions --- .../src/main/bundle-spectral-ruleset.ts | 19 ++++++++++++++----- packages/insomnia/src/main/ipc/main.ts | 11 +++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/insomnia/src/main/bundle-spectral-ruleset.ts b/packages/insomnia/src/main/bundle-spectral-ruleset.ts index 05f1a25b4895..2df50c58d02c 100644 --- a/packages/insomnia/src/main/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/main/bundle-spectral-ruleset.ts @@ -22,7 +22,11 @@ type Ruleset = Record & { // - Excessively deep nesting of extends (e.g. A extends B extends C extends D extends E extends F) // - Cycles in extends (e.g. A extends B extends A) // - Extends that point to non-YAML files (e.g. A extends B.txt) -function assertAllowed(absolute: string, visited: Set, depth: number): void { +// - Extends that escape the root directory of the originally-selected ruleset +// (e.g. extends: '../../../etc/secret.yaml'). Without this, a malicious or +// shared ruleset could exfiltrate arbitrary .yaml files on the user's disk +// via the bundled output returned to the renderer. +function assertAllowed(absolute: string, visited: Set, depth: number, rootDir: string): void { if (depth > MAX_EXTENDS_DEPTH) { throw new Error(`"extends" nested too deeply (max ${MAX_EXTENDS_DEPTH}) at ${absolute}`); } @@ -32,6 +36,10 @@ function assertAllowed(absolute: string, visited: Set, depth: number): v if (!ALLOWED_EXTENSIONS.includes(path.extname(absolute).toLowerCase())) { throw new Error(`"extends" target must be a .yaml or .yml file: ${absolute}`); } + const rel = path.relative(rootDir, absolute); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error(`"extends" target must stay within the ruleset's root directory: ${absolute}`); + } } // Reads and parses a ruleset file @@ -62,9 +70,9 @@ function mergeInto(target: Ruleset, source: Ruleset): void { // Recursively resolves local-file "extends" entries, returning a singular ruleset whose "extends" // contains only built-in spectral identifiers and remote URLs. Rules are merged such that the parent overrides // its extends, and among multiple extends entries the later ones override earlier. (ref: https://docs.stoplight.io/docs/spectral/83527ef2dd8c0-extending-rulesets) -async function flattenRuleset(filePath: string, visited: Set, depth: number): Promise { +async function flattenRuleset(filePath: string, visited: Set, depth: number, rootDir: string): Promise { const absolute = path.resolve(filePath); - assertAllowed(absolute, visited, depth); + assertAllowed(absolute, visited, depth, rootDir); const ruleset = await readRuleset(absolute); const baseDir = path.dirname(absolute); @@ -95,7 +103,7 @@ async function flattenRuleset(filePath: string, visited: Set, depth: num continue; } // Local file paths are recursively loaded and flattened. - const childRuleset = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1); + const childRuleset = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1, rootDir); if (childRuleset.extends) { remainingExtends.push(...childRuleset.extends); } @@ -129,6 +137,7 @@ async function flattenRuleset(filePath: string, visited: Set, depth: num } export async function bundleSpectralRuleset(sourcePath: string): Promise { - const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0); + const rootDir = path.dirname(path.resolve(sourcePath)); + const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir); return YAML.stringify(flattenedRuleset); } diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 2885e9d584af..d54ea41ec980 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -368,6 +368,17 @@ export function registerMainHandlers() { //defensive validation for ruleset file before spawning the spectral lint worker if (rulesetPath) { + // Contain rulesetPath within userData/ to prevent the renderer from passing an + // arbitrary path (e.g. /etc/passwd, ~/.ssh/id_rsa) into the file read below. + const userDataDir = path.resolve(app.getPath('userData')); + const resolvedRulesetPath = path.resolve(rulesetPath); + const relativeToUserData = path.relative(userDataDir, resolvedRulesetPath); + const isInsideUserData = relativeToUserData !== '' && !relativeToUserData.startsWith('..') && !path.isAbsolute(relativeToUserData); + if (!isInsideUserData || path.basename(resolvedRulesetPath) !== '.spectral.yaml') { + return { error: 'Invalid ruleset path' }; + } + rulesetPath = resolvedRulesetPath; + try { const rulesetContent = await fs.promises.readFile(rulesetPath, { encoding: 'utf8' }); const validation = validateSpectralRuleset(rulesetContent); From 2a27c4c47ac20afa61c9ef38c3c50f94a1f9e339 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 21 May 2026 17:06:23 -0400 Subject: [PATCH 59/75] chore: attempt to fix SSRF dns resolve --- .../src/commands/lint-specification.ts | 71 ++++++++++- .../src/commands}/safe-ref-resolver.test.ts | 113 +++++++++++++++++- .../insomnia/src/common/safe-ref-resolver.ts | 39 ------ packages/insomnia/src/main/lint-process.mjs | 30 ++++- 4 files changed, 208 insertions(+), 45 deletions(-) rename packages/{insomnia/src/common/__tests__ => insomnia-inso/src/commands}/safe-ref-resolver.test.ts (62%) delete mode 100644 packages/insomnia/src/common/safe-ref-resolver.ts diff --git a/packages/insomnia-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index 896e4e7e1031..2ac87375f268 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -2,16 +2,81 @@ import type { RulesetDefinition } from '@stoplight/spectral-core'; import { Spectral } from '@stoplight/spectral-core'; const { bundleAndLoadRuleset } = require('@stoplight/spectral-ruleset-bundler/with-loader'); +import dns from 'node:dns/promises'; import fs from 'node:fs'; import path from 'node:path'; +import { Resolver } from '@stoplight/spectral-ref-resolver'; import { oas } from '@stoplight/spectral-rulesets'; +import spectralRuntime from '@stoplight/spectral-runtime'; import { DiagnosticSeverity } from '@stoplight/types'; -import { safeRefResolver } from 'insomnia/src/common/safe-ref-resolver'; -import { validateSpectralRuleset } from 'insomnia/src/common/spectral-ruleset-validator'; +import { isPrivateOrLoopbackHost, validateSpectralRuleset } from 'insomnia/src/common/spectral-ruleset-validator'; import { InsoError } from '../errors'; import { logger } from '../logger'; + +// Protect against SSRF attacks in spec $ref resolution. +// Note: This is duplicated in insomnia's main/lint-process.mjs. Remember to mirror changes there as well. +function isSafeRefUrl(href: string): boolean { + let url: URL; + try { + url = new URL(href); + } catch { + return false; + } + if (url.protocol !== 'https:') { + return false; + } + return Boolean(url.hostname) && !isPrivateOrLoopbackHost(url.hostname.toLowerCase()); +} + +// Block hosts that resolve to private/loopback addresses (e.g. *.localtest.me → 127.0.0.1), +// which the static isSafeRefUrl check cannot catch since it only inspects the literal hostname. +// Note: This is duplicated in insomnia's main/lint-process.mjs. Remember to mirror changes there as well. +async function assertResolvesToPublicHost(hostname: string): Promise { + const records = await dns.lookup(hostname, { all: true }); + for (const { address } of records) { + if (isPrivateOrLoopbackHost(address.toLowerCase())) { + throw new Error(`Failed to resolve $ref — host "${hostname}" resolves to a private or loopback address.`); + } + } +} + +// Note: This is duplicated in insomnia's main/lint-process.mjs. Remember to mirror changes there as well. +const safeHttpResolver = { + async resolve(ref: { href: () => string }): Promise { + const href = ref.href(); + if (!isSafeRefUrl(href)) { + throw new Error(`Failed to resolve $ref "${href}" — only https URLs to public hosts are allowed.`); + } + await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); + const response = await fetch(href); + if (!response.ok) { + throw new Error(`Failed to fetch $ref "${href}": ${response.status} ${response.statusText}`); + } + return response.text(); + }, +}; + +// Note: This is duplicated in insomnia's main/lint-process.mjs. Remember to mirror changes there as well. +export const safeRefResolver = new Resolver({ + resolvers: { + http: safeHttpResolver, + https: safeHttpResolver, + }, +}); + +// Hardened fetch for remote ruleset "extends" loading. +// Note: This is duplicated in insomnia's main/lint-process.mjs. Remember to mirror changes there as well. +export async function safeFetch(url: string | URL, init?: RequestInit): Promise { + const href = String(url); + if (!isSafeRefUrl(href)) { + throw new Error(`Failed to fetch "${href}" — only https URLs to public hosts are allowed.`); + } + await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); + return spectralRuntime.fetch(href, init); +} + export const getRuleSetFileFromFolderByFilename = async (filePath: string) => { try { const filesInSpecFolder = await fs.promises.readdir(path.dirname(filePath)); @@ -44,7 +109,7 @@ export async function lintSpecification({ logger.fatal(`Invalid Spectral ruleset: ${validation.error}`); return { isValid: false }; } - ruleset = await bundleAndLoadRuleset(rulesetFileName, { fs }); + ruleset = await bundleAndLoadRuleset(rulesetFileName, { fs, fetch: safeFetch }); } } catch (error) { logger.fatal(error.message); diff --git a/packages/insomnia/src/common/__tests__/safe-ref-resolver.test.ts b/packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts similarity index 62% rename from packages/insomnia/src/common/__tests__/safe-ref-resolver.test.ts rename to packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts index ac7e8f17580f..e6f2121ca858 100644 --- a/packages/insomnia/src/common/__tests__/safe-ref-resolver.test.ts +++ b/packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts @@ -1,6 +1,24 @@ +import dns from 'node:dns/promises'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { safeRefResolver } from '../safe-ref-resolver'; +import { safeFetch, safeRefResolver } from './lint-specification'; + +vi.mock('node:dns/promises', () => ({ default: { lookup: vi.fn() } })); + +// safeFetch delegates to the spectralRuntime.fetch transport captured at import time. +// Expose that transport as a mock so we can assert on it without real network calls. +const { transportFetch } = vi.hoisted(() => ({ transportFetch: vi.fn() })); +vi.mock('@stoplight/spectral-runtime', async importOriginal => { + const actual = (await importOriginal()) as any; + return { ...actual, default: { ...actual.default, fetch: transportFetch } }; +}); + +// Stub dns.lookup({ all: true }) to return the given addresses. +const mockResolvedAddresses = (addresses: string[]) => + vi.mocked(dns.lookup).mockResolvedValue( + addresses.map(address => ({ address, family: address.includes(':') ? 6 : 4 })) as any, + ); function getHttpResolver() { return (safeRefResolver as any).resolvers.http; @@ -11,6 +29,10 @@ describe('safeHttpResolver', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); + transportFetch.mockReset(); + transportFetch.mockResolvedValue({ ok: true } as unknown as Response); + // Default: hosts resolve to a public address unless a test overrides this. + mockResolvedAddresses(['93.184.216.34']); }); afterEach(() => { @@ -237,4 +259,93 @@ describe('safeHttpResolver', () => { expect(resolvers.http).toBe(resolvers.https); }); }); + + describe('DNS resolution checks', () => { + it('rejects a public hostname that resolves to loopback', async () => { + mockResolvedAddresses(['127.0.0.1']); + + await expect( + httpResolver.resolve({ + href: () => 'https://app.localtest.me/schema.yaml', + }), + ).rejects.toThrow('resolves to a private or loopback address'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects a public hostname that resolves to a private IP', async () => { + mockResolvedAddresses(['10.0.0.5']); + + await expect( + httpResolver.resolve({ + href: () => 'https://internal.example.com/schema.yaml', + }), + ).rejects.toThrow('resolves to a private or loopback address'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects when any one of several resolved addresses is private', async () => { + mockResolvedAddresses(['93.184.216.34', '::1']); + + await expect( + httpResolver.resolve({ + href: () => 'https://example.com/schema.yaml', + }), + ).rejects.toThrow('resolves to a private or loopback address'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('allows a hostname that resolves only to public addresses', async () => { + mockResolvedAddresses(['93.184.216.34']); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('openapi: 3.1.0'), + } as unknown as Response); + + await expect( + httpResolver.resolve({ + href: () => 'https://example.com/schema.yaml', + }), + ).resolves.toBe('openapi: 3.1.0'); + }); + }); + + describe('safeFetch (remote ruleset "extends" loading)', () => { + it('rejects non-https URLs', async () => { + await expect(safeFetch('http://example.com/rules.yaml')).rejects.toThrow( + 'only https URLs to public hosts are allowed', + ); + + expect(transportFetch).not.toHaveBeenCalled(); + }); + + it('rejects literal loopback addresses', async () => { + await expect(safeFetch('https://127.0.0.1/rules.yaml')).rejects.toThrow( + 'only https URLs to public hosts are allowed', + ); + + expect(transportFetch).not.toHaveBeenCalled(); + }); + + it('rejects hosts that resolve to loopback', async () => { + mockResolvedAddresses(['127.0.0.1']); + + await expect(safeFetch('https://app.localtest.me/rules.yaml')).rejects.toThrow( + 'resolves to a private or loopback address', + ); + + expect(transportFetch).not.toHaveBeenCalled(); + }); + + it('fetches via the spectral-runtime transport when the host resolves to a public address', async () => { + mockResolvedAddresses(['93.184.216.34']); + + await safeFetch('https://example.com/rules.yaml'); + + expect(transportFetch).toHaveBeenCalledWith('https://example.com/rules.yaml', undefined); + }); + }); + }); diff --git a/packages/insomnia/src/common/safe-ref-resolver.ts b/packages/insomnia/src/common/safe-ref-resolver.ts deleted file mode 100644 index 1ab9a775f64a..000000000000 --- a/packages/insomnia/src/common/safe-ref-resolver.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Resolver } from '@stoplight/spectral-ref-resolver'; - -import { isPrivateOrLoopbackHost } from './spectral-ruleset-validator'; - -// Protect against SSRF attacks in spec $ref resolution. -// Note: This is duplicated in lint-process.mjs. Remember to mirror changes there as well. -function isSafeRefUrl(href: string): boolean { - let url: URL; - try { - url = new URL(href); - } catch { - return false; - } - if (url.protocol !== 'https:') { - return false; - } - return Boolean(url.hostname) && !isPrivateOrLoopbackHost(url.hostname.toLowerCase()); -} - -const safeHttpResolver = { - async resolve(ref: { href: () => string }): Promise { - const href = ref.href(); - if (!isSafeRefUrl(href)) { - throw new Error(`Failed to resolve $ref "${href}" — only https URLs to public hosts are allowed.`); - } - const response = await fetch(href); - if (!response.ok) { - throw new Error(`Failed to fetch $ref "${href}": ${response.status} ${response.statusText}`); - } - return response.text(); - }, -}; - -export const safeRefResolver = new Resolver({ - resolvers: { - http: safeHttpResolver, - https: safeHttpResolver, - }, -}); diff --git a/packages/insomnia/src/main/lint-process.mjs b/packages/insomnia/src/main/lint-process.mjs index 9d30cea232b5..18f757ead766 100644 --- a/packages/insomnia/src/main/lint-process.mjs +++ b/packages/insomnia/src/main/lint-process.mjs @@ -1,5 +1,6 @@ /* eslint-disable no-undef */ console.log('[lint-process] Lint worker started'); +import dns from 'node:dns/promises'; import fs from 'node:fs'; import Spectral from '@stoplight/spectral-core'; @@ -13,6 +14,7 @@ process.on('uncaughtException', error => { console.error(error); }); +// Note: This is duplicated in inso's lint-specification.ts. Remember to mirror changes there as well. function isPrivateOrLoopbackHost(hostname) { if (hostname === 'localhost' || hostname.endsWith('.localhost')) { return true; @@ -24,6 +26,7 @@ function isPrivateOrLoopbackHost(hostname) { return ipaddr.process(host).range() !== 'unicast'; } +// Note: This is duplicated in inso's lint-specification.ts. Remember to mirror changes there as well. function isSafeRefUrl(href) { let url; try { @@ -37,12 +40,24 @@ function isSafeRefUrl(href) { return Boolean(url.hostname) && !isPrivateOrLoopbackHost(url.hostname.toLowerCase()); } +// Note: This is duplicated in inso's lint-specification.ts. Remember to mirror changes there as well. +async function assertResolvesToPublicHost(hostname) { + const records = await dns.lookup(hostname, { all: true }); + for (const { address } of records) { + if (isPrivateOrLoopbackHost(address.toLowerCase())) { + throw new Error(`Refused to resolve $ref — host "${hostname}" resolves to a private or loopback address.`); + } + } +} + +// Note: This is duplicated in inso's lint-specification.ts. Remember to mirror changes there as well. const safeHttpResolver = { async resolve(ref) { const href = ref.href(); if (!isSafeRefUrl(href)) { throw new Error(`Refused to resolve $ref "${href}" — only https URLs to public hosts are allowed.`); } + await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); const response = await fetch(href); if (!response.ok) { throw new Error(`Failed to fetch $ref "${href}": ${response.status} ${response.statusText}`); @@ -51,6 +66,7 @@ const safeHttpResolver = { }, }; +// Note: This is duplicated in inso's lint-specification.ts. Remember to mirror changes there as well. const safeResolver = new Resolver({ resolvers: { http: safeHttpResolver, @@ -58,6 +74,17 @@ const safeResolver = new Resolver({ }, }); +// Hardened fetch for remote ruleset "extends" loading. +// Note: This is duplicated in inso's lint-specification.ts. Remember to mirror changes there as well. +async function safeFetch(url, init) { + const href = String(url); + if (!isSafeRefUrl(href)) { + throw new Error(`Refused to fetch "${href}" — only https URLs to public hosts are allowed.`); + } + await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); + return spectralRuntime.fetch(href, init); +} + process.parentPort.on('message', async ({ data: { documentContent, rulesetPath } }) => { let hasValidCustomRuleset = false; if (rulesetPath) { @@ -68,8 +95,7 @@ process.parentPort.on('message', async ({ data: { documentContent, rulesetPath } } try { const spectral = new Spectral.Spectral({ resolver: safeResolver }); - const { fetch } = spectralRuntime; - const ruleset = hasValidCustomRuleset ? await bundleAndLoadRuleset(rulesetPath, { fs, fetch }) : oas; + const ruleset = hasValidCustomRuleset ? await bundleAndLoadRuleset(rulesetPath, { fs, fetch: safeFetch }) : oas; spectral.setRuleset(ruleset); console.log('[lint-process] Ruleset loaded:', rulesetPath || 'default OAS ruleset'); const diagnostics = await spectral.run(documentContent); From 2e13084b0565b7022620e3fa9307dab348bf1912 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 21 May 2026 23:51:58 -0400 Subject: [PATCH 60/75] chore: update error messages --- packages/insomnia-inso/src/commands/lint-specification.ts | 8 ++++---- packages/insomnia/src/main/lint-process.mjs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/insomnia-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index 2ac87375f268..72c5bfe6471a 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -37,7 +37,7 @@ async function assertResolvesToPublicHost(hostname: string): Promise { const records = await dns.lookup(hostname, { all: true }); for (const { address } of records) { if (isPrivateOrLoopbackHost(address.toLowerCase())) { - throw new Error(`Failed to resolve $ref — host "${hostname}" resolves to a private or loopback address.`); + throw new Error(`Failed to resolve host. "${hostname}" resolves to a private or loopback address.`); } } } @@ -47,12 +47,12 @@ const safeHttpResolver = { async resolve(ref: { href: () => string }): Promise { const href = ref.href(); if (!isSafeRefUrl(href)) { - throw new Error(`Failed to resolve $ref "${href}" — only https URLs to public hosts are allowed.`); + throw new Error(`Failed to resolve "${href}". Only https URLs to public hosts are allowed.`); } await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); const response = await fetch(href); if (!response.ok) { - throw new Error(`Failed to fetch $ref "${href}": ${response.status} ${response.statusText}`); + throw new Error(`Failed to fetch "${href}": ${response.status} ${response.statusText}`); } return response.text(); }, @@ -71,7 +71,7 @@ export const safeRefResolver = new Resolver({ export async function safeFetch(url: string | URL, init?: RequestInit): Promise { const href = String(url); if (!isSafeRefUrl(href)) { - throw new Error(`Failed to fetch "${href}" — only https URLs to public hosts are allowed.`); + throw new Error(`Failed to fetch "${href}". Only https URLs to public hosts are allowed.`); } await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); return spectralRuntime.fetch(href, init); diff --git a/packages/insomnia/src/main/lint-process.mjs b/packages/insomnia/src/main/lint-process.mjs index 18f757ead766..d4ff358b1f20 100644 --- a/packages/insomnia/src/main/lint-process.mjs +++ b/packages/insomnia/src/main/lint-process.mjs @@ -45,7 +45,7 @@ async function assertResolvesToPublicHost(hostname) { const records = await dns.lookup(hostname, { all: true }); for (const { address } of records) { if (isPrivateOrLoopbackHost(address.toLowerCase())) { - throw new Error(`Refused to resolve $ref — host "${hostname}" resolves to a private or loopback address.`); + throw new Error(`Failed to resolve host. "${hostname}" resolves to a private or loopback address.`); } } } @@ -55,12 +55,12 @@ const safeHttpResolver = { async resolve(ref) { const href = ref.href(); if (!isSafeRefUrl(href)) { - throw new Error(`Refused to resolve $ref "${href}" — only https URLs to public hosts are allowed.`); + throw new Error(`Failed to fetch "${href}". Only https URLs to public hosts are allowed.`); } await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); const response = await fetch(href); if (!response.ok) { - throw new Error(`Failed to fetch $ref "${href}": ${response.status} ${response.statusText}`); + throw new Error(`Failed to fetch "${href}": ${response.status} ${response.statusText}`); } return response.text(); }, @@ -79,7 +79,7 @@ const safeResolver = new Resolver({ async function safeFetch(url, init) { const href = String(url); if (!isSafeRefUrl(href)) { - throw new Error(`Refused to fetch "${href}" — only https URLs to public hosts are allowed.`); + throw new Error(`Failed to fetch "${href}". Only https URLs to public hosts are allowed.`); } await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); return spectralRuntime.fetch(href, init); From dd06a304dc36ce01c08ac77d25d8c18b91b1f7d1 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Thu, 21 May 2026 23:53:58 -0400 Subject: [PATCH 61/75] test: update tests --- .../__tests__/bundle-spectral-ruleset.test.ts | 187 +++++++++++++++--- 1 file changed, 165 insertions(+), 22 deletions(-) diff --git a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts index a1caff9f458d..ba10176b0108 100644 --- a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts +++ b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts @@ -1,8 +1,8 @@ import path from 'node:path'; -import { beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest'; -// Mock fs so no real files are needed. +// Mock fs and dns so no real files or DNS lookups are needed. vi.mock('node:fs', () => ({ default: { promises: { @@ -10,7 +10,13 @@ vi.mock('node:fs', () => ({ }, }, })); +vi.mock('node:dns/promises', () => ({ + default: { + lookup: vi.fn(), + }, +})); +import dns from 'node:dns/promises'; import fs from 'node:fs'; import { bundleSpectralRuleset } from '../bundle-spectral-ruleset'; @@ -22,8 +28,41 @@ function abs(fakePath: string) { return path.resolve(fakePath); } +// Stub dns.lookup({ all: true }) to return the given addresses. +function mockResolvedAddresses(addresses: string[]) { + vi.mocked(dns.lookup).mockResolvedValue( + addresses.map(address => ({ address, family: address.includes(':') ? 6 : 4 })) as any, + ); +} + +// Builds a fake fetch Response carrying a remote ruleset body. +function rulesetResponse(body: string, init?: { ok?: boolean; status?: number; statusText?: string }) { + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + statusText: init?.statusText ?? 'OK', + text: async () => body, + } as unknown as Response; +} + +const VALID_RULE = ` + remote-rule: + given: "$.paths" + severity: warn + then: + function: truthy +`; + beforeEach(() => { mockReadFile.mockReset(); + vi.mocked(dns.lookup).mockReset(); + // Default: any hostname resolves to a public address unless a test overrides this. + mockResolvedAddresses(['93.184.216.34']); + vi.stubGlobal('fetch', vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); describe('bundleSpectralRuleset', () => { @@ -44,25 +83,6 @@ rules: expect(result).not.toContain('extends'); }); - it('passes through remote URL extends unchanged', async () => { - mockReadFile.mockResolvedValueOnce( - ` -extends: - - "https://example.com/ruleset.yaml" -rules: - my-rule: - given: "$.info" - severity: warn - then: - function: truthy -`, - ); - - const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); - expect(result).toContain('https://example.com/ruleset.yaml'); - expect(result).toContain('my-rule'); - }); - it('passes through spectral built-in identifier extends unchanged', async () => { mockReadFile.mockResolvedValueOnce( ` @@ -214,7 +234,7 @@ extends: await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('must be an object at the top level'); }); - it('deduplicates remote extends from multiple child files', async () => { + it('deduplicates spectral identifiers from multiple child files', async () => { const parentPath = '/fake/parent.yaml'; const childAPath = '/fake/childA.yaml'; const childBPath = '/fake/childB.yaml'; @@ -236,4 +256,127 @@ extends: const matches = (result.match(/spectral:oas/g) ?? []).length; expect(matches).toBe(1); }); + + describe('remote URL extends', () => { + it('fetches, validates and flattens a remote ruleset into the output', async () => { + mockReadFile.mockResolvedValueOnce( + ` +extends: + - "https://example.com/remote.yaml" +rules: + local-rule: + given: "$.info" + severity: warn + then: + function: truthy +`, + ); + vi.mocked(fetch).mockResolvedValue(rulesetResponse(`rules:${VALID_RULE}`)); + + const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); + expect(result).toContain('local-rule'); + expect(result).toContain('remote-rule'); + // The remote URL is resolved away — nothing remote is left to fetch at lint time. + expect(result).not.toContain('https://example.com'); + }); + + it('rejects a remote ruleset that declares custom functions (RCE vector)', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/exec.yaml"\n`); + vi.mocked(fetch).mockResolvedValue( + rulesetResponse( + ` +functions: + - exec +rules: + env-check: + given: "$" + then: + function: exec +`, + ), + ); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('is not allowed'); + }); + + it('carries through a spectral identifier declared by a remote ruleset', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/remote.yaml"\n`); + vi.mocked(fetch).mockResolvedValue( + rulesetResponse(`extends:\n - "spectral:oas"\nrules:${VALID_RULE}`), + ); + + const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); + expect(result).toContain('spectral:oas'); + expect(result).toContain('remote-rule'); + }); + + it('recursively flattens nested remote extends', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/a.yaml"\n`); + vi.mocked(fetch).mockImplementation(async (input: any) => { + const href = String(input); + if (href === 'https://example.com/a.yaml') { + return rulesetResponse( + ` +extends: + - "./b.yaml" +rules: + a-rule: + given: "$.info" + severity: warn + then: + function: truthy +`, + ); + } + if (href === 'https://example.com/b.yaml') { + return rulesetResponse( + ` +rules: + b-rule: + given: "$.paths" + severity: warn + then: + function: truthy +`, + ); + } + throw new Error(`Unexpected fetch call: ${href}`); + }); + + const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); + expect(result).toContain('a-rule'); + expect(result).toContain('b-rule'); + }); + + it('rejects a non-https remote extends without fetching', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "http://example.com/remote.yaml"\n`); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('must use https'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects a remote extends pointing at a loopback host without fetching', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://localhost/remote.yaml"\n`); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('disallowed host'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects a remote host that resolves to a loopback address without fetching', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://app.localtest.me/remote.yaml"\n`); + mockResolvedAddresses(['127.0.0.1']); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow( + 'private or loopback address', + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('throws when a remote ruleset cannot be fetched', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/missing.yaml"\n`); + vi.mocked(fetch).mockResolvedValue(rulesetResponse('', { ok: false, status: 404, statusText: 'Not Found' })); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('Failed to fetch remote'); + }); + }); }); From c2e4657ef127c6a15755e270e10473ecee95e232 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 22 May 2026 16:02:01 -0400 Subject: [PATCH 62/75] chore: move helpers to common so inso can use them --- eslint.config.mjs | 1 + .../src/commands/lint-specification.ts | 3 +- .../bundle-spectral-ruleset.ts | 7 ++- packages/insomnia/src/common/private-host.ts | 18 +++++++ .../src/common/spectral-ruleset-validator.ts | 51 +++---------------- packages/insomnia/src/main/ipc/main.ts | 5 +- 6 files changed, 37 insertions(+), 48 deletions(-) rename packages/insomnia/src/{main => common}/bundle-spectral-ruleset.ts (98%) create mode 100644 packages/insomnia/src/common/private-host.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index b605817f4d88..a8fa212166d6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,7 @@ const rendererNodeRestrictionIgnores = [ ...rendererNodeMigrationOffenders, 'packages/insomnia/src/common/__tests__/**/*.{ts,tsx}', 'packages/insomnia/src/common/send-request.ts', + 'packages/insomnia/src/common/bundle-spectral-ruleset.ts', ]; export default defineConfig([ diff --git a/packages/insomnia-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index 72c5bfe6471a..3035f42d4672 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -10,7 +10,8 @@ import { Resolver } from '@stoplight/spectral-ref-resolver'; import { oas } from '@stoplight/spectral-rulesets'; import spectralRuntime from '@stoplight/spectral-runtime'; import { DiagnosticSeverity } from '@stoplight/types'; -import { isPrivateOrLoopbackHost, validateSpectralRuleset } from 'insomnia/src/common/spectral-ruleset-validator'; +import { isPrivateOrLoopbackHost } from 'insomnia/src/common/private-host'; +import { validateSpectralRuleset } from 'insomnia/src/common/spectral-ruleset-validator'; import { InsoError } from '../errors'; import { logger } from '../logger'; diff --git a/packages/insomnia/src/main/bundle-spectral-ruleset.ts b/packages/insomnia/src/common/bundle-spectral-ruleset.ts similarity index 98% rename from packages/insomnia/src/main/bundle-spectral-ruleset.ts rename to packages/insomnia/src/common/bundle-spectral-ruleset.ts index 2df50c58d02c..aa8d2802d446 100644 --- a/packages/insomnia/src/main/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/common/bundle-spectral-ruleset.ts @@ -70,7 +70,12 @@ function mergeInto(target: Ruleset, source: Ruleset): void { // Recursively resolves local-file "extends" entries, returning a singular ruleset whose "extends" // contains only built-in spectral identifiers and remote URLs. Rules are merged such that the parent overrides // its extends, and among multiple extends entries the later ones override earlier. (ref: https://docs.stoplight.io/docs/spectral/83527ef2dd8c0-extending-rulesets) -async function flattenRuleset(filePath: string, visited: Set, depth: number, rootDir: string): Promise { +async function flattenRuleset( + filePath: string, + visited: Set, + depth: number, + rootDir: string, +): Promise { const absolute = path.resolve(filePath); assertAllowed(absolute, visited, depth, rootDir); diff --git a/packages/insomnia/src/common/private-host.ts b/packages/insomnia/src/common/private-host.ts new file mode 100644 index 000000000000..3c01ef3ba22a --- /dev/null +++ b/packages/insomnia/src/common/private-host.ts @@ -0,0 +1,18 @@ +import ipaddr from 'ipaddr.js'; + +// Classifies a hostname or IP literal as private/loopback. Used as an SSRF guard when deciding +// whether a remote URL is safe to fetch. This is a synchronous check on the literal value only; +// callers that must also defend against DNS rebinding resolve the host and re-check the resulting +// addresses with this same function (see common/bundle-spectral-ruleset.ts). +// Note: duplicated in the Spectral lint worker (main/lint-process.mjs), which is a plain .mjs +// module and cannot import this file. If this logic changes, mirror it there. +export function isPrivateOrLoopbackHost(hostname: string): boolean { + if (hostname === 'localhost' || hostname.endsWith('.localhost')) { + return true; + } + const host = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; + if (!ipaddr.isValid(host)) { + return false; + } + return ipaddr.process(host).range() !== 'unicast'; +} diff --git a/packages/insomnia/src/common/spectral-ruleset-validator.ts b/packages/insomnia/src/common/spectral-ruleset-validator.ts index c361373f335b..05924449db54 100644 --- a/packages/insomnia/src/common/spectral-ruleset-validator.ts +++ b/packages/insomnia/src/common/spectral-ruleset-validator.ts @@ -1,4 +1,3 @@ -import ipaddr from 'ipaddr.js'; import YAML from 'yaml'; export type SpectralRulesetValidationResult = { isValid: true } | { isValid: false; error: string }; @@ -8,7 +7,7 @@ export type SpectralRulesetValidationResult = { isValid: true } | { isValid: fal const ALLOWED_TOP_LEVEL_PROPERTIES = ['rules', 'extends']; // These are the only built-in Spectral identities we allow in the extends property. -const ALLOWED_EXTENDS_IDENTIFIERS = ['spectral:oas', 'spectral:asyncapi', 'spectral:arazzo']; +export const ALLOWED_EXTENDS_IDENTIFIERS = ['spectral:oas', 'spectral:asyncapi', 'spectral:arazzo']; // These are the only built-in Spectral functions we allow in ruleset "then" clauses const ALLOWED_BUILTIN_FUNCTIONS = [ @@ -31,9 +30,6 @@ const ALLOWED_BUILTIN_FUNCTIONS = [ // For security reasons we do not allow rulesets to contain certain tokens that could be used for JavaScript prototype pollution when used in certain Spectral properties (e.g. "field"). const PROTOTYPE_POLLUTION_TOKENS = ['__proto__', 'prototype', 'constructor']; -// For security reasons we only allow extends URLs with certain safe schemes and hosts. -const SAFE_URL_SCHEMES = ['https:']; - // Check if path is absolute file path (e.g. /foo/bar.yaml, C:\foo\bar.yaml, \\server\share\file.yaml) function isAbsoluteFilePath(value: string): boolean { return value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value); @@ -51,27 +47,14 @@ export function toArray(value: T | T[] | undefined): T[] { return Array.isArray(value) ? value : [value]; // handles both array and single value cases for extends in a given ruleset } -// Given our support for remote extends, we need to protect against the possibility of SSRF attacks. We block any hostname that is a loopback or private network address, as well as "localhost". -// Note: The logic in this function is duplicated in the main process's Spectral linting handler (lint-process.mjs) to protect against SSRF via $ref resolution in spec files. -// If logic is changed here, mirror it there. -export function isPrivateOrLoopbackHost(hostname: string): boolean { - if (hostname === 'localhost' || hostname.endsWith('.localhost')) { - return true; - } - const host = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; - if (!ipaddr.isValid(host)) { - return false; - } - return ipaddr.process(host).range() !== 'unicast'; -} - function containsPrototypePollution(value: string): boolean { return PROTOTYPE_POLLUTION_TOKENS.some(token => value.includes(token)); } +// Guards a rule's "documentationUrl" function isSafeUrl(value: string): boolean { try { - return SAFE_URL_SCHEMES.includes(new URL(value).protocol); + return new URL(value).protocol === 'https:'; } catch { return false; } @@ -98,37 +81,17 @@ function validateThen(ruleName: string, then: Record): string | return null; } +// Structural check only: each "extends" entry must be a plain string. Whether an entry is a valid +// identifier, local path, or remote URL — and whether a remote URL is safe to fetch (SSRF) — is +// decided when the ruleset is bundled (see common/bundle-spectral-ruleset.ts). function validateExtends(value: unknown): string | null { for (const entry of toArray(value)) { if (Array.isArray(entry)) { return `"extends" entry ${JSON.stringify(entry)} uses tuple format (e.g. [path, severity]) which is not supported. Use a plain string instead.`; } - - const path = entry; - if (typeof path !== 'string') { + if (typeof entry !== 'string') { return '"extends" entries must be strings.'; } - - // allow built in identifier and local file paths without further validation - if (ALLOWED_EXTENDS_IDENTIFIERS.includes(path) || isLocalFilePath(path)) { - continue; - } - - // validate remote URLs - let url: URL; - try { - url = new URL(path); - } catch { - return `"extends" entry "${path}" is not a recognized Spectral identifier or a valid URL.`; - } - - if (!SAFE_URL_SCHEMES.includes(url.protocol)) { - return `"extends" entry "${path}" must use https (got "${url.protocol}").`; - } - - if (!url.hostname || isPrivateOrLoopbackHost(url.hostname.toLocaleLowerCase())) { - return `"extends" entry "${path}" targets a disallowed host`; - } } return null; } diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index d54ea41ec980..0dfa973c03f5 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -18,12 +18,12 @@ import { import type { UtilityProcess } from 'electron/main'; import iconv from 'iconv-lite'; +import { bundleSpectralRuleset } from '~/common/bundle-spectral-ruleset'; import { AI_PLUGIN_NAME } from '~/common/constants'; import { cannotAccessPathError } from '~/common/misc'; import { validateSpectralRuleset } from '~/common/spectral-ruleset-validator'; import type { AuthTypeOAuth2, OAuth2Token, RequestHeader, Services } from '~/insomnia-data'; import { services } from '~/insomnia-data'; -import { bundleSpectralRuleset } from '~/main/bundle-spectral-ruleset'; import { initializeWorkspaceBackendProject, syncNewWorkspaceIfNeeded } from '~/main/cloud-sync/initialization'; import type { SyncBridgeAPI } from '~/main/cloud-sync/ipc'; import { convert } from '~/main/importers/convert'; @@ -373,7 +373,8 @@ export function registerMainHandlers() { const userDataDir = path.resolve(app.getPath('userData')); const resolvedRulesetPath = path.resolve(rulesetPath); const relativeToUserData = path.relative(userDataDir, resolvedRulesetPath); - const isInsideUserData = relativeToUserData !== '' && !relativeToUserData.startsWith('..') && !path.isAbsolute(relativeToUserData); + const isInsideUserData = + relativeToUserData !== '' && !relativeToUserData.startsWith('..') && !path.isAbsolute(relativeToUserData); if (!isInsideUserData || path.basename(resolvedRulesetPath) !== '.spectral.yaml') { return { error: 'Invalid ruleset path' }; } From 9b46af6385b520c58f614b39d212605a44d1aded Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Fri, 22 May 2026 16:56:34 -0400 Subject: [PATCH 63/75] feat: adds functionality to flatten remote urls in spectral extends --- .../src/commands/lint-specification.ts | 32 +-- .../src/common/bundle-spectral-ruleset.ts | 214 +++++++++++++----- .../src/common/spectral-ruleset-validator.ts | 4 +- packages/insomnia/src/main/ipc/main.ts | 2 +- packages/insomnia/src/main/lint-process.mjs | 13 +- 5 files changed, 181 insertions(+), 84 deletions(-) diff --git a/packages/insomnia-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index 3035f42d4672..b2330dddad00 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -4,12 +4,13 @@ import { Spectral } from '@stoplight/spectral-core'; const { bundleAndLoadRuleset } = require('@stoplight/spectral-ruleset-bundler/with-loader'); import dns from 'node:dns/promises'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { Resolver } from '@stoplight/spectral-ref-resolver'; import { oas } from '@stoplight/spectral-rulesets'; -import spectralRuntime from '@stoplight/spectral-runtime'; import { DiagnosticSeverity } from '@stoplight/types'; +import { bundleSpectralRuleset } from 'insomnia/src/common/bundle-spectral-ruleset'; import { isPrivateOrLoopbackHost } from 'insomnia/src/common/private-host'; import { validateSpectralRuleset } from 'insomnia/src/common/spectral-ruleset-validator'; @@ -59,7 +60,6 @@ const safeHttpResolver = { }, }; -// Note: This is duplicated in insomnia's main/lint-process.mjs. Remember to mirror changes there as well. export const safeRefResolver = new Resolver({ resolvers: { http: safeHttpResolver, @@ -67,16 +67,6 @@ export const safeRefResolver = new Resolver({ }, }); -// Hardened fetch for remote ruleset "extends" loading. -// Note: This is duplicated in insomnia's main/lint-process.mjs. Remember to mirror changes there as well. -export async function safeFetch(url: string | URL, init?: RequestInit): Promise { - const href = String(url); - if (!isSafeRefUrl(href)) { - throw new Error(`Failed to fetch "${href}". Only https URLs to public hosts are allowed.`); - } - await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); - return spectralRuntime.fetch(href, init); -} export const getRuleSetFileFromFolderByFilename = async (filePath: string) => { try { @@ -104,13 +94,25 @@ export async function lintSpecification({ let ruleset = oas; try { if (rulesetFileName) { - const rulesetContent = await fs.promises.readFile(rulesetFileName, 'utf8'); - const validation = validateSpectralRuleset(rulesetContent); + // Flatten all local and remote extends through our SSRF-safe code before any + // content reaches Spectral. This ensures remote "functions" and other disallowed + // keys are caught by validateSpectralRuleset before execution. + const bundledContent = await bundleSpectralRuleset(rulesetFileName, { resolveRemote: true }); + const validation = validateSpectralRuleset(bundledContent); if (!validation.isValid) { logger.fatal(`Invalid Spectral ruleset: ${validation.error}`); return { isValid: false }; } - ruleset = await bundleAndLoadRuleset(rulesetFileName, { fs, fetch: safeFetch }); + // bundleAndLoadRuleset requires a file path, so write the pre-validated bundle to + // a uniquely-named temp directory and clean it up immediately after loading. + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'spectral-')); + try { + const tempPath = path.join(tempDir, '.spectral.yaml'); + await fs.promises.writeFile(tempPath, bundledContent, { encoding: 'utf8' }); + ruleset = await bundleAndLoadRuleset(tempPath, { fs }); + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } } } catch (error) { logger.fatal(error.message); diff --git a/packages/insomnia/src/common/bundle-spectral-ruleset.ts b/packages/insomnia/src/common/bundle-spectral-ruleset.ts index aa8d2802d446..3c5745610f3e 100644 --- a/packages/insomnia/src/common/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/common/bundle-spectral-ruleset.ts @@ -1,31 +1,35 @@ +import dns from 'node:dns/promises'; import fs from 'node:fs'; import path from 'node:path'; import YAML from 'yaml'; -import { isLocalFilePath, toArray } from '~/common/spectral-ruleset-validator'; +import { isPrivateOrLoopbackHost } from './private-host'; +import { ALLOWED_EXTENDS_IDENTIFIERS, isLocalFilePath, toArray } from './spectral-ruleset-validator'; // Maximum depth of nested extends to follow when bundling. Guards against deep nesting and cycles. const MAX_EXTENDS_DEPTH = 5; const ALLOWED_EXTENSIONS = ['.yaml', '.yml']; -// `extends` is the only key we touch by name in this file: local paths get resolved away and -// remote URLs and spectral identifier entries are carried through. Every other top-level key — 'rules', 'aliases', -// 'parserOptions', anything we may add later — flows through the generic 'mergeInto' step. -// The validator that runs after bundling (ref: spectral-ruleset-validator.ts) decides which keys are actually allowed and all of the constraints. +// Abort a remote ruleset fetch that takes longer than this. +const REMOTE_FETCH_TIMEOUT_MS = 10_000; + +// `extends` is the only key this file interprets by name. Local-file and remote-URL extends are +// resolved away (flattened in); only built-in spectral identifiers (spectral:oas, …) are carried +// through. Every other top-level key — 'rules', 'aliases', anything added later — flows through the +// generic 'mergeInto' step. This file only flattens: content validation (validateSpectralRuleset — +// rejecting custom "functions" etc.) is a separate concern applied to the fully-flattened output. type Ruleset = Record & { extends?: string[]; }; -// Prevents the below -// - Excessively deep nesting of extends (e.g. A extends B extends C extends D extends E extends F) -// - Cycles in extends (e.g. A extends B extends A) -// - Extends that point to non-YAML files (e.g. A extends B.txt) +// Guards for local-file extends: +// - Excessively deep nesting / cycles +// - Extends that point to non-YAML files // - Extends that escape the root directory of the originally-selected ruleset -// (e.g. extends: '../../../etc/secret.yaml'). Without this, a malicious or -// shared ruleset could exfiltrate arbitrary .yaml files on the user's disk -// via the bundled output returned to the renderer. +// (e.g. extends: '../../../etc/secret.yaml'), which could exfiltrate arbitrary +// .yaml files on the user's disk via the bundled output returned to the renderer. function assertAllowed(absolute: string, visited: Set, depth: number, rootDir: string): void { if (depth > MAX_EXTENDS_DEPTH) { throw new Error(`"extends" nested too deeply (max ${MAX_EXTENDS_DEPTH}) at ${absolute}`); @@ -42,7 +46,7 @@ function assertAllowed(absolute: string, visited: Set, depth: number, ro } } -// Reads and parses a ruleset file +// Reads and parses a local ruleset file. async function readRuleset(absolute: string): Promise { const raw = await fs.promises.readFile(absolute, { encoding: 'utf8' }); const parsed = YAML.parse(raw); @@ -58,7 +62,7 @@ function isPlainObject(value: unknown): value is Record { // One level deep merge for top-level spectral keys. // Object values are merged shallowly (e.g. rules) with "source" taking precedence over "target". -// Non-object values (e.g. extends ) are overridden by "source" if they exist, otherwise left as-is from "target". +// Non-object values are overridden by "source" if present, otherwise left as-is from "target". function mergeInto(target: Ruleset, source: Ruleset): void { for (const key of Object.keys(source)) { const sourceVal = source[key]; @@ -67,14 +71,116 @@ function mergeInto(target: Ruleset, source: Ruleset): void { } } -// Recursively resolves local-file "extends" entries, returning a singular ruleset whose "extends" -// contains only built-in spectral identifiers and remote URLs. Rules are merged such that the parent overrides -// its extends, and among multiple extends entries the later ones override earlier. (ref: https://docs.stoplight.io/docs/spectral/83527ef2dd8c0-extending-rulesets) +// Parses an "extends" entry into a URL. `base` resolves relative entries within a remote ruleset. +function parseRemoteExtendsUrl(entry: string, base?: URL): URL { + try { + return new URL(entry, base); + } catch { + throw new Error(`"extends" entry "${entry}" is not a valid spectral identifier, local path, or URL.`); + } +} + +// SSRF guard: a remote "extends" URL must be https and resolve only to public addresses. +async function assertSafeRemoteUrl(url: URL): Promise { + if (url.protocol !== 'https:') { + throw new Error(`Remote "extends" URL must use https: ${url.href}`); + } + const hostname = url.hostname.toLowerCase(); + if (!hostname || isPrivateOrLoopbackHost(hostname)) { + throw new Error(`Remote "extends" URL targets a disallowed host: ${url.href}`); + } + // The literal hostname can still resolve to an internal address (e.g. *.localtest.me → 127.0.0.1). + const records = await dns.lookup(hostname, { all: true }); + for (const { address } of records) { + if (isPrivateOrLoopbackHost(address.toLowerCase())) { + throw new Error(`Failed to resolve host. "${url.href}" resolves to a private or loopback address.`); + } + } +} + +// Fetches a remote ruleset over the network (SSRF-guarded) and parses it. Content validation — +// rejecting custom "functions" and other disallowed keys — is intentionally NOT done here: +// bundling only flattens, and mergeInto carries every key into the output, so the single +// downstream validation pass still catches anything a remote ruleset tried to introduce. +async function readRemoteRuleset(url: URL): Promise { + await assertSafeRemoteUrl(url); + + let response: Response; + try { + // redirect: 'error' — a redirect could send us to an unvalidated (internal) host. + response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS) }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to fetch remote "extends" ruleset "${url.href}": ${reason}`); + } + if (!response.ok) { + throw new Error( + `Failed to fetch remote "extends" ruleset "${url.href}": ${response.status} ${response.statusText}`, + ); + } + + const parsed = YAML.parse(await response.text()); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Remote "extends" ruleset "${url.href}" must be an object at the top level.`); + } + return parsed as Ruleset; +} + +// Recursively resolves a remote-URL "extends" into a flattened ruleset. Within a remote ruleset +// every non-identifier "extends" entry is itself a URL (relative entries resolve against `url`) — +// there are no local files on the user's disk here, so this path only ever fetches over the network. +async function flattenRemoteRuleset(url: URL, visited: Set, depth: number): Promise { + if (depth > MAX_EXTENDS_DEPTH) { + throw new Error(`"extends" nested too deeply (max ${MAX_EXTENDS_DEPTH}) at ${url.href}`); + } + if (visited.has(url.href)) { + throw new Error(`"extends" cycle detected at ${url.href}`); + } + + const ruleset = await readRemoteRuleset(url); + const nextVisited = new Set(visited).add(url.href); + + const flattenedRuleset: Ruleset = {}; + const remainingExtends: string[] = []; + + for (const entry of toArray(ruleset.extends)) { + if (Array.isArray(entry)) { + throw new TypeError( + `Failed to process "extends" entry ${JSON.stringify(entry)}: tuple format (e.g. [path, severity]) is not supported. Use a plain string instead.`, + ); + } + // Built-in spectral identifiers are resolved locally by Spectral; carry them through. + if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry)) { + remainingExtends.push(entry); + continue; + } + const childRuleset = await flattenRemoteRuleset(parseRemoteExtendsUrl(entry, url), nextVisited, depth + 1); + if (childRuleset.extends) { + remainingExtends.push(...childRuleset.extends); + } + mergeInto(flattenedRuleset, childRuleset); + } + + const parentOverrides: Ruleset = { ...ruleset }; + delete parentOverrides.extends; + mergeInto(flattenedRuleset, parentOverrides); + + const uniqueExtends = [...new Set(remainingExtends)]; + delete flattenedRuleset.extends; + return uniqueExtends.length > 0 ? { extends: uniqueExtends, ...flattenedRuleset } : flattenedRuleset; +} + +// Recursively resolves "extends" entries into a single ruleset. Local-file extends are always +// flattened. Remote-URL extends are flattened only when `resolveRemote` is true — otherwise they +// are left untouched in "extends" to be resolved later. Rules are merged such that the parent +// overrides its extends, and among multiple extends entries later ones override earlier. +// (ref: https://docs.stoplight.io/docs/spectral/83527ef2dd8c0-extending-rulesets) async function flattenRuleset( filePath: string, visited: Set, depth: number, rootDir: string, + resolveRemote: boolean, ): Promise { const absolute = path.resolve(filePath); assertAllowed(absolute, visited, depth, rootDir); @@ -83,66 +189,68 @@ async function flattenRuleset( const baseDir = path.dirname(absolute); const nextVisited = new Set(visited).add(absolute); - const flattenedRuleset: Ruleset = {}; // Flattended ruleset containing all rules within this file path and its local extends - const remainingExtends: string[] = []; // non local file paths — built-in identifiers and remote URLs - - // Process everything listed in "extends". - // - // For local file paths: - // - recursively load and flatten them - // - merge their rules into the current result - // - // For non-local entries (built in identifiers / remote URLs): - // - keep them in a separate list - // - include them later in the final "extends" array + const flattenedRuleset: Ruleset = {}; // Flattened ruleset containing all rules within this file and its extends. + const remainingExtends: string[] = []; // built-in spectral identifiers, plus remote URLs when resolveRemote is false. + for (const entry of toArray(ruleset.extends)) { if (Array.isArray(entry)) { throw new TypeError( `Failed to process "extends" entry ${JSON.stringify(entry)}: tuple format (e.g. [path, severity]) is not supported. Use a plain string instead.`, ); } - // If this entry is NOT a local file path, - // keep it as-is for the final output. - if (!isLocalFilePath(entry)) { + // Built-in spectral identifiers (spectral:oas, …) — Spectral resolves these locally; carry through. + if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry)) { remainingExtends.push(entry); continue; } + // Remote URL extends. + if (!isLocalFilePath(entry)) { + if (!resolveRemote) { + // Leave remote URLs in place — they are flattened later, at lint time, against the + // current remote ruleset (which may have changed since the ruleset was selected). + remainingExtends.push(entry); + continue; + } + const remoteRuleset = await flattenRemoteRuleset(parseRemoteExtendsUrl(entry), nextVisited, depth + 1); + if (remoteRuleset.extends) { + remainingExtends.push(...remoteRuleset.extends); + } + mergeInto(flattenedRuleset, remoteRuleset); + continue; + } // Local file paths are recursively loaded and flattened. - const childRuleset = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1, rootDir); + const childRuleset = await flattenRuleset( + path.resolve(baseDir, entry), + nextVisited, + depth + 1, + rootDir, + resolveRemote, + ); if (childRuleset.extends) { remainingExtends.push(...childRuleset.extends); } - - mergeInto(flattenedRuleset, childRuleset); // merge child's rules and other keys into the flattenedRuleset, with child taking precedence over parent + mergeInto(flattenedRuleset, childRuleset); // child takes precedence over parent } - // After all inherited rulesets are merged, - // apply the current file's own rules on top. - // - // If parent and child define the same rule, - // the parent value wins. - // - // Do NOT merge the parent's "extends" field here, - // because: - // - local file paths were already flattened above - // - non-local entries are already stored in "remainingExtends" + // Apply the current file's own rules on top; if parent and child define the same rule, the parent wins. const parentOverrides: Ruleset = { ...ruleset }; delete parentOverrides.extends; mergeInto(flattenedRuleset, parentOverrides); - // At this point: - // - all local file-based "extends" have been flattened - // - only built-in spectral identifiers and remote URLs remain - // - // Remove duplicate entries while preserving order, - // then return the final flattened ruleset. + // Local-file extends have been flattened in. What remains in "extends" is built-in spectral + // identifiers, plus remote URLs when resolveRemote is false. Remove duplicates, preserving order. const uniqueExtends = [...new Set(remainingExtends)]; delete flattenedRuleset.extends; return uniqueExtends.length > 0 ? { extends: uniqueExtends, ...flattenedRuleset } : flattenedRuleset; } -export async function bundleSpectralRuleset(sourcePath: string): Promise { +// Flattens a ruleset's "extends" into a single self-contained ruleset. +// - resolveRemote: false — flatten local-file extends only (used at file-selection time, the only +// moment the user's sibling files are reachable). Remote URLs are left in "extends". +// - resolveRemote: true — also fetch and flatten remote-URL extends (used at lint time, against +// the current remote ruleset, before the output is validated and handed to Spectral). +export async function bundleSpectralRuleset(sourcePath: string, options: { resolveRemote: boolean }): Promise { const rootDir = path.dirname(path.resolve(sourcePath)); - const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir); + const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir, options.resolveRemote); return YAML.stringify(flattenedRuleset); } diff --git a/packages/insomnia/src/common/spectral-ruleset-validator.ts b/packages/insomnia/src/common/spectral-ruleset-validator.ts index 05924449db54..00facebc57f9 100644 --- a/packages/insomnia/src/common/spectral-ruleset-validator.ts +++ b/packages/insomnia/src/common/spectral-ruleset-validator.ts @@ -171,9 +171,7 @@ export function validateSpectralRuleset(content: string): SpectralRulesetValidat const disallowed = keys.filter(key => !ALLOWED_TOP_LEVEL_PROPERTIES.includes(key)); if (disallowed.length > 0) { - return fail( - `Ruleset contains unsupported top-level keys: ${disallowed.join(', ')}. Only "rules" and "extends" are allowed.`, - ); + return fail(`Ruleset contains unsupported top-level keys. Only "rules" and "extends" are allowed.`); } if ('extends' in ruleset) { diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 0dfa973c03f5..ca4c2d17f725 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -352,7 +352,7 @@ export function registerMainHandlers() { }); ipcMainHandle('bundleSpectralRuleset', async (_, options: { sourcePath: string }) => { try { - const content = await bundleSpectralRuleset(options.sourcePath); + const content = await bundleSpectralRuleset(options.sourcePath, { resolveRemote: true }); const validation = validateSpectralRuleset(content); if (!validation.isValid) { return { error: `Invalid Spectral ruleset: ${validation.error}` }; diff --git a/packages/insomnia/src/main/lint-process.mjs b/packages/insomnia/src/main/lint-process.mjs index d4ff358b1f20..c7f248ab8a5b 100644 --- a/packages/insomnia/src/main/lint-process.mjs +++ b/packages/insomnia/src/main/lint-process.mjs @@ -7,7 +7,6 @@ import Spectral from '@stoplight/spectral-core'; import { Resolver } from '@stoplight/spectral-ref-resolver'; import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; import { oas } from '@stoplight/spectral-rulesets'; -import spectralRuntime from '@stoplight/spectral-runtime'; import ipaddr from 'ipaddr.js'; process.on('uncaughtException', error => { @@ -74,16 +73,6 @@ const safeResolver = new Resolver({ }, }); -// Hardened fetch for remote ruleset "extends" loading. -// Note: This is duplicated in inso's lint-specification.ts. Remember to mirror changes there as well. -async function safeFetch(url, init) { - const href = String(url); - if (!isSafeRefUrl(href)) { - throw new Error(`Failed to fetch "${href}". Only https URLs to public hosts are allowed.`); - } - await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); - return spectralRuntime.fetch(href, init); -} process.parentPort.on('message', async ({ data: { documentContent, rulesetPath } }) => { let hasValidCustomRuleset = false; @@ -95,7 +84,7 @@ process.parentPort.on('message', async ({ data: { documentContent, rulesetPath } } try { const spectral = new Spectral.Spectral({ resolver: safeResolver }); - const ruleset = hasValidCustomRuleset ? await bundleAndLoadRuleset(rulesetPath, { fs, fetch: safeFetch }) : oas; + const ruleset = hasValidCustomRuleset ? await bundleAndLoadRuleset(rulesetPath, { fs }) : oas; spectral.setRuleset(ruleset); console.log('[lint-process] Ruleset loaded:', rulesetPath || 'default OAS ruleset'); const diagnostics = await spectral.run(documentContent); From 9bcf19ae9382ee1aa370b53ea69e824a989698f2 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Sat, 23 May 2026 01:54:54 -0400 Subject: [PATCH 64/75] test: update tests --- .../src/commands/safe-ref-resolver.test.ts | 68 ++---------- .../src/common/__tests__/private-host.test.ts | 80 ++++++++++++++ .../spectral-ruleset-validator.test.ts | 69 +----------- .../__tests__/bundle-spectral-ruleset.test.ts | 103 ++++++++++-------- 4 files changed, 153 insertions(+), 167 deletions(-) create mode 100644 packages/insomnia/src/common/__tests__/private-host.test.ts diff --git a/packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts b/packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts index e6f2121ca858..1b8f919d8d0a 100644 --- a/packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts +++ b/packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts @@ -2,17 +2,10 @@ import dns from 'node:dns/promises'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { safeFetch, safeRefResolver } from './lint-specification'; +import { safeRefResolver } from './lint-specification'; vi.mock('node:dns/promises', () => ({ default: { lookup: vi.fn() } })); -// safeFetch delegates to the spectralRuntime.fetch transport captured at import time. -// Expose that transport as a mock so we can assert on it without real network calls. -const { transportFetch } = vi.hoisted(() => ({ transportFetch: vi.fn() })); -vi.mock('@stoplight/spectral-runtime', async importOriginal => { - const actual = (await importOriginal()) as any; - return { ...actual, default: { ...actual.default, fetch: transportFetch } }; -}); // Stub dns.lookup({ all: true }) to return the given addresses. const mockResolvedAddresses = (addresses: string[]) => @@ -29,8 +22,6 @@ describe('safeHttpResolver', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); - transportFetch.mockReset(); - transportFetch.mockResolvedValue({ ok: true } as unknown as Response); // Default: hosts resolve to a public address unless a test overrides this. mockResolvedAddresses(['93.184.216.34']); }); @@ -45,7 +36,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => 'not-a-url', }), - ).rejects.toThrow('Failed to resolve $ref "not-a-url"'); + ).rejects.toThrow('Failed to resolve "not-a-url"'); expect(fetch).not.toHaveBeenCalled(); }); @@ -55,7 +46,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => '/foo/bar.yaml', }), - ).rejects.toThrow('Failed to resolve $ref "/foo/bar.yaml"'); + ).rejects.toThrow('Failed to resolve "/foo/bar.yaml"'); expect(fetch).not.toHaveBeenCalled(); }); @@ -65,7 +56,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => 'http://example.com/schema.yaml', }), - ).rejects.toThrow('only https URLs to public hosts are allowed'); + ).rejects.toThrow('Failed to resolve "http://example.com/schema.yaml"'); expect(fetch).not.toHaveBeenCalled(); }); @@ -75,7 +66,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => 'ftp://example.com/schema.yaml', }), - ).rejects.toThrow('only https URLs to public hosts are allowed'); + ).rejects.toThrow('Failed to resolve "ftp://example.com/schema.yaml"'); expect(fetch).not.toHaveBeenCalled(); }); @@ -85,7 +76,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => 'https://localhost/schema.yaml', }), - ).rejects.toThrow('only https URLs to public hosts are allowed'); + ).rejects.toThrow('Failed to resolve "https://localhost/schema.yaml"'); expect(fetch).not.toHaveBeenCalled(); }); @@ -95,7 +86,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => 'https://127.0.0.1/schema.yaml', }), - ).rejects.toThrow('only https URLs to public hosts are allowed'); + ).rejects.toThrow('Failed to resolve "https://127.0.0.1/schema.yaml"'); expect(fetch).not.toHaveBeenCalled(); }); @@ -112,7 +103,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => url, }), - ).rejects.toThrow('only https URLs to public hosts are allowed'); + ).rejects.toThrow('Only https URLs to public hosts are allowed'); } expect(fetch).not.toHaveBeenCalled(); @@ -123,7 +114,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => 'https://169.254.169.254/latest/meta-data', }), - ).rejects.toThrow('only https URLs to public hosts are allowed'); + ).rejects.toThrow('Failed to resolve "https://169.254.169.254/latest/meta-data"'); expect(fetch).not.toHaveBeenCalled(); }); @@ -133,7 +124,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => 'https://[::1]/schema.yaml', }), - ).rejects.toThrow('only https URLs to public hosts are allowed'); + ).rejects.toThrow('Failed to resolve "https://[::1]/schema.yaml"'); expect(fetch).not.toHaveBeenCalled(); }); @@ -210,7 +201,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => 'https://example.com/missing.yaml', }), - ).rejects.toThrow('Failed to fetch $ref "https://example.com/missing.yaml": 404 Not Found'); + ).rejects.toThrow('Failed to fetch "https://example.com/missing.yaml": 404 Not Found'); }); it('throws on 500 responses', async () => { @@ -224,7 +215,7 @@ describe('safeHttpResolver', () => { httpResolver.resolve({ href: () => 'https://example.com/error.yaml', }), - ).rejects.toThrow('Failed to fetch $ref "https://example.com/error.yaml": 500 Internal Server Error'); + ).rejects.toThrow('Failed to fetch "https://example.com/error.yaml": 500 Internal Server Error'); }); it('propagates fetch network errors', async () => { @@ -312,40 +303,5 @@ describe('safeHttpResolver', () => { }); }); - describe('safeFetch (remote ruleset "extends" loading)', () => { - it('rejects non-https URLs', async () => { - await expect(safeFetch('http://example.com/rules.yaml')).rejects.toThrow( - 'only https URLs to public hosts are allowed', - ); - - expect(transportFetch).not.toHaveBeenCalled(); - }); - - it('rejects literal loopback addresses', async () => { - await expect(safeFetch('https://127.0.0.1/rules.yaml')).rejects.toThrow( - 'only https URLs to public hosts are allowed', - ); - - expect(transportFetch).not.toHaveBeenCalled(); - }); - - it('rejects hosts that resolve to loopback', async () => { - mockResolvedAddresses(['127.0.0.1']); - - await expect(safeFetch('https://app.localtest.me/rules.yaml')).rejects.toThrow( - 'resolves to a private or loopback address', - ); - - expect(transportFetch).not.toHaveBeenCalled(); - }); - - it('fetches via the spectral-runtime transport when the host resolves to a public address', async () => { - mockResolvedAddresses(['93.184.216.34']); - - await safeFetch('https://example.com/rules.yaml'); - - expect(transportFetch).toHaveBeenCalledWith('https://example.com/rules.yaml', undefined); - }); - }); }); diff --git a/packages/insomnia/src/common/__tests__/private-host.test.ts b/packages/insomnia/src/common/__tests__/private-host.test.ts new file mode 100644 index 000000000000..c9d87a447b0a --- /dev/null +++ b/packages/insomnia/src/common/__tests__/private-host.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { isPrivateOrLoopbackHost } from '../private-host'; + +describe('isPrivateOrLoopbackHost', () => { + describe('localhost', () => { + it('rejects "localhost"', () => { + expect(isPrivateOrLoopbackHost('localhost')).toBe(true); + }); + + it('rejects subdomains of localhost', () => { + expect(isPrivateOrLoopbackHost('app.localhost')).toBe(true); + expect(isPrivateOrLoopbackHost('foo.bar.localhost')).toBe(true); + }); + }); + + describe('loopback addresses', () => { + it('rejects IPv4 loopback', () => { + expect(isPrivateOrLoopbackHost('127.0.0.1')).toBe(true); + expect(isPrivateOrLoopbackHost('127.255.255.255')).toBe(true); + }); + + it('rejects IPv6 loopback', () => { + expect(isPrivateOrLoopbackHost('::1')).toBe(true); + }); + + it('rejects IPv6 loopback in bracket notation', () => { + expect(isPrivateOrLoopbackHost('[::1]')).toBe(true); + }); + }); + + describe('private IP ranges', () => { + it('rejects 10.x.x.x addresses', () => { + expect(isPrivateOrLoopbackHost('10.0.0.1')).toBe(true); + expect(isPrivateOrLoopbackHost('10.255.255.255')).toBe(true); + }); + + it('rejects 172.16.x.x–172.31.x.x addresses', () => { + expect(isPrivateOrLoopbackHost('172.16.0.1')).toBe(true); + expect(isPrivateOrLoopbackHost('172.31.255.255')).toBe(true); + }); + + it('rejects 192.168.x.x addresses', () => { + expect(isPrivateOrLoopbackHost('192.168.0.1')).toBe(true); + expect(isPrivateOrLoopbackHost('192.168.255.255')).toBe(true); + }); + + it('rejects link-local addresses (169.254.x.x)', () => { + expect(isPrivateOrLoopbackHost('169.254.169.254')).toBe(true); + }); + + it('rejects IPv6 private (fc00::/7)', () => { + expect(isPrivateOrLoopbackHost('fc00::1')).toBe(true); + expect(isPrivateOrLoopbackHost('fd00::1')).toBe(true); + }); + }); + + describe('public addresses', () => { + it('allows public IPv4 addresses', () => { + expect(isPrivateOrLoopbackHost('93.184.216.34')).toBe(false); + expect(isPrivateOrLoopbackHost('8.8.8.8')).toBe(false); + expect(isPrivateOrLoopbackHost('1.1.1.1')).toBe(false); + }); + + it('allows public IPv6 addresses', () => { + expect(isPrivateOrLoopbackHost('2606:2800:220:1:248:1893:25c8:1946')).toBe(false); + }); + + it('allows public hostnames', () => { + expect(isPrivateOrLoopbackHost('example.com')).toBe(false); + expect(isPrivateOrLoopbackHost('api.github.com')).toBe(false); + }); + + it('returns false for non-IP hostnames that are not localhost', () => { + // ipaddr.js cannot parse these so isValid returns false → returns false + expect(isPrivateOrLoopbackHost('not-an-ip')).toBe(false); + expect(isPrivateOrLoopbackHost('')).toBe(false); + }); + }); +}); diff --git a/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts b/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts index bb15a6c4bf21..983d21563700 100644 --- a/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts +++ b/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - isLocalFilePath, - isPrivateOrLoopbackHost, - toArray, - validateSpectralRuleset, -} from '../spectral-ruleset-validator'; +import { isLocalFilePath, toArray, validateSpectralRuleset } from '../spectral-ruleset-validator'; const expectInvalid = (content: string, errorContains?: string | RegExp): string => { const result = validateSpectralRuleset(content); @@ -51,41 +46,6 @@ describe('isLocalFilePath()', () => { }); }); -describe('isPrivateOrLoopbackHost()', () => { - it('returns true for localhost variants', () => { - expect(isPrivateOrLoopbackHost('localhost')).toBe(true); - expect(isPrivateOrLoopbackHost('foo.localhost')).toBe(true); - }); - - it('returns true for loopback IPs (v4 and v6)', () => { - expect(isPrivateOrLoopbackHost('127.0.0.1')).toBe(true); - expect(isPrivateOrLoopbackHost('::1')).toBe(true); - }); - - it('returns true for RFC 1918 private ranges', () => { - expect(isPrivateOrLoopbackHost('10.0.0.1')).toBe(true); - expect(isPrivateOrLoopbackHost('172.16.0.1')).toBe(true); - expect(isPrivateOrLoopbackHost('192.168.1.1')).toBe(true); - }); - - it('returns true for link-local IPv4', () => { - expect(isPrivateOrLoopbackHost('169.254.0.1')).toBe(true); - }); - - it('returns true for bracketed IPv6 hostnames (as produced by new URL().hostname)', () => { - expect(isPrivateOrLoopbackHost('[::1]')).toBe(true); - }); - - it('returns false for public unicast IPs', () => { - expect(isPrivateOrLoopbackHost('8.8.8.8')).toBe(false); - expect(isPrivateOrLoopbackHost('1.1.1.1')).toBe(false); - }); - - it('returns false for non-IP hostnames (DNS resolution is handled elsewhere)', () => { - expect(isPrivateOrLoopbackHost('example.com')).toBe(false); - }); -}); - describe('toArray()', () => { it('returns [] for undefined', () => { const value = undefined; @@ -128,8 +88,7 @@ describe('validateSpectralRuleset()', () => { }); it('rejects unsupported top-level keys', () => { - const error = expectInvalid('functions:\n - exec\n', /unsupported top-level/i); - expect(error).toContain('functions'); + expectInvalid('functions:\n - exec\n', /unsupported top-level/i); }); it('accepts JSON input (YAML is a superset of JSON)', () => { @@ -162,30 +121,6 @@ describe('validateSpectralRuleset()', () => { expectInvalid('extends:\n - 42\n', /must be strings/i); }); - it('rejects http URLs in extends', () => { - expectInvalid('extends:\n - http://example.com/rules.yaml\n', /must use https/i); - }); - - it('rejects extends URLs targeting localhost variants', () => { - expectInvalid('extends:\n - https://localhost/rules.yaml\n', /disallowed host/i); - expectInvalid('extends:\n - https://foo.localhost/rules.yaml\n', /disallowed host/i); - }); - - it('rejects extends URLs targeting loopback IPs (v4 and v6)', () => { - expectInvalid('extends:\n - https://127.0.0.1/rules.yaml\n', /disallowed host/i); - expectInvalid('extends:\n - "https://[::1]/rules.yaml"\n', /disallowed host/i); - }); - - it('rejects extends URLs targeting RFC 1918 private ranges', () => { - expectInvalid('extends:\n - https://10.0.0.1/rules.yaml\n', /disallowed host/i); - expectInvalid('extends:\n - https://192.168.1.1/rules.yaml\n', /disallowed host/i); - expectInvalid('extends:\n - https://172.16.0.1/rules.yaml\n', /disallowed host/i); - }); - - it('rejects extends strings that are neither identifiers nor paths nor valid URLs', () => { - expectInvalid('extends:\n - not-a-real-thing\n', /not a recognized|valid URL/i); - }); - // rules + rule body + then — covers validateRules(), validateRuleBody(), validateThen() it('rejects rules that is not an object', () => { expectInvalid('rules:\n - foo\n', /"rules" must be an object/); diff --git a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts index ba10176b0108..d1b91537ee77 100644 --- a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts +++ b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts @@ -19,7 +19,7 @@ vi.mock('node:dns/promises', () => ({ import dns from 'node:dns/promises'; import fs from 'node:fs'; -import { bundleSpectralRuleset } from '../bundle-spectral-ruleset'; +import { bundleSpectralRuleset } from '~/common/bundle-spectral-ruleset'; const mockReadFile = vi.mocked(fs.promises.readFile) as MockedFunction<(path: string) => Promise>; @@ -234,6 +234,22 @@ extends: await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('must be an object at the top level'); }); + it('rejects a local ruleset that declares custom functions (RCE vector)', async () => { + mockReadFile.mockResolvedValueOnce( + ` +functions: + - exec +rules: + env-check: + given: "$" + then: + function: exec +`, + ); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('Invalid Spectral ruleset'); + }); + it('deduplicates spectral identifiers from multiple child files', async () => { const parentPath = '/fake/parent.yaml'; const childAPath = '/fake/childA.yaml'; @@ -258,7 +274,7 @@ extends: }); describe('remote URL extends', () => { - it('fetches, validates and flattens a remote ruleset into the output', async () => { + it('validates a remote ruleset and preserves the URL in extends', async () => { mockReadFile.mockResolvedValueOnce( ` extends: @@ -274,10 +290,11 @@ rules: vi.mocked(fetch).mockResolvedValue(rulesetResponse(`rules:${VALID_RULE}`)); const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); + // Local rules are merged in; remote URL is preserved for Spectral to fetch at lint time. expect(result).toContain('local-rule'); - expect(result).toContain('remote-rule'); - // The remote URL is resolved away — nothing remote is left to fetch at lint time. - expect(result).not.toContain('https://example.com'); + expect(result).toContain('https://example.com/remote.yaml'); + // Remote content is NOT merged into the bundle. + expect(result).not.toContain('remote-rule'); }); it('rejects a remote ruleset that declares custom functions (RCE vector)', async () => { @@ -296,56 +313,27 @@ rules: ), ); - await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('is not allowed'); - }); - - it('carries through a spectral identifier declared by a remote ruleset', async () => { - mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/remote.yaml"\n`); - vi.mocked(fetch).mockResolvedValue( - rulesetResponse(`extends:\n - "spectral:oas"\nrules:${VALID_RULE}`), - ); - - const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); - expect(result).toContain('spectral:oas'); - expect(result).toContain('remote-rule'); + // validateRemoteExtends calls validateSpectralRuleset on each fetched remote ruleset, + // blocking "functions" before the URL is accepted into "extends". + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('failed validation'); }); - it('recursively flattens nested remote extends', async () => { + it('recursively validates nested remote extends', async () => { mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/a.yaml"\n`); vi.mocked(fetch).mockImplementation(async (input: any) => { const href = String(input); if (href === 'https://example.com/a.yaml') { - return rulesetResponse( - ` -extends: - - "./b.yaml" -rules: - a-rule: - given: "$.info" - severity: warn - then: - function: truthy -`, - ); + return rulesetResponse(`extends:\n - "./b.yaml"\nrules:${VALID_RULE}`); } if (href === 'https://example.com/b.yaml') { - return rulesetResponse( - ` -rules: - b-rule: - given: "$.paths" - severity: warn - then: - function: truthy -`, - ); + return rulesetResponse(`rules:${VALID_RULE}`); } throw new Error(`Unexpected fetch call: ${href}`); }); const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); - expect(result).toContain('a-rule'); - expect(result).toContain('b-rule'); + // The top-level remote URL is preserved; nested remote extends are validated but not merged. + expect(result).toContain('https://example.com/a.yaml'); }); it('rejects a non-https remote extends without fetching', async () => { @@ -356,9 +344,36 @@ rules: }); it('rejects a remote extends pointing at a loopback host without fetching', async () => { - mockReadFile.mockResolvedValueOnce(`extends:\n - "https://localhost/remote.yaml"\n`); + const urls = [ + 'https://localhost/remote.yaml', + 'https://foo.localhost/remote.yaml', + 'https://127.0.0.1/remote.yaml', + 'https://[::1]/remote.yaml', + ]; + for (const url of urls) { + mockReadFile.mockResolvedValueOnce(`extends:\n - "${url}"\n`); + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('disallowed host'); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it('rejects a remote extends pointing at a private IP range without fetching', async () => { + const urls = [ + 'https://10.0.0.1/remote.yaml', + 'https://192.168.1.1/remote.yaml', + 'https://172.16.0.1/remote.yaml', + ]; + for (const url of urls) { + mockReadFile.mockResolvedValueOnce(`extends:\n - "${url}"\n`); + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('disallowed host'); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it('rejects an extends entry that is not a valid identifier, path, or URL', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "not-a-real-thing"\n`); - await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('disallowed host'); + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow(/not a valid spectral identifier|valid URL/i); expect(fetch).not.toHaveBeenCalled(); }); From 0162ca4aac19d6e05ea0e0769f1ba9316898e437 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Sat, 23 May 2026 01:55:32 -0400 Subject: [PATCH 65/75] feat: adds logic to validate remote extends before passing ruleset to spectral --- .../src/commands/lint-specification.ts | 23 +-- .../src/common/bundle-spectral-ruleset.ts | 157 ++++++++---------- packages/insomnia/src/main/ipc/main.ts | 22 +-- packages/insomnia/src/main/lint-process.mjs | 7 +- 4 files changed, 89 insertions(+), 120 deletions(-) diff --git a/packages/insomnia-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index b2330dddad00..3d593b050a4e 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -9,10 +9,10 @@ import path from 'node:path'; import { Resolver } from '@stoplight/spectral-ref-resolver'; import { oas } from '@stoplight/spectral-rulesets'; +import { fetch as spectralFetch } from '@stoplight/spectral-runtime'; import { DiagnosticSeverity } from '@stoplight/types'; import { bundleSpectralRuleset } from 'insomnia/src/common/bundle-spectral-ruleset'; import { isPrivateOrLoopbackHost } from 'insomnia/src/common/private-host'; -import { validateSpectralRuleset } from 'insomnia/src/common/spectral-ruleset-validator'; import { InsoError } from '../errors'; import { logger } from '../logger'; @@ -38,7 +38,7 @@ function isSafeRefUrl(href: string): boolean { async function assertResolvesToPublicHost(hostname: string): Promise { const records = await dns.lookup(hostname, { all: true }); for (const { address } of records) { - if (isPrivateOrLoopbackHost(address.toLowerCase())) { + if (isPrivateOrLoopbackHost(address)) { throw new Error(`Failed to resolve host. "${hostname}" resolves to a private or loopback address.`); } } @@ -67,7 +67,6 @@ export const safeRefResolver = new Resolver({ }, }); - export const getRuleSetFileFromFolderByFilename = async (filePath: string) => { try { const filesInSpecFolder = await fs.promises.readdir(path.dirname(filePath)); @@ -94,22 +93,16 @@ export async function lintSpecification({ let ruleset = oas; try { if (rulesetFileName) { - // Flatten all local and remote extends through our SSRF-safe code before any - // content reaches Spectral. This ensures remote "functions" and other disallowed - // keys are caught by validateSpectralRuleset before execution. - const bundledContent = await bundleSpectralRuleset(rulesetFileName, { resolveRemote: true }); - const validation = validateSpectralRuleset(bundledContent); - if (!validation.isValid) { - logger.fatal(`Invalid Spectral ruleset: ${validation.error}`); - return { isValid: false }; - } + // Flatten all local extends and validate remote extends (SSRF + disallowed keys) + // before any content reaches Spectral. + const bundledContent = await bundleSpectralRuleset(rulesetFileName); // bundleAndLoadRuleset requires a file path, so write the pre-validated bundle to // a uniquely-named temp directory and clean it up immediately after loading. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'spectral-')); try { - const tempPath = path.join(tempDir, '.spectral.yaml'); - await fs.promises.writeFile(tempPath, bundledContent, { encoding: 'utf8' }); - ruleset = await bundleAndLoadRuleset(tempPath, { fs }); + const tempRulesetPath = path.join(tempDir, '.spectral.yaml'); + await fs.promises.writeFile(tempRulesetPath, bundledContent, { encoding: 'utf8' }); + ruleset = await bundleAndLoadRuleset(tempRulesetPath, { fs, fetch: spectralFetch }); } finally { await fs.promises.rm(tempDir, { recursive: true, force: true }); } diff --git a/packages/insomnia/src/common/bundle-spectral-ruleset.ts b/packages/insomnia/src/common/bundle-spectral-ruleset.ts index 3c5745610f3e..dbf48d11a196 100644 --- a/packages/insomnia/src/common/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/common/bundle-spectral-ruleset.ts @@ -5,31 +5,30 @@ import path from 'node:path'; import YAML from 'yaml'; import { isPrivateOrLoopbackHost } from './private-host'; -import { ALLOWED_EXTENDS_IDENTIFIERS, isLocalFilePath, toArray } from './spectral-ruleset-validator'; +import { + ALLOWED_EXTENDS_IDENTIFIERS, + isLocalFilePath, + toArray, + validateSpectralRuleset, +} from './spectral-ruleset-validator'; -// Maximum depth of nested extends to follow when bundling. Guards against deep nesting and cycles. const MAX_EXTENDS_DEPTH = 5; const ALLOWED_EXTENSIONS = ['.yaml', '.yml']; -// Abort a remote ruleset fetch that takes longer than this. const REMOTE_FETCH_TIMEOUT_MS = 10_000; -// `extends` is the only key this file interprets by name. Local-file and remote-URL extends are -// resolved away (flattened in); only built-in spectral identifiers (spectral:oas, …) are carried -// through. Every other top-level key — 'rules', 'aliases', anything added later — flows through the -// generic 'mergeInto' step. This file only flattens: content validation (validateSpectralRuleset — -// rejecting custom "functions" etc.) is a separate concern applied to the fully-flattened output. +// Represents a parsed Spectral ruleset object. Every top-level key other than +// "extends" is treated as opaque data and passed through unchanged. type Ruleset = Record & { extends?: string[]; }; -// Guards for local-file extends: -// - Excessively deep nesting / cycles -// - Extends that point to non-YAML files -// - Extends that escape the root directory of the originally-selected ruleset -// (e.g. extends: '../../../etc/secret.yaml'), which could exfiltrate arbitrary -// .yaml files on the user's disk via the bundled output returned to the renderer. +// Safety checks for local-file extends entries: +// - Depth / cycle guard against infinite recursion. +// - Extension check ensures we only load YAML files. +// - rootDir guard prevents path traversal (e.g. '../../../etc/passwd') from +// reaching files outside the directory of the originally-selected ruleset. function assertAllowed(absolute: string, visited: Set, depth: number, rootDir: string): void { if (depth > MAX_EXTENDS_DEPTH) { throw new Error(`"extends" nested too deeply (max ${MAX_EXTENDS_DEPTH}) at ${absolute}`); @@ -46,7 +45,7 @@ function assertAllowed(absolute: string, visited: Set, depth: number, ro } } -// Reads and parses a local ruleset file. +// Reads a local ruleset file from disk and parses it. async function readRuleset(absolute: string): Promise { const raw = await fs.promises.readFile(absolute, { encoding: 'utf8' }); const parsed = YAML.parse(raw); @@ -60,9 +59,9 @@ function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } -// One level deep merge for top-level spectral keys. -// Object values are merged shallowly (e.g. rules) with "source" taking precedence over "target". -// Non-object values are overridden by "source" if present, otherwise left as-is from "target". +// Shallow-merges top-level keys from source into target. +// Object values (e.g. "rules") are merged one level deep with source taking precedence. +// Scalar values are overwritten by source. function mergeInto(target: Ruleset, source: Ruleset): void { for (const key of Object.keys(source)) { const sourceVal = source[key]; @@ -71,7 +70,8 @@ function mergeInto(target: Ruleset, source: Ruleset): void { } } -// Parses an "extends" entry into a URL. `base` resolves relative entries within a remote ruleset. +// Resolves an "extends" entry into a URL. When `base` is provided, relative paths are +// resolved against it — used when processing extends entries inside a remote ruleset. function parseRemoteExtendsUrl(entry: string, base?: URL): URL { try { return new URL(entry, base); @@ -80,7 +80,10 @@ function parseRemoteExtendsUrl(entry: string, base?: URL): URL { } } -// SSRF guard: a remote "extends" URL must be https and resolve only to public addresses. +// Rejects URLs that could be used for SSRF attacks: +// - Must be https (no http, ftp, file, etc.) +// - Hostname must not be a known private/loopback address +// - DNS resolution must not yield a private/loopback address async function assertSafeRemoteUrl(url: URL): Promise { if (url.protocol !== 'https:') { throw new Error(`Remote "extends" URL must use https: ${url.href}`); @@ -98,16 +101,14 @@ async function assertSafeRemoteUrl(url: URL): Promise { } } -// Fetches a remote ruleset over the network (SSRF-guarded) and parses it. Content validation — -// rejecting custom "functions" and other disallowed keys — is intentionally NOT done here: -// bundling only flattens, and mergeInto carries every key into the output, so the single -// downstream validation pass still catches anything a remote ruleset tried to introduce. +// Fetches and parses a remote ruleset over the network. The URL is SSRF-checked before +// any network call is made. Redirects are rejected because a redirect could forward us +// to an internal host that bypassed the assertSafeRemoteUrl check. async function readRemoteRuleset(url: URL): Promise { await assertSafeRemoteUrl(url); let response: Response; try { - // redirect: 'error' — a redirect could send us to an unvalidated (internal) host. response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(REMOTE_FETCH_TIMEOUT_MS) }); } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -126,10 +127,13 @@ async function readRemoteRuleset(url: URL): Promise { return parsed as Ruleset; } -// Recursively resolves a remote-URL "extends" into a flattened ruleset. Within a remote ruleset -// every non-identifier "extends" entry is itself a URL (relative entries resolve against `url`) — -// there are no local files on the user's disk here, so this path only ever fetches over the network. -async function flattenRemoteRuleset(url: URL, visited: Set, depth: number): Promise { +// Validates a remote "extends" URL and all its children. +// For each URL in the chain: fetches the content (SSRF-guarded), runs validateSpectralRuleset +// to block disallowed keys such as custom "functions" (the RCE vector), then recurses into +// any nested extends entries. Content is never merged — the original URL is preserved in +// "extends" for Spectral to fetch at lint time using spectralRuntime.fetch. +// Note: We do not flatten the content of remote extends URL entries because users would need to re-upload their ruleset anytime a change is made to a ruleset they extend. +async function validateRemoteExtends(url: URL, visited: Set, depth: number): Promise { if (depth > MAX_EXTENDS_DEPTH) { throw new Error(`"extends" nested too deeply (max ${MAX_EXTENDS_DEPTH}) at ${url.href}`); } @@ -138,49 +142,34 @@ async function flattenRemoteRuleset(url: URL, visited: Set, depth: numbe } const ruleset = await readRemoteRuleset(url); - const nextVisited = new Set(visited).add(url.href); - - const flattenedRuleset: Ruleset = {}; - const remainingExtends: string[] = []; + const validation = validateSpectralRuleset(YAML.stringify(ruleset)); + if (!validation.isValid) { + throw new Error(`Remote ruleset at "${url.href}" failed validation: ${validation.error}`); + } + const nextVisited = new Set(visited).add(url.href); for (const entry of toArray(ruleset.extends)) { if (Array.isArray(entry)) { throw new TypeError( `Failed to process "extends" entry ${JSON.stringify(entry)}: tuple format (e.g. [path, severity]) is not supported. Use a plain string instead.`, ); } - // Built-in spectral identifiers are resolved locally by Spectral; carry them through. - if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry)) { - remainingExtends.push(entry); - continue; - } - const childRuleset = await flattenRemoteRuleset(parseRemoteExtendsUrl(entry, url), nextVisited, depth + 1); - if (childRuleset.extends) { - remainingExtends.push(...childRuleset.extends); - } - mergeInto(flattenedRuleset, childRuleset); + if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry)) continue; + await validateRemoteExtends(parseRemoteExtendsUrl(entry, url), nextVisited, depth + 1); } - - const parentOverrides: Ruleset = { ...ruleset }; - delete parentOverrides.extends; - mergeInto(flattenedRuleset, parentOverrides); - - const uniqueExtends = [...new Set(remainingExtends)]; - delete flattenedRuleset.extends; - return uniqueExtends.length > 0 ? { extends: uniqueExtends, ...flattenedRuleset } : flattenedRuleset; } -// Recursively resolves "extends" entries into a single ruleset. Local-file extends are always -// flattened. Remote-URL extends are flattened only when `resolveRemote` is true — otherwise they -// are left untouched in "extends" to be resolved later. Rules are merged such that the parent -// overrides its extends, and among multiple extends entries later ones override earlier. -// (ref: https://docs.stoplight.io/docs/spectral/83527ef2dd8c0-extending-rulesets) +// Recursively processes a local ruleset file's "extends" entries: +// - Local file paths are loaded and their rules merged into the output. +// - Remote URLs are validated (SSRF + content) then kept in "extends" for Spectral to fetch at lint time. +// - Built-in identifiers (spectral:oas, …) are kept in "extends" for Spectral to resolve locally. +// Parent rules always override child rules with the same name; among multiple extends entries +// later ones override earlier ones. (ref: https://docs.stoplight.io/docs/spectral/83527ef2dd8c0-extending-rulesets) async function flattenRuleset( filePath: string, visited: Set, depth: number, rootDir: string, - resolveRemote: boolean, ): Promise { const absolute = path.resolve(filePath); assertAllowed(absolute, visited, depth, rootDir); @@ -189,8 +178,10 @@ async function flattenRuleset( const baseDir = path.dirname(absolute); const nextVisited = new Set(visited).add(absolute); - const flattenedRuleset: Ruleset = {}; // Flattened ruleset containing all rules within this file and its extends. - const remainingExtends: string[] = []; // built-in spectral identifiers, plus remote URLs when resolveRemote is false. + const flattenedRuleset: Ruleset = {}; + // Collects entries that stay in "extends": built-in spectral identifiers and remote URLs + // (already validated by validateRemoteExtends). Local file paths are flattened out entirely. + const remainingExtends: string[] = []; for (const entry of toArray(ruleset.extends)) { if (Array.isArray(entry)) { @@ -203,29 +194,15 @@ async function flattenRuleset( remainingExtends.push(entry); continue; } - // Remote URL extends. + // Remote URL extends — validate upfront (SSRF + content checks), then preserve the URL + // in "extends" for Spectral to fetch fresh at lint time via spectralRuntime.fetch. if (!isLocalFilePath(entry)) { - if (!resolveRemote) { - // Leave remote URLs in place — they are flattened later, at lint time, against the - // current remote ruleset (which may have changed since the ruleset was selected). - remainingExtends.push(entry); - continue; - } - const remoteRuleset = await flattenRemoteRuleset(parseRemoteExtendsUrl(entry), nextVisited, depth + 1); - if (remoteRuleset.extends) { - remainingExtends.push(...remoteRuleset.extends); - } - mergeInto(flattenedRuleset, remoteRuleset); + await validateRemoteExtends(parseRemoteExtendsUrl(entry), nextVisited, depth + 1); + remainingExtends.push(entry); continue; } // Local file paths are recursively loaded and flattened. - const childRuleset = await flattenRuleset( - path.resolve(baseDir, entry), - nextVisited, - depth + 1, - rootDir, - resolveRemote, - ); + const childRuleset = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1, rootDir); if (childRuleset.extends) { remainingExtends.push(...childRuleset.extends); } @@ -237,20 +214,24 @@ async function flattenRuleset( delete parentOverrides.extends; mergeInto(flattenedRuleset, parentOverrides); - // Local-file extends have been flattened in. What remains in "extends" is built-in spectral - // identifiers, plus remote URLs when resolveRemote is false. Remove duplicates, preserving order. + // Deduplicate while preserving order (e.g. two local extends both pulling in spectral:oas). const uniqueExtends = [...new Set(remainingExtends)]; delete flattenedRuleset.extends; return uniqueExtends.length > 0 ? { extends: uniqueExtends, ...flattenedRuleset } : flattenedRuleset; } -// Flattens a ruleset's "extends" into a single self-contained ruleset. -// - resolveRemote: false — flatten local-file extends only (used at file-selection time, the only -// moment the user's sibling files are reachable). Remote URLs are left in "extends". -// - resolveRemote: true — also fetch and flatten remote-URL extends (used at lint time, against -// the current remote ruleset, before the output is validated and handed to Spectral). -export async function bundleSpectralRuleset(sourcePath: string, options: { resolveRemote: boolean }): Promise { +// Entry point for ruleset processing. Flattens all local "extends" into a single ruleset, +// validates all remote "extends" URLs (SSRF + content), validates the merged output for +// disallowed keys (e.g. "functions"), and returns the result as a YAML string. +// The output is safe to store and pass to Spectral: local content is fully merged, remote URLs +// have been pre-vetted and are preserved in "extends" for Spectral to resolve at lint time. +export async function bundleSpectralRuleset(sourcePath: string): Promise { const rootDir = path.dirname(path.resolve(sourcePath)); - const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir, options.resolveRemote); - return YAML.stringify(flattenedRuleset); + const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir); + const yaml = YAML.stringify(flattenedRuleset); + const validation = validateSpectralRuleset(yaml); + if (!validation.isValid) { + throw new Error(`Invalid Spectral ruleset: ${validation.error}`); + } + return yaml; } diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index ca4c2d17f725..0304da857d70 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -21,7 +21,6 @@ import iconv from 'iconv-lite'; import { bundleSpectralRuleset } from '~/common/bundle-spectral-ruleset'; import { AI_PLUGIN_NAME } from '~/common/constants'; import { cannotAccessPathError } from '~/common/misc'; -import { validateSpectralRuleset } from '~/common/spectral-ruleset-validator'; import type { AuthTypeOAuth2, OAuth2Token, RequestHeader, Services } from '~/insomnia-data'; import { services } from '~/insomnia-data'; import { initializeWorkspaceBackendProject, syncNewWorkspaceIfNeeded } from '~/main/cloud-sync/initialization'; @@ -352,11 +351,7 @@ export function registerMainHandlers() { }); ipcMainHandle('bundleSpectralRuleset', async (_, options: { sourcePath: string }) => { try { - const content = await bundleSpectralRuleset(options.sourcePath, { resolveRemote: true }); - const validation = validateSpectralRuleset(content); - if (!validation.isValid) { - return { error: `Invalid Spectral ruleset: ${validation.error}` }; - } + const content = await bundleSpectralRuleset(options.sourcePath); return { content }; } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; @@ -381,17 +376,16 @@ export function registerMainHandlers() { rulesetPath = resolvedRulesetPath; try { - const rulesetContent = await fs.promises.readFile(rulesetPath, { encoding: 'utf8' }); - const validation = validateSpectralRuleset(rulesetContent); - if (!validation.isValid) { - return { error: `Invalid Spectral ruleset: ${validation.error}` }; - } + // Validate the ruleset (flattens local extends, checks remote URLs for SSRF and + // disallowed keys such as "functions") before passing the path to the lint worker. + // Result is discarded — validation only; the original file is not modified. + await bundleSpectralRuleset(rulesetPath); } catch (err) { - // Fall back to the default OAS ruleset instead of erroring when a user deletes their custom ruleset - if (err && err.code === 'ENOENT') { + // Fall back to the default OAS ruleset + if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') { rulesetPath = ''; } else { - return { error: `Failed to read ruleset file: ${err instanceof Error ? err.message : String(err)}` }; + return { error: err instanceof Error ? err.message : String(err) }; } } } diff --git a/packages/insomnia/src/main/lint-process.mjs b/packages/insomnia/src/main/lint-process.mjs index c7f248ab8a5b..381ea564c054 100644 --- a/packages/insomnia/src/main/lint-process.mjs +++ b/packages/insomnia/src/main/lint-process.mjs @@ -7,6 +7,7 @@ import Spectral from '@stoplight/spectral-core'; import { Resolver } from '@stoplight/spectral-ref-resolver'; import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; import { oas } from '@stoplight/spectral-rulesets'; +import spectralRuntime from '@stoplight/spectral-runtime'; import ipaddr from 'ipaddr.js'; process.on('uncaughtException', error => { @@ -43,7 +44,7 @@ function isSafeRefUrl(href) { async function assertResolvesToPublicHost(hostname) { const records = await dns.lookup(hostname, { all: true }); for (const { address } of records) { - if (isPrivateOrLoopbackHost(address.toLowerCase())) { + if (isPrivateOrLoopbackHost(address)) { throw new Error(`Failed to resolve host. "${hostname}" resolves to a private or loopback address.`); } } @@ -73,7 +74,6 @@ const safeResolver = new Resolver({ }, }); - process.parentPort.on('message', async ({ data: { documentContent, rulesetPath } }) => { let hasValidCustomRuleset = false; if (rulesetPath) { @@ -84,7 +84,8 @@ process.parentPort.on('message', async ({ data: { documentContent, rulesetPath } } try { const spectral = new Spectral.Spectral({ resolver: safeResolver }); - const ruleset = hasValidCustomRuleset ? await bundleAndLoadRuleset(rulesetPath, { fs }) : oas; + const { fetch } = spectralRuntime; + const ruleset = hasValidCustomRuleset ? await bundleAndLoadRuleset(rulesetPath, { fs, fetch }) : oas; spectral.setRuleset(ruleset); console.log('[lint-process] Ruleset loaded:', rulesetPath || 'default OAS ruleset'); const diagnostics = await spectral.run(documentContent); From 3c5862e4179277956d371de854561566f41f3407 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Sat, 23 May 2026 23:56:52 -0400 Subject: [PATCH 66/75] fix: set canDuplicate to false on the ProjectLintRuleset model Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/insomnia-data/src/models/project-lint-ruleset.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/insomnia/src/insomnia-data/src/models/project-lint-ruleset.ts b/packages/insomnia/src/insomnia-data/src/models/project-lint-ruleset.ts index 9b44b014ef33..528d174ed654 100644 --- a/packages/insomnia/src/insomnia-data/src/models/project-lint-ruleset.ts +++ b/packages/insomnia/src/insomnia-data/src/models/project-lint-ruleset.ts @@ -6,7 +6,7 @@ export const type = 'ProjectLintRuleset'; export const prefix = 'plr'; -export const canDuplicate = true; +export const canDuplicate = false; export const canSync = true; From 20cd0caff79d75c7a8237817d7790c98cce9610f Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Sat, 23 May 2026 23:35:48 -0400 Subject: [PATCH 67/75] test: adds more test cases for verifying remote extends validations --- .../__tests__/bundle-spectral-ruleset.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts index d1b91537ee77..eb9aecc259d2 100644 --- a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts +++ b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts @@ -393,5 +393,31 @@ rules: await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('Failed to fetch remote'); }); + + it('rejects a nested http:// extends inside a remote ruleset (recursive SSRF check)', async () => { + // Local ruleset extends a valid https remote... + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/base.yaml"\n`); + // ...but that remote itself extends an http:// localhost URL. + vi.mocked(fetch).mockResolvedValueOnce( + rulesetResponse(`extends:\n - "http://localhost:8000/exec.yaml"\n`), + ); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow( + 'Remote "extends" URL must use https:', + ); + }); + + it('rejects a functions: key inside a nested remote ruleset', async () => { + // Local ruleset extends a valid https remote... + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/base.yaml"\n`); + // ...but that remote contains a functions: key (the RCE vector). + vi.mocked(fetch).mockResolvedValueOnce( + rulesetResponse(`functions:\n - exec\nrules:\n env-check:\n given: "$"\n then:\n function: exec\n`), + ); + + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow( + 'failed validation', + ); + }); }); }); From a3f0aafd977022c11944a6ce66336f5c91d1e6b7 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Sat, 23 May 2026 23:37:33 -0400 Subject: [PATCH 68/75] test: adds more test cases for verifying remote extends validations --- .../__tests__/bundle-spectral-ruleset.test.ts | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts index eb9aecc259d2..83284cb60443 100644 --- a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts +++ b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts @@ -105,7 +105,7 @@ rules: const parentPath = '/fake/parent.yaml'; const childPath = '/fake/child.yaml'; - mockReadFile.mockImplementation(async (filePath) => { + mockReadFile.mockImplementation(async filePath => { if (filePath === abs(parentPath)) { return ` extends: @@ -141,7 +141,7 @@ rules: const parentPath = '/fake/parent.yaml'; const childPath = '/fake/child.yaml'; - mockReadFile.mockImplementation(async (filePath) => { + mockReadFile.mockImplementation(async filePath => { if (filePath === abs(parentPath)) { return ` extends: @@ -177,7 +177,7 @@ rules: const aPath = '/fake/a.yaml'; const bPath = '/fake/b.yaml'; - mockReadFile.mockImplementation(async (filePath) => { + mockReadFile.mockImplementation(async filePath => { if (filePath === abs(aPath)) { return `extends:\n - "./b.yaml"\n`; } @@ -198,7 +198,7 @@ rules: files[abs(`/fake/depth${i}.yaml`)] = next; } - mockReadFile.mockImplementation(async (filePath) => { + mockReadFile.mockImplementation(async filePath => { if (files[filePath]) { return files[filePath]; } @@ -255,7 +255,7 @@ rules: const childAPath = '/fake/childA.yaml'; const childBPath = '/fake/childB.yaml'; - mockReadFile.mockImplementation(async (filePath) => { + mockReadFile.mockImplementation(async filePath => { if (filePath === abs(parentPath)) { return `extends:\n - "./childA.yaml"\n - "./childB.yaml"\n`; } @@ -373,7 +373,9 @@ rules: it('rejects an extends entry that is not a valid identifier, path, or URL', async () => { mockReadFile.mockResolvedValueOnce(`extends:\n - "not-a-real-thing"\n`); - await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow(/not a valid spectral identifier|valid URL/i); + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow( + /not a valid spectral identifier|valid URL/i, + ); expect(fetch).not.toHaveBeenCalled(); }); @@ -381,9 +383,7 @@ rules: mockReadFile.mockResolvedValueOnce(`extends:\n - "https://app.localtest.me/remote.yaml"\n`); mockResolvedAddresses(['127.0.0.1']); - await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow( - 'private or loopback address', - ); + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('private or loopback address'); expect(fetch).not.toHaveBeenCalled(); }); @@ -398,13 +398,9 @@ rules: // Local ruleset extends a valid https remote... mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/base.yaml"\n`); // ...but that remote itself extends an http:// localhost URL. - vi.mocked(fetch).mockResolvedValueOnce( - rulesetResponse(`extends:\n - "http://localhost:8000/exec.yaml"\n`), - ); + vi.mocked(fetch).mockResolvedValueOnce(rulesetResponse(`extends:\n - "http://localhost:8000/exec.yaml"\n`)); - await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow( - 'Remote "extends" URL must use https:', - ); + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('Remote "extends" URL must use https:'); }); it('rejects a functions: key inside a nested remote ruleset', async () => { @@ -412,12 +408,12 @@ rules: mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/base.yaml"\n`); // ...but that remote contains a functions: key (the RCE vector). vi.mocked(fetch).mockResolvedValueOnce( - rulesetResponse(`functions:\n - exec\nrules:\n env-check:\n given: "$"\n then:\n function: exec\n`), + rulesetResponse( + `functions:\n - exec\nrules:\n env-check:\n given: "$"\n then:\n function: exec\n`, + ), ); - await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow( - 'failed validation', - ); + await expect(bundleSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('failed validation'); }); }); }); From 6ebcf0607504dd5c3a0a1ddc5d16c8b06f4b68aa Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Sat, 23 May 2026 23:48:52 -0400 Subject: [PATCH 69/75] chore: disallow redirects when fetching in spectral resolver --- packages/insomnia-inso/src/commands/lint-specification.ts | 3 +-- packages/insomnia/src/main/lint-process.mjs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/insomnia-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index 3d593b050a4e..3e8d31b039ca 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -33,7 +33,6 @@ function isSafeRefUrl(href: string): boolean { } // Block hosts that resolve to private/loopback addresses (e.g. *.localtest.me → 127.0.0.1), -// which the static isSafeRefUrl check cannot catch since it only inspects the literal hostname. // Note: This is duplicated in insomnia's main/lint-process.mjs. Remember to mirror changes there as well. async function assertResolvesToPublicHost(hostname: string): Promise { const records = await dns.lookup(hostname, { all: true }); @@ -52,7 +51,7 @@ const safeHttpResolver = { throw new Error(`Failed to resolve "${href}". Only https URLs to public hosts are allowed.`); } await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); - const response = await fetch(href); + const response = await fetch(href, { redirect: 'error' }); if (!response.ok) { throw new Error(`Failed to fetch "${href}": ${response.status} ${response.statusText}`); } diff --git a/packages/insomnia/src/main/lint-process.mjs b/packages/insomnia/src/main/lint-process.mjs index 381ea564c054..3a6aa5446749 100644 --- a/packages/insomnia/src/main/lint-process.mjs +++ b/packages/insomnia/src/main/lint-process.mjs @@ -58,7 +58,7 @@ const safeHttpResolver = { throw new Error(`Failed to fetch "${href}". Only https URLs to public hosts are allowed.`); } await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); - const response = await fetch(href); + const response = await fetch(href, { redirect: 'error' }); if (!response.ok) { throw new Error(`Failed to fetch "${href}": ${response.status} ${response.statusText}`); } From e1ef658519e16c5ad632ac4a2c33da14ff992b99 Mon Sep 17 00:00:00 2001 From: Fares Osman <43153226+fiosman@users.noreply.github.com> Date: Sun, 24 May 2026 00:07:13 -0400 Subject: [PATCH 70/75] chore: adds aria labels for a11y --- ...ject.$projectId.workspace.$workspaceId.spec.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index db82ff114631..567b255f5ae6 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -1067,7 +1067,11 @@ const Component = ({ params }: Route.ComponentProps) => { Existing Ruleset Contents -
{rulesetContent && ( { {selectedRulesetPath ? ( <> - @@ -1141,6 +1149,7 @@ const Component = ({ params }: Route.ComponentProps) => { {selectedRulesetPath ? (