fix(security): validate bedrock endpoint URLs
This commit is contained in:
1 parent
84b92c859e
commit
8b0b06f765
7 files changed
+141
-8
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
|
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
|
||||||
import { getEnvApiKey } from "@mariozechner/pi-ai";
|
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 { getConnectorDefinition, isPiKnownProvider } from "@main/connectors";
|
||||||
import { UpstreamError } from "../errors/index.js";
|
import { UpstreamError } from "../errors/index.js";
|
||||||
import { CredentialStore } from "./credential-store";
|
import { CredentialStore } from "./credential-store";
|
||||||
@@ -133,7 +133,16 @@ export class CredentialResolver {
|
|||||||
env.AWS_BEARER_TOKEN_BEDROCK = cfg.bearerToken;
|
env.AWS_BEARER_TOKEN_BEDROCK = cfg.bearerToken;
|
||||||
}
|
}
|
||||||
if (cfg.forceCache) env.AWS_BEDROCK_FORCE_CACHE = "1";
|
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.skipAuth) env.AWS_BEDROCK_SKIP_AUTH = "1";
|
||||||
if (cfg.forceHttp1) env.AWS_BEDROCK_FORCE_HTTP1 = "1";
|
if (cfg.forceHttp1) env.AWS_BEDROCK_FORCE_HTTP1 = "1";
|
||||||
return { apiKey: "", extraEnv: env };
|
return { apiKey: "", extraEnv: env };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { isAwsBedrockEndpointUrl } from "@shared/contracts/schemas-credentials";
|
||||||
import { ActionButton, Input, Select } from "../../ui";
|
import { ActionButton, Input, Select } from "../../ui";
|
||||||
import { useT } from "../../../i18n";
|
import { useT } from "../../../i18n";
|
||||||
|
|
||||||
@@ -43,6 +44,11 @@ export function AwsForm({ connectorId, onSaved }: Props) {
|
|||||||
setError(t("form.aws.errBearerRequired"));
|
setError(t("form.aws.errBearerRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const trimmedEndpointUrl = endpointUrl.trim();
|
||||||
|
if (trimmedEndpointUrl && !isAwsBedrockEndpointUrl(trimmedEndpointUrl)) {
|
||||||
|
setError(t("form.aws.errEndpointUrlInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const result = await desktop.saveCredential({
|
const result = await desktop.saveCredential({
|
||||||
@@ -59,7 +65,7 @@ export function AwsForm({ connectorId, onSaved }: Props) {
|
|||||||
authMode === "iam" && sessionToken.trim() ? sessionToken.trim() : undefined,
|
authMode === "iam" && sessionToken.trim() ? sessionToken.trim() : undefined,
|
||||||
bearerToken: authMode === "bearer" ? bearerToken.trim() : undefined,
|
bearerToken: authMode === "bearer" ? bearerToken.trim() : undefined,
|
||||||
forceCache,
|
forceCache,
|
||||||
endpointUrl: endpointUrl.trim() || undefined,
|
endpointUrl: trimmedEndpointUrl || undefined,
|
||||||
skipAuth,
|
skipAuth,
|
||||||
forceHttp1,
|
forceHttp1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1181,6 +1181,7 @@ export const en: Record<string, string> = {
|
|||||||
"form.aws.errProfileRequired": "AWS profile name required.",
|
"form.aws.errProfileRequired": "AWS profile name required.",
|
||||||
"form.aws.errIamRequired": "Access key id and secret access key required for IAM auth.",
|
"form.aws.errIamRequired": "Access key id and secret access key required for IAM auth.",
|
||||||
"form.aws.errBearerRequired": "Bearer token required.",
|
"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.apiKey": "API key",
|
||||||
"form.azure.baseUrl": "Base URL (optional)",
|
"form.azure.baseUrl": "Base URL (optional)",
|
||||||
"form.azure.resourceName": "Resource name (optional)",
|
"form.azure.resourceName": "Resource name (optional)",
|
||||||
|
|||||||
@@ -1181,6 +1181,7 @@ export const zh: Record<string, string> = {
|
|||||||
"form.aws.errProfileRequired": "请填写 AWS Profile 名称。",
|
"form.aws.errProfileRequired": "请填写 AWS Profile 名称。",
|
||||||
"form.aws.errIamRequired": "IAM 认证需要 Access Key ID 和 Secret Access Key。",
|
"form.aws.errIamRequired": "IAM 认证需要 Access Key ID 和 Secret Access Key。",
|
||||||
"form.aws.errBearerRequired": "请填写 Bearer Token。",
|
"form.aws.errBearerRequired": "请填写 Bearer Token。",
|
||||||
|
"form.aws.errEndpointUrlInvalid": "Endpoint URL 必须是 HTTPS AWS Bedrock Runtime endpoint 或私有 endpoint。",
|
||||||
"form.azure.apiKey": "API Key",
|
"form.azure.apiKey": "API Key",
|
||||||
"form.azure.baseUrl": "Base URL(可选)",
|
"form.azure.baseUrl": "Base URL(可选)",
|
||||||
"form.azure.resourceName": "资源名称(可选)",
|
"form.azure.resourceName": "资源名称(可选)",
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ export const apiKeyValueSchema = z.object({
|
|||||||
value: z.string().min(1),
|
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({
|
export const azureCredentialSchema = z.object({
|
||||||
apiKey: z.string().min(1),
|
apiKey: z.string().min(1),
|
||||||
baseUrl: z.string().url().optional().or(z.literal("")),
|
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(),
|
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({
|
export const awsCredentialSchema = z.object({
|
||||||
authMode: z.enum(["profile", "iam", "bearer"]),
|
authMode: z.enum(["profile", "iam", "bearer"]),
|
||||||
region: z.string().min(1).default("us-east-1"),
|
region: z.string().min(1).default("us-east-1"),
|
||||||
@@ -39,7 +55,7 @@ export const awsCredentialSchema = z.object({
|
|||||||
sessionToken: z.string().optional(),
|
sessionToken: z.string().optional(),
|
||||||
bearerToken: z.string().optional(),
|
bearerToken: z.string().optional(),
|
||||||
forceCache: z.boolean().optional(),
|
forceCache: z.boolean().optional(),
|
||||||
endpointUrl: z.string().optional(),
|
endpointUrl: awsEndpointUrlSchema.optional(),
|
||||||
skipAuth: z.boolean().optional(),
|
skipAuth: z.boolean().optional(),
|
||||||
forceHttp1: 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);
|
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 {
|
export function isLocalConnectorBaseUrl(value: string): boolean {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
@@ -95,8 +126,7 @@ export function isLocalConnectorBaseUrl(value: string): boolean {
|
|||||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||||
if (url.username || url.password) return false;
|
if (url.username || url.password) return false;
|
||||||
const hostname = normalizeUrlHostname(url.hostname);
|
const hostname = normalizeUrlHostname(url.hostname);
|
||||||
if (hostname === "localhost") return true;
|
return isPrivateNetworkHostname(hostname);
|
||||||
return isPrivateIpv4(hostname) || isPrivateIpv6(hostname);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const localBaseUrlSchema = z
|
export const localBaseUrlSchema = z
|
||||||
@@ -111,6 +141,24 @@ export const localCredentialSchema = z.object({
|
|||||||
baseUrl: localBaseUrlSchema,
|
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", [
|
export const credentialPayloadSchema = z.discriminatedUnion("kind", [
|
||||||
z.object({ kind: z.literal("api_key"), value: apiKeyValueSchema }),
|
z.object({ kind: z.literal("api_key"), value: apiKeyValueSchema }),
|
||||||
z.object({ kind: z.literal("azure"), value: azureCredentialSchema }),
|
z.object({ kind: z.literal("azure"), value: azureCredentialSchema }),
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("CredentialResolver", () => {
|
|||||||
region: "eu-west-1",
|
region: "eu-west-1",
|
||||||
awsProfile: "production",
|
awsProfile: "production",
|
||||||
forceCache: true,
|
forceCache: true,
|
||||||
endpointUrl: "https://bedrock-runtime.example.com",
|
endpointUrl: "https://bedrock-runtime.eu-west-1.amazonaws.com",
|
||||||
skipAuth: true,
|
skipAuth: true,
|
||||||
forceHttp1: true,
|
forceHttp1: true,
|
||||||
},
|
},
|
||||||
@@ -112,12 +112,39 @@ describe("CredentialResolver", () => {
|
|||||||
AWS_REGION: "eu-west-1",
|
AWS_REGION: "eu-west-1",
|
||||||
AWS_PROFILE: "production",
|
AWS_PROFILE: "production",
|
||||||
AWS_BEDROCK_FORCE_CACHE: "1",
|
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_SKIP_AUTH: "1",
|
||||||
AWS_BEDROCK_FORCE_HTTP1: "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 () => {
|
it("assembles Azure extraEnv with deploymentMap", async () => {
|
||||||
store.save("azure-openai", {
|
store.save("azure-openai", {
|
||||||
kind: "azure",
|
kind: "azure",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
azureCredentialSchema,
|
azureCredentialSchema,
|
||||||
credentialPayloadSchema,
|
credentialPayloadSchema,
|
||||||
gcpAdcCredentialSchema,
|
gcpAdcCredentialSchema,
|
||||||
|
isAwsBedrockEndpointUrl,
|
||||||
isLocalConnectorBaseUrl,
|
isLocalConnectorBaseUrl,
|
||||||
localCredentialSchema,
|
localCredentialSchema,
|
||||||
removeCredentialInputSchema,
|
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", () => {
|
it("azureCredentialSchema requires apiKey", () => {
|
||||||
expect(azureCredentialSchema.safeParse({}).success).toBe(false);
|
expect(azureCredentialSchema.safeParse({}).success).toBe(false);
|
||||||
expect(azureCredentialSchema.safeParse({ apiKey: "azure-secret" }).success).toBe(true);
|
expect(azureCredentialSchema.safeParse({ apiKey: "azure-secret" }).success).toBe(true);
|
||||||
|
|||||||
Reference in new issue
Block a user