diff --git a/src/main/credentials/credential-resolver.ts b/src/main/credentials/credential-resolver.ts index 65e465a4..9ec90062 100644 --- a/src/main/credentials/credential-resolver.ts +++ b/src/main/credentials/credential-resolver.ts @@ -1,6 +1,6 @@ import { getOAuthProvider } from "@mariozechner/pi-ai/oauth"; import { getEnvApiKey } from "@mariozechner/pi-ai"; -import { isLocalConnectorBaseUrl } from "@shared/contracts/schemas-credentials"; +import { isAwsBedrockEndpointUrl, isLocalConnectorBaseUrl } from "@shared/contracts/schemas-credentials"; import { getConnectorDefinition, isPiKnownProvider } from "@main/connectors"; import { UpstreamError } from "../errors/index.js"; import { CredentialStore } from "./credential-store"; @@ -133,7 +133,16 @@ export class CredentialResolver { env.AWS_BEARER_TOKEN_BEDROCK = cfg.bearerToken; } if (cfg.forceCache) env.AWS_BEDROCK_FORCE_CACHE = "1"; - if (cfg.endpointUrl) env.AWS_ENDPOINT_URL_BEDROCK_RUNTIME = cfg.endpointUrl; + if (cfg.endpointUrl) { + if (!isAwsBedrockEndpointUrl(cfg.endpointUrl)) { + throw new CredentialError( + "CREDENTIAL_INVALID", + connectorId, + "AWS Bedrock endpointUrl must be an HTTPS AWS Bedrock runtime endpoint or a private endpoint.", + ); + } + env.AWS_ENDPOINT_URL_BEDROCK_RUNTIME = cfg.endpointUrl; + } if (cfg.skipAuth) env.AWS_BEDROCK_SKIP_AUTH = "1"; if (cfg.forceHttp1) env.AWS_BEDROCK_FORCE_HTTP1 = "1"; return { apiKey: "", extraEnv: env }; diff --git a/src/renderer/components/settings/forms/AwsForm.tsx b/src/renderer/components/settings/forms/AwsForm.tsx index 57d4bd7e..bdbbdbb2 100644 --- a/src/renderer/components/settings/forms/AwsForm.tsx +++ b/src/renderer/components/settings/forms/AwsForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { isAwsBedrockEndpointUrl } from "@shared/contracts/schemas-credentials"; import { ActionButton, Input, Select } from "../../ui"; import { useT } from "../../../i18n"; @@ -43,6 +44,11 @@ export function AwsForm({ connectorId, onSaved }: Props) { setError(t("form.aws.errBearerRequired")); return; } + const trimmedEndpointUrl = endpointUrl.trim(); + if (trimmedEndpointUrl && !isAwsBedrockEndpointUrl(trimmedEndpointUrl)) { + setError(t("form.aws.errEndpointUrlInvalid")); + return; + } setBusy(true); setError(null); const result = await desktop.saveCredential({ @@ -59,7 +65,7 @@ export function AwsForm({ connectorId, onSaved }: Props) { authMode === "iam" && sessionToken.trim() ? sessionToken.trim() : undefined, bearerToken: authMode === "bearer" ? bearerToken.trim() : undefined, forceCache, - endpointUrl: endpointUrl.trim() || undefined, + endpointUrl: trimmedEndpointUrl || undefined, skipAuth, forceHttp1, }, diff --git a/src/renderer/i18n/en.ts b/src/renderer/i18n/en.ts index 28dd700b..f3beeb50 100644 --- a/src/renderer/i18n/en.ts +++ b/src/renderer/i18n/en.ts @@ -1181,6 +1181,7 @@ export const en: Record = { "form.aws.errProfileRequired": "AWS profile name required.", "form.aws.errIamRequired": "Access key id and secret access key required for IAM auth.", "form.aws.errBearerRequired": "Bearer token required.", + "form.aws.errEndpointUrlInvalid": "Endpoint URL must be an HTTPS AWS Bedrock runtime endpoint or a private endpoint.", "form.azure.apiKey": "API key", "form.azure.baseUrl": "Base URL (optional)", "form.azure.resourceName": "Resource name (optional)", diff --git a/src/renderer/i18n/zh.ts b/src/renderer/i18n/zh.ts index 5a588861..6ef2c6ea 100644 --- a/src/renderer/i18n/zh.ts +++ b/src/renderer/i18n/zh.ts @@ -1181,6 +1181,7 @@ export const zh: Record = { "form.aws.errProfileRequired": "请填写 AWS Profile 名称。", "form.aws.errIamRequired": "IAM 认证需要 Access Key ID 和 Secret Access Key。", "form.aws.errBearerRequired": "请填写 Bearer Token。", + "form.aws.errEndpointUrlInvalid": "Endpoint URL 必须是 HTTPS AWS Bedrock Runtime endpoint 或私有 endpoint。", "form.azure.apiKey": "API Key", "form.azure.baseUrl": "Base URL(可选)", "form.azure.resourceName": "资源名称(可选)", diff --git a/src/shared/contracts/schemas-credentials.ts b/src/shared/contracts/schemas-credentials.ts index 9958e938..f23a1a27 100644 --- a/src/shared/contracts/schemas-credentials.ts +++ b/src/shared/contracts/schemas-credentials.ts @@ -22,6 +22,14 @@ export const apiKeyValueSchema = z.object({ value: z.string().min(1), }); +const AWS_REGION_SEGMENT = "[a-z]{2}(?:-gov)?-[a-z]+-\\d"; +const AWS_BEDROCK_RUNTIME_HOSTNAME = new RegExp( + `^bedrock-runtime(?:-fips)?\\.${AWS_REGION_SEGMENT}\\.amazonaws\\.com(?:\\.cn)?$`, +); +const AWS_BEDROCK_VPCE_HOSTNAME = new RegExp( + `^vpce-[a-z0-9-]+\\.bedrock-runtime(?:-fips)?\\.${AWS_REGION_SEGMENT}\\.vpce\\.amazonaws\\.com(?:\\.cn)?$`, +); + export const azureCredentialSchema = z.object({ apiKey: z.string().min(1), baseUrl: z.string().url().optional().or(z.literal("")), @@ -30,6 +38,14 @@ export const azureCredentialSchema = z.object({ deploymentMap: z.record(z.string(), z.string()).optional(), }); +export const awsEndpointUrlSchema = z + .string() + .url() + .refine( + isAwsBedrockEndpointUrl, + "AWS Bedrock endpointUrl must be an HTTPS AWS Bedrock runtime endpoint or a private endpoint.", + ); + export const awsCredentialSchema = z.object({ authMode: z.enum(["profile", "iam", "bearer"]), region: z.string().min(1).default("us-east-1"), @@ -39,7 +55,7 @@ export const awsCredentialSchema = z.object({ sessionToken: z.string().optional(), bearerToken: z.string().optional(), forceCache: z.boolean().optional(), - endpointUrl: z.string().optional(), + endpointUrl: awsEndpointUrlSchema.optional(), skipAuth: z.boolean().optional(), forceHttp1: z.boolean().optional(), }); @@ -85,6 +101,21 @@ function isPrivateIpv6(hostname: string): boolean { return /^f[cd][0-9a-f]{2}:/i.test(hostname) || /^fe80:/i.test(hostname); } +function isPrivateNetworkHostname(hostname: string): boolean { + return hostname === "localhost" || isPrivateIpv4(hostname) || isPrivateIpv6(hostname); +} + +function hasOnlyRootUrlPath(value: string): boolean { + const trimmed = value.trim(); + if (trimmed.includes("?") || trimmed.includes("#")) return false; + const schemeSeparator = trimmed.indexOf("://"); + if (schemeSeparator < 0) return false; + const authorityAndPath = trimmed.slice(schemeSeparator + 3); + const pathStart = authorityAndPath.indexOf("/"); + if (pathStart < 0) return true; + return authorityAndPath.slice(pathStart) === "/"; +} + export function isLocalConnectorBaseUrl(value: string): boolean { let url: URL; try { @@ -95,8 +126,7 @@ export function isLocalConnectorBaseUrl(value: string): boolean { if (url.protocol !== "http:" && url.protocol !== "https:") return false; if (url.username || url.password) return false; const hostname = normalizeUrlHostname(url.hostname); - if (hostname === "localhost") return true; - return isPrivateIpv4(hostname) || isPrivateIpv6(hostname); + return isPrivateNetworkHostname(hostname); } export const localBaseUrlSchema = z @@ -111,6 +141,24 @@ export const localCredentialSchema = z.object({ baseUrl: localBaseUrlSchema, }); +function isAwsBedrockHostname(hostname: string): boolean { + return AWS_BEDROCK_RUNTIME_HOSTNAME.test(hostname) || AWS_BEDROCK_VPCE_HOSTNAME.test(hostname); +} + +export function isAwsBedrockEndpointUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (url.protocol !== "https:") return false; + if (url.username || url.password) return false; + if (!hasOnlyRootUrlPath(value)) return false; + const hostname = normalizeUrlHostname(url.hostname); + return isAwsBedrockHostname(hostname) || isPrivateNetworkHostname(hostname); +} + export const credentialPayloadSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("api_key"), value: apiKeyValueSchema }), z.object({ kind: z.literal("azure"), value: azureCredentialSchema }), diff --git a/tests/unit/credential-resolver.test.ts b/tests/unit/credential-resolver.test.ts index 4cd28473..d07ac68f 100644 --- a/tests/unit/credential-resolver.test.ts +++ b/tests/unit/credential-resolver.test.ts @@ -102,7 +102,7 @@ describe("CredentialResolver", () => { region: "eu-west-1", awsProfile: "production", forceCache: true, - endpointUrl: "https://bedrock-runtime.example.com", + endpointUrl: "https://bedrock-runtime.eu-west-1.amazonaws.com", skipAuth: true, forceHttp1: true, }, @@ -112,12 +112,39 @@ describe("CredentialResolver", () => { AWS_REGION: "eu-west-1", AWS_PROFILE: "production", AWS_BEDROCK_FORCE_CACHE: "1", - AWS_ENDPOINT_URL_BEDROCK_RUNTIME: "https://bedrock-runtime.example.com", + AWS_ENDPOINT_URL_BEDROCK_RUNTIME: "https://bedrock-runtime.eu-west-1.amazonaws.com", AWS_BEDROCK_SKIP_AUTH: "1", AWS_BEDROCK_FORCE_HTTP1: "1", }); }); + it("rejects unsafe AWS endpointUrl values from stored credentials", async () => { + store.save("amazon-bedrock", { + kind: "aws", + value: { + authMode: "profile", + region: "us-east-1", + awsProfile: "default", + endpointUrl: "https://example.com", + }, + }); + + let thrown: unknown; + try { + await resolver.resolve("amazon-bedrock"); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CredentialError); + expect(thrown).toMatchObject({ + kind: "upstream", + code: "CREDENTIAL_INVALID", + connectorId: "amazon-bedrock", + context: { connectorId: "amazon-bedrock" }, + }); + }); + it("assembles Azure extraEnv with deploymentMap", async () => { store.save("azure-openai", { kind: "azure", diff --git a/tests/unit/credentials-schemas.test.ts b/tests/unit/credentials-schemas.test.ts index e7d05126..3c01aa7c 100644 --- a/tests/unit/credentials-schemas.test.ts +++ b/tests/unit/credentials-schemas.test.ts @@ -5,6 +5,7 @@ import { azureCredentialSchema, credentialPayloadSchema, gcpAdcCredentialSchema, + isAwsBedrockEndpointUrl, isLocalConnectorBaseUrl, localCredentialSchema, removeCredentialInputSchema, @@ -51,6 +52,46 @@ describe("credentials Zod schemas", () => { } }); + it("awsCredentialSchema accepts AWS Bedrock and private HTTPS endpointUrl values", () => { + for (const endpointUrl of [ + "https://bedrock-runtime.us-east-1.amazonaws.com", + "https://bedrock-runtime.us-east-1.amazonaws.com/", + "https://bedrock-runtime-fips.us-gov-west-1.amazonaws.com", + "https://bedrock-runtime.cn-north-1.amazonaws.com.cn", + "https://vpce-123abc.bedrock-runtime.us-east-1.vpce.amazonaws.com", + "https://vpce-123abc-us-east-1a.bedrock-runtime.us-east-1.vpce.amazonaws.com", + "https://10.0.0.20", + "https://192.168.1.20", + "https://[fd12::1]", + ]) { + const result = awsCredentialSchema.safeParse({ authMode: "profile", region: "us-east-1", awsProfile: "default", endpointUrl }); + expect(result.success).toBe(true); + expect(isAwsBedrockEndpointUrl(endpointUrl)).toBe(true); + } + }); + + it("awsCredentialSchema rejects unsafe endpointUrl values", () => { + for (const endpointUrl of [ + "http://bedrock-runtime.us-east-1.amazonaws.com", + "https://api.openai.com/v1", + "https://bedrock-runtime.example.com", + "https://user:pass@bedrock-runtime.us-east-1.amazonaws.com", + "https://bedrock-runtime.us-east-1.amazonaws.com/model/test", + "https://bedrock-runtime.us-east-1.amazonaws.com?proxy=1", + "ftp://bedrock-runtime.us-east-1.amazonaws.com", + "https://bedrock-runtime.us-east-1.amazonaws.com?", + "https://bedrock-runtime.us-east-1.amazonaws.com#", + "https://bedrock-runtime.us-east-1.amazonaws.com/%2e%2e", + "https://bedrock-runtime.us-east-1.amazonaws.com/a/..", + "https://bedrock-runtime.us-east-1.vpce.amazonaws.com", + "https://foo.bar.bedrock-runtime.us-east-1.vpce.amazonaws.com", + ]) { + const result = awsCredentialSchema.safeParse({ authMode: "profile", region: "us-east-1", awsProfile: "default", endpointUrl }); + expect(result.success).toBe(false); + expect(isAwsBedrockEndpointUrl(endpointUrl)).toBe(false); + } + }); + it("azureCredentialSchema requires apiKey", () => { expect(azureCredentialSchema.safeParse({}).success).toBe(false); expect(azureCredentialSchema.safeParse({ apiKey: "azure-secret" }).success).toBe(true);