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..f2472683d09 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, @@ -1458,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') @@ -1561,6 +1576,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..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', { @@ -897,6 +898,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..35282292617 --- /dev/null +++ b/apps/sim/providers/vertex/index.test.ts @@ -0,0 +1,105 @@ +/** + * @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('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()) + + 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..59c22efc2ad 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' @@ -31,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( @@ -39,6 +49,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.'