diff --git a/changes/47492-windows-scep-challenge-failed-profile b/changes/47492-windows-scep-challenge-failed-profile new file mode 100644 index 0000000000..037eb6fbfd --- /dev/null +++ b/changes/47492-windows-scep-challenge-failed-profile @@ -0,0 +1 @@ +- Windows SCEP profiles now fail with a clear message when the certificate authority challenge contains characters Windows doesn't support (ASN.1 PrintableString), instead of showing "Verified" while no certificate is installed. diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx index cffd1c3b84..2ff04f5fc1 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tests.tsx @@ -25,6 +25,21 @@ describe("generateErrorTooltip", () => { expect(result).toBeNull(); }); + it("renders a windows certificate install error as is, without key-value formatting", () => { + const detail = `Couldn't install certificate. The "WINSCEPTEST" certificate authority challenge includes characters Windows doesn't support. Allowed: letters, numbers, spaces, and ' ( ) + , - . / : = ?`; + const tooltip = generateErrorTooltip( + createMockHostMdmProfile({ + platform: "windows", + status: "failed", + detail, + }) + ); + + renderTooltip(tooltip); + + expect(screen.getByText(detail)).toBeInTheDocument(); + }); + it("formats a windows profile error with key-value pairs", () => { const tooltip = generateErrorTooltip( createMockHostMdmProfile({ diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx index 719af5fddd..7d3974cc6e 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/errorTooltipHelpers.tsx @@ -129,11 +129,12 @@ const formatDetailWindowsProfile = (detail: string) => { const keyValuePairs = detail.split(/, */); const formattedElements: JSX.Element[] = []; - // Special case to handle bitlocker error message. It does not follow the - // expected string format so we will just render the error message as is. + // Special case to handle bitlocker and certificate install error messages. + // They do not follow the expected string format so we will just render the error message as is. if ( detail.includes("BitLocker") || - detail.includes("preparing volume for encryption") + detail.includes("preparing volume for encryption") || + detail.startsWith("Couldn't install certificate") ) { return detail; } diff --git a/server/mdm/microsoft/profile_variables.go b/server/mdm/microsoft/profile_variables.go index dd74827203..4b6c2438e8 100644 --- a/server/mdm/microsoft/profile_variables.go +++ b/server/mdm/microsoft/profile_variables.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "regexp" "slices" "strings" "time" @@ -22,6 +23,16 @@ func PreprocessWindowsProfileContentsForDeployment(deps ProfilePreprocessDepende return preprocessWindowsProfileContents(deps, params, profileContents) } +// windowsSCEPChallengeRegexp matches challenges made up entirely of characters valid in an ASN.1 PrintableString: letters, +// digits, space, and ' ( ) + , - . / : = ?. Windows encodes the SCEP challenge password as a PrintableString, so a challenge with +// any other character (most commonly "_") makes enrollment fail on-device with "The string contains a non-printable character." +// The space is allowed anywhere, including leading and trailing, and was verified to enroll fine on Windows 11. +var windowsSCEPChallengeRegexp = regexp.MustCompile(`^[A-Za-z0-9 '()+,./:=?-]*$`) + +// scepChallengeInvalidCharsDetail is the host profile failure detail shown on the Host details page when a custom SCEP proxy +// challenge contains characters Windows can't encode as a PrintableString. +const scepChallengeInvalidCharsDetail = `Couldn't install certificate. The "%s" certificate authority challenge includes characters Windows doesn't support. Allowed: letters, numbers, spaces, and ' ( ) + , - . / : = ?` + // MicrosoftProfileProcessingError is used to indicate errors during Microsoft profile processing, such as variable replacement failures. // It should not break the entire deployment flow, but rather be handled gracefully at the profile level, setting it to failed and detail = Error() type MicrosoftProfileProcessingError struct { @@ -135,6 +146,11 @@ func preprocessWindowsProfileContents(deps ProfilePreprocessDependencies, params if err != nil { return profileContents, err } + if ca := deps.CustomSCEPCAs[caName]; ca != nil && !windowsSCEPChallengeRegexp.MatchString(ca.Challenge) { + return profileContents, &MicrosoftProfileProcessingError{ + message: fmt.Sprintf(scepChallengeInvalidCharsDetail, caName), + } + } replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(deps.Context, deps.Logger, fleetVar, deps.CustomSCEPCAs, result) if err != nil { return profileContents, ctxerr.Wrap(deps.Context, err, "replacing custom SCEP challenge variable") diff --git a/server/mdm/microsoft/profile_variables_test.go b/server/mdm/microsoft/profile_variables_test.go index 93cec4c493..6195d6baa2 100644 --- a/server/mdm/microsoft/profile_variables_test.go +++ b/server/mdm/microsoft/profile_variables_test.go @@ -268,6 +268,45 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) { } }, }, + { + name: "custom scep proxy challenge with character windows doesn't support", + hostUUID: "test-host-1234-uuid", + profileContents: `$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE`, + expectError: true, + processingError: fmt.Sprintf(scepChallengeInvalidCharsDetail, "CERTIFICATE"), + setup: func() { + ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) { + return []*fleet.CertificateAuthority{ + { + ID: 1, + Name: new("CERTIFICATE"), + Type: string(fleet.CATypeCustomSCEPProxy), + URL: new("https://scep.proxy.url/scep"), + Challenge: new("super_secret"), + }, + }, nil + } + }, + }, + { + name: "custom scep proxy challenge with leading and trailing spaces preserved", + hostUUID: "test-host-1234-uuid", + profileContents: `$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_CERTIFICATE`, + expectedContents: ` super secret `, + setup: func() { + ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) { + return []*fleet.CertificateAuthority{ + { + ID: 1, + Name: new("CERTIFICATE"), + Type: string(fleet.CATypeCustomSCEPProxy), + URL: new("https://scep.proxy.url/scep"), + Challenge: new(" super secret "), + }, + }, nil + } + }, + }, { name: "all idp variables", hostUUID: "idp-host-uuid",