From e0e7fb70a4da5e6475f245ae756ab815610302bf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 11:58:27 -0700 Subject: [PATCH 1/3] fix(security): validate cloud region/project inputs and bind vertex credentials to the executing workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two credential-exposure bugs on the Vertex AI path. 1. `vertexLocation` reached `new GoogleGenAI({ location })` unvalidated. The SDK interpolates that value straight into the API hostname (`https://${location}-aiplatform.googleapis.com/`), so a value like `attacker.tld/x` terminates the authority component and relocates the request — with the workspace's GCP bearer token attached by the auth client — to an arbitrary host. Reachable from agent/router/evaluator blocks and from `POST /api/guardrails/validate` with only a session cookie. `vertexProject` (interpolated into the URL path) and the Bedrock region (interpolated into the endpoint hostname) had the same shape of problem. Adds `validateGoogleCloudLocation` / `validateGoogleCloudProject` to the shared input-validation module and applies them, plus the existing `validateAwsRegion`, at the provider chokepoints. Every path to the SDK goes through `executeRequest`, so no caller can bypass them. `azureEndpoint` already routes through the DNS-pinning SSRF guard. 2. `resolveVertexCredential` enforced only the user↔credential predicate and never the workflow-workspace↔credential predicate that `authorizeCredentialUse` applies on the HTTP path. A credential held in workspace B could be pasted into a workflow in workspace A and consumed by workspace-A principals with no access to B — including on deployed runs, where `enforceCredentialAccess` is false and `ctx.userId` is the workflow owner rather than the trigger caller. Service-account credentials mint a `cloud-platform`-scoped token, so this handed workspace A the use of workspace B's GCP identity. The resolver now takes the executing `workspaceId` and rejects a credential belonging to a different workspace, mirroring `credential-access.ts`. All four executor call sites pass `ctx.workspaceId`. --- .../executor/handlers/agent/agent-handler.ts | 11 ++- .../handlers/evaluator/evaluator-handler.ts | 11 ++- .../handlers/router/router-handler.ts | 22 +++-- .../executor/utils/vertex-credential.test.ts | 95 ++++++++++++++++++ apps/sim/executor/utils/vertex-credential.ts | 33 +++++-- .../core/security/input-validation.test.ts | 87 ++++++++++++++++ .../sim/lib/core/security/input-validation.ts | 78 +++++++++++++++ apps/sim/providers/bedrock/index.ts | 10 ++ apps/sim/providers/vertex/index.test.ts | 99 +++++++++++++++++++ apps/sim/providers/vertex/index.ts | 20 ++++ 10 files changed, 438 insertions(+), 28 deletions(-) create mode 100644 apps/sim/executor/utils/vertex-credential.test.ts create mode 100644 apps/sim/providers/vertex/index.test.ts diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 43ab9d7c1b0..31a2268a404 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -1173,11 +1173,12 @@ export class AgentBlockHandler implements BlockHandler { let finalApiKey: string | undefined = providerRequest.apiKey if (providerId === 'vertex' && providerRequest.vertexCredential) { - finalApiKey = await resolveVertexCredential( - providerRequest.vertexCredential, - ctx.userId, - 'vertex-agent' - ) + finalApiKey = await resolveVertexCredential({ + credentialId: providerRequest.vertexCredential, + actingUserId: ctx.userId, + workspaceId: ctx.workspaceId, + callerLabel: 'vertex-agent', + }) } const { blockData, blockNameMapping } = collectBlockData(ctx) diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index a3a4475aa39..db0a9f4217f 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -135,11 +135,12 @@ export class EvaluatorBlockHandler implements BlockHandler { let finalApiKey: string | undefined = evaluatorConfig.apiKey if (providerId === 'vertex' && evaluatorConfig.vertexCredential) { - finalApiKey = await resolveVertexCredential( - evaluatorConfig.vertexCredential, - ctx.userId, - 'vertex-evaluator' - ) + finalApiKey = await resolveVertexCredential({ + credentialId: evaluatorConfig.vertexCredential, + actingUserId: ctx.userId, + workspaceId: ctx.workspaceId, + callerLabel: 'vertex-evaluator', + }) } try { diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 0a4961d7095..0fffbe33c41 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -99,11 +99,12 @@ export class RouterBlockHandler implements BlockHandler { let finalApiKey: string | undefined = routerConfig.apiKey if (providerId === 'vertex' && routerConfig.vertexCredential) { - finalApiKey = await resolveVertexCredential( - routerConfig.vertexCredential, - ctx.userId, - 'vertex-router' - ) + finalApiKey = await resolveVertexCredential({ + credentialId: routerConfig.vertexCredential, + actingUserId: ctx.userId, + workspaceId: ctx.workspaceId, + callerLabel: 'vertex-router', + }) } const providerRequest: Record = { @@ -239,11 +240,12 @@ export class RouterBlockHandler implements BlockHandler { let finalApiKey: string | undefined = routerConfig.apiKey if (providerId === 'vertex' && routerConfig.vertexCredential) { - finalApiKey = await resolveVertexCredential( - routerConfig.vertexCredential, - ctx.userId, - 'vertex-router' - ) + finalApiKey = await resolveVertexCredential({ + credentialId: routerConfig.vertexCredential, + actingUserId: ctx.userId, + workspaceId: ctx.workspaceId, + callerLabel: 'vertex-router', + }) } const providerRequest: Record = { diff --git a/apps/sim/executor/utils/vertex-credential.test.ts b/apps/sim/executor/utils/vertex-credential.test.ts new file mode 100644 index 00000000000..9776fac39d2 --- /dev/null +++ b/apps/sim/executor/utils/vertex-credential.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetCredentialActorContext, mockGetServiceAccountToken, mockRefreshTokenIfNeeded } = + vi.hoisted(() => ({ + mockGetCredentialActorContext: vi.fn(), + mockGetServiceAccountToken: vi.fn(), + mockRefreshTokenIfNeeded: vi.fn(), + })) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) +vi.mock('@/app/api/auth/oauth/utils', () => ({ + getServiceAccountToken: mockGetServiceAccountToken, + refreshTokenIfNeeded: mockRefreshTokenIfNeeded, +})) + +import { resolveVertexCredential } from '@/executor/utils/vertex-credential' + +function actorContext(workspaceId: string) { + return { + credential: { + id: 'cred-b', + workspaceId, + type: 'service_account', + accountId: null, + }, + member: { id: 'member-1' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: false, + } +} + +describe('resolveVertexCredential workspace binding', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetServiceAccountToken.mockResolvedValue('gcp-access-token') + }) + + it('rejects a credential owned by a different workspace than the executing workflow', async () => { + mockGetCredentialActorContext.mockResolvedValue(actorContext('workspace-b')) + + await expect( + resolveVertexCredential({ + credentialId: 'cred-b', + actingUserId: 'user-1', + workspaceId: 'workspace-a', + }) + ).rejects.toThrow('Credential is not accessible from this workflow workspace') + + expect(mockGetServiceAccountToken).not.toHaveBeenCalled() + }) + + it('resolves a credential owned by the executing workspace', async () => { + mockGetCredentialActorContext.mockResolvedValue(actorContext('workspace-a')) + + await expect( + resolveVertexCredential({ + credentialId: 'cred-b', + actingUserId: 'user-1', + workspaceId: 'workspace-a', + }) + ).resolves.toBe('gcp-access-token') + }) + + it('still enforces the user-to-credential check within the same workspace', async () => { + mockGetCredentialActorContext.mockResolvedValue({ + ...actorContext('workspace-a'), + member: null, + isAdmin: false, + }) + + await expect( + resolveVertexCredential({ + credentialId: 'cred-b', + actingUserId: 'user-1', + workspaceId: 'workspace-a', + }) + ).rejects.toThrow('Not authorized to use this Vertex AI credential') + }) + + it('requires an authenticated acting user', async () => { + await expect( + resolveVertexCredential({ + credentialId: 'cred-b', + actingUserId: undefined, + workspaceId: 'workspace-a', + }) + ).rejects.toThrow('requires an authenticated user') + }) +}) diff --git a/apps/sim/executor/utils/vertex-credential.ts b/apps/sim/executor/utils/vertex-credential.ts index b902f29f2fb..9da58ffe40e 100644 --- a/apps/sim/executor/utils/vertex-credential.ts +++ b/apps/sim/executor/utils/vertex-credential.ts @@ -7,17 +7,27 @@ import { getServiceAccountToken, refreshTokenIfNeeded } from '@/app/api/auth/oau const logger = createLogger('VertexCredential') +export interface ResolveVertexCredentialParams { + credentialId: string + actingUserId: string | undefined + /** Workspace of the executing workflow. The credential must belong to it. */ + workspaceId: string | null | undefined + callerLabel?: string +} + /** * Resolves a Vertex AI OAuth credential to an access token. - * Shared across agent, evaluator, and router handlers. Authorizes the executing - * user against the credential first — workspace credentials are usable by their - * members and by derived workspace admins, matching `authorizeCredentialUse`. + * Shared across agent, evaluator, and router handlers. Enforces the same two + * predicates as `authorizeCredentialUse`: the executing user must be a member + * (or derived workspace admin) of the credential, and the credential must belong + * to the workspace the workflow is executing in. */ -export async function resolveVertexCredential( - credentialId: string, - actingUserId: string | undefined, - callerLabel = 'vertex' -): Promise { +export async function resolveVertexCredential({ + credentialId, + actingUserId, + workspaceId, + callerLabel = 'vertex', +}: ResolveVertexCredentialParams): Promise { const requestId = `${callerLabel}-${Date.now()}` logger.info(`[${requestId}] Resolving Vertex AI credential: ${credentialId}`) @@ -31,6 +41,13 @@ export async function resolveVertexCredential( if (!cred) { throw new Error(`Vertex AI credential not found: ${credentialId}`) } + if (workspaceId && cred.workspaceId !== workspaceId) { + logger.warn(`[${requestId}] Vertex AI credential belongs to a different workspace`, { + credentialId, + executingWorkspaceId: workspaceId, + }) + throw new Error('Credential is not accessible from this workflow workspace') + } if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) { throw new Error('Not authorized to use this Vertex AI credential') } diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index df0200af3a6..f3322b5727f 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -9,6 +9,8 @@ import { validateExternalUrl, validateFileExtension, validateGoogleCalendarId, + validateGoogleCloudLocation, + validateGoogleCloudProject, validateHostname, validateImageUrl, validateInteger, @@ -1561,6 +1563,91 @@ describe('validateAwsRegion', () => { }) }) +describe('validateGoogleCloudLocation', () => { + describe('valid locations', () => { + it.concurrent.each([ + 'us-central1', + 'us-east5', + 'europe-west4', + 'northamerica-northeast1', + 'southamerica-east1', + 'asia-northeast3', + 'australia-southeast2', + 'africa-south1', + 'me-central2', + 'global', + ])('should accept %s', (location) => { + const result = validateGoogleCloudLocation(location) + expect(result.isValid).toBe(true) + expect(result.sanitized).toBe(location) + }) + }) + + describe('hostname injection', () => { + it.concurrent.each([ + 'attacker.example.com/x', + 'us-central1/../attacker.tld', + 'us-central1:8080', + 'user@attacker.tld', + 'us-central1?a=b', + 'us-central1#frag', + 'us central1', + 'us-central1\n', + 'US-CENTRAL1', + '../us-central1', + ])('should reject %j', (location) => { + const result = validateGoogleCloudLocation(location) + expect(result.isValid).toBe(false) + }) + }) + + it.concurrent('should reject empty and missing values', () => { + expect(validateGoogleCloudLocation('').isValid).toBe(false) + expect(validateGoogleCloudLocation(null).isValid).toBe(false) + expect(validateGoogleCloudLocation(undefined).isValid).toBe(false) + }) + + it.concurrent('should name the parameter in the error', () => { + const result = validateGoogleCloudLocation('bad host', 'vertexLocation') + expect(result.error).toContain('vertexLocation') + }) +}) + +describe('validateGoogleCloudProject', () => { + describe('valid projects', () => { + it.concurrent.each(['my-project', 'sim-prod-1', 'abcdef', '123456789012'])( + 'should accept %s', + (project) => { + const result = validateGoogleCloudProject(project) + expect(result.isValid).toBe(true) + expect(result.sanitized).toBe(project) + } + ) + }) + + describe('path injection and malformed ids', () => { + it.concurrent.each([ + 'my-project/../../other', + 'my-project:alias', + 'my project', + 'My-Project', + '1project', + 'my-project-', + 'abc', + 'a'.repeat(31), + ])('should reject %j', (project) => { + const result = validateGoogleCloudProject(project) + expect(result.isValid).toBe(false) + }) + }) + + it.concurrent('should reject empty and missing values', () => { + expect(validateGoogleCloudProject('').isValid).toBe(false) + expect(validateGoogleCloudProject(null).isValid).toBe(false) + expect(validateGoogleCloudProject(undefined).isValid).toBe(false) + }) +}) + describe('validateS3BucketName', () => { describe('valid bucket names', () => { it.concurrent('should accept simple bucket name', () => { diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 823fa3b2ef3..7f7861bf1ed 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -897,6 +897,84 @@ export function validateAwsRegion( return { isValid: true, sanitized: value } } +/** + * Validates a Google Cloud location (region) identifier. + * + * Google SDKs interpolate this value directly into the API hostname + * (`https://{location}-aiplatform.googleapis.com/`), so an unvalidated value + * containing `/`, `:`, `@`, or whitespace can terminate the authority component + * and relocate the request — along with any attached credential — to an + * attacker-controlled host. + * + * Accepts `global` plus the documented `{geography}-{direction}{index}` region + * form (e.g. us-central1, europe-west4, northamerica-northeast1, me-central2). + * + * @param value - The location to validate + * @param paramName - Name of the parameter for error messages + * @returns ValidationResult + */ +export function validateGoogleCloudLocation( + value: string | null | undefined, + paramName = 'location' +): ValidationResult { + if (value === null || value === undefined || value === '') { + return { isValid: false, error: `${paramName} is required` } + } + + const googleLocationPattern = + /^(global|(africa|asia|australia|europe|me|northamerica|southamerica|us)-(central|east|north|northeast|northwest|south|southeast|southwest|west)\d{1,2})$/ + + if (!googleLocationPattern.test(value)) { + logger.warn('Invalid Google Cloud location format', { + paramName, + value: value.substring(0, 50), + }) + return { + isValid: false, + error: `${paramName} must be a valid Google Cloud location (e.g., us-central1, europe-west4, global)`, + } + } + + return { isValid: true, sanitized: value } +} + +/** + * Validates a Google Cloud project identifier. + * + * Accepts either a project ID (6-30 chars, starts with a lowercase letter, + * lowercase letters/digits/hyphens, no trailing hyphen) or a numeric project + * number. This value is interpolated into the API URL path, so anything that + * could introduce path or authority separators is rejected. + * + * @param value - The project to validate + * @param paramName - Name of the parameter for error messages + * @returns ValidationResult + */ +export function validateGoogleCloudProject( + value: string | null | undefined, + paramName = 'project' +): ValidationResult { + if (value === null || value === undefined || value === '') { + return { isValid: false, error: `${paramName} is required` } + } + + const projectIdPattern = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/ + const projectNumberPattern = /^\d{1,20}$/ + + if (!projectIdPattern.test(value) && !projectNumberPattern.test(value)) { + logger.warn('Invalid Google Cloud project format', { + paramName, + value: value.substring(0, 50), + }) + return { + isValid: false, + error: `${paramName} must be a valid Google Cloud project ID or project number`, + } + } + + return { isValid: true, sanitized: value } +} + /** * Validates an S3 bucket name according to AWS naming rules * diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index a35f0ab6bc5..11fb206683d 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -17,6 +17,7 @@ import { import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { validateAwsRegion } from '@/lib/core/security/input-validation' import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { buildBedrockMessageContent } from '@/providers/attachments' @@ -124,6 +125,15 @@ export const bedrockProvider: ProviderConfig = { request: ProviderRequest ): Promise => { const region = request.bedrockRegion || 'us-east-1' + + // The AWS SDK interpolates the region into the Bedrock endpoint hostname, so an + // unvalidated value can redirect the signed request to an attacker-chosen host. + const regionValidation = validateAwsRegion(region, 'bedrockRegion') + if (!regionValidation.isValid) { + logger.warn('Blocked invalid Bedrock region', { error: regionValidation.error }) + throw new Error(`Invalid Bedrock region: ${regionValidation.error}`) + } + const bedrockModelId = getBedrockInferenceProfileId(request.model, region) logger.info('Bedrock request', { diff --git a/apps/sim/providers/vertex/index.test.ts b/apps/sim/providers/vertex/index.test.ts new file mode 100644 index 00000000000..adcd57464da --- /dev/null +++ b/apps/sim/providers/vertex/index.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { mockGoogleGenAI, genAIArgs, mockExecuteGeminiRequest } = vi.hoisted(() => { + const genAIArgs: Array> = [] + class MockGoogleGenAI { + constructor(opts: Record) { + genAIArgs.push(opts) + } + } + return { + mockGoogleGenAI: MockGoogleGenAI, + genAIArgs, + mockExecuteGeminiRequest: vi.fn(), + } +}) + +vi.mock('@google/genai', () => ({ GoogleGenAI: mockGoogleGenAI })) +vi.mock('google-auth-library', () => ({ + OAuth2Client: class { + setCredentials() {} + }, +})) +vi.mock('@/providers/gemini/core', () => ({ executeGeminiRequest: mockExecuteGeminiRequest })) +vi.mock('@/providers/models', () => ({ + getProviderModels: () => ['vertex/gemini-2.0-flash'], + getProviderDefaultModel: () => 'vertex/gemini-2.0-flash', +})) +vi.mock('@/lib/core/config/env', () => ({ env: {} })) + +import { vertexProvider } from '@/providers/vertex' + +function request(overrides: Partial = {}): ProviderRequest { + return { + model: 'vertex/gemini-2.0-flash', + apiKey: 'ya29.canary-token', + vertexProject: 'pentest-proj', + messages: [], + ...overrides, + } as ProviderRequest +} + +describe('vertexProvider location and project validation', () => { + beforeEach(() => { + vi.clearAllMocks() + genAIArgs.length = 0 + mockExecuteGeminiRequest.mockResolvedValue({ content: 'ok' }) + }) + + it('rejects a location that terminates the URL authority', async () => { + await expect( + vertexProvider.executeRequest(request({ vertexLocation: 'attacker.example.com/x' })) + ).rejects.toThrow(/Invalid Vertex AI location/) + + expect(genAIArgs).toHaveLength(0) + expect(mockExecuteGeminiRequest).not.toHaveBeenCalled() + }) + + it.each(['us-central1:8080', 'user@attacker.tld', 'us-central1 attacker.tld', '../us-central1'])( + 'rejects the malformed location %j without constructing a client', + async (vertexLocation) => { + await expect(vertexProvider.executeRequest(request({ vertexLocation }))).rejects.toThrow( + /Invalid Vertex AI location/ + ) + expect(genAIArgs).toHaveLength(0) + } + ) + + it('rejects a project that injects extra URL path segments', async () => { + await expect( + vertexProvider.executeRequest( + request({ vertexProject: 'proj/../../attacker', vertexLocation: 'us-central1' }) + ) + ).rejects.toThrow(/Invalid Vertex AI project/) + + expect(genAIArgs).toHaveLength(0) + }) + + it('passes a valid location and project through to the SDK', async () => { + await vertexProvider.executeRequest(request({ vertexLocation: 'europe-west4' })) + + expect(genAIArgs).toHaveLength(1) + expect(genAIArgs[0]).toMatchObject({ + vertexai: true, + project: 'pentest-proj', + location: 'europe-west4', + }) + expect(mockExecuteGeminiRequest).toHaveBeenCalledTimes(1) + }) + + it('defaults to us-central1 when no location is supplied', async () => { + await vertexProvider.executeRequest(request()) + + expect(genAIArgs[0]).toMatchObject({ location: 'us-central1' }) + }) +}) diff --git a/apps/sim/providers/vertex/index.ts b/apps/sim/providers/vertex/index.ts index 145de12388e..e2b0283d184 100644 --- a/apps/sim/providers/vertex/index.ts +++ b/apps/sim/providers/vertex/index.ts @@ -2,6 +2,10 @@ import { GoogleGenAI } from '@google/genai' import { createLogger } from '@sim/logger' import { OAuth2Client } from 'google-auth-library' import { env } from '@/lib/core/config/env' +import { + validateGoogleCloudLocation, + validateGoogleCloudProject, +} from '@/lib/core/security/input-validation' import type { StreamingExecution } from '@/executor/types' import { executeGeminiRequest } from '@/providers/gemini/core' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' @@ -39,6 +43,22 @@ export const vertexProvider: ProviderConfig = { ) } + // The @google/genai SDK interpolates location into the API hostname and project + // into the URL path. Both must be validated before they reach the client, or a + // crafted value relocates the request — and the attached bearer token — to an + // arbitrary host. + const locationValidation = validateGoogleCloudLocation(vertexLocation, 'vertexLocation') + if (!locationValidation.isValid) { + logger.warn('Blocked invalid Vertex AI location', { error: locationValidation.error }) + throw new Error(`Invalid Vertex AI location: ${locationValidation.error}`) + } + + const projectValidation = validateGoogleCloudProject(vertexProject, 'vertexProject') + if (!projectValidation.isValid) { + logger.warn('Blocked invalid Vertex AI project', { error: projectValidation.error }) + throw new Error(`Invalid Vertex AI project: ${projectValidation.error}`) + } + if (!request.apiKey) { throw new Error( 'Access token is required for Vertex AI. Run `gcloud auth print-access-token` to get one, or use a service account.' From 95202a8acfc67f5e14199012373039d51e816f04 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 12:07:23 -0700 Subject: [PATCH 2/3] fix(providers): normalize vertex location case before validating Hostnames are case-insensitive, so a mixed-case location like US-Central1 reaches Google today. Lowercase it before the region check rather than rejecting an input that currently works. --- apps/sim/providers/vertex/index.test.ts | 6 ++++++ apps/sim/providers/vertex/index.ts | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/sim/providers/vertex/index.test.ts b/apps/sim/providers/vertex/index.test.ts index adcd57464da..35282292617 100644 --- a/apps/sim/providers/vertex/index.test.ts +++ b/apps/sim/providers/vertex/index.test.ts @@ -91,6 +91,12 @@ describe('vertexProvider location and project validation', () => { expect(mockExecuteGeminiRequest).toHaveBeenCalledTimes(1) }) + it('normalizes a mixed-case location rather than rejecting it', async () => { + await vertexProvider.executeRequest(request({ vertexLocation: 'US-Central1' })) + + expect(genAIArgs[0]).toMatchObject({ location: 'us-central1' }) + }) + it('defaults to us-central1 when no location is supplied', async () => { await vertexProvider.executeRequest(request()) diff --git a/apps/sim/providers/vertex/index.ts b/apps/sim/providers/vertex/index.ts index e2b0283d184..59c22efc2ad 100644 --- a/apps/sim/providers/vertex/index.ts +++ b/apps/sim/providers/vertex/index.ts @@ -35,7 +35,13 @@ export const vertexProvider: ProviderConfig = { request: ProviderRequest ): Promise => { const vertexProject = request.vertexProject || env.VERTEX_PROJECT - const vertexLocation = request.vertexLocation || env.VERTEX_LOCATION || 'us-central1' + // Hostnames are case-insensitive, so a mixed-case location reaches Google fine + // today. Normalize before validating rather than rejecting it as malformed. + const vertexLocation = ( + request.vertexLocation || + env.VERTEX_LOCATION || + 'us-central1' + ).toLowerCase() if (!vertexProject) { throw new Error( From 270ecbd3bf7fddf3c74dfb58abf7870844528dd7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 17:00:41 -0700 Subject: [PATCH 3/3] fix(security): accept AWS European Sovereign Cloud regions in validateAwsRegion eusc-de-east-1 is a real Bedrock-enabled region that the existing pattern did not cover, so applying the validator to bedrockRegion would have rejected a config that worked before. Adds the eusc- partition. --- apps/sim/lib/core/security/input-validation.test.ts | 13 +++++++++++++ apps/sim/lib/core/security/input-validation.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index f3322b5727f..f2472683d09 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1460,6 +1460,19 @@ describe('validateAwsRegion', () => { }) }) + describe('valid European Sovereign Cloud regions', () => { + it.concurrent('should accept eusc-de-east-1', () => { + const result = validateAwsRegion('eusc-de-east-1') + expect(result.isValid).toBe(true) + expect(result.sanitized).toBe('eusc-de-east-1') + }) + + it.concurrent('should reject a malformed eusc region', () => { + expect(validateAwsRegion('eusc-de-east').isValid).toBe(false) + expect(validateAwsRegion('eusc-deu-east-1').isValid).toBe(false) + }) + }) + describe('valid China regions', () => { it.concurrent('should accept cn-north-1', () => { const result = validateAwsRegion('cn-north-1') diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 7f7861bf1ed..fb802353daa 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -856,6 +856,7 @@ export function validateAirtableId( * - ISO partitions: us-iso-east-1, us-iso-west-1, us-isob-east-1 * - Mexico: mx-central-1 * - EU Sovereign Cloud: eu-isoe-west-1 + * - European Sovereign Cloud: eusc-de-east-1 * * @param value - The AWS region to validate * @param paramName - Name of the parameter for error messages @@ -881,7 +882,7 @@ export function validateAwsRegion( } const awsRegionPattern = - /^(eu-isoe|us-isob|us-iso|us-gov|af|ap|ca|cn|eu|il|me|mx|sa|us)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/ + /^(eu-isoe|eusc-[a-z]{2}|us-isob|us-iso|us-gov|af|ap|ca|cn|eu|il|me|mx|sa|us)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/ if (!awsRegionPattern.test(value)) { logger.warn('Invalid AWS region format', {