diff --git a/backend/src/ee/routes/v1/external-kms-router.ts b/backend/src/ee/routes/v1/external-kms-router.ts index b46b525fe74..76bc463e7b6 100644 --- a/backend/src/ee/routes/v1/external-kms-router.ts +++ b/backend/src/ee/routes/v1/external-kms-router.ts @@ -3,10 +3,10 @@ import { z } from "zod"; import { ExternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { - ExternalKmsAwsSchema, ExternalKmsGcpSchema, ExternalKmsInputSchema, - ExternalKmsInputUpdateSchema + ExternalKmsInputUpdateSchema, + SanitizedExternalKmsAwsSchema } from "@app/ee/services/external-kms/providers/model"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -45,8 +45,11 @@ const sanitizedExternalSchemaForGetById = KmsKeysSchema.extend({ statusDetails: true, provider: true }).extend({ - // for GCP, we don't return the credential object as it is sensitive data that should not be exposed - providerInput: z.union([ExternalKmsAwsSchema, ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true })]) + // neither provider returns sensitive credential material in read responses: AWS via the sanitized schema (access key only, no secret key), GCP via the picked fields only + providerInput: z.union([ + SanitizedExternalKmsAwsSchema, + ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true }) + ]) }) }); diff --git a/backend/src/ee/services/external-kms/providers/model.test.ts b/backend/src/ee/services/external-kms/providers/model.test.ts new file mode 100644 index 00000000000..1524bd60a27 --- /dev/null +++ b/backend/src/ee/services/external-kms/providers/model.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import { ExternalKmsAwsSchema, KmsAwsCredentialType, SanitizedExternalKmsAwsSchema } from "./model"; + +describe("SanitizedExternalKmsAwsSchema (external-KMS read response)", () => { + const awsAccessKeyInput = { + credential: { + type: KmsAwsCredentialType.AccessKey, + data: { + accessKey: "AKIAEXAMPLE", + secretKey: "super-secret-value" + } + }, + awsRegion: "us-east-1", + kmsKeyId: "key-123" + }; + + test("strips secretKey from an access-key credential on read", () => { + const parsed = SanitizedExternalKmsAwsSchema.parse(awsAccessKeyInput); + + expect(parsed.credential.type).toBe(KmsAwsCredentialType.AccessKey); + expect(parsed.credential.data).toHaveProperty("accessKey", "AKIAEXAMPLE"); + expect(parsed.credential.data).not.toHaveProperty("secretKey"); + // the secret value must not survive serialization anywhere in the response + expect(JSON.stringify(parsed)).not.toContain("super-secret-value"); + }); + + test("negative control: the pre-fix unsanitized schema would have returned secretKey", () => { + const parsed = ExternalKmsAwsSchema.parse(awsAccessKeyInput); + + expect(parsed.credential.data).toHaveProperty("secretKey", "super-secret-value"); + }); + + test("retains assume-role identifiers (that branch carries no secret material)", () => { + const parsed = SanitizedExternalKmsAwsSchema.parse({ + credential: { + type: KmsAwsCredentialType.AssumeRole, + data: { + assumeRoleArn: "arn:aws:iam::123456789012:role/infisical", + externalId: "ext-1" + } + }, + awsRegion: "us-east-1" + }); + + expect(parsed.credential.data).toMatchObject({ + assumeRoleArn: "arn:aws:iam::123456789012:role/infisical", + externalId: "ext-1" + }); + }); +});