diff --git a/eslint.config.mjs b/eslint.config.mjs index 634d5178adb5..11002318505c 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/package-lock.json b/package-lock.json index f3e2c1a15216..50aa0dc24fc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29191,6 +29191,7 @@ "@sentry/electron": "^6.5.0", "@stoplight/spectral-core": "^1.22.0", "@stoplight/spectral-formats": "^1.8.2", + "@stoplight/spectral-ref-resolver": "^1.0.5", "@stoplight/spectral-ruleset-bundler": "1.7.0", "@stoplight/spectral-rulesets": "^1.22.1", "@tailwindcss/typography": "^0.5.16", @@ -29236,6 +29237,7 @@ "https-proxy-agent": "^7.0.5", "httpsnippet": "^3.0.10", "iconv-lite": "^0.6.3", + "ipaddr.js": "^1.9.1", "isbot": "^5", "isomorphic-git": "1.25.7", "js-yaml": "^4.1.0", 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-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index 531d7540a3c6..3e8d31b039ca 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -2,14 +2,70 @@ 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 os from 'node:os'; 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 { 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), +// 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)) { + throw new Error(`Failed to resolve 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 "${href}". Only https URLs to public hosts are allowed.`); + } + await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); + const response = await fetch(href, { redirect: 'error' }); + if (!response.ok) { + throw new Error(`Failed to fetch "${href}": ${response.status} ${response.statusText}`); + } + return response.text(); + }, +}; + +export const safeRefResolver = new Resolver({ + resolvers: { + http: safeHttpResolver, + https: safeHttpResolver, + }, +}); + export const getRuleSetFileFromFolderByFilename = async (filePath: string) => { try { const filesInSpecFolder = await fs.promises.readdir(path.dirname(filePath)); @@ -31,12 +87,24 @@ export async function lintSpecification({ specContent: string; rulesetFileName?: string; }) { - const spectral = new Spectral(); + const spectral = new Spectral({ resolver: safeRefResolver }); // Use custom ruleset if present let ruleset = oas; try { if (rulesetFileName) { - ruleset = await bundleAndLoadRuleset(rulesetFileName, { fs }); + // 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 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 }); + } } } catch (error) { logger.fatal(error.message); @@ -45,6 +113,7 @@ export async function lintSpecification({ spectral.setRuleset(ruleset as RulesetDefinition); const results = await spectral.run(specContent); + if (!results.length) { logger.log('No linting errors or warnings.'); return { results, isValid: true }; diff --git a/packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts b/packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts new file mode 100644 index 000000000000..6440a3838f79 --- /dev/null +++ b/packages/insomnia-inso/src/commands/safe-ref-resolver.test.ts @@ -0,0 +1,304 @@ +import dns from 'node:dns/promises'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { safeRefResolver } from './lint-specification'; + +vi.mock('node:dns/promises', () => ({ default: { lookup: vi.fn() } })); + +// 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; +} + +describe('safeHttpResolver', () => { + const httpResolver = getHttpResolver(); + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + // Default: hosts resolve to a public address unless a test overrides this. + mockResolvedAddresses(['93.184.216.34']); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('URL validation', () => { + it('rejects invalid URLs', async () => { + await expect( + httpResolver.resolve({ + href: () => 'not-a-url', + }), + ).rejects.toThrow('Failed to resolve "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 "/foo/bar.yaml"'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects http URLs', async () => { + await expect( + httpResolver.resolve({ + href: () => 'http://example.com/schema.yaml', + }), + ).rejects.toThrow('Failed to resolve "http://example.com/schema.yaml"'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects ftp URLs', async () => { + await expect( + httpResolver.resolve({ + href: () => 'ftp://example.com/schema.yaml', + }), + ).rejects.toThrow('Failed to resolve "ftp://example.com/schema.yaml"'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects localhost', async () => { + await expect( + httpResolver.resolve({ + href: () => 'https://localhost/schema.yaml', + }), + ).rejects.toThrow('Failed to resolve "https://localhost/schema.yaml"'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects loopback IPv4 addresses', async () => { + await expect( + httpResolver.resolve({ + href: () => 'https://127.0.0.1/schema.yaml', + }), + ).rejects.toThrow('Failed to resolve "https://127.0.0.1/schema.yaml"'); + + 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('Failed to resolve "https://169.254.169.254/latest/meta-data"'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects IPv6 loopback addresses', async () => { + await expect( + httpResolver.resolve({ + href: () => 'https://[::1]/schema.yaml', + }), + ).rejects.toThrow('Failed to resolve "https://[::1]/schema.yaml"'); + + 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', { redirect: 'error' }); + }); + + 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 "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 "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); + }); + }); + + 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'); + }); + }); +}); diff --git a/packages/insomnia-smoke-test/fixtures/files/custom.spectral.yaml b/packages/insomnia-smoke-test/fixtures/files/custom.spectral.yaml new file mode 100644 index 000000000000..7004ddb37295 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/files/custom.spectral.yaml @@ -0,0 +1,10 @@ +extends: [spectral:oas] +rules: + require-x-smoke-test-marker: + description: info object must contain a custom x-smoke-test-marker field + message: "{{description}}" + severity: error + given: $.info + then: + field: x-smoke-test-marker + function: truthy diff --git a/packages/insomnia-smoke-test/playwright/launch.ts b/packages/insomnia-smoke-test/playwright/launch.ts new file mode 100644 index 000000000000..a002dbd7ddb1 --- /dev/null +++ b/packages/insomnia-smoke-test/playwright/launch.ts @@ -0,0 +1,52 @@ +import type { ElectronApplication, PlaywrightWorkerArgs } from '@playwright/test'; + +import { bundleType, cwd, executablePath, mainPath } from './paths'; + +export interface EnvOptions { + INSOMNIA_DATA_PATH: string; + INSOMNIA_API_URL: string; + INSOMNIA_APP_WEBSITE_URL: string; + INSOMNIA_AI_URL: string; + INSOMNIA_MOCK_API_URL: string; + INSOMNIA_GITHUB_REST_API_URL: string; + INSOMNIA_GITHUB_API_URL: string; + INSOMNIA_GITLAB_API_URL: string; + INSOMNIA_UPDATES_URL: string; + INSOMNIA_SKIP_ONBOARDING: string; + INSOMNIA_PUBLIC_KEY: string; + INSOMNIA_SECRET_KEY: string; + INSOMNIA_SESSION?: string; + INSOMNIA_VAULT_KEY: string; + INSOMNIA_VAULT_SALT: string; + INSOMNIA_VAULT_SRP_SECRET: string; +} + +/** + * Tracks every ElectronApplication launched during a test so the `app` fixture + * teardown can close any that survive (e.g. instances created by relaunch()). + */ +export const liveApps = new Set(); + +/** + * Launches Insomnia with the given env options. Extracted from the `app` fixture + * so tests can perform a real process-level relaunch (see InsomniaApp.relaunch). + */ +export async function launchInsomnia( + playwright: PlaywrightWorkerArgs['playwright'], + envOptions: EnvOptions, +): Promise { + const { ELECTRON_RUN_AS_NODE: _ignored, ...launchEnv } = process.env; + const app = await playwright._electron.launch({ + cwd, + executablePath, + args: bundleType() === 'package' ? ['--no-sandbox'] : ['--no-sandbox', mainPath], + env: { + ...launchEnv, + ...envOptions, + PLAYWRIGHT: 'true', + }, + }); + liveApps.add(app); + app.on('close', () => liveApps.delete(app)); + return app; +} diff --git a/packages/insomnia-smoke-test/playwright/pages/insomnia-app.ts b/packages/insomnia-smoke-test/playwright/pages/insomnia-app.ts index 3cd951a308f5..91eb16d26bd6 100644 --- a/packages/insomnia-smoke-test/playwright/pages/insomnia-app.ts +++ b/packages/insomnia-smoke-test/playwright/pages/insomnia-app.ts @@ -1,5 +1,6 @@ import type { ElectronApplication, Page } from '@playwright/test'; +import { launchInsomnia } from '../launch'; import { ExportModal } from './components/export-modal'; import { NavigationSidebar } from './components/navigation-sidebar'; import { StatusbarComponent } from './components/statusbar'; @@ -7,6 +8,23 @@ import { PreferencesPage } from './preferences'; import { ProjectPage } from './project'; import { WorkspacePage } from './workspace'; +/** + * `ElectronApplication` with the launch-env metadata stashed by the `app` + * fixture. Named here to avoid the duplicated intersection cast that used to + * appear inside `relaunch()` and `launchClone()`. + */ +type StashedApp = ElectronApplication & { + __launchEnv?: Record; + __playwright?: any; +}; + +/** Attach launch-env metadata to an `ElectronApplication` instance. */ +function stashLaunchEnv(app: ElectronApplication, env: Record, playwright: any) { + const s = app as StashedApp; + s.__launchEnv = env; + s.__playwright = playwright; +} + /** * Root facade for the Insomnia E2E Page Object Model. * @@ -42,40 +60,67 @@ export class InsomniaApp { // =========================================================================== /** Statusbar (footer) — always visible. */ - readonly statusbar: StatusbarComponent; + statusbar!: StatusbarComponent; // global export modal - readonly exportModal: ExportModal; + exportModal!: ExportModal; /** Project navigation sidebar — always visible (except login). */ - readonly navigationSidebar: NavigationSidebar; + navigationSidebar!: NavigationSidebar; // =========================================================================== // Page objects // =========================================================================== /** Project page (project/file list). */ - readonly projectPage: ProjectPage; + projectPage!: ProjectPage; /** Workspace page (debug view). */ - readonly workspacePage: WorkspacePage; + workspacePage!: WorkspacePage; /** Preferences page (settings modal). */ - readonly preferencesPage: PreferencesPage; - - constructor( - readonly page: Page, - readonly app: ElectronApplication, - ) { - // Shared components - this.statusbar = new StatusbarComponent(page); - this.exportModal = new ExportModal(page); - this.navigationSidebar = new NavigationSidebar(page); - - // Pages - this.projectPage = new ProjectPage(page, app); - this.workspacePage = new WorkspacePage(page, app); - this.preferencesPage = new PreferencesPage(page, app); + preferencesPage!: PreferencesPage; + + // Private backing fields exposed as readonly getters so that external callers + // cannot reassign them, while `relaunch()` can update them after a relaunch. + private _page: Page; + private _app: ElectronApplication; + + get page(): Page { + return this._page; + } + + get app(): ElectronApplication { + return this._app; + } + + constructor(page: Page, app: ElectronApplication) { + this._page = page; + this._app = app; + this._initPageObjects(); + } + + private _initPageObjects() { + this.statusbar = new StatusbarComponent(this._page); + this.exportModal = new ExportModal(this._page); + this.navigationSidebar = new NavigationSidebar(this._page); + this.projectPage = new ProjectPage(this._page, this._app); + this.workspacePage = new WorkspacePage(this._page, this._app); + this.preferencesPage = new PreferencesPage(this._page, this._app); + } + + /** Read the stashed launch env/playwright from the underlying Electron app, or throw. */ + private _unstash(): { env: Record; playwright: any } { + const s = this._app as StashedApp; + const env = s.__launchEnv; + const playwright = s.__playwright; + if (!env || !playwright) { + throw new Error( + 'Launch env was not stashed on the ElectronApplication. ' + + 'Ensure the test was started via the `app` fixture in playwright/test.ts.', + ); + } + return { env, playwright }; } // =========================================================================== @@ -84,6 +129,66 @@ export class InsomniaApp { /** Press Escape on the app container (closes modals, dropdowns, overlays). */ async pressEscape(): Promise { - await this.page.locator('.app').press('Escape'); + await this._page.locator('.app').press('Escape'); + } + + /** + * Queue a fake response for the next Electron `showOpenDialog` call. + * Consumed by the main-process handler when `PLAYWRIGHT === 'true'`. + */ + async queueOpenDialogResponse(filePaths: string[], canceled = false): Promise { + await this._app.evaluate( + (_electron, payload) => { + const g = globalThis as any; + g.__PLAYWRIGHT_OPEN_DIALOG_QUEUE__ ||= []; + g.__PLAYWRIGHT_OPEN_DIALOG_QUEUE__.push(payload); + }, + { filePaths, canceled }, + ); + } + + /** + * Close the current Electron process and relaunch it reusing the same env + * vars (including INSOMNIA_DATA_PATH) so on-disk state — NeDB, secret store — + * is preserved across the cycle. After this returns, `this.app` and + * `this.page` point at the fresh process; all page objects are rebuilt. + * + * The launch env is stashed on the app instance by the `app` fixture in + * `playwright/test.ts`; callers don't need to pass anything. + */ + async relaunch(): Promise { + const { env, playwright } = this._unstash(); + await this._app.close(); + + const next = await launchInsomnia(playwright, env as any); + stashLaunchEnv(next, env, playwright); + + this._app = next; + this._page = await next.firstWindow({ timeout: 60_000 }); + await this._page.waitForLoadState(); + // Re-seed the konnect PAT like the page fixture does. + await this._page.evaluate(() => (window as any).main.secretStorage.setSecret('konnectPat', 'kpat_test')); + + this._initPageObjects(); + } + + /** + * Launch a second Electron instance with a fresh data path and optional env + * overrides (e.g. a different INSOMNIA_SESSION for a different user). The + * returned InsomniaApp is independent — it has its own page and app references + * and will be cleaned up by the `app` fixture's liveApps teardown. + */ + async launchClone(newDataPath: string, envOverrides: Record = {}): Promise { + const { env, playwright } = this._unstash(); + const cloneEnv = { ...env, INSOMNIA_DATA_PATH: newDataPath, ...envOverrides }; + + const next = await launchInsomnia(playwright, cloneEnv as any); + stashLaunchEnv(next, cloneEnv, playwright); + + const page = await next.firstWindow({ timeout: 60_000 }); + await page.waitForLoadState(); + await page.evaluate(() => (window as any).main.secretStorage.setSecret('konnectPat', 'kpat_test')); + + return new InsomniaApp(page, next); } } diff --git a/packages/insomnia-smoke-test/playwright/test.ts b/packages/insomnia-smoke-test/playwright/test.ts index 4be9625908d2..8072b4f56859 100644 --- a/packages/insomnia-smoke-test/playwright/test.ts +++ b/packages/insomnia-smoke-test/playwright/test.ts @@ -2,11 +2,13 @@ // Read more about creating fixtures https://playwright.dev/docs/test-fixtures import path from 'node:path'; -import type { ElectronApplication, TraceMode } from '@playwright/test'; +import type { ElectronApplication, PlaywrightWorkerArgs, TraceMode } from '@playwright/test'; import { test as baseTest } from '@playwright/test'; +import type { EnvOptions } from './launch'; +import { launchInsomnia, liveApps } from './launch'; import { InsomniaApp } from './pages'; -import { bundleType, cwd, executablePath, mainPath, randomDataPath } from './paths'; +import { randomDataPath } from './paths'; // Throw an error if the condition fails // > Not providing an inline default argument for message as the result is smaller @@ -24,25 +26,6 @@ export function invariant( throw new Error(typeof message === 'function' ? message() : message); } -interface EnvOptions { - INSOMNIA_DATA_PATH: string; - INSOMNIA_API_URL: string; - INSOMNIA_APP_WEBSITE_URL: string; - INSOMNIA_AI_URL: string; - INSOMNIA_MOCK_API_URL: string; - INSOMNIA_GITHUB_REST_API_URL: string; - INSOMNIA_GITHUB_API_URL: string; - INSOMNIA_GITLAB_API_URL: string; - INSOMNIA_UPDATES_URL: string; - INSOMNIA_SKIP_ONBOARDING: string; - INSOMNIA_PUBLIC_KEY: string; - INSOMNIA_SECRET_KEY: string; - INSOMNIA_SESSION?: string; - INSOMNIA_VAULT_KEY: string; - INSOMNIA_VAULT_SALT: string; - INSOMNIA_VAULT_SRP_SECRET: string; -} - interface AESMessage { iv: string; t: string; @@ -97,18 +80,16 @@ export const test = baseTest.extend<{ INSOMNIA_VAULT_SRP_SECRET: userConfig.vaultSrpSecret || '', ...(userConfig.session ? { INSOMNIA_SESSION: JSON.stringify(userConfig.session) } : {}), }; - const { ELECTRON_RUN_AS_NODE: _ignored, ...launchEnv } = process.env; - - const electronApp = await playwright._electron.launch({ - cwd, - executablePath, - args: bundleType() === 'package' ? ['--no-sandbox'] : ['--no-sandbox', mainPath], - env: { - ...launchEnv, - ...options, - PLAYWRIGHT: 'true', - }, - }); + + const electronApp = await launchInsomnia(playwright, options); + // Stash the launch options on the app so InsomniaApp.relaunch() can reuse them + // without re-deriving env from fixtures. + const stashed = electronApp as ElectronApplication & { + __launchEnv?: EnvOptions; + __playwright?: PlaywrightWorkerArgs['playwright']; + }; + stashed.__launchEnv = options; + stashed.__playwright = playwright; const appContext = electronApp.context(); @@ -150,7 +131,16 @@ export const test = baseTest.extend<{ : appContext.tracing.stop()); } - await electronApp.close(); + // Close any apps that are still alive (e.g. relaunched copies). Snapshot + // first: close() fires the 'close' listener which calls liveApps.delete(), + // so iterating the live Set would skip un-visited entries. + for (const live of Array.from(liveApps)) { + try { + await live.close(); + } catch { + // Best-effort: an already-closed app rejects; ignore. + } + } }, page: async ({ app }, use) => { // The plugin window is created after the main window's did-finish-load, so diff --git a/packages/insomnia-smoke-test/server/cloud-sync-api.ts b/packages/insomnia-smoke-test/server/cloud-sync-api.ts index 991860d96e26..f23b07dd0f02 100644 --- a/packages/insomnia-smoke-test/server/cloud-sync-api.ts +++ b/packages/insomnia-smoke-test/server/cloud-sync-api.ts @@ -1,10 +1,10 @@ +import crypto from 'node:crypto'; import zlib from 'node:zlib'; import type { Application } from 'express'; import { json } from 'express'; import type { FieldNode, OperationDefinitionNode } from 'graphql'; import { parse } from 'graphql'; -import forge from 'node-forge'; export interface AESMessage { iv: string; @@ -13,33 +13,35 @@ export interface AESMessage { ad: string; } -// copy from packages/insomnia/src/account/crypt.ts -export function encryptAESBuffer(jwkOrKey: string | JsonWebKey, buff: Buffer, additionalData = ''): AESMessage { - const _b64UrlToHex = (s: string) => { - const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); - // Node.js compatible base64 decoding using Buffer - const decoded = Buffer.from(b64, 'base64').toString('binary'); - return forge.util.bytesToHex(decoded); - }; - const rawKey = typeof jwkOrKey === 'string' ? jwkOrKey : _b64UrlToHex(jwkOrKey.k || ''); - const key = forge.util.hexToBytes(rawKey); - const iv = forge.random.getBytesSync(12); - const cipher = forge.cipher.createCipher('AES-GCM', key); - cipher.start({ - additionalData, - iv, - tagLength: 128, +function jwkToKeyBuf(jwkOrKey: string | JsonWebKey): Buffer { + return typeof jwkOrKey === 'string' + ? Buffer.from(jwkOrKey, 'hex') + : Buffer.from(jwkOrKey.k || '', 'base64url'); +} + +export function decryptAESBuffer(jwkOrKey: string | JsonWebKey, msg: AESMessage): Buffer { + const decipher = crypto.createDecipheriv('aes-256-gcm', jwkToKeyBuf(jwkOrKey), Buffer.from(msg.iv, 'hex'), { + authTagLength: 16, }); - // @ts-expect-error -- TSCONVERSION needs to be converted to string - cipher.update(forge.util.createBuffer(buff)); - cipher.finish(); + decipher.setAuthTag(Buffer.from(msg.t, 'hex')); + if (msg.ad) { + decipher.setAAD(Buffer.from(msg.ad, 'hex')); + } + return Buffer.concat([decipher.update(Buffer.from(msg.d, 'hex')), decipher.final()]); +} + +export function encryptAESBuffer(jwkOrKey: string | JsonWebKey, buff: Buffer, additionalData = ''): AESMessage { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', jwkToKeyBuf(jwkOrKey), iv); + if (additionalData) { + cipher.setAAD(Buffer.from(additionalData, 'binary')); + } + const d = Buffer.concat([cipher.update(buff), cipher.final()]); return { - iv: forge.util.bytesToHex(iv), - // @ts-expect-error -- TSCONVERSION needs to be converted to string - t: forge.util.bytesToHex(cipher.mode.tag), - ad: forge.util.bytesToHex(additionalData), - // @ts-expect-error -- TSCONVERSION needs to be converted to string - d: forge.util.bytesToHex(cipher.output), + iv: iv.toString('hex'), + t: cipher.getAuthTag().toString('hex'), + ad: additionalData ? Buffer.from(additionalData, 'binary').toString('hex') : '', + d: d.toString('hex'), }; } @@ -80,6 +82,12 @@ const cloudSyncProject = [ rootDocumentId: 'wrk_efab8e758b97459bab2659d8fdcf8627', teams, }, + { + id: 'proj_5145140e072d4007a30bfa6630ddae73', + name: 'Design Project', + rootDocumentId: 'wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4', + teams, + }, ]; const commonSnapshotProps = { @@ -216,6 +224,28 @@ const projectSnapshots: Record = { ], }, ], + // design project snapshots + proj_5145140e072d4007a30bfa6630ddae73: [ + { + ...commonSnapshotProps, + created: '2026-01-22T06:20:00.759Z', + id: '2ce4bced4220de84704bee82b6174890ba4a89f0', + name: 'Initial Snapshot', + parent: '0000000000000000000000000000000000000000', + state: [ + { + blob: 'fadc80fb00f45096a75ee3acc10a7e4c9141cff8', + key: 'wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4', + name: 'Design Project', + }, + { + blob: 'd3548e99123d182dd6fdcf89d01a61174c162c00', + key: 'spc_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b5', + name: 'Design Project.yaml', + }, + ], + }, + ], }; const environmentProjectNewCommitSnapshot = [ { @@ -285,11 +315,17 @@ const rawBlobs: Record = { '{"_id":"mcp-req_18ee6d8bec7645ada7c4ac48d416bdb0","authentication":{},"connected":false,"created":1769408435331,"description":"","env":[],"headers":[{"name":"User-Agent","value":"insomnia/12.3.0"}],"mcpStdioAccess":false,"parentId":"wrk_efab8e758b97459bab2659d8fdcf8627","roots":[],"sslValidation":true,"subscribeResources":[],"transportType":"streamable-http","type":"McpRequest","url":""}', '379b74a13b742b573c16dda3ed38abde8cfdb0c3': '{"_id":"mcp-req_18ee6d8bec7645ada7c4ac48d416bdb0","authentication":{},"connected":false,"created":1769408435331,"description":"","env":[],"headers":[{"name":"User-Agent","value":"insomnia/12.3.0"}],"mcpStdioAccess":false,"parentId":"wrk_efab8e758b97459bab2659d8fdcf8627","roots":[],"sslValidation":true,"subscribeResources":[],"transportType":"streamable-http","type":"McpRequest","url":"http://localhost:4010/mcp"}', + // design project blobs + 'fadc80fb00f45096a75ee3acc10a7e4c9141cff8': + '{"_id":"wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4","created":1769408700000,"description":"","name":"Design Project","parentId":null,"scope":"design","type":"Workspace"}', + 'd3548e99123d182dd6fdcf89d01a61174c162c00': + '{"_id":"spc_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b5","contents":"openapi: 3.0.0\ninfo:\n title: Petstore\n version: 1.0.0\npaths: {}","created":1769408700001,"fileName":"Design Project.yaml","parentId":"wrk_f3e4a2b1c9d0e5f6a7b8c9d0e1f2a3b4","type":"ApiSpec"}', }; const defaultBranches = [{ name: 'master' }, { name: 'develop' }]; let deletedProjectIds: string[] = []; let cloudSyncApiEnabled = false; let remoteHasNewCommit = false; +let multiUserMode = false; const resetCloudSyncTestState = () => { Object.keys(newSnapshots).forEach(projectId => { @@ -300,6 +336,7 @@ const resetCloudSyncTestState = () => { }); deletedProjectIds = []; remoteHasNewCommit = false; + multiUserMode = false; }; const getSnapshotsForProject = (projectId: string) => { @@ -335,6 +372,12 @@ export default function setup(app: Application) { return res.status(200).send(); }); + app.post('/__test-config/cloud-sync/team-members', json(), (req, res) => { + const { multi = false } = req.body ?? {}; + multiUserMode = !!multi; + return res.status(200).send(); + }); + // handling response for all graphql requests app.post('/graphql', json(), (req, res) => { if (!cloudSyncApiEnabled) { @@ -495,12 +538,23 @@ export default function setup(app: Application) { } case 'teamMemberKeys': { - return res.status(200).json({ - data: { - teamMemberKeys: { - memberKeys: [ + const memberKeys = [ + { + accountId: 'acct_64a477e6b59d43a5a607f84b4f73e3ce', + publicKey: JSON.stringify({ + alg: 'RSA-OAEP-256', + e: 'AQAB', + ext: true, + key_ops: ['encrypt'], + kty: 'RSA', + n: 'pTQVaUaiqggIldSKm6ib6eFRLLoGj9W-2O4gTbiorR-2b8-ZmKUwQ0F-jgYX71AjYaFn5VjOHOHSP6byNAjN7WzJ6A_Z3tytNraLoZfwK8KdfflOCZiZzQeD3nO8BNgh_zEgCHStU61b6N6bSpCKjbyPkmZcOkJfsz0LJMAxrXvFB-I42WYA2vJKReTJKXeYx4d6L_XGNIoYtmGZit8FldT4AucfQUXgdlKvr4_OZmt6hgjwt_Pjcu-_jO7m589mMWMebfUhjte3Lp1jps0MqTOvgRb0FQf5eoBHnL01OZjvFPDKeqlvoz7II9wFNHIKzSvgAKnyemh6DiyPuIukyQ', + }), + autoLinked: false, + }, + ...(multiUserMode + ? [ { - accountId: 'acct_64a477e6b59d43a5a607f84b4f73e3ce', + accountId: 'acct_74b577e6b59d43a5a607f84b4f73e3df', publicKey: JSON.stringify({ alg: 'RSA-OAEP-256', e: 'AQAB', @@ -511,8 +565,12 @@ export default function setup(app: Application) { }), autoLinked: false, }, - ], - }, + ] + : []), + ]; + return res.status(200).json({ + data: { + teamMemberKeys: { memberKeys }, }, }); } @@ -567,7 +625,14 @@ export default function setup(app: Application) { case 'blobsCreate': { const blobs = variables.blobs || []; blobs.forEach((blob: { id: string; content: string }) => { - newBlobs[blob.id] = blob.content; + try { + const aesMsg: AESMessage = JSON.parse(blob.content); + const decryptedBuf = decryptAESBuffer(symmetricKey, aesMsg); + newBlobs[blob.id] = zlib.gunzipSync(decryptedBuf).toString('utf8'); + } catch (e) { + console.error('[mock] blobsCreate: decrypt failed for blob', blob.id, e); + newBlobs[blob.id] = blob.content; + } }); return res.status(200).json({ data: { diff --git a/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts b/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts new file mode 100644 index 000000000000..47eae71f1540 --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/custom-lint-rules.test.ts @@ -0,0 +1,416 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import YAML from 'yaml'; + +import playwrightConfig from '../../playwright.config'; +import { launchInsomnia } from '../../playwright/launch'; +import { InsomniaApp } from '../../playwright/pages'; +import { getFixturePath, randomDataPath } from '../../playwright/paths'; +import { test } from '../../playwright/test'; + +// @ts-expect-error playwrightConfig.webServer.url must exist +const devServerUrl: string = playwrightConfig?.webServer?.url || 'http://127.0.0.1:4010'; + +const RULESET_FIXTURE = getFixturePath('files/custom.spectral.yaml'); +const RULESET_RULE_NAME = 'require-x-smoke-test-marker'; +const GIT_LINT_PROJECT_NAME = 'Git Lint Rules Test'; + +/** + * User B session: different identity (accountId / email / sessionId) but the + * same RSA key pair and symmetric key as user A so the shared + * `encryptedSymmetricKey` in the mock is still decryptable. This lets us test + * a two-user collaboration scenario without generating a second key pair. + */ +const USER_B_SESSION = { + id: 'sess_74b577e6b59d43a5a607f84b4f73e3df', + sessionExpiry: new Date(2_147_483_647_000), + publicKey: { + alg: 'RSA-OAEP-256', + e: 'AQAB', + ext: true, + key_ops: ['encrypt'], + kty: 'RSA', + n: 'pTQVaUaiqggIldSKm6ib6eFRLLoGj9W-2O4gTbiorR-2b8-ZmKUwQ0F-jgYX71AjYaFn5VjOHOHSP6byNAjN7WzJ6A_Z3tytNraLoZfwK8KdfflOCZiZzQeD3nO8BNgh_zEgCHStU61b6N6bSpCKjbyPkmZcOkJfsz0LJMAxrXvFB-I42WYA2vJKReTJKXeYx4d6L_XGNIoYtmGZit8FldT4AucfQUXgdlKvr4_OZmt6hgjwt_Pjcu-_jO7m589mMWMebfUhjte3Lp1jps0MqTOvgRb0FQf5eoBHnL01OZjvFPDKeqlvoz7II9wFNHIKzSvgAKnyemh6DiyPuIukyQ', + }, + encPrivateKey: { + iv: '3a1f2bdb8acbf15f469d57a2', + t: '904d6b1bc0ece8e5df6fefb9efefda7c', + d: '2a7b0c4beb773fa3e3c2158f0bfa654a88c4041184c3b1e01b4ddd2da2c647244a0d66d258b6abb6a9385251bf5d79e6b03ef35bdfafcb400547f8f88adb8bceb7020f2d873d5a74fb5fc561e7bd67cea0a37c49107bf5c96631374dc44ddb1e4a8b5688dc6560fc6143294ed92c3ad8e1696395dfdf15975aa67b9212366dbfcb31191e4f4fe3559c89a92fb1f0f1cc6cbf90d8a062307fce6e7701f6f5169d9247c56dae79b55fba1e10fde562b971ca708c9a4d87e6e9d9e890b88fa0480360420e610c4e41459570e52ae72f349eadf84fc0a68153722de3280becf8a1762e7faebe964f0ad706991c521feda3440d3e1b22f2c221a80490359879bd47c0d059ace81213c74a1e192dbebd8a80cf58c9eb1fe461a971b88d3899baf4c4ef7141623c93fb4a54758f5e1cf9ee35cd00777fa89b24e4ded57219e770de2670619c6e971935c61ae72e3276cf8db49dfa0e91c68222f02d7e0c69b399af505de7e5a90852d83e0a30934b0362db986f3aaefaaf1a96fef3e8165287a3a7f0ee1e072d9dee3aefb86194e1d877d6b34529d45a70ec4573c35a7fe27833c77c3154b0ad02187e4fcecd408bcf4b29a85a5dc358cb479140f4983fcd936141f581764669651530af97d2b7d9416aea7de67e787f3e29ae3eba6672bcd934dc1e308783aa63a4ab46d48d213cf53ad6bd8828011f5bfa3aa5ee24551c694e829b54c93b1dda6c3ddda04756d68a28bec8d044c8af4147680dc5b972d0ca74299b0ab6306b9e7b99bf0557558df120455a272145b7aa792654730f3d670b76d72408f5ce1cf5fbd453d2903fa72cf26397437854ba8abbb731a8107f6a86a01fa98edc81bb42a4c1330f779e7a0fbd1820eaed78e03e40a996e03884b707556be06fd14ee8f4035469210d1d2bb8f58285fc2ab6de3d3cc0e4e1f40c6d9d24b50dc8e2e2374a0aff52031b3736c2982133bb19dd551ce1f953f4ba02b0cf53382c15752e202c138cb42b2322df103ff17fd886dfd5f992b711673cdf16048c4bff19038138b161c2e1783b85fc7b965a91ac4795fcbfebf827940cacdeae57946863aee027df43b36612f3cb8f34dc44396e87c564bf10f5b1a9dfbd6da3d7f4f65024b0b4f8ce51d01c230840941fc4523b17eb1c2522032f410e8328239a11a15ab755c32945ce52966d5bfb4666909ed2ca04d536e4bf92091563dd44d46cbb35e53c2481400058ab3b52a0280d262551073f61db125ee280e2cc1ec0bdf9c4817824261465011e34c2296411384f7f5e16742157c5520f137631edf498aa39c7c32b107e3634cbeb70feea19a233c8bd939d665135c9f7c1bb33cb47edc58bdbbcde9b0b9eb73a46642e4639289a62638fb7813e1eeaadd105c803de8357236f33c4bcf31a876b5867591af8f165eba0b35cf0b0886af17dab35a6a39f8f576387d6ffb9e677ee46fc0f11ff069a2a068fce441ff8f4125095fad228c2bf45c788d641941ed13c0a16fffcafd7c7eff11bb7550c0b7d54eebdbd2066e3bbdb47aaee2b5f1e499726324a40015458c7de1db0abe872594d8e6802deff7ea9518bdb3a3e46f07139267fd67dc570ba8ab04c2b37ce6a34ec73b802c7052a2eef0cae1b0979322ef86395535db80cf2a9a88aa7c2e5cc28a93612a8dafe1982f741d7cec28a866f6c09dba5b99ead24c3df0ca03c6c5afae41f3d39608a8f49b0d6a0b541a159409791c25ede103eb4f79cfbd0cc9c9aa6b591755c1e9fd07b5b9e38ed85b5939e65d127256f6a4c078f8c9d655c4f072f9cbcfb2e1e17eaa83dc62aaab2a6dc3735ee76ce7a215740f795f1fbe7136c7734ae3714438015e8fc383d63775a8abddb23cbc5f906c046bb0b5b31d492a7c151b40ea82c7c966e25820641c55b343b89d6378f90de5983fa76547e9d6c634effdf019a0fd9b6d3e488a5aa94f0710d517ba4f7c1ed82f9f3072612e953e036c0ec7f3c618368362f6da6f3af76056a66aef914805cc8b628f1c11695f760b535ded9ff66727273ae7e12d67a01243d75f22fec8ed1b043122a211c923aa92ecbbe01dd0d7195c3c0e09a2a6ab3eca354963122d5a0ec16e2b2b81b0ddce6ec0a312c492a96a4fd392f1deb6a1f3318541a3f87e5c9e73ee7edd3b855910f412789e25038108e1eaae04dcfb02b4d958c00c630dc8caa87a40798ce7156d2ade882e68832d39fe8f9bce6a995249a7383013a5093c4af55c3b7232de0f2593d82c30b8dabd0784455037f25f6bb66a6d0d8f72bc7be0dee2d0a8af44bb4e143257d873268d331722c3253ea5c004e72daf04c875e2054f2b4b2bca2979fd046a1e835600045edf2f159d851a540a91a1ab8fbcb64594d21942bbaa2160535d32496ba7ce4a76c6bdeb9bb4c5cab7bed1ae26564058d0be125803d7019b83b3953c4b0cc1f8299c4edcf6a5faa4765092412d368b277689900e71fb5d47581057adaa2dd494e0f66dc1aa16f3741973b0d9ffa1728aeafab84b777394a7afae0f8eabaa6b740f1c60ca26469f0c9356ec880ad6f4dc01b99bd14d7a4bb8afc97662a9e68b0155e4cdf3caa3402819ac6ce562c8fe06edb50a31cfd7a', + ad: '', + }, + symmetricKey: { + alg: 'A256GCM', + ext: true, + k: 'w62OJNWF4G8iWA8ZrTpModiY8dICyHI7ko1vMLb877g=', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }, + email: 'insomnia-user-b@konghq.com', + accountId: 'acct_74b577e6b59d43a5a607f84b4f73e3df', + firstName: 'User', + lastName: 'B', +}; + +/** + * Open a fresh design document seeded with the Pet Store example. The Pet Store + * does not include `info.x-smoke-test-marker`, so once our custom ruleset is + * uploaded the rule defined in fixtures/files/custom.spectral.yaml will fire. + */ +async function openPetStoreDesignDoc(page: Page) { + await page.getByRole('button', { name: 'Create document' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click(); + await page.getByText('Use example').click(); + await page.getByText('Pet Store').click(); + await expect.soft(page.locator('.pane-one').getByTestId('CodeEditor')).toContainText('openapi: 3.0'); +} + +async function uploadRuleset(insomnia: InsomniaApp, page: Page) { + await insomnia.queueOpenDialogResponse([RULESET_FIXTURE]); + await page.getByLabel('Upload custom ruleset').click(); + // Soft assert per ESLint rule; a failure here will surface downstream as well. + await expect.soft(page.getByRole('button', { name: 'View selected ruleset content' })).toBeVisible({ timeout: 10_000 }); +} + +async function removeRuleset(page: Page) { + await page.getByLabel('Remove custom ruleset').click(); + await page.getByRole('button', { name: 'Remove', exact: true }).click(); + await expect.soft(page.getByText('Default OAS Ruleset')).toBeVisible({ timeout: 10_000 }); +} + +async function commitAndPush(page: Page, message: string) { + await page.getByLabel('Git Sync').click(); + await page.getByLabel('Commit').click({ delay: 500 }); + // Stage all unstaged rows in the commit dialog (bounded to avoid an infinite loop). + const plusIcons = page.locator('[data-icon="plus"]'); + for (let i = 0; i < 50 && (await plusIcons.count()) > 0; i++) { + await plusIcons.first().click(); + } + await page.getByRole('textbox', { name: 'Message' }).fill(message); + await page.getByRole('button', { name: 'Commit and push' }).click(); + // The dialog closes on a successful push; fail the test if it stays open. + await page.getByRole('dialog').waitFor({ state: 'hidden', timeout: 15_000 }); +} + +async function addAccessTokenGitCredential(insomnia: InsomniaApp) { + await insomnia.statusbar.openPreferences(); + await insomnia.preferencesPage.switchToPreferenceTab('Credentials'); + await insomnia.preferencesPage.credentialsTab.addAccessTokenGitCredential(); + await expect.soft(insomnia.page.getByRole('row', { name: 'Custom Git Credential' })).toBeVisible(); + await insomnia.preferencesPage.closePreferences(); +} + +async function createGitDesignDocument(insomnia: InsomniaApp, page: Page, projectName: string) { + await insomnia.navigationSidebar.selectProjectDropdownOption({ + actionName: 'Design Document', + projectName, + }); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Lint Test Spec'); + await page.getByRole('textbox', { name: /File name/ }).fill('lint_test_spec'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByRole('dialog').waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}); + // Populate with Pet Store example so the lint panel renders (requires non-empty apiSpec.contents). + await page.getByText('Use example').click(); + await page.getByText('Pet Store').click(); + await expect.soft(page.locator('.pane-one').getByTestId('CodeEditor')).toContainText('openapi: 3.0'); +} + +/** + * Find the RepoFileWatcher mirror directory for the first GitRepository in + * `dataPath`. Polls for up to 6 seconds because NeDB flushes to disk + * asynchronously after the project is created. + */ +async function gitRepoMirrorPath(dataPath: string): Promise { + const dbPath = path.join(dataPath, 'insomnia.GitRepository.db'); + for (let attempt = 0; attempt < 30; attempt++) { + try { + const content = await fs.promises.readFile(dbPath, 'utf8'); + const repos = content + .split('\n') + .filter(Boolean) + .map((l: string) => JSON.parse(l)) + .filter((r: any) => !r.$$deleted); + if (repos.length > 0) { + return path.join(dataPath, 'version-control', 'git', repos[0]._id); + } + } catch { + // file not yet written + } + await new Promise(resolve => setTimeout(resolve, 200)); + } + throw new Error(`No GitRepository found in ${dbPath} after waiting`); +} + +test.describe('Custom Spectral Lint Rules', () => { + // --------------------------------------------------------------------------- + // 1. Upload + lint + reopen (full process relaunch) + // --------------------------------------------------------------------------- + test('upload custom ruleset, lint reflects it, persists after app relaunch', async ({ insomnia }) => { + await openPetStoreDesignDoc(insomnia.page); + + // Baseline: Pet Store should produce no lint problems under default OAS ruleset. + await expect.soft(insomnia.page.getByText('Default OAS Ruleset')).toBeVisible(); + await expect.soft(insomnia.page.getByText('No lint problems')).toBeVisible({ timeout: 15_000 }); + + await uploadRuleset(insomnia, insomnia.page); + + // Our custom rule should now fire on Pet Store. + await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeVisible({ + timeout: 15_000, + }); + + // Close the Electron process and relaunch it against the same data path. + // This exercises the full persistence boundary: NeDB on disk, main-process + // startup, renderer init, clientLoader. + await insomnia.relaunch(); + + // Re-navigate to the same design document after relaunch. The workspace is + // created with the default name 'My Design Document'. + await insomnia.page.getByLabel('My Design Document').first().click(); + + await expect + .soft(insomnia.page.getByRole('button', { name: 'View selected ruleset content' })) + .toBeVisible({ timeout: 15_000 }); + await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeVisible({ + timeout: 15_000, + }); + }); + + // --------------------------------------------------------------------------- + // 2. Remove + reopen (full process relaunch) + // --------------------------------------------------------------------------- + test('remove custom ruleset reverts to default OAS, persists after app relaunch', async ({ insomnia }) => { + await openPetStoreDesignDoc(insomnia.page); + await uploadRuleset(insomnia, insomnia.page); + await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeVisible({ + timeout: 15_000, + }); + + await removeRuleset(insomnia.page); + await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeHidden(); + await expect.soft(insomnia.page.getByText('No lint problems')).toBeVisible({ timeout: 15_000 }); + + await insomnia.relaunch(); + await insomnia.page.getByLabel('My Design Document').first().click(); + + await expect.soft(insomnia.page.getByText('Default OAS Ruleset')).toBeVisible({ timeout: 15_000 }); + await expect.soft(insomnia.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })).toBeHidden(); + }); + + // --------------------------------------------------------------------------- + // 3. Cloud sync — upload, reload, and three sync round-trips + // + // The custom ruleset is stored as a `ProjectLintRuleset` doc (canSync = true) + // parented to the project, so it rides cloud-sync push/pull with the rest of + // the project's resources. + // + // Sub-tests: + // 3a. basic upload within a synced design project (smoke: UI works) + // 3b. same user, different machine (push → pull round-trip) + // 3c. different users, same project (user A push → user B pull) + // --------------------------------------------------------------------------- + test.describe('within a cloud-sync project', () => { + test.beforeAll(async () => { + await fetch(`${devServerUrl}/__test-config/cloud-sync`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ enabled: true }), + }); + }); + + test.afterAll(async () => { + await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' }); + await fetch(`${devServerUrl}/__test-config/cloud-sync`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ enabled: false }), + }); + }); + + // ------------------------------------------------------------------------- + // 3a. Basic upload in a cloud-sync design project + // ------------------------------------------------------------------------- + test('upload custom ruleset in a synced design project', async ({ page, insomnia }) => { + await insomnia.navigationSidebar.fetchUnsyncedWorkspace('Design Project'); + + await uploadRuleset(insomnia, page); + + await page.reload(); + await expect.soft(page.getByRole('button', { name: 'View selected ruleset content' })).toBeVisible({ + timeout: 15_000, + }); + }); + + // ------------------------------------------------------------------------- + // 3b. Same user, different machine — push/pull round-trip + // + // Machine A uploads the ruleset, commits, and pushes. Machine B starts + // fresh (separate dataPath, same user session) and pulls the project; the + // mock now stores pushed blobs and serves them back after decrypting the + // incoming AES-GCM payload, so machine B should receive the + // ProjectLintRuleset in its sync pull. + // ------------------------------------------------------------------------- + test.describe.serial('same user, different machine (push → pull round-trip)', () => { + test.beforeAll(async () => { + await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' }); + }); + + test.afterAll(async () => { + await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' }); + }); + + test('machine A - upload ruleset and push to remote', async ({ page, insomnia }) => { + await insomnia.navigationSidebar.fetchUnsyncedWorkspace('Design Project'); + + await uploadRuleset(insomnia, page); + + await commitAndPush(page, 'Add custom lint ruleset'); + }); + + test('machine B - pull from fresh state and see the ruleset', async ({ page, insomnia }) => { + await insomnia.navigationSidebar.fetchUnsyncedWorkspace('Design Project'); + + await expect + .soft(page.getByRole('button', { name: 'View selected ruleset content' })) + .toBeVisible({ timeout: 15_000 }); + await expect + .soft(page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })) + .toBeVisible({ timeout: 15_000 }); + }); + }); + + // ------------------------------------------------------------------------- + // 3c. Different users, same project + // + // User A uploads the ruleset and pushes. User B (different accountId, + // same symmetric key so the project blobs are decryptable) starts on a + // fresh machine, pulls the project, and verifies the ruleset survived the + // sync handoff. + // ------------------------------------------------------------------------- + test.describe.serial('different users, same project', () => { + test.beforeAll(async () => { + await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' }); + await fetch(`${devServerUrl}/__test-config/cloud-sync/team-members`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ multi: true }), + }); + }); + + test.afterAll(async () => { + await fetch(`${devServerUrl}/__test-config/cloud-sync/reset`, { method: 'POST' }); + }); + + test('user A - upload ruleset and push', async ({ page, insomnia }) => { + await insomnia.navigationSidebar.fetchUnsyncedWorkspace('Design Project'); + + await uploadRuleset(insomnia, page); + + await commitAndPush(page, 'User A: add custom lint ruleset'); + }); + + // Launch user B directly to avoid an idle user-A Electron process. + test('user B - pull project and see user A ruleset', async ({ playwright, userConfig }) => { + const echoServer = 'http://localhost:4010'; + const cloneEnv = { + INSOMNIA_DATA_PATH: randomDataPath(), + INSOMNIA_API_URL: echoServer, + INSOMNIA_APP_WEBSITE_URL: `${echoServer}/website`, + INSOMNIA_AI_URL: `${echoServer}/ai`, + INSOMNIA_GITHUB_REST_API_URL: `${echoServer}/github-api/rest`, + INSOMNIA_GITHUB_API_URL: `${echoServer}/github-api/graphql`, + INSOMNIA_GITLAB_API_URL: `${echoServer}/gitlab-api`, + INSOMNIA_UPDATES_URL: echoServer, + INSOMNIA_MOCK_API_URL: 'https://mock-stage.insomnia.run', + INSOMNIA_SKIP_ONBOARDING: 'true', + INSOMNIA_PUBLIC_KEY: userConfig.publicKey, + INSOMNIA_SECRET_KEY: userConfig.secretKey, + INSOMNIA_VAULT_KEY: userConfig.vaultKey || '', + INSOMNIA_VAULT_SALT: userConfig.vaultSalt || '', + INSOMNIA_VAULT_SRP_SECRET: userConfig.vaultSrpSecret || '', + INSOMNIA_SESSION: JSON.stringify(USER_B_SESSION), + }; + const userBApp = await launchInsomnia(playwright, cloneEnv as any); + (userBApp as any).__launchEnv = cloneEnv; + (userBApp as any).__playwright = playwright; + const userBPage = await userBApp.firstWindow({ timeout: 60_000 }); + await userBPage.waitForLoadState(); + await userBPage.evaluate(() => (window as any).main.secretStorage.setSecret('konnectPat', 'kpat_test')); + const machineB = new InsomniaApp(userBPage, userBApp); + + await machineB.navigationSidebar.fetchUnsyncedWorkspace('Design Project'); + + await expect + .soft(machineB.page.getByRole('button', { name: 'View selected ruleset content' })) + .toBeVisible({ timeout: 15_000 }); + await expect + .soft(machineB.page.getByRole('option', { name: new RegExp(RULESET_RULE_NAME) })) + .toBeVisible({ timeout: 15_000 }); + }); + }); + }); + + // --------------------------------------------------------------------------- + // 4. Git project parity — bidirectional RepoFileWatcher sync + // + // The RepoFileWatcher mirrors NeDB ↔ .spectral.yaml in the workspace's + // checkout directory at ${userData}/version-control/git/${repoId}/. + // + // 4a. DB→FS: upload via UI; file should appear on disk. + // 4b. FS→DB: write .spectral.yaml directly; badge should appear. + // 4c. Removal: remove via UI; file should be deleted from disk. + // --------------------------------------------------------------------------- + test.describe('within a git-sync project', () => { + test.slow(); + + test.beforeEach(async ({ insomnia, request }) => { + await request.post('http://127.0.0.1:4010/v1/test-utils/git/setup'); + await addAccessTokenGitCredential(insomnia); + await insomnia.projectPage.createGitSyncProject(GIT_LINT_PROJECT_NAME); + await createGitDesignDocument(insomnia, insomnia.page, GIT_LINT_PROJECT_NAME); + }); + + test.afterEach(async ({ request }) => { + await request.delete('http://127.0.0.1:4010/v1/test-utils/git/setup'); + }); + + test('4a. upload ruleset via UI mirrors to .spectral.yaml on disk', async ({ insomnia, dataPath }) => { + const mirrorDir = await gitRepoMirrorPath(dataPath); + await uploadRuleset(insomnia, insomnia.page); + + const spectralPath = path.join(mirrorDir, '.spectral.yaml'); + await expect.poll(() => fs.existsSync(spectralPath), { timeout: 10_000 }).toBe(true); + + const onDisk = await fs.promises.readFile(spectralPath, 'utf8'); + const fixture = await fs.promises.readFile(RULESET_FIXTURE, 'utf8'); + expect.soft(YAML.parse(onDisk)).toEqual(YAML.parse(fixture)); + }); + + test('4b. .spectral.yaml on disk syncs to UI badge', async ({ insomnia, dataPath }) => { + await expect.soft(insomnia.page.getByText('Default OAS Ruleset')).toBeVisible({ timeout: 10_000 }); + + const mirrorDir = await gitRepoMirrorPath(dataPath); + const fixture = await fs.promises.readFile(RULESET_FIXTURE, 'utf8'); + await fs.promises.writeFile(path.join(mirrorDir, '.spectral.yaml'), fixture, 'utf8'); + + // The RepoFileWatcher debounces at 300 ms then writes to NeDB; the renderer + // picks up the db.changes IPC event and re-renders. + await expect + .soft(insomnia.page.getByRole('button', { name: 'View selected ruleset content' })) + .toBeVisible({ timeout: 15_000 }); + }); + + test('4c. remove ruleset via UI deletes .spectral.yaml from disk', async ({ insomnia, dataPath }) => { + const mirrorDir = await gitRepoMirrorPath(dataPath); + await uploadRuleset(insomnia, insomnia.page); + + const spectralPath = path.join(mirrorDir, '.spectral.yaml'); + await expect.poll(() => fs.existsSync(spectralPath), { timeout: 10_000 }).toBe(true); + + await removeRuleset(insomnia.page); + await expect.poll(() => !fs.existsSync(spectralPath), { timeout: 10_000 }).toBe(true); + }); + }); +}); 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(); }); diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 9995d1830e6a..e6752756b4f8 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -67,6 +67,7 @@ "@sentry/electron": "^6.5.0", "@stoplight/spectral-core": "^1.22.0", "@stoplight/spectral-formats": "^1.8.2", + "@stoplight/spectral-ref-resolver": "^1.0.5", "@stoplight/spectral-ruleset-bundler": "1.7.0", "@stoplight/spectral-rulesets": "^1.22.1", "@tailwindcss/typography": "^0.5.16", @@ -112,6 +113,7 @@ "https-proxy-agent": "^7.0.5", "httpsnippet": "^3.0.10", "iconv-lite": "^0.6.3", + "ipaddr.js": "^1.9.1", "isbot": "^5", "isomorphic-git": "1.25.7", "js-yaml": "^4.1.0", 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 new file mode 100644 index 000000000000..c4608a8f024a --- /dev/null +++ b/packages/insomnia/src/common/__tests__/spectral-ruleset-validator.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from 'vitest'; + +import { 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('toArray()', () => { + it('returns [] for undefined', () => { + const value = undefined; + expect(toArray(value)).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', () => { + expectInvalid('functions:\n - exec\n', /unsupported top-level/i); + }); + + 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); + }); + + // 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 +`); + }); +}); diff --git a/packages/insomnia/src/common/bundle-spectral-ruleset.ts b/packages/insomnia/src/common/bundle-spectral-ruleset.ts new file mode 100644 index 000000000000..523c68c24a7f --- /dev/null +++ b/packages/insomnia/src/common/bundle-spectral-ruleset.ts @@ -0,0 +1,232 @@ +import dns from 'node:dns/promises'; +import fs from 'node:fs'; +import path from 'node:path'; + +import YAML from 'yaml'; + +import { isPrivateOrLoopbackHost } from './private-host'; +import { ALLOWED_EXTENDS_IDENTIFIERS, toArray, validateSpectralRuleset } from './spectral-ruleset-validator'; + +const MAX_EXTENDS_DEPTH = 5; + +const ALLOWED_EXTENSIONS = ['.yaml', '.yml']; + +const REMOTE_FETCH_TIMEOUT_MS = 10_000; + +// 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[]; +}; + +// 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}`); + } + 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}`); + } + 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 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); + 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 Ruleset; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +// 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]; + const targetVal = target[key]; + target[key] = isPlainObject(targetVal) && isPlainObject(sourceVal) ? { ...targetVal, ...sourceVal } : sourceVal; + } +} + +// 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); + } catch { + throw new Error(`"extends" entry "${entry}" is not a valid spectral identifier, local path, or URL.`); + } +} + +// 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}`); + } + 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 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 { + 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; +} + +// 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}`); + } + if (visited.has(url.href)) { + throw new Error(`"extends" cycle detected at ${url.href}`); + } + + const ruleset = await readRemoteRuleset(url); + 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.`, + ); + } + if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry)) continue; + await validateRemoteExtends(parseRemoteExtendsUrl(entry, url), nextVisited, depth + 1); + } +} + +// 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, +): Promise { + const absolute = path.resolve(filePath); + assertAllowed(absolute, visited, depth, rootDir); + + const ruleset = await readRuleset(absolute); + const baseDir = path.dirname(absolute); + const nextVisited = new Set(visited).add(absolute); + + 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)) { + 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 (spectral:oas, …) — Spectral resolves these locally; carry through. + if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry)) { + remainingExtends.push(entry); + continue; + } + // 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 (!entry.startsWith('./') && !entry.startsWith('../') && !path.isAbsolute(entry)) { + 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); + if (childRuleset.extends) { + remainingExtends.push(...childRuleset.extends); + } + mergeInto(flattenedRuleset, childRuleset); // child takes precedence over parent + } + + // 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); + + // 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; +} + +// 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); + 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/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/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/common/spectral-ruleset-validator.ts b/packages/insomnia/src/common/spectral-ruleset-validator.ts new file mode 100644 index 000000000000..f478b1c6a215 --- /dev/null +++ b/packages/insomnia/src/common/spectral-ruleset-validator.ts @@ -0,0 +1,183 @@ +import YAML from 'yaml'; + +export type SpectralRulesetValidationResult = { isValid: true } | { isValid: false; error: string }; + +// 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. +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 = [ + 'alphabetical', + 'casing', + 'defined', + 'enumeration', + 'falsy', + 'length', + 'pattern', + 'schema', + 'truthy', + 'typedEnum', + 'undefined', + 'unreferencedReusableObject', + 'or', + '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']; + +export function toArray(value: T | T[] | undefined): T[] { + //no extends key in the ruleset + if (value === undefined) { + return []; + } + return Array.isArray(value) ? value : [value]; // handles both array and single value cases for extends in a given ruleset +} + +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 new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +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 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.`; + } + + // only Spectral's documented built-in functions are reachable. + 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; +} + +// 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.`; + } + if (typeof entry !== 'string') { + return '"extends" entries must be strings.'; + } + } + return null; +} + +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 === 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; + } + } + return null; +} + +function validateRuleBody(ruleName: string, rule: Record): string | null { + for (const given of toArray(rule.given)) { + if (typeof given === 'string' && containsPrototypePollution(given)) { + return `Rule "${ruleName}" has a "given" expression containing a disallowed token.`; + } + } + + if (typeof rule.documentationUrl === 'string' && !isSafeUrl(rule.documentationUrl)) { + return `Rule "${ruleName}" has a "documentationUrl" with a disallowed URL scheme.`; + } + + const thenEntries = toArray(rule.then); + for (const then of thenEntries) { + if (then === null || typeof then !== 'object') { + continue; + } + const thenError = validateThen(ruleName, then as Record); + if (thenError) { + return thenError; + } + } + return null; +} + +export function validateSpectralRuleset(content: string): SpectralRulesetValidationResult { + if (typeof content !== 'string' || content.trim() === '') { + return fail('Ruleset file is empty.'); + } + + let parsed: unknown; + try { + parsed = YAML.parse(content); + } catch { + return fail(`Ruleset is not valid YAML or JSON`); + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return fail('Ruleset must be an object at the top level.'); + } + + 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.'); + } + + const disallowed = keys.filter(key => !ALLOWED_TOP_LEVEL_PROPERTIES.includes(key)); + if (disallowed.length > 0) { + return fail(`Ruleset contains unsupported top-level keys. Only "rules" and "extends" are allowed.`); + } + + if ('extends' in ruleset) { + const extendsError = validateExtends(ruleset.extends); + if (extendsError) { + return fail(extendsError); + } + } + + if ('rules' in ruleset) { + const rulesError = validateRules(ruleset.rules); + if (rulesError) { + return fail(rulesError); + } + } + + return { isValid: true }; +} diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index ee6fe4e3732d..7a13898d588e 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -279,6 +279,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), @@ -295,6 +296,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/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/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..7e187c08901b --- /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 existing = await db.findOne(type, { + parentId: projectId, + }); + + if (!existing) { + return db.docCreate(type, { ...patch, parentId: projectId }); + } + + return db.docUpdate(existing, patch); +} + +export function remove(projectId: string) { + return db.removeWhere(type, { parentId: projectId }); +} 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..528d174ed654 --- /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 = false; + +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/main/__tests__/bundle-spectral-ruleset.test.ts b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts new file mode 100644 index 000000000000..83284cb60443 --- /dev/null +++ b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts @@ -0,0 +1,419 @@ +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest'; + +// Mock fs and dns so no real files or DNS lookups are needed. +vi.mock('node:fs', () => ({ + default: { + promises: { + readFile: vi.fn(), + }, + }, +})); +vi.mock('node:dns/promises', () => ({ + default: { + lookup: vi.fn(), + }, +})); + +import dns from 'node:dns/promises'; +import fs from 'node:fs'; + +import { bundleSpectralRuleset } from '~/common/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); +} + +// 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', () => { + 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 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('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'; + 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); + }); + + describe('remote URL extends', () => { + it('validates a remote ruleset and preserves the URL in extends', 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'); + // Local rules are merged in; remote URL is preserved for Spectral to fetch at lint time. + expect(result).toContain('local-rule'); + 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 () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/exec.yaml"\n`); + vi.mocked(fetch).mockResolvedValue( + rulesetResponse( + ` +functions: + - exec +rules: + env-check: + given: "$" + then: + function: exec +`, + ), + ); + + // 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 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:\n - "./b.yaml"\nrules:${VALID_RULE}`); + } + if (href === 'https://example.com/b.yaml') { + return rulesetResponse(`rules:${VALID_RULE}`); + } + throw new Error(`Unexpected fetch call: ${href}`); + }); + + const result = await bundleSpectralRuleset('/fake/ruleset.yaml'); + // 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 () => { + 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 () => { + 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( + /not a valid spectral identifier|valid URL/i, + ); + 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'); + }); + + 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'); + }); + }); +}); 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/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 57b70ca6435d..1e5674d2a3d7 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -85,6 +85,7 @@ export type HandleChannels = | 'insecureReadFileWithEncoding' | 'installPlugin' | 'lintSpec' + | 'bundleSpectralRuleset' | 'llm.clearActiveBackend' | 'llm.getActiveBackend' | 'llm.getAIFeatureEnabled' @@ -163,6 +164,7 @@ export type HandleChannels = | 'webSocket.open' | 'webSocket.readyState' | 'writeFile' + | 'deleteFile' | 'writeResponseBodyToFile'; export const ipcMainHandle = ( @@ -397,6 +399,16 @@ export function registerElectronHandlers() { }); }); ipcMainHandle('showOpenDialog', async (_, options: OpenDialogOptions) => { + // Playwright test hook: consume queued responses set via `electronApp.evaluate` + // instead of opening the native dialog. See packages/insomnia-smoke-test. + if (process.env.PLAYWRIGHT === 'true') { + const queue = (globalThis as any).__PLAYWRIGHT_OPEN_DIALOG_QUEUE__ as + | { filePaths: string[]; canceled: boolean }[] + | undefined; + if (queue && queue.length > 0) { + return queue.shift(); + } + } const { filePaths, canceled } = await dialog.showOpenDialog(options); return { filePaths, canceled }; }); diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index bca7fe60baf4..0304da857d70 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -18,6 +18,7 @@ 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 type { AuthTypeOAuth2, OAuth2Token, RequestHeader, Services } from '~/insomnia-data'; @@ -154,6 +155,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; @@ -205,6 +207,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; @@ -329,6 +332,16 @@ export function registerMainHandlers() { throw new Error(err); } }); + ipcMainHandle('deleteFile', async (_, options: { path: string }) => { + try { + await fs.promises.unlink(options.path); + } catch (err) { + if (err?.code === 'ENOENT') { + return; + } + throw new Error(err); + } + }); ipcMainHandle('writeResponseBodyToFile', writeResponseBodyToFile); ipcMainHandle('getAuthHeader', (_, renderedRequest: RenderedRequest, url: string) => { return getAuthHeaderInMain(renderedRequest, url); @@ -336,8 +349,47 @@ 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); + 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; + const { documentContent } = options; + let { rulesetPath } = options; + + //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 { + // 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 + if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') { + rulesetPath = ''; + } else { + return { error: err instanceof Error ? err.message : String(err) }; + } + } + } + return new Promise((resolve, reject) => { // Use a filescoped variable to store and terminate the last open // This ensures we use a last in first out type of process management @@ -350,12 +402,27 @@ 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 / 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 deeply nested schema.`, + }); + } + }, LINT_WORKER_TIMEOUT_MS); + process.on('exit', code => { console.log('[lint-process] exited with code:', code); + clearTimeout(timeoutHandle); resolve({ cancelled: true }); }); process.on('message', msg => { + clearTimeout(timeoutHandle); resolve(msg); process?.kill(); process = null; @@ -363,12 +430,14 @@ export function registerMainHandlers() { process.on('error', err => { console.error('[lint-process] error:', err); + clearTimeout(timeoutHandle); reject({ error: err.toString() }); }); process.postMessage({ documentContent, rulesetPath }); }); }); + ipcMainHandle('insecureReadFile', async (_, options: { path: string }) => { return insecureReadFile(options.path); }); diff --git a/packages/insomnia/src/main/lint-process.mjs b/packages/insomnia/src/main/lint-process.mjs index abc80ad7711d..3a6aa5446749 100644 --- a/packages/insomnia/src/main/lint-process.mjs +++ b/packages/insomnia/src/main/lint-process.mjs @@ -1,15 +1,79 @@ /* 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'; +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 => { 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; + } + const host = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; + if (!ipaddr.isValid(host)) { + return false; + } + 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 { + url = new URL(href); + } catch { + return false; + } + if (url.protocol !== 'https:') { + return false; + } + 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)) { + throw new Error(`Failed to resolve 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(`Failed to fetch "${href}". Only https URLs to public hosts are allowed.`); + } + await assertResolvesToPublicHost(new URL(href).hostname.toLowerCase()); + const response = await fetch(href, { redirect: 'error' }); + if (!response.ok) { + throw new Error(`Failed to fetch "${href}": ${response.status} ${response.statusText}`); + } + return response.text(); + }, +}; + +// Note: This is duplicated in inso's lint-specification.ts. Remember to mirror changes there as well. +const safeResolver = new Resolver({ + resolvers: { + http: safeHttpResolver, + https: safeHttpResolver, + }, +}); + process.parentPort.on('message', async ({ data: { documentContent, rulesetPath } }) => { let hasValidCustomRuleset = false; if (rulesetPath) { @@ -19,7 +83,7 @@ process.parentPort.on('message', async ({ data: { documentContent, rulesetPath } } catch {} } try { - const spectral = new Spectral.Spectral(); + const spectral = new Spectral.Spectral({ resolver: safeResolver }); const { fetch } = spectralRuntime; const ruleset = hasValidCustomRuleset ? await bundleAndLoadRuleset(rulesetPath, { fs, fetch }) : oas; spectral.setRuleset(ruleset); 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 01698af2b9ff..b776194922f3 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 @@ -4,6 +4,7 @@ import type { OpenAPIV3 } from 'openapi-types'; import { Fragment, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Button, + Dialog, GridList, GridListItem, Heading, @@ -12,6 +13,8 @@ import { Menu, MenuItem, MenuTrigger, + Modal, + ModalOverlay, Popover, ToggleButton, Tooltip, @@ -26,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, @@ -41,7 +47,8 @@ import { DesignEmptyState } from '~/ui/components/design-empty-state'; import { DocumentTab } from '~/ui/components/document-tab'; 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 { 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'; @@ -79,16 +86,22 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { } const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId); + 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 // 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`) : ''; + // 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; try { @@ -97,8 +110,10 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { return { apiSpec, - rulesetPath, + gitSyncRulesetPath, + isConnectedGitProject, parsedSpec, + rulesetContent, }; } @@ -161,6 +176,7 @@ const Component = ({ params }: Route.ComponentProps) => { const [_isEnvironmentPickerOpen, setIsEnvironmentPickerOpen] = useState(false); const [isCertificatesModalOpen, setCertificatesModalOpen] = useState(false); const [isNewMockServerModalOpen, setNewMockServerModalOpen] = useState(false); + const [isViewRulesetModalOpen, setIsViewRulesetModalOpen] = useState(false); const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); @@ -176,15 +192,29 @@ const Component = ({ params }: Route.ComponentProps) => { const { isGenerateMockServersWithAIEnabled } = useAIFeatureStatus(); - const { apiSpec, rulesetPath, parsedSpec } = useLoaderData(); + const { apiSpec, gitSyncRulesetPath, isConnectedGitProject, parsedSpec, rulesetContent } = + useLoaderData(); const [lintMessages, setLintMessages] = useState([]); 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); const [isSpecPaneOpen, setIsSpecPaneOpen] = useState(Boolean(parsedSpec)); + const [selectedRulesetPath, setSelectedRulesetPath] = useState(''); + + // Spectral requires a file path on disk to lint with a ruleset. Ref: lint-process.mjs. + // 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( + () => window.path.join(window.app.getPath('userData'), `projects/${projectId}/.spectral.yaml`), + [projectId], + ); const { components, info, servers, paths } = parsedSpec || {}; const { requestBodies, responses, parameters, headers, schemas, securitySchemes } = components || {}; @@ -200,7 +230,7 @@ const Component = ({ params }: Route.ComponentProps) => { rulesetPath, }); if (cancelled) { - return; + return []; } if (error) { console.log('Handled error detected while linting:', error); @@ -208,7 +238,7 @@ const Component = ({ params }: Route.ComponentProps) => { title: 'Linting Error', message: `An error occurred while linting the OpenAPI specification: ${error}`, }); - throw error; + return []; } const lintResult = diagnostics?.map(({ severity, code, message, range }) => { return { @@ -230,16 +260,53 @@ const Component = ({ params }: Route.ComponentProps) => { title: 'Linting Error', message: `An error occurred while linting the OpenAPI specification: ${error}`, }); - throw error; + return []; } }); }; useEffect(() => { - registerCodeMirrorLint(rulesetPath); + 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 }); - }, [rulesetPath]); + }, [selectedRulesetPath, rulesetContent]); + + useEffect(() => { + if (lintErrors.length > 0 || lintWarnings.length > 0) { + setIsLintPaneOpen(true); + } + }, [lintErrors.length, lintWarnings.length]); + + useEffect(() => { + const syncRuleset = async () => { + if (gitSyncRulesetPath) { + setSelectedRulesetPath(rulesetContent ? gitSyncRulesetPath : ''); + } else if (rulesetContent) { + // 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 + 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); + } + } + } else { + // No ruleset content, ensure file is deleted + await window.main.deleteFile({ path: rulesetWritePath }); + setSelectedRulesetPath(''); + } + }; + + syncRuleset(); + }, [rulesetContent, rulesetWritePath, gitSyncRulesetPath]); reactUse.useUnmount(() => { // delete the helper to avoid it run multiple times when user enter the page next time @@ -384,6 +451,59 @@ 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; + } + + // We bundle the ruleset to resolve any extended rulesets and to validate the content + const { content, error } = await window.main.bundleSpectralRuleset({ sourcePath: filePath }); + if (error || !content) { + showError({ + title: 'Invalid Spectral Ruleset', + message: error ?? 'Failed to bundle ruleset.', + }); + return; + } + + await updateProjectRuleset({ organizationId, projectId, rulesetContent: content }); + if (!gitSyncRulesetPath) { + // cloud/local: no RepoFileWatcher — write the file to disk so Spectral can lint against it. + // git projects: the RepoFileWatcher mirrors the ProjectLintRuleset record to .spectral.yaml automatically. + await window.main.writeFile({ path: rulesetWritePath, content }); + } + setSelectedRulesetPath(gitSyncRulesetPath || rulesetWritePath); + }; + + const handleUnselectSpectralFile = async () => { + showModal(AskModal, { + title: 'Remove Ruleset File', + message: + 'Are you sure you want to remove this custom ruleset? 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) { + await deleteProjectRuleset({ + organizationId, + projectId, + }); + if (!gitSyncRulesetPath) { + await window.main.deleteFile({ path: rulesetWritePath }); + } + setSelectedRulesetPath(''); + } + }, + }); + }; + const specActionList: SpecActionItem[] = [ { id: 'generate-request-collection', @@ -434,7 +554,6 @@ const Component = ({ params }: Route.ComponentProps) => { const disabledKeys = specActionList.filter(item => item.isDisabled).map(item => item.id); - const gitVersion = useGitVCSVersion(); const uniquenessKey = `${apiSpec?._id}::${apiSpec?.created}::${gitVersion}::${vcsVersion}`; const [direction, setDirection] = useState<'horizontal' | 'vertical'>( @@ -925,6 +1044,43 @@ const Component = ({ params }: Route.ComponentProps) => { onOpenChange={setNewMockServerModalOpen} /> )} + {isViewRulesetModalOpen && ( + + + + {({ close }) => ( + <> +
+ + Existing Ruleset Contents + +
+ {rulesetContent && ( + + )} + + )} +
+
+
+ )} @@ -963,59 +1119,112 @@ const Component = ({ params }: Route.ComponentProps) => {
-
- - +
+ + +
+ + {selectedRulesetPath ? ( + <> + + + ) : ( + 'Default OAS Ruleset' + )} + + {selectedRulesetPath ? ( + + + +

Clear custom ruleset and use default OAS ruleset

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

Using ruleset from

- {rulesetPath} +

Using ruleset from

+ {selectedRulesetPath}
) : ( -

Using default OAS ruleset.

- To use a custom ruleset add a .spectral.yaml file to the - root of your git repository + Upload a custom Spectral ruleset + {isConnectedGitProject && ( + + {' '} + or add a .spectral.yaml file to the root of your + connected git repository + + )} + . Any local files referenced via extends will be bundled + into a single ruleset on upload.

)}
- {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 && ( - - )} +
+ {lintErrors.length > 0 && ( +
+ +
+ )} + {lintWarnings.length > 0 && ( +
+ +
+ )} + {apiSpec.contents && ( +
+ {lintMessages.length === 0 && ( + + )} + {lintMessages.length === 0 ? ( + 'No lint problems' + ) : ( + + )} +
+ )} +
{isLintPaneOpen && ( this.flushProjectWorkspacesToDisk()); + this.queue.enqueue(() => this.flushProjectLintRulesetToDisk()); await this.queue.waitUntilDone(); } @@ -302,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. * @@ -359,6 +406,7 @@ class RepoFileWatcher { this.flushDebounce = setTimeout(() => { this.flushDebounce = null; this.queue.enqueue(() => this.flushProjectWorkspacesToDisk()); + this.queue.enqueue(() => this.flushProjectLintRulesetToDisk()); }, DEBOUNCE_MS); }); } @@ -431,6 +479,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 +586,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 +630,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 +793,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 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));