Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions packages/insomnia-inso/src/commands/lint-specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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 { compileSpectralRuleset } from 'insomnia/src/common/bundle-spectral-ruleset';
import { isPrivateOrLoopbackHost } from 'insomnia/src/common/private-host';

import { InsoError } from '../errors';
Expand Down Expand Up @@ -92,15 +92,16 @@ export async function lintSpecification({
let ruleset = oas;
try {
if (rulesetFileName) {
// 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.
// Flatten all local extends and fetch + validate + fully inline remote extends (SSRF +
// disallowed keys) into a single URL-free object before any content reaches Spectral. This
// leaves bundleAndLoadRuleset with nothing remote to fetch, closing the validate-then-use race.
const compiledContent = await compileSpectralRuleset(rulesetFileName);
// bundleAndLoadRuleset requires a file path, so write the compiled object 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' });
await fs.promises.writeFile(tempRulesetPath, compiledContent, { encoding: 'utf8' });
ruleset = await bundleAndLoadRuleset(tempRulesetPath, { fs, fetch: spectralFetch });
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
Expand Down
111 changes: 99 additions & 12 deletions packages/insomnia/src/common/bundle-spectral-ruleset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ function parseRemoteExtendsUrl(entry: string, base?: URL): URL {
// - Hostname must not be a known private/loopback address
// - DNS resolution must not yield a private/loopback address
async function assertSafeRemoteUrl(url: URL): Promise<void> {
const hostname = url.hostname.toLowerCase();
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}`);
}
Expand Down Expand Up @@ -154,6 +154,61 @@ async function validateRemoteExtends(url: URL, visited: Set<string>, depth: numb
}
}

// Fetches a remote "extends" URL and fully inlines it into a self-contained ruleset object.
// Unlike validateRemoteExtends (which preserves the URL for Spectral to fetch later), this
// fetches the content (SSRF-guarded), validates it (blocking the "functions" RCE vector and
// prototype pollution), recursively flattens any nested remote/relative extends, and merges
// everything into a single object. Built-in spectral identifiers (spectral:oas, …) are the only
// entries preserved in "extends"; no remote URLs remain. This is the basis for the compiled
// ruleset the lint worker consumes, eliminating the second (unguarded) fetch that created the
// validate-then-use race.
async function flattenRemoteExtends(url: URL, visited: Set<string>, depth: number): Promise<Ruleset> {
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);
const flattened: Ruleset = {};
const builtinExtends: 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 identifiers (spectral:oas, …) are resolved by Spectral locally; carry through.
if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry)) {
builtinExtends.push(entry);
continue;
}
// Nested remote/relative URL — resolve against the current URL and flatten recursively.
const child = await flattenRemoteExtends(parseRemoteExtendsUrl(entry, url), nextVisited, depth + 1);
if (child.extends) {
builtinExtends.push(...child.extends);
}
mergeInto(flattened, child); // later extends entries override earlier ones
}

// Apply this remote ruleset's own rules on top; parent rules override child rules of the same name.
const ownOverrides: Ruleset = { ...ruleset };
delete ownOverrides.extends;
mergeInto(flattened, ownOverrides);

const uniqueExtends = [...new Set(builtinExtends)];
delete flattened.extends;
return uniqueExtends.length > 0 ? { extends: uniqueExtends, ...flattened } : flattened;
}

// 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.
Expand All @@ -165,6 +220,7 @@ async function flattenRuleset(
visited: Set<string>,
depth: number,
rootDir: string,
inlineRemote: boolean,
): Promise<Ruleset> {
const absolute = path.resolve(filePath);
assertAllowed(absolute, visited, depth, rootDir);
Expand All @@ -174,8 +230,9 @@ async function flattenRuleset(
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.
// Collects entries that stay in "extends": built-in spectral identifiers and, in bundle mode,
// remote URLs (already validated by validateRemoteExtends). Local file paths are flattened out
// entirely; in compile mode (inlineRemote) remote URLs are flattened out too.
const remainingExtends: string[] = [];

for (const entry of toArray(ruleset.extends)) {
Expand All @@ -189,15 +246,27 @@ async function flattenRuleset(
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.
// Remote URL extends.
if (!entry.startsWith('./') && !entry.startsWith('../') && !path.isAbsolute(entry)) {
if (inlineRemote) {
// Compile mode: fetch + validate + fully inline the remote content so the lint worker
// never re-fetches it (eliminates the validate-then-use race). Built-in identifiers
// surfaced by the remote ruleset are preserved.
const remoteRuleset = await flattenRemoteExtends(parseRemoteExtendsUrl(entry), nextVisited, depth + 1);
if (remoteRuleset.extends) {
remainingExtends.push(...remoteRuleset.extends);
}
mergeInto(flattenedRuleset, remoteRuleset);
continue;
}
// Bundle mode: validate upfront (SSRF + content checks), then preserve the URL in "extends"
// as the pollable source. The compile step inlines it before linting.
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);
const childRuleset = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1, rootDir, inlineRemote);
if (childRuleset.extends) {
remainingExtends.push(...childRuleset.extends);
}
Expand All @@ -215,14 +284,32 @@ async function flattenRuleset(
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.
// Entry point for ruleset processing at upload/storage time. 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: local content is fully merged, remote URLs have been pre-vetted
// and are preserved in "extends" as the pollable source. Use compileSpectralRuleset to produce
// the URL-free object that is actually linted.
export async function bundleSpectralRuleset(sourcePath: string): Promise<string> {
const rootDir = path.dirname(path.resolve(sourcePath));
const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir);
const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir, /* inlineRemote */ false);
const yaml = YAML.stringify(flattenedRuleset);
const validation = validateSpectralRuleset(yaml);
if (!validation.isValid) {
throw new Error(`Invalid Spectral ruleset: ${validation.error}`);
}
return yaml;
}

// Entry point for ruleset processing at lint time. Behaves like bundleSpectralRuleset but also
// fetches, validates, and fully inlines remote "extends" URLs, leaving only built-in identifiers
// (spectral:oas, …) in "extends". The returned YAML is a single self-contained object with no
// remote references, so the lint worker has nothing left to fetch — closing the validate-then-use
// race that allowed a server to serve clean content to the validator and malicious content to the
// worker.
export async function compileSpectralRuleset(sourcePath: string): Promise<string> {
const rootDir = path.dirname(path.resolve(sourcePath));
const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir, /* inlineRemote */ true);
const yaml = YAML.stringify(flattenedRuleset);
const validation = validateSpectralRuleset(yaml);
if (!validation.isValid) {
Expand Down
3 changes: 3 additions & 0 deletions packages/insomnia/src/entry.main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import fs from 'node:fs/promises';

Check failure on line 1 in packages/insomnia/src/entry.main.ts

View workflow job for this annotation

GitHub Actions / Test

Run autofix to sort these imports!
import inspector from 'node:inspector';
import { arch, release } from 'node:os';
import path from 'node:path';
Expand Down Expand Up @@ -39,6 +39,7 @@
import { watchProxySettings } from './main/proxy';
import { initializeSentry, sentryWatchAnalyticsEnabled } from './main/sentry';
import { checkIfRestartNeeded } from './main/squirrel-startup';
import * as spectralRulesetRefresh from './main/spectral-ruleset-refresh';
import * as updates from './main/updates';
import * as windowUtils from './main/window-utils';

Expand Down Expand Up @@ -137,6 +138,8 @@

// Init the rest
await updates.init();
// Periodically re-check remote spectral "extends" for upstream changes and re-lint.
spectralRulesetRefresh.init();
// recursive = ignore already exists error
await fs.mkdir(path.join(dataPath, 'responses'), { recursive: true });
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { database as db, models } from '~/insomnia-data';

const { type } = models.projectLintRuleset;

export function all() {
return db.find<ProjectLintRuleset>(type);
}

export function getByParentId(projectId: string) {
return db.findOne<ProjectLintRuleset>(type, { parentId: projectId });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock('node:dns/promises', () => ({
import dns from 'node:dns/promises';
import fs from 'node:fs';

import { bundleSpectralRuleset } from '~/common/bundle-spectral-ruleset';
import { bundleSpectralRuleset, compileSpectralRuleset } from '~/common/bundle-spectral-ruleset';

const mockReadFile = vi.mocked(fs.promises.readFile) as MockedFunction<(path: string) => Promise<string>>;

Expand Down Expand Up @@ -417,3 +417,82 @@ rules:
});
});
});

describe('compileSpectralRuleset', () => {
it('fully inlines remote ruleset content and drops the URL', 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 compileSpectralRuleset('/fake/ruleset.yaml');
// Local rules stay, remote rules are inlined, and no remote URL remains for the worker to fetch.
expect(result).toContain('local-rule');
expect(result).toContain('remote-rule');
expect(result).not.toContain('https://example.com/remote.yaml');
});

it('recursively inlines 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:\n nested-rule:\n given: "$.servers"\n severity: warn\n then:\n function: truthy\n`,
);
}
throw new Error(`Unexpected fetch call: ${href}`);
});

const result = await compileSpectralRuleset('/fake/ruleset.yaml');
// Both levels are inlined; no remote URLs remain.
expect(result).toContain('remote-rule');
expect(result).toContain('nested-rule');
expect(result).not.toContain('https://example.com');
});

it('preserves built-in identifiers surfaced by a remote ruleset', async () => {
mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/remote.yaml"\n`);
vi.mocked(fetch).mockResolvedValue(rulesetResponse(`extends:\n - spectral:oas\nrules:${VALID_RULE}`));

const result = await compileSpectralRuleset('/fake/ruleset.yaml');
expect(result).toContain('spectral:oas');
expect(result).toContain('remote-rule');
expect(result).not.toContain('https://example.com/remote.yaml');
});

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:\n - exec\nrules:\n env-check:\n given: "$"\n then:\n function: exec\n`),
);

await expect(compileSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('failed validation');
});

it('rejects a non-https remote extends without fetching', async () => {
mockReadFile.mockResolvedValueOnce(`extends:\n - "http://example.com/remote.yaml"\n`);

await expect(compileSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('must use https');
expect(fetch).not.toHaveBeenCalled();
});

it('rejects a remote extends pointing at a loopback host without fetching', async () => {
mockReadFile.mockResolvedValueOnce(`extends:\n - "https://127.0.0.1/remote.yaml"\n`);

await expect(compileSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('disallowed host');
expect(fetch).not.toHaveBeenCalled();
});
});
Loading
Loading