From d4990ce01297616e60b46e7b35c59e810f46d621 Mon Sep 17 00:00:00 2001 From: Lydia Vilchez Date: Wed, 2 Sep 2026 13:17:05 +0200 Subject: [PATCH] fix(ui): match API whitespace stripping and lock the cert cap + extensions --- ui/lib/azure-cert-generator.test.ts | 31 ++++++++++++++++++ ui/types/formSchemas.test.ts | 50 +++++++++++++++++++++++++++++ ui/types/formSchemas.ts | 30 +++++++++++++---- 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/ui/lib/azure-cert-generator.test.ts b/ui/lib/azure-cert-generator.test.ts index a6ed026387..10643a7385 100644 --- a/ui/lib/azure-cert-generator.test.ts +++ b/ui/lib/azure-cert-generator.test.ts @@ -96,6 +96,37 @@ describe("generateProwlerCertificate", () => { writable: true, }); }); + + it("emits a leaf cert with BasicConstraints(cA=false) and KeyUsage(digitalSignature)", async () => { + // Guardrail: audit tooling and strict CA validators flag self-signed + // leaves that omit these extensions. A future edit that drops them + // from the `extensions:` array must not slip past review. + const { + BasicConstraintsExtension, + KeyUsageFlags, + KeyUsagesExtension, + X509Certificate, + } = await import("@peculiar/x509"); + + const result = await generateProwlerCertificate({ modulusLength: 2048 }); + + const publicDer = Uint8Array.from( + atob(result.publicCertificateBase64Der), + (c) => c.charCodeAt(0), + ); + const cert = new X509Certificate(publicDer); + + const basicConstraints = cert.getExtension(BasicConstraintsExtension); + expect(basicConstraints).toBeDefined(); + expect(basicConstraints!.ca).toBe(false); + + const keyUsages = cert.getExtension(KeyUsagesExtension); + expect(keyUsages).toBeDefined(); + // `usages` is a bitmask — verify the digitalSignature bit is set. + expect(keyUsages!.usages & KeyUsageFlags.digitalSignature).toBe( + KeyUsageFlags.digitalSignature, + ); + }); }); describe("downloadPublicCertificateFile", () => { diff --git a/ui/types/formSchemas.test.ts b/ui/types/formSchemas.test.ts index 8990832e62..ff8ffd0fe0 100644 --- a/ui/types/formSchemas.test.ts +++ b/ui/types/formSchemas.test.ts @@ -7,7 +7,9 @@ import { addCredentialsFormSchema, addCredentialsRoleFormSchema, addProviderFormSchema, + CERTIFICATE_CONTENT_MAX_SIZE_ERROR, KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR, + MAX_CERTIFICATE_CONTENT_LENGTH, samlConfigFormSchema, } from "./formSchemas"; @@ -125,6 +127,54 @@ describe("addCredentialsFormSchema - azure certificate", () => { // Then expect(result.success).toBe(false); }); + + it("rejects certificate content that exceeds the base64 length cap", () => { + // Guardrail against a future edit dropping the `.max(...)` on the + // Azure `certificate_content` field: a payload larger than the API's + // `_MAX_CERTIFICATE_CONTENT_LENGTH` must never leave the browser. + const schema = addCredentialsFormSchema("azure", "app_certificate"); + // Padding-safe: 4-char multiple of "A" (all valid base64 chars) longer + // than the cap, so only the size check rejects (isValidBase64 passes). + const oversized = "A".repeat(MAX_CERTIFICATE_CONTENT_LENGTH + 4); + + const result = schema.safeParse({ + ...BASE_AZURE_VALUES, + [ProviderCredentialFields.CERTIFICATE_CONTENT]: oversized, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + message: CERTIFICATE_CONTENT_MAX_SIZE_ERROR, + path: [ProviderCredentialFields.CERTIFICATE_CONTENT], + }), + ); + }); + + it("accepts a wrapped-line certificate content that fits after stripping whitespace", () => { + // openssl and PowerShell wrap base64 output at 64 chars with LF/CRLF. + // The API strips whitespace before enforcing the cap; the client does + // the same via `.transform(strip)`. A legitimate ~50 KB base64 that + // exceeds the cap only because of embedded whitespace must still pass. + const schema = addCredentialsFormSchema("azure", "app_certificate"); + const rawBase64 = "A".repeat(MAX_CERTIFICATE_CONTENT_LENGTH); + const wrapped = rawBase64.match(/.{1,64}/g)!.join("\r\n"); + // Sanity: wrapping made it longer than the cap. + expect(wrapped.length).toBeGreaterThan(MAX_CERTIFICATE_CONTENT_LENGTH); + + const result = schema.safeParse({ + ...BASE_AZURE_VALUES, + [ProviderCredentialFields.CERTIFICATE_CONTENT]: wrapped, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + // The parsed value is the stripped base64 — matches what the API sees. + expect( + result.data[ProviderCredentialFields.CERTIFICATE_CONTENT], + ).toBe(rawBase64); + }); }); describe("addProviderFormSchema - okta", () => { diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index a7e654baac..5010b6246d 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -251,9 +251,18 @@ export const addCredentialsFormSchema = ( [ProviderCredentialFields.CLIENT_SECRET]: z.string().optional(), [ProviderCredentialFields.CERTIFICATE_CONTENT]: z .string() - .max( - MAX_CERTIFICATE_CONTENT_LENGTH, - CERTIFICATE_CONTENT_MAX_SIZE_ERROR, + // The API strips base64 whitespace before enforcing its + // 68268-char cap; measure the same value on the client so + // a legitimate CRLF-wrapped paste (openssl / PowerShell + // default line wrap) is not rejected as "too large". + .transform((value) => value.replace(/\s+/g, "")) + .pipe( + z + .string() + .max( + MAX_CERTIFICATE_CONTENT_LENGTH, + CERTIFICATE_CONTENT_MAX_SIZE_ERROR, + ), ) .optional(), [ProviderCredentialFields.TENANT_ID]: z.guid({ @@ -298,9 +307,18 @@ export const addCredentialsFormSchema = ( .optional(), [ProviderCredentialFields.CERTIFICATE_CONTENT]: z .string() - .max( - MAX_CERTIFICATE_CONTENT_LENGTH, - CERTIFICATE_CONTENT_MAX_SIZE_ERROR, + // Same whitespace-then-cap contract as Azure — the + // API strips base64 whitespace before enforcing the + // 68268-char cap, so the client measures the same + // stripped value. + .transform((value) => value.replace(/\s+/g, "")) + .pipe( + z + .string() + .max( + MAX_CERTIFICATE_CONTENT_LENGTH, + CERTIFICATE_CONTENT_MAX_SIZE_ERROR, + ), ) .optional(), [ProviderCredentialFields.TENANT_ID]: z