From 2786d5f0495fbb27a3d34870300a805228637d27 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 13 Nov 2025 16:35:04 -0800 Subject: [PATCH 1/9] Add tests --- .../bdd/features/pki/acme/challenge.feature | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 67f73aab2..8d9b467ff 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -22,6 +22,37 @@ Feature: Challenge And I parse the full-chain certificate from order finalized_order as cert And the value cert with jq ".subject.common_name" should be equal to "localhost" + Scenario Outline: Validate with SANs only CSR + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + + """ + And I add subject alternative name to certificate signing request csr + """ + [ + "localhost" + ] + """ + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I select challenge with type http-01 for domain localhost from order in order as challenge + And I serve challenge response for challenge at localhost + And I tell ACME server that challenge is ready to be verified + And I poll and finalize the ACME order order as finalized_order + And the value finalized_order.body with jq ".status" should be equal to "valid" + And I parse the full-chain certificate from order finalized_order as cert + And the value cert with jq ".subject.common_name" should be equal to "localhost" + + Examples: + | names | + | {} | + | {"COMMON_NAME": ""} + Scenario: Validate challenges for multiple domains Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" From 5ad4aaf0170785a19c1dd45e7337623408768bad Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 13 Nov 2025 16:39:35 -0800 Subject: [PATCH 2/9] Add tests --- .../bdd/features/pki/acme/internal-ca.feature | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 backend/bdd/features/pki/acme/internal-ca.feature diff --git a/backend/bdd/features/pki/acme/internal-ca.feature b/backend/bdd/features/pki/acme/internal-ca.feature new file mode 100644 index 000000000..c594f9a9f --- /dev/null +++ b/backend/bdd/features/pki/acme/internal-ca.feature @@ -0,0 +1,32 @@ +Feature: Internal CA + + Scenario Outline: CSR with SANs only + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + + """ + And I add subject alternative name to certificate signing request csr + """ + [ + "localhost" + ] + """ + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I select challenge with type http-01 for domain localhost from order in order as challenge + And I serve challenge response for challenge at localhost + And I tell ACME server that challenge is ready to be verified + And I poll and finalize the ACME order order as finalized_order + And the value finalized_order.body with jq ".status" should be equal to "valid" + And I parse the full-chain certificate from order finalized_order as cert + And the value cert with jq ".subject.common_name" should be equal to "localhost" + + Examples: + | names | + | {} | + | {"COMMON_NAME": ""} From 3a6e287f1b5cd668ae56e8e16a53ed4c691182ef Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 20 Nov 2025 13:40:17 -0800 Subject: [PATCH 3/9] Use axio instead of fetch for acme HTTP-01 challenge --- .../pki-acme/pki-acme-challenge-service.ts | 48 ++++++++----------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 7a3747fed..def30b126 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -1,3 +1,5 @@ +import axios, { AxiosError } from "axios"; + import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { isPrivateIp } from "@app/lib/ip/ipRange"; @@ -13,10 +15,6 @@ import { import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; -type FetchError = Error & { - code?: string; -}; - type TPkiAcmeChallengeServiceFactoryDep = { acmeChallengeDAL: Pick< TPkiAcmeChallengeDALFactory, @@ -74,18 +72,20 @@ export const pkiAcmeChallengeServiceFactory = ({ // Notice: well, we are in a transaction, ideally we should not hold transaction and perform // a long running operation for long time. But assuming we are not performing a tons of // challenge validation at the same time, it should be fine. - const challengeResponse = await fetch(challengeUrl, { + const challengeResponse = await axios.get(challengeUrl.toString(), { // In case if we override the host in the development mode, still provide the original host in the header // to help the upstream server to validate the request headers: { Host: host }, - signal: AbortSignal.timeout(timeoutMs) + timeout: timeoutMs, + responseType: "text", + validateStatus: () => true }); if (challengeResponse.status !== 200) { throw new AcmeIncorrectResponseError({ message: `ACME challenge response is not 200: ${challengeResponse.status}` }); } - const challengeResponseBody = await challengeResponse.text(); + const challengeResponseBody: string = challengeResponse.data; const thumbprint = challenge.auth.account.publicKeyThumbprint; const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) { @@ -96,35 +96,25 @@ export const pkiAcmeChallengeServiceFactory = ({ // TODO: we should retry the challenge validation a few times, but let's keep it simple for now await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx); // Properly type and inspect the error - if (exp instanceof TypeError && exp.message.includes("fetch failed")) { - const { cause } = exp; - let errors: Error[] = []; - if (cause instanceof AggregateError) { - errors = cause.errors as Error[]; - } else if (cause instanceof Error) { - errors = [cause]; + if (axios.isAxiosError(exp)) { + const axiosError = exp as AxiosError; + const errorCode = axiosError.code; + const errorMessage = axiosError.message; + + if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { + return new AcmeConnectionError({ message: "Connection refused" }); } - // eslint-disable-next-line no-unreachable-loop - for (const err of errors) { - // TODO: handle multiple errors, return a compound error instead of just the first error - const fetchError = err as FetchError; - if (fetchError.code === "ECONNREFUSED" || fetchError.message.includes("ECONNREFUSED")) { - return new AcmeConnectionError({ message: "Connection refused" }); - } - if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) { - return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); - } - logger.error(exp, "Unknown error validating ACME challenge response"); - return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); + if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) { + return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); } - } else if (exp instanceof DOMException) { - if (exp.name === "TimeoutError") { + if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) { logger.error(exp, "Connection timed out while validating ACME challenge response"); return new AcmeConnectionError({ message: "Connection timed out" }); } logger.error(exp, "Unknown error validating ACME challenge response"); return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); - } else if (exp instanceof Error) { + } + if (exp instanceof Error) { logger.error(exp, "Error validating ACME challenge response"); } else { logger.error(exp, "Unknown error validating ACME challenge response"); From 02c346c77aa99e6b76837eff86929046426e9dd6 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 20 Nov 2025 16:07:24 -0800 Subject: [PATCH 4/9] Revert --- .../bdd/features/pki/acme/challenge.feature | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 8d9b467ff..67f73aab2 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -22,37 +22,6 @@ Feature: Challenge And I parse the full-chain certificate from order finalized_order as cert And the value cert with jq ".subject.common_name" should be equal to "localhost" - Scenario Outline: Validate with SANs only CSR - Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" - Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account - When I create certificate signing request as csr - Then I add names to certificate signing request csr - """ - - """ - And I add subject alternative name to certificate signing request csr - """ - [ - "localhost" - ] - """ - And I create a RSA private key pair as cert_key - And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - And I select challenge with type http-01 for domain localhost from order in order as challenge - And I serve challenge response for challenge at localhost - And I tell ACME server that challenge is ready to be verified - And I poll and finalize the ACME order order as finalized_order - And the value finalized_order.body with jq ".status" should be equal to "valid" - And I parse the full-chain certificate from order finalized_order as cert - And the value cert with jq ".subject.common_name" should be equal to "localhost" - - Examples: - | names | - | {} | - | {"COMMON_NAME": ""} - Scenario: Validate challenges for multiple domains Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" From cf83ae25538a8cc494a6b3b1be0285a40e8f310d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 20 Nov 2025 16:56:18 -0800 Subject: [PATCH 5/9] Allow empty CN --- backend/bdd/features/pki/acme/internal-ca.feature | 9 ++------- .../services/pki-acme/pki-acme-challenge-service.ts | 2 +- .../src/ee/services/pki-acme/pki-acme-service.ts | 8 +++----- .../internal-certificate-authority-service.ts | 13 ++++++++++--- .../internal-certificate-authority-types.ts | 2 ++ .../certificate-v3/certificate-v3-service.ts | 6 ++++-- .../services/certificate-v3/certificate-v3-types.ts | 1 + 7 files changed, 23 insertions(+), 18 deletions(-) diff --git a/backend/bdd/features/pki/acme/internal-ca.feature b/backend/bdd/features/pki/acme/internal-ca.feature index c594f9a9f..6bd6ec4e9 100644 --- a/backend/bdd/features/pki/acme/internal-ca.feature +++ b/backend/bdd/features/pki/acme/internal-ca.feature @@ -1,13 +1,13 @@ Feature: Internal CA - Scenario Outline: CSR with SANs only + Scenario: CSR with SANs only Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr """ - + {} """ And I add subject alternative name to certificate signing request csr """ @@ -25,8 +25,3 @@ Feature: Internal CA And the value finalized_order.body with jq ".status" should be equal to "valid" And I parse the full-chain certificate from order finalized_order as cert And the value cert with jq ".subject.common_name" should be equal to "localhost" - - Examples: - | names | - | {} | - | {"COMMON_NAME": ""} diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index def30b126..e084c8f0a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -75,7 +75,7 @@ export const pkiAcmeChallengeServiceFactory = ({ const challengeResponse = await axios.get(challengeUrl.toString(), { // In case if we override the host in the development mode, still provide the original host in the header // to help the upstream server to validate the request - headers: { Host: host }, + headers: { Host: challenge.auth.identifierValue }, timeout: timeoutMs, responseType: "text", validateStatus: () => true diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 4f560ade7..9005d4bce 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -703,9 +703,6 @@ export const pkiAcmeServiceFactory = ({ // Check and validate the CSR const certificateRequest = extractCertificateRequestFromCSR(csr); - if (!certificateRequest.commonName) { - throw new AcmeBadCSRError({ message: "Invalid CSR: Common name is required" }); - } if ( certificateRequest.subjectAlternativeNames?.some( (san) => san.type !== CertSubjectAlternativeNameType.DNS_NAME @@ -721,7 +718,7 @@ export const pkiAcmeServiceFactory = ({ const csrIdentifierValues = new Set( (certificateRequest.subjectAlternativeNames ?? []) .map((san) => san.value.toLowerCase()) - .concat([certificateRequest.commonName.toLowerCase()]) + .concat(certificateRequest.commonName ? [certificateRequest.commonName.toLowerCase()] : []) ); if ( csrIdentifierValues.size !== orderWithAuthorizations.authorizations.length || @@ -758,7 +755,8 @@ export const pkiAcmeServiceFactory = ({ } : // ttl is not used if notAfter is provided ({ ttl: "0d" } as const), - enrollmentType: EnrollmentType.ACME + enrollmentType: EnrollmentType.ACME, + allowEmptyCommonName: true }); return { certificateId: result.certificateId }; } diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index a7292e366..0b1609366 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -1577,7 +1577,8 @@ export const internalCertificateAuthorityServiceFactory = ({ keyUsages, extendedKeyUsages, signatureAlgorithm, - keyAlgorithm + keyAlgorithm, + allowEmptyCommonName } = dto; let collectionId = pkiCollectionId; @@ -1716,12 +1717,18 @@ export const internalCertificateAuthorityServiceFactory = ({ const csrObj = new x509.Pkcs10CertificateRequest(csr); const dn = parseDistinguishedName(csrObj.subject); - const cn = commonName || dn.commonName; + let cn = commonName || dn.commonName; - if (!cn) + if ((allowEmptyCommonName ?? false) && !cn) { + // Notice: for modern TLS certificates, the CN is deprecated, many ACME clients will generate CSRs with without a CN + // we allow empty CN here to support ACME clients mostly. Since it's unclear what's the side effect of + // allowing empty CN for legacy PKI code, let's only do it if a true allowEmptyCommonName value is provided. + cn = ""; + } else if (!cn) { throw new BadRequestError({ message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" }); + } const { caPrivateKey, caSecret } = await getCaCredentials({ caId: ca.id, diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index b4b037933..8d669f1d2 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -164,6 +164,7 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; + allowEmptyCommonName?: boolean; } | ({ isInternal: false; @@ -183,6 +184,7 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; + allowEmptyCommonName?: boolean; } & Omit); export type TGetCaCertificateTemplatesDTO = { diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index a537ddc06..1549c63a5 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -511,7 +511,8 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, enrollmentType, - removeRootsFromChain + removeRootsFromChain, + allowEmptyCommonName }: TSignCertificateFromProfileDTO): Promise> => { const profile = await validateProfileAndPermissions( profileId, @@ -582,7 +583,8 @@ export const certificateV3ServiceFactory = ({ notAfter: normalizeDateForApi(notAfter), signatureAlgorithm: effectiveSignatureAlgorithm, keyAlgorithm: effectiveKeyAlgorithm, - isFromProfile: true + isFromProfile: true, + allowEmptyCommonName }); const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index ab638c5ed..4f47e9099 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -39,6 +39,7 @@ export type TSignCertificateFromProfileDTO = { notAfter?: Date; enrollmentType: EnrollmentType; removeRootsFromChain?: boolean; + allowEmptyCommonName?: boolean; } & Omit; export type TOrderCertificateFromProfileDTO = { From 7c477ef0274a69f83174154cadc090d346c85d2e Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 20 Nov 2025 17:05:43 -0800 Subject: [PATCH 6/9] Fix internal no CN test --- backend/bdd/features/pki/acme/internal-ca.feature | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/bdd/features/pki/acme/internal-ca.feature b/backend/bdd/features/pki/acme/internal-ca.feature index 6bd6ec4e9..9db4328d2 100644 --- a/backend/bdd/features/pki/acme/internal-ca.feature +++ b/backend/bdd/features/pki/acme/internal-ca.feature @@ -24,4 +24,10 @@ Feature: Internal CA And I poll and finalize the ACME order order as finalized_order And the value finalized_order.body with jq ".status" should be equal to "valid" And I parse the full-chain certificate from order finalized_order as cert - And the value cert with jq ".subject.common_name" should be equal to "localhost" + And the value cert with jq ".subject.common_name" should be equal to null + And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json + """ + [ + "localhost" + ] + """ \ No newline at end of file From 2b19425cc74374d523e0c15977658039dd40d8e7 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 20 Nov 2025 17:28:54 -0800 Subject: [PATCH 7/9] Fix linter error --- .../internal-certificate-authority-service.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index 0b1609366..2a4aa173b 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -1719,15 +1719,17 @@ export const internalCertificateAuthorityServiceFactory = ({ const dn = parseDistinguishedName(csrObj.subject); let cn = commonName || dn.commonName; - if ((allowEmptyCommonName ?? false) && !cn) { - // Notice: for modern TLS certificates, the CN is deprecated, many ACME clients will generate CSRs with without a CN - // we allow empty CN here to support ACME clients mostly. Since it's unclear what's the side effect of - // allowing empty CN for legacy PKI code, let's only do it if a true allowEmptyCommonName value is provided. - cn = ""; - } else if (!cn) { - throw new BadRequestError({ - message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" - }); + if (!cn) { + if (allowEmptyCommonName ?? false) { + // Notice: for modern TLS certificates, the CN is deprecated, many ACME clients will generate CSRs with without a CN + // we allow empty CN here to support ACME clients mostly. Since it's unclear what's the side effect of + // allowing empty CN for legacy PKI code, let's only do it if a true allowEmptyCommonName value is provided. + cn = ""; + } else { + throw new BadRequestError({ + message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" + }); + } } const { caPrivateKey, caSecret } = await getCaCredentials({ @@ -1946,7 +1948,7 @@ export const internalCertificateAuthorityServiceFactory = ({ certificateTemplateId: certificateTemplate?.id, status: CertStatus.ACTIVE, friendlyName: friendlyName || csrObj.subject, - commonName: cn, + commonName: cn!, altNames: altNamesFromCsr || altNames, serialNumber, notBefore: notBeforeDate, From 810c06c6d7dd88a86d8cd9700dccef7f6ed765b5 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 10:33:36 -0800 Subject: [PATCH 8/9] No need for allowEmptyCommonName flag, just allow empty CN for all --- .../internal-certificate-authority-service.ts | 20 +++---------------- .../internal-certificate-authority-types.ts | 2 -- .../certificate-v3/certificate-v3-service.ts | 6 ++---- .../certificate-v3/certificate-v3-types.ts | 1 - 4 files changed, 5 insertions(+), 24 deletions(-) diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index 2a4aa173b..5e42dc383 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -1577,8 +1577,7 @@ export const internalCertificateAuthorityServiceFactory = ({ keyUsages, extendedKeyUsages, signatureAlgorithm, - keyAlgorithm, - allowEmptyCommonName + keyAlgorithm } = dto; let collectionId = pkiCollectionId; @@ -1717,20 +1716,7 @@ export const internalCertificateAuthorityServiceFactory = ({ const csrObj = new x509.Pkcs10CertificateRequest(csr); const dn = parseDistinguishedName(csrObj.subject); - let cn = commonName || dn.commonName; - - if (!cn) { - if (allowEmptyCommonName ?? false) { - // Notice: for modern TLS certificates, the CN is deprecated, many ACME clients will generate CSRs with without a CN - // we allow empty CN here to support ACME clients mostly. Since it's unclear what's the side effect of - // allowing empty CN for legacy PKI code, let's only do it if a true allowEmptyCommonName value is provided. - cn = ""; - } else { - throw new BadRequestError({ - message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" - }); - } - } + const cn = (commonName || dn.commonName) ?? ""; const { caPrivateKey, caSecret } = await getCaCredentials({ caId: ca.id, @@ -1948,7 +1934,7 @@ export const internalCertificateAuthorityServiceFactory = ({ certificateTemplateId: certificateTemplate?.id, status: CertStatus.ACTIVE, friendlyName: friendlyName || csrObj.subject, - commonName: cn!, + commonName: cn, altNames: altNamesFromCsr || altNames, serialNumber, notBefore: notBeforeDate, diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index 8d669f1d2..b4b037933 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -164,7 +164,6 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; - allowEmptyCommonName?: boolean; } | ({ isInternal: false; @@ -184,7 +183,6 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; - allowEmptyCommonName?: boolean; } & Omit); export type TGetCaCertificateTemplatesDTO = { diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 1549c63a5..a537ddc06 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -511,8 +511,7 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, enrollmentType, - removeRootsFromChain, - allowEmptyCommonName + removeRootsFromChain }: TSignCertificateFromProfileDTO): Promise> => { const profile = await validateProfileAndPermissions( profileId, @@ -583,8 +582,7 @@ export const certificateV3ServiceFactory = ({ notAfter: normalizeDateForApi(notAfter), signatureAlgorithm: effectiveSignatureAlgorithm, keyAlgorithm: effectiveKeyAlgorithm, - isFromProfile: true, - allowEmptyCommonName + isFromProfile: true }); const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index 4f47e9099..ab638c5ed 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -39,7 +39,6 @@ export type TSignCertificateFromProfileDTO = { notAfter?: Date; enrollmentType: EnrollmentType; removeRootsFromChain?: boolean; - allowEmptyCommonName?: boolean; } & Omit; export type TOrderCertificateFromProfileDTO = { From 709b3d74490422b27ed534011f95832b41ce6a12 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 10:34:31 -0800 Subject: [PATCH 9/9] Remove allowEmptyCommonName flag --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 9005d4bce..69a6a648f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -755,8 +755,7 @@ export const pkiAcmeServiceFactory = ({ } : // ttl is not used if notAfter is provided ({ ttl: "0d" } as const), - enrollmentType: EnrollmentType.ACME, - allowEmptyCommonName: true + enrollmentType: EnrollmentType.ACME }); return { certificateId: result.certificateId }; }