fix(security): validate base_url / console_gateway_url as real http(s) URLs

The config file accepted any value that merely starts with "http" (so even
"httpfoo://evil" passed) for base_url and console_gateway_url — origins the
client sends the Bearer token to. Validate them with `new URL()` and an
http:/https: protocol check instead, rejecting malformed values. Valid http(s)
URLs (including custom proxies and local http) are unaffected.

https://claude.ai/code/session_017ZGQCjwNQF5Pz96gLUnnG1
This commit is contained in:
Claude
2026-05-29 12:44:50 +00:00
parent bb9f941849
commit ba074f566d
2 changed files with 31 additions and 3 deletions
+17 -3
View File
@@ -38,6 +38,21 @@ export interface ConfigFile {
const VALID_REGIONS = new Set<string>(["cn", "us", "intl"]);
const VALID_OUTPUTS = new Set<string>(["text", "json"]);
/**
* A syntactically valid absolute http(s) URL. Used to validate `base_url` and
* `console_gateway_url` from the config file: the credential-bearing client
* sends the Bearer token to these origins, so a bare `startsWith("http")` check
* (which also accepts e.g. "httpfoo://…") is too loose.
*/
function isHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
export function parseConfigFile(raw: unknown): ConfigFile {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const obj = raw as Record<string, unknown>;
@@ -50,8 +65,7 @@ export function parseConfigFile(raw: unknown): ConfigFile {
out.access_token = obj.accessToken;
if (typeof obj.region === "string" && VALID_REGIONS.has(obj.region))
out.region = obj.region as Region;
if (typeof obj.base_url === "string" && obj.base_url.startsWith("http"))
out.base_url = obj.base_url;
if (typeof obj.base_url === "string" && isHttpUrl(obj.base_url)) out.base_url = obj.base_url;
if (typeof obj.output === "string" && VALID_OUTPUTS.has(obj.output))
out.output = obj.output as ConfigFile["output"];
if (typeof obj.output_dir === "string" && obj.output_dir.length > 0)
@@ -73,7 +87,7 @@ export function parseConfigFile(raw: unknown): ConfigFile {
out.access_key_secret = obj.access_key_secret;
if (typeof obj.workspace_id === "string" && obj.workspace_id.length > 0)
out.workspace_id = obj.workspace_id;
if (typeof obj.console_gateway_url === "string" && obj.console_gateway_url.startsWith("http"))
if (typeof obj.console_gateway_url === "string" && isHttpUrl(obj.console_gateway_url))
out.console_gateway_url = obj.console_gateway_url;
if (typeof obj.telemetry === "boolean") out.telemetry = obj.telemetry;
+14
View File
@@ -1,6 +1,7 @@
import { expect, test } from "vite-plus/test";
import type { Config } from "../src/index.ts";
import { BailianError, ExitCode, McpClient, mapApiError, request } from "../src/index.ts";
import { parseConfigFile } from "../src/config/schema.ts";
function testConfig(overrides: Partial<Config> = {}): Config {
return {
@@ -173,3 +174,16 @@ test("McpClient uses injected client identity for initialize and User-Agent", as
params: { clientInfo: { name: "test-client", version: "9.8.7" } },
});
});
test("parseConfigFile accepts only well-formed http(s) base_url / console_gateway_url", () => {
expect(parseConfigFile({ base_url: "https://dashscope.aliyuncs.com" }).base_url).toBe(
"https://dashscope.aliyuncs.com",
);
expect(parseConfigFile({ base_url: "http://localhost:8080" }).base_url).toBe(
"http://localhost:8080",
);
// Previously accepted because the value merely "starts with http".
expect(parseConfigFile({ base_url: "httpfoo://evil" }).base_url).toBeUndefined();
expect(parseConfigFile({ base_url: "not a url" }).base_url).toBeUndefined();
expect(parseConfigFile({ console_gateway_url: "ftp://x" }).console_gateway_url).toBeUndefined();
});