fix(security): add configurable overrides for command allowlist and SSRF blocklist
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.
This commit is contained in:
parent
46d5fa56af
commit
71caa18552
10 changed files with 485 additions and 5 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<keyof Settings> = 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<string, unknown>)[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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string> = 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;
|
||||
|
|
|
|||
42
src/security-overrides.ts
Normal file
42
src/security-overrides.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/tests/security-overrides.test.ts
Normal file
105
src/tests/security-overrides.test.ts
Normal file
|
|
@ -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<string, string | undefined> = {};
|
||||
|
||||
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()], []);
|
||||
});
|
||||
});
|
||||
54
src/tests/url-utils-override.test.ts
Normal file
54
src/tests/url-utils-override.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue