From 71caa185522e3e005cca93dade568239ba5e7643 Mon Sep 17 00:00:00 2001 From: Justin Wyer Date: Thu, 2 Apr 2026 13:35:01 +0200 Subject: [PATCH] fix(security): add configurable overrides for command allowlist and SSRF blocklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #666 introduced hardcoded SAFE_COMMAND_PREFIXES and SSRF URL blocklists with no override mechanism. Users with non-standard credential tools (sops, doppler, age, infisical) or needing to fetch from internal URLs (self-hosted docs, VPN services) were silently blocked with no recourse. Add two global-only settings (ignored in project-level settings.json to preserve the security property against malicious repos): - allowedCommandPrefixes: replaces the built-in command allowlist - fetchAllowedUrls: exempts hostnames from SSRF blocking Both also support env var overrides (GSD_ALLOWED_COMMAND_PREFIXES, GSD_FETCH_ALLOWED_URLS) for CI/container environments. Env vars take precedence over settings.json. Security model: global-only keys are stripped from project settings at load time via stripGlobalOnlyKeys(), applied at all three assignment points for this.projectSettings. The merge function stays untouched — no future caller can accidentally skip stripping. 15 new tests covering override behavior, cache invalidation, allowlist exemptions, and global-only enforcement. --- .../resolve-config-value-override.test.ts | 86 ++++++++++++++ .../src/core/resolve-config-value.ts | 28 ++++- .../core/settings-manager-security.test.ts | 102 +++++++++++++++++ .../src/core/settings-manager.ts | 47 +++++++- packages/pi-coding-agent/src/index.ts | 5 + src/cli.ts | 2 + .../extensions/search-the-web/url-utils.ts | 19 ++++ src/security-overrides.ts | 42 +++++++ src/tests/security-overrides.test.ts | 105 ++++++++++++++++++ src/tests/url-utils-override.test.ts | 54 +++++++++ 10 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 packages/pi-coding-agent/src/core/resolve-config-value-override.test.ts create mode 100644 packages/pi-coding-agent/src/core/settings-manager-security.test.ts create mode 100644 src/security-overrides.ts create mode 100644 src/tests/security-overrides.test.ts create mode 100644 src/tests/url-utils-override.test.ts diff --git a/packages/pi-coding-agent/src/core/resolve-config-value-override.test.ts b/packages/pi-coding-agent/src/core/resolve-config-value-override.test.ts new file mode 100644 index 000000000..cbcd2111e --- /dev/null +++ b/packages/pi-coding-agent/src/core/resolve-config-value-override.test.ts @@ -0,0 +1,86 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + resolveConfigValue, + clearConfigValueCache, + SAFE_COMMAND_PREFIXES, + setAllowedCommandPrefixes, + getAllowedCommandPrefixes, +} from "./resolve-config-value.js"; + +describe("setAllowedCommandPrefixes — user override", () => { + beforeEach(() => { + clearConfigValueCache(); + }); + + afterEach(() => { + // Restore defaults after each test + setAllowedCommandPrefixes(SAFE_COMMAND_PREFIXES); + clearConfigValueCache(); + }); + + it("overrides built-in prefixes with custom list", () => { + setAllowedCommandPrefixes(["sops", "doppler"]); + assert.deepEqual([...getAllowedCommandPrefixes()], ["sops", "doppler"]); + }); + + it("custom prefix is allowed through to execution", (t) => { + const stderrChunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => { + stderrChunks.push(chunk.toString()); + return true; + }; + t.after(() => { + process.stderr.write = originalWrite; + }); + + setAllowedCommandPrefixes(["mycli"]); + resolveConfigValue("!mycli get-secret"); + const blocked = stderrChunks.some((line) => line.includes("Blocked disallowed command")); + assert.equal(blocked, false, "mycli should not be blocked when in the custom allowlist"); + }); + + it("previously-allowed prefix is blocked after override", (t) => { + const stderrChunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => { + stderrChunks.push(chunk.toString()); + return true; + }; + t.after(() => { + process.stderr.write = originalWrite; + }); + + // 'pass' is in the default list + setAllowedCommandPrefixes(["sops"]); + const result = resolveConfigValue("!pass show secret"); + assert.equal(result, undefined); + const blocked = stderrChunks.some((line) => line.includes("Blocked disallowed command")); + assert.equal(blocked, true, "pass should be blocked when not in the custom allowlist"); + }); + + it("clears cache when overriding prefixes", (t) => { + const stderrChunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => { + stderrChunks.push(chunk.toString()); + return true; + }; + t.after(() => { + process.stderr.write = originalWrite; + }); + + // First: block 'mycli' under defaults + resolveConfigValue("!mycli get-secret"); + assert.ok(stderrChunks.some((line) => line.includes("Blocked"))); + + stderrChunks.length = 0; + + // Now allow it — cache should be cleared so re-evaluation happens + setAllowedCommandPrefixes(["mycli"]); + resolveConfigValue("!mycli get-secret"); + const blocked = stderrChunks.some((line) => line.includes("Blocked")); + assert.equal(blocked, false, "Should re-evaluate after allowlist change"); + }); +}); diff --git a/packages/pi-coding-agent/src/core/resolve-config-value.ts b/packages/pi-coding-agent/src/core/resolve-config-value.ts index e12c4c2ae..9b72ca65f 100644 --- a/packages/pi-coding-agent/src/core/resolve-config-value.ts +++ b/packages/pi-coding-agent/src/core/resolve-config-value.ts @@ -24,6 +24,30 @@ export const SAFE_COMMAND_PREFIXES = [ "lpass", ]; +/** + * Active command prefix allowlist. Defaults to SAFE_COMMAND_PREFIXES but can be + * overridden via setAllowedCommandPrefixes() (called from settings or env var). + */ +let activeCommandPrefixes: string[] = SAFE_COMMAND_PREFIXES; + +/** + * Replace the active command prefix allowlist. + * Called during initialization when the user has configured `allowedCommandPrefixes` + * in global settings.json or via the GSD_ALLOWED_COMMAND_PREFIXES env var. + */ +export function setAllowedCommandPrefixes(prefixes: string[]): void { + if (prefixes.length === 0) { + process.stderr.write("[resolve-config-value] Warning: empty command prefix allowlist — all !commands will be blocked\n"); + } + activeCommandPrefixes = prefixes; + clearConfigValueCache(); +} + +/** Get the currently active command prefix allowlist. */ +export function getAllowedCommandPrefixes(): readonly string[] { + return activeCommandPrefixes; +} + /** * Resolve a config value (API key, header value, etc.) to an actual value. * - If starts with "!", executes the rest as a shell command and uses stdout (cached) @@ -45,8 +69,8 @@ function executeCommand(commandConfig: string): string | undefined { const command = commandConfig.slice(1); const tokens = command.split(/\s+/).filter(Boolean); const firstToken = tokens[0]; - if (!SAFE_COMMAND_PREFIXES.includes(firstToken)) { - process.stderr.write(`[resolve-config-value] Blocked disallowed command: "${firstToken}". Allowed: ${SAFE_COMMAND_PREFIXES.join(", ")}\n`); + if (!activeCommandPrefixes.includes(firstToken)) { + process.stderr.write(`[resolve-config-value] Blocked disallowed command: "${firstToken}". Allowed: ${activeCommandPrefixes.join(", ")}\n`); commandResultCache.set(commandConfig, undefined); return undefined; } diff --git a/packages/pi-coding-agent/src/core/settings-manager-security.test.ts b/packages/pi-coding-agent/src/core/settings-manager-security.test.ts new file mode 100644 index 000000000..b052a2bd6 --- /dev/null +++ b/packages/pi-coding-agent/src/core/settings-manager-security.test.ts @@ -0,0 +1,102 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { SettingsManager } from "./settings-manager.js"; +import { CONFIG_DIR_NAME } from "../config.js"; + +function makeTempDirs() { + const base = mkdtempSync(join(tmpdir(), "settings-security-test-")); + const agentDir = join(base, "agent"); + const cwd = join(base, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(join(cwd, CONFIG_DIR_NAME), { recursive: true }); + return { base, agentDir, cwd }; +} + +describe("SettingsManager — global-only security settings", () => { + let tmpBase: string | undefined; + + afterEach(() => { + if (tmpBase) { + rmSync(tmpBase, { recursive: true, force: true }); + tmpBase = undefined; + } + }); + + it("returns allowedCommandPrefixes set via setAllowedCommandPrefixes", () => { + const sm = SettingsManager.inMemory(); + assert.equal(sm.getAllowedCommandPrefixes(), undefined); + sm.setAllowedCommandPrefixes(["sops", "doppler"]); + assert.deepEqual(sm.getAllowedCommandPrefixes(), ["sops", "doppler"]); + }); + + it("returns fetchAllowedUrls set via setFetchAllowedUrls", () => { + const sm = SettingsManager.inMemory(); + assert.equal(sm.getFetchAllowedUrls(), undefined); + sm.setFetchAllowedUrls(["internal.company.com"]); + assert.deepEqual(sm.getFetchAllowedUrls(), ["internal.company.com"]); + }); + + it("strips allowedCommandPrefixes from project settings at load time", () => { + const { base, agentDir, cwd } = makeTempDirs(); + tmpBase = base; + + // Global settings: allowedCommandPrefixes = ["sops"] + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ + allowedCommandPrefixes: ["sops"], + })); + + // Malicious project settings trying to override with a dangerous command + writeFileSync(join(cwd, CONFIG_DIR_NAME, "settings.json"), JSON.stringify({ + allowedCommandPrefixes: ["curl", "bash", "wget"], + })); + + const sm = SettingsManager.create(cwd, agentDir); + + // The getter reads from globalSettings — project override must be stripped + assert.deepEqual(sm.getAllowedCommandPrefixes(), ["sops"]); + }); + + it("strips fetchAllowedUrls from project settings at load time", () => { + const { base, agentDir, cwd } = makeTempDirs(); + tmpBase = base; + + // Global: no fetchAllowedUrls + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({})); + + // Project tries to allowlist cloud metadata + writeFileSync(join(cwd, CONFIG_DIR_NAME, "settings.json"), JSON.stringify({ + fetchAllowedUrls: ["metadata.google.internal", "169.254.169.254"], + })); + + const sm = SettingsManager.create(cwd, agentDir); + + // Global has none — project override must not leak through + assert.equal(sm.getFetchAllowedUrls(), undefined); + }); + + it("project settings for non-security fields still merge normally", () => { + const { base, agentDir, cwd } = makeTempDirs(); + tmpBase = base; + + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ + allowedCommandPrefixes: ["sops"], + theme: "dark", + })); + + writeFileSync(join(cwd, CONFIG_DIR_NAME, "settings.json"), JSON.stringify({ + allowedCommandPrefixes: ["curl"], + theme: "light", + quietStartup: true, + })); + + const sm = SettingsManager.create(cwd, agentDir); + + // Security field: global wins + assert.deepEqual(sm.getAllowedCommandPrefixes(), ["sops"]); + // Normal fields: project overrides global + assert.equal(sm.getQuietStartup(), true); + }); +}); diff --git a/packages/pi-coding-agent/src/core/settings-manager.ts b/packages/pi-coding-agent/src/core/settings-manager.ts index 092f86315..de75daa0f 100644 --- a/packages/pi-coding-agent/src/core/settings-manager.ts +++ b/packages/pi-coding-agent/src/core/settings-manager.ts @@ -152,6 +152,23 @@ export interface Settings { modelDiscovery?: ModelDiscoverySettings; editMode?: "standard" | "hashline"; // Edit tool mode: "standard" (text match) or "hashline" (LINE#ID anchors). Default: "standard" timestampFormat?: "date-time-iso" | "date-time-us"; // Timestamp display format for messages. Default: "date-time-iso" + allowedCommandPrefixes?: string[]; // Override built-in SAFE_COMMAND_PREFIXES for !command resolution (global-only — ignored in project settings) + fetchAllowedUrls?: string[]; // Hostnames exempted from SSRF blocklist in fetch_page (global-only — ignored in project settings) +} + +/** Settings keys that are only respected from global config — project settings cannot override these. */ +const GLOBAL_ONLY_KEYS: ReadonlySet = new Set([ + "allowedCommandPrefixes", + "fetchAllowedUrls", +]); + +/** Remove global-only keys from a settings object. Applied once at load time. */ +function stripGlobalOnlyKeys(settings: Settings): Settings { + const result = { ...settings }; + for (const key of GLOBAL_ONLY_KEYS) { + delete (result as Record)[key]; + } + return result; } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -304,7 +321,7 @@ export class SettingsManager { ) { this.storage = storage; this.globalSettings = initialGlobal; - this.projectSettings = initialProject; + this.projectSettings = stripGlobalOnlyKeys(initialProject); this.globalSettingsLoadError = globalLoadError; this.projectSettingsLoadError = projectLoadError; this.errors = [...initialErrors]; @@ -441,7 +458,7 @@ export class SettingsManager { const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project"); if (!projectLoad.error) { - this.projectSettings = projectLoad.settings; + this.projectSettings = stripGlobalOnlyKeys(projectLoad.settings); this.projectSettingsLoadError = null; } else { this.projectSettingsLoadError = projectLoad.error; @@ -571,7 +588,7 @@ export class SettingsManager { } private saveProjectSettings(settings: Settings): void { - this.projectSettings = structuredClone(settings); + this.projectSettings = stripGlobalOnlyKeys(structuredClone(settings)); this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); if (this.projectSettingsLoadError) { @@ -1096,4 +1113,28 @@ export class SettingsManager { setTimestampFormat(format: "date-time-iso" | "date-time-us"): void { this.setGlobalSetting("timestampFormat", format); } + + /** + * Get the allowed command prefixes from global settings only. + * Returns undefined if not configured (caller should use built-in defaults). + */ + getAllowedCommandPrefixes(): string[] | undefined { + return this.globalSettings.allowedCommandPrefixes; + } + + setAllowedCommandPrefixes(prefixes: string[]): void { + this.setGlobalSetting("allowedCommandPrefixes", prefixes); + } + + /** + * Get the fetch URL allowlist from global settings only. + * Returns undefined if not configured (caller should use empty allowlist). + */ + getFetchAllowedUrls(): string[] | undefined { + return this.globalSettings.fetchAllowedUrls; + } + + setFetchAllowedUrls(urls: string[]): void { + this.setGlobalSetting("fetchAllowedUrls", urls); + } } diff --git a/packages/pi-coding-agent/src/index.ts b/packages/pi-coding-agent/src/index.ts index 9b0a50fc7..86686caf0 100644 --- a/packages/pi-coding-agent/src/index.ts +++ b/packages/pi-coding-agent/src/index.ts @@ -225,6 +225,11 @@ export { SettingsManager, type TaskIsolationSettings, } from "./core/settings-manager.js"; +export { + SAFE_COMMAND_PREFIXES, + setAllowedCommandPrefixes, + getAllowedCommandPrefixes, +} from "./core/resolve-config-value.js"; // Skills export { ECOSYSTEM_SKILLS_DIR, diff --git a/src/cli.ts b/src/cli.ts index fa8292d4a..d4f59cb44 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ import { shouldRunOnboarding, runOnboarding } from './onboarding.js' import chalk from 'chalk' import { checkForUpdates } from './update-check.js' import { printHelp, printSubcommandHelp } from './help-text.js' +import { applySecurityOverrides } from './security-overrides.js' import { parseCliArgs as parseWebCliArgs, runWebCliBranch, @@ -337,6 +338,7 @@ const modelsJsonPath = resolveModelsJsonPath() const modelRegistry = new ModelRegistry(authStorage, modelsJsonPath) markStartup('ModelRegistry') const settingsManager = SettingsManager.create(agentDir) +applySecurityOverrides(settingsManager) markStartup('SettingsManager.create') // Run onboarding wizard on first launch (no LLM provider configured) diff --git a/src/resources/extensions/search-the-web/url-utils.ts b/src/resources/extensions/search-the-web/url-utils.ts index 24b3caedd..fca98e173 100644 --- a/src/resources/extensions/search-the-web/url-utils.ts +++ b/src/resources/extensions/search-the-web/url-utils.ts @@ -21,11 +21,30 @@ const PRIVATE_IP_PATTERNS = [ /^fe80:/i, ]; +/** + * Hostnames exempted from SSRF blocking. Set via setFetchAllowedUrls() + * from global settings.json or GSD_FETCH_ALLOWED_URLS env var. + */ +let fetchAllowedHostnames: Set = new Set(); + +/** + * Replace the fetch URL allowlist (hostnames exempted from SSRF checks). + */ +export function setFetchAllowedUrls(hostnames: string[]): void { + fetchAllowedHostnames = new Set(hostnames.map((h) => h.toLowerCase())); +} + +/** Get the currently active fetch URL allowlist. */ +export function getFetchAllowedUrls(): readonly string[] { + return [...fetchAllowedHostnames]; +} + export function isBlockedUrl(url: string): boolean { try { const parsed = new URL(url); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return true; const hostname = parsed.hostname.toLowerCase(); + if (fetchAllowedHostnames.has(hostname)) return false; if (BLOCKED_HOSTNAMES.has(hostname)) return true; for (const pattern of PRIVATE_IP_PATTERNS) { if (pattern.test(hostname)) return true; diff --git a/src/security-overrides.ts b/src/security-overrides.ts new file mode 100644 index 000000000..9a0609d6c --- /dev/null +++ b/src/security-overrides.ts @@ -0,0 +1,42 @@ +/** + * Apply user-configured security overrides from global settings.json and env vars. + * + * Both overrides are global-only (not project-level) because the threat model is + * malicious project-level config in cloned repos. Global settings and env vars + * represent the user's own authority on their machine. + * + * Precedence: env var > settings.json > built-in defaults + */ + +import { type SettingsManager, setAllowedCommandPrefixes } from '@gsd/pi-coding-agent' +import { setFetchAllowedUrls } from './resources/extensions/search-the-web/url-utils.js' + +export function applySecurityOverrides(settingsManager: SettingsManager): void { + // --- Command prefix allowlist --- + const envPrefixes = process.env.GSD_ALLOWED_COMMAND_PREFIXES + if (envPrefixes) { + const prefixes = envPrefixes.split(',').map(s => s.trim()).filter(Boolean) + if (prefixes.length > 0) { + setAllowedCommandPrefixes(prefixes) + } + } else { + const settingsPrefixes = settingsManager.getAllowedCommandPrefixes() + if (settingsPrefixes && settingsPrefixes.length > 0) { + setAllowedCommandPrefixes(settingsPrefixes) + } + } + + // --- Fetch URL allowlist (SSRF exemptions) --- + const envUrls = process.env.GSD_FETCH_ALLOWED_URLS + if (envUrls) { + const urls = envUrls.split(',').map(s => s.trim()).filter(Boolean) + if (urls.length > 0) { + setFetchAllowedUrls(urls) + } + } else { + const settingsUrls = settingsManager.getFetchAllowedUrls() + if (settingsUrls && settingsUrls.length > 0) { + setFetchAllowedUrls(settingsUrls) + } + } +} diff --git a/src/tests/security-overrides.test.ts b/src/tests/security-overrides.test.ts new file mode 100644 index 000000000..826065dbd --- /dev/null +++ b/src/tests/security-overrides.test.ts @@ -0,0 +1,105 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { SettingsManager, getAllowedCommandPrefixes, SAFE_COMMAND_PREFIXES, setAllowedCommandPrefixes } from "@gsd/pi-coding-agent"; +import { getFetchAllowedUrls, setFetchAllowedUrls } from "../resources/extensions/search-the-web/url-utils.ts"; +import { applySecurityOverrides } from "../security-overrides.ts"; + +describe("applySecurityOverrides — env var and settings precedence", () => { + const savedEnv: Record = {}; + + beforeEach(() => { + // Snapshot env vars we might touch + savedEnv.GSD_ALLOWED_COMMAND_PREFIXES = process.env.GSD_ALLOWED_COMMAND_PREFIXES; + savedEnv.GSD_FETCH_ALLOWED_URLS = process.env.GSD_FETCH_ALLOWED_URLS; + delete process.env.GSD_ALLOWED_COMMAND_PREFIXES; + delete process.env.GSD_FETCH_ALLOWED_URLS; + + // Reset runtime state to defaults + setAllowedCommandPrefixes(SAFE_COMMAND_PREFIXES); + setFetchAllowedUrls([]); + }); + + afterEach(() => { + // Restore env vars + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) { + delete process.env[key]; + } else { + process.env[key] = val; + } + } + // Restore runtime defaults + setAllowedCommandPrefixes(SAFE_COMMAND_PREFIXES); + setFetchAllowedUrls([]); + }); + + // --- Command prefixes --- + + it("applies command prefixes from settings when no env var is set", () => { + const sm = SettingsManager.inMemory({ allowedCommandPrefixes: ["sops", "doppler"] }); + applySecurityOverrides(sm); + assert.deepEqual([...getAllowedCommandPrefixes()], ["sops", "doppler"]); + }); + + it("env var overrides settings for command prefixes", () => { + process.env.GSD_ALLOWED_COMMAND_PREFIXES = "age,infisical"; + const sm = SettingsManager.inMemory({ allowedCommandPrefixes: ["sops", "doppler"] }); + applySecurityOverrides(sm); + assert.deepEqual([...getAllowedCommandPrefixes()], ["age", "infisical"]); + }); + + it("empty env var does not override settings (falls through to settings)", () => { + process.env.GSD_ALLOWED_COMMAND_PREFIXES = ""; + const sm = SettingsManager.inMemory({ allowedCommandPrefixes: ["sops"] }); + applySecurityOverrides(sm); + assert.deepEqual([...getAllowedCommandPrefixes()], ["sops"]); + }); + + it("env var with whitespace and trailing commas is trimmed correctly", () => { + process.env.GSD_ALLOWED_COMMAND_PREFIXES = " sops , doppler , , "; + const sm = SettingsManager.inMemory(); + applySecurityOverrides(sm); + assert.deepEqual([...getAllowedCommandPrefixes()], ["sops", "doppler"]); + }); + + it("keeps built-in defaults when neither env var nor settings are set", () => { + const sm = SettingsManager.inMemory(); + applySecurityOverrides(sm); + assert.deepEqual([...getAllowedCommandPrefixes()], [...SAFE_COMMAND_PREFIXES]); + }); + + // --- Fetch URL allowlist --- + + it("applies fetch allowed URLs from settings when no env var is set", () => { + const sm = SettingsManager.inMemory({ fetchAllowedUrls: ["internal.co", "192.168.1.50"] }); + applySecurityOverrides(sm); + assert.deepEqual([...getFetchAllowedUrls()].sort(), ["192.168.1.50", "internal.co"]); + }); + + it("env var overrides settings for fetch allowed URLs", () => { + process.env.GSD_FETCH_ALLOWED_URLS = "my-docs.internal"; + const sm = SettingsManager.inMemory({ fetchAllowedUrls: ["other.internal"] }); + applySecurityOverrides(sm); + assert.deepEqual([...getFetchAllowedUrls()], ["my-docs.internal"]); + }); + + it("empty env var does not override settings for fetch URLs", () => { + process.env.GSD_FETCH_ALLOWED_URLS = ""; + const sm = SettingsManager.inMemory({ fetchAllowedUrls: ["docs.internal"] }); + applySecurityOverrides(sm); + assert.deepEqual([...getFetchAllowedUrls()], ["docs.internal"]); + }); + + it("env var with whitespace and trailing commas is trimmed correctly for URLs", () => { + process.env.GSD_FETCH_ALLOWED_URLS = " a.internal , b.internal , , "; + const sm = SettingsManager.inMemory(); + applySecurityOverrides(sm); + assert.deepEqual([...getFetchAllowedUrls()].sort(), ["a.internal", "b.internal"]); + }); + + it("keeps empty allowlist when neither env var nor settings are set", () => { + const sm = SettingsManager.inMemory(); + applySecurityOverrides(sm); + assert.deepEqual([...getFetchAllowedUrls()], []); + }); +}); diff --git a/src/tests/url-utils-override.test.ts b/src/tests/url-utils-override.test.ts new file mode 100644 index 000000000..381238944 --- /dev/null +++ b/src/tests/url-utils-override.test.ts @@ -0,0 +1,54 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { isBlockedUrl, setFetchAllowedUrls, getFetchAllowedUrls } from "../resources/extensions/search-the-web/url-utils.ts"; + +describe("setFetchAllowedUrls — user override", () => { + afterEach(() => { + // Reset to empty allowlist after each test + setFetchAllowedUrls([]); + }); + + it("defaults to empty allowlist", () => { + assert.deepEqual(getFetchAllowedUrls(), []); + }); + + it("exempts an allowed hostname from blocking", () => { + assert.equal(isBlockedUrl("http://192.168.1.100/docs"), true, "blocked by default"); + setFetchAllowedUrls(["192.168.1.100"]); + assert.equal(isBlockedUrl("http://192.168.1.100/docs"), false, "allowed after override"); + }); + + it("exempts localhost when explicitly allowed", () => { + assert.equal(isBlockedUrl("http://localhost:3000/api"), true, "blocked by default"); + setFetchAllowedUrls(["localhost"]); + assert.equal(isBlockedUrl("http://localhost:3000/api"), false, "allowed after override"); + }); + + it("exempts cloud metadata hostname when allowed", () => { + assert.equal(isBlockedUrl("http://metadata.google.internal/computeMetadata/"), true, "blocked by default"); + setFetchAllowedUrls(["metadata.google.internal"]); + assert.equal(isBlockedUrl("http://metadata.google.internal/computeMetadata/"), false, "allowed after override"); + }); + + it("does not affect URLs not in the allowlist", () => { + setFetchAllowedUrls(["192.168.1.100"]); + assert.equal(isBlockedUrl("http://192.168.1.200/secret"), true, "other private IPs still blocked"); + assert.equal(isBlockedUrl("http://localhost/admin"), true, "localhost still blocked"); + }); + + it("still allows public URLs without configuration", () => { + setFetchAllowedUrls(["192.168.1.100"]); + assert.equal(isBlockedUrl("https://example.com"), false); + }); + + it("still blocks non-HTTP protocols even with allowlist", () => { + setFetchAllowedUrls(["localhost"]); + assert.equal(isBlockedUrl("file:///etc/passwd"), true, "file:// still blocked"); + assert.equal(isBlockedUrl("ftp://localhost/data"), true, "ftp:// still blocked"); + }); + + it("is case-insensitive for hostnames", () => { + setFetchAllowedUrls(["MyHost.Internal"]); + assert.equal(isBlockedUrl("http://myhost.internal/api"), false); + }); +});