-
Notifications
You must be signed in to change notification settings - Fork 25.6k
Add Issuer to failed SAML Signature validation logs when available #126310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 12 commits
39fe357
8d4d813
8533c8e
f714d35
312b2ab
198cdf9
7de3149
f5ff8b1
301f6a0
fa9b48c
16cb4b8
de44463
42ebafc
474a79c
4893144
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| pr: 126310 | ||
| summary: Add Issuer to failed SAML Signature validation logs when available | ||
| area: Security | ||
| type: enhancement | ||
| issues: | ||
| - 111022 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -161,13 +161,13 @@ protected static String describe(Collection<X509Credential> credentials) { | |
| return credentials.stream().map(credential -> describe(credential.getEntityCertificate())).collect(Collectors.joining(",")); | ||
| } | ||
|
|
||
| void validateSignature(Signature signature) { | ||
| void validateSignature(Signature signature, @Nullable Issuer issuer) { | ||
| final String signatureText = text(signature, 32); | ||
| SAMLSignatureProfileValidator profileValidator = new SAMLSignatureProfileValidator(); | ||
| try { | ||
| profileValidator.validate(signature); | ||
| } catch (SignatureException e) { | ||
| throw samlSignatureException(idp.getSigningCredentials(), signatureText, e); | ||
| throw samlSignatureException(issuer, idp.getSigningCredentials(), signatureText, e); | ||
| } | ||
|
|
||
| checkIdpSignature(credential -> { | ||
|
|
@@ -200,21 +200,21 @@ void validateSignature(Signature signature) { | |
| ); | ||
| return true; | ||
| } catch (PrivilegedActionException e) { | ||
| logger.warn("SecurityException while attempting to validate SAML signature", e); | ||
| logger.warn("SecurityException while attempting to validate SAML signature." + describeIssuer(issuer), e); | ||
| return false; | ||
| } | ||
| }); | ||
| } catch (PrivilegedActionException e) { | ||
| throw new SecurityException("SecurityException while attempting to validate SAML signature", e); | ||
| } | ||
| }, signatureText); | ||
| }, signatureText, issuer); | ||
| } | ||
|
|
||
| /** | ||
| * Tests whether the provided function returns {@code true} for any of the IdP's signing credentials. | ||
| * @throws ElasticsearchSecurityException - A SAML exception if not matching credential is found. | ||
| * @throws ElasticsearchSecurityException - A SAML exception if no matching credential is found. | ||
| */ | ||
| protected void checkIdpSignature(CheckedFunction<Credential, Boolean, Exception> check, String signatureText) { | ||
| protected void checkIdpSignature(CheckedFunction<Credential, Boolean, Exception> check, String signatureText, @Nullable Issuer issuer) { | ||
| final Predicate<Credential> predicate = credential -> { | ||
| try { | ||
| return check.apply(credential); | ||
|
|
@@ -231,35 +231,52 @@ protected void checkIdpSignature(CheckedFunction<Credential, Boolean, Exception> | |
| logger.trace("SAML Signature failure caused by", e); | ||
| return false; | ||
| } catch (Exception e) { | ||
| logger.warn("Exception while attempting to validate SAML Signature", e); | ||
| logger.warn("Exception while attempting to validate SAML Signature." + describeIssuer(issuer), e); | ||
| return false; | ||
| } | ||
| }; | ||
| final List<Credential> credentials = idp.getSigningCredentials(); | ||
| if (credentials.stream().anyMatch(predicate) == false) { | ||
| throw samlSignatureException(credentials, signatureText); | ||
| throw samlSignatureException(issuer, credentials, signatureText); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Constructs a SAML specific exception with a consistent message regarding SAML Signature validation failures | ||
| */ | ||
| private ElasticsearchSecurityException samlSignatureException(List<Credential> credentials, String signature, Exception cause) { | ||
| private ElasticsearchSecurityException samlSignatureException( | ||
| @Nullable Issuer issuer, | ||
| List<Credential> credentials, | ||
| String signature, | ||
| Exception cause | ||
| ) { | ||
| logger.warn( | ||
| "The XML Signature of this SAML message cannot be validated. Please verify that the saml realm uses the correct SAML" | ||
| + "metadata file/URL for this Identity Provider" | ||
| "The XML Signature of this SAML message cannot be validated. Please verify that the saml realm uses the correct SAML " | ||
| + "metadata file/URL for this Identity Provider.{}", | ||
| describeIssuer(issuer) | ||
| ); | ||
| final String msg = "SAML Signature [{}] could not be validated against [{}]"; | ||
| return samlException(msg, cause, signature, describeCredentials(credentials)); | ||
| if (cause != null) { | ||
| return samlException(msg, cause, signature, describeCredentials(credentials)); | ||
| } else { | ||
| return samlException(msg, signature, describeCredentials(credentials)); | ||
| } | ||
| } | ||
|
|
||
| private ElasticsearchSecurityException samlSignatureException(List<Credential> credentials, String signature) { | ||
| logger.warn( | ||
| "The XML Signature of this SAML message cannot be validated. Please verify that the saml realm uses the correct SAML" | ||
| + "metadata file/URL for this Identity Provider" | ||
| ); | ||
| final String msg = "SAML Signature [{}] could not be validated against [{}]"; | ||
| return samlException(msg, signature, describeCredentials(credentials)); | ||
| private ElasticsearchSecurityException samlSignatureException(Issuer issuer, List<Credential> credentials, String signature) { | ||
| return samlSignatureException(issuer, credentials, signature, null); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove duplication (since I need to edit the exact same log message twice) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! |
||
| } | ||
|
|
||
| // package private for testing | ||
| String describeIssuer(@Nullable Issuer issuer) { | ||
|
||
| if (issuer == null || issuer.getValue() == null) { | ||
| return ""; | ||
| } | ||
| final String msg = " The issuer included in the SAML message was [%s]"; | ||
| if (issuer.getValue().length() > 64) { | ||
|
||
| return Strings.format(msg + "...", Strings.cleanTruncate(issuer.getValue(), 64)); | ||
| } | ||
| return Strings.format(msg, issuer.getValue()); | ||
| } | ||
|
|
||
| private static String describeCredentials(List<Credential> credentials) { | ||
|
|
@@ -423,7 +440,7 @@ private void validateSignature(String inputString, String signatureAlgorithm, St | |
| ); | ||
| return false; | ||
| } | ||
| }, signatureText); | ||
| }, signatureText, null); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the context of this is SAML Logout with a query string; the message gets parsed into a SAML document after the signature has been verified, so I'm not sure it's possible to pass an Issuer here There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I agree. We could change the order of things here so the issuer is parsed from the message before throwing the error, but if that parsing fails, it ends up causing more confusion than help so I think it makes sense to leave it as it. |
||
| } | ||
|
|
||
| protected byte[] decodeBase64(String content) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,7 @@ | |
| import org.opensaml.saml.saml2.core.SubjectConfirmation; | ||
| import org.opensaml.saml.saml2.core.SubjectConfirmationData; | ||
| import org.opensaml.saml.saml2.core.impl.AuthnStatementBuilder; | ||
| import org.opensaml.saml.saml2.core.impl.IssuerBuilder; | ||
| import org.opensaml.saml.saml2.encryption.Encrypter; | ||
| import org.opensaml.security.credential.BasicCredential; | ||
| import org.opensaml.security.credential.Credential; | ||
|
|
@@ -83,7 +84,9 @@ | |
| import static org.hamcrest.Matchers.contains; | ||
| import static org.hamcrest.Matchers.containsInAnyOrder; | ||
| import static org.hamcrest.Matchers.containsString; | ||
| import static org.hamcrest.Matchers.endsWith; | ||
| import static org.hamcrest.Matchers.equalTo; | ||
| import static org.hamcrest.Matchers.hasLength; | ||
| import static org.hamcrest.Matchers.instanceOf; | ||
| import static org.hamcrest.Matchers.is; | ||
| import static org.hamcrest.Matchers.iterableWithSize; | ||
|
|
@@ -106,6 +109,9 @@ public class SamlAuthenticatorTests extends SamlResponseHandlerTests { | |
| + "Attributes with a name clash may prevent authentication or interfere will role mapping. " | ||
| + "Change your IdP configuration to use a different attribute *" | ||
| + " that will not clash with any of [*]"; | ||
| private static final String SIGNATURE_VALIDATION_FAILED_LOG_MESSAGE = "The XML Signature of this SAML message cannot be validated. " | ||
| + "Please verify that the saml realm uses the correct SAML metadata file/URL for this Identity Provider. " | ||
| + "The issuer included in the SAML message was [https://idp.saml.elastic.test/]"; | ||
|
|
||
| private SamlAuthenticator authenticator; | ||
|
|
||
|
|
@@ -741,16 +747,29 @@ public void testIncorrectSigningKeyIsRejected() throws Exception { | |
| // check that the content is valid when signed by the correct key-pair | ||
| assertThat(authenticator.authenticate(token(signer.transform(xml, idpSigningCertificatePair))), notNullValue()); | ||
|
|
||
| // check is rejected when signed by a different key-pair | ||
| final Tuple<X509Certificate, PrivateKey> wrongKey = readKeyPair("RSA_4096_updated"); | ||
| final ElasticsearchSecurityException exception = expectThrows( | ||
| ElasticsearchSecurityException.class, | ||
| () -> authenticator.authenticate(token(signer.transform(xml, wrongKey))) | ||
| ); | ||
| assertThat(exception.getMessage(), containsString("SAML Signature")); | ||
| assertThat(exception.getMessage(), containsString("could not be validated")); | ||
| assertThat(exception.getCause(), nullValue()); | ||
| assertThat(SamlUtils.isSamlException(exception), is(true)); | ||
| try (var mockLog = MockLog.capture(authenticator.getClass())) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. update existing tests to check for log message There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! |
||
| mockLog.addExpectation( | ||
| new MockLog.SeenEventExpectation( | ||
| "Invalid Signature", | ||
| authenticator.getClass().getName(), | ||
| Level.WARN, | ||
| SIGNATURE_VALIDATION_FAILED_LOG_MESSAGE | ||
| ) | ||
| ); | ||
|
|
||
| // check is rejected when signed by a different key-pair | ||
| final Tuple<X509Certificate, PrivateKey> wrongKey = readKeyPair("RSA_4096_updated"); | ||
| final ElasticsearchSecurityException exception = expectThrows( | ||
| ElasticsearchSecurityException.class, | ||
| () -> authenticator.authenticate(token(signer.transform(xml, wrongKey))) | ||
| ); | ||
| assertThat(exception.getMessage(), containsString("SAML Signature")); | ||
| assertThat(exception.getMessage(), containsString("could not be validated")); | ||
| assertThat(exception.getCause(), nullValue()); | ||
| assertThat(SamlUtils.isSamlException(exception), is(true)); | ||
|
|
||
| mockLog.assertAllExpectationsMatched(); | ||
| } | ||
| } | ||
|
|
||
| public void testSigningKeyIsReloadedForEachRequest() throws Exception { | ||
|
|
@@ -1301,24 +1320,84 @@ public void testFailureWhenIdPCredentialsAreEmpty() throws Exception { | |
| authenticator = buildAuthenticator(() -> emptyList(), emptyList()); | ||
| final String xml = getSimpleResponseAsString(clock.instant()); | ||
| final SamlToken token = token(signResponse(xml)); | ||
| final ElasticsearchSecurityException exception = expectSamlException(() -> authenticator.authenticate(token)); | ||
| assertThat(exception.getCause(), nullValue()); | ||
| assertThat(exception.getMessage(), containsString("SAML Signature")); | ||
| assertThat(exception.getMessage(), containsString("could not be validated")); | ||
| // Restore the authenticator with credentials for the rest of the test cases | ||
| authenticator = buildAuthenticator(() -> buildOpenSamlCredential(idpSigningCertificatePair), emptyList()); | ||
|
|
||
| try (var mockLog = MockLog.capture(authenticator.getClass())) { | ||
| mockLog.addExpectation( | ||
| new MockLog.SeenEventExpectation( | ||
| "Invalid signature", | ||
| authenticator.getClass().getName(), | ||
| Level.WARN, | ||
| SIGNATURE_VALIDATION_FAILED_LOG_MESSAGE | ||
| ) | ||
| ); | ||
|
|
||
| final ElasticsearchSecurityException exception = expectSamlException(() -> authenticator.authenticate(token)); | ||
| assertThat(exception.getCause(), nullValue()); | ||
| assertThat(exception.getMessage(), containsString("SAML Signature")); | ||
| assertThat(exception.getMessage(), containsString("could not be validated")); | ||
|
|
||
| mockLog.awaitAllExpectationsMatched(); | ||
| } | ||
| } | ||
|
|
||
| public void testFailureWhenIdPCredentialsAreNull() throws Exception { | ||
| authenticator = buildAuthenticator(() -> singletonList(null), emptyList()); | ||
| final String xml = getSimpleResponseAsString(clock.instant()); | ||
| final SamlToken token = token(signResponse(xml)); | ||
| final ElasticsearchSecurityException exception = expectSamlException(() -> authenticator.authenticate(token)); | ||
| assertThat(exception.getCause(), nullValue()); | ||
| assertThat(exception.getMessage(), containsString("SAML Signature")); | ||
| assertThat(exception.getMessage(), containsString("could not be validated")); | ||
| // Restore the authenticator with credentials for the rest of the test cases | ||
| authenticator = buildAuthenticator(() -> buildOpenSamlCredential(idpSigningCertificatePair), emptyList()); | ||
|
|
||
| try (var mockLog = MockLog.capture(authenticator.getClass())) { | ||
| mockLog.addExpectation( | ||
| new MockLog.SeenEventExpectation( | ||
| "Invalid signature", | ||
| authenticator.getClass().getName(), | ||
| Level.WARN, | ||
| SIGNATURE_VALIDATION_FAILED_LOG_MESSAGE | ||
| ) | ||
| ); | ||
| mockLog.addExpectation( | ||
| new MockLog.SeenEventExpectation( | ||
| "Null credentials", | ||
| authenticator.getClass().getName(), | ||
| Level.WARN, | ||
| "Exception while attempting to validate SAML Signature. " | ||
| + "The issuer included in the SAML message was [https://idp.saml.elastic.test/]" | ||
| ) | ||
| ); | ||
|
|
||
| final ElasticsearchSecurityException exception = expectSamlException(() -> authenticator.authenticate(token)); | ||
| assertThat(exception.getCause(), nullValue()); | ||
| assertThat(exception.getMessage(), containsString("SAML Signature")); | ||
| assertThat(exception.getMessage(), containsString("could not be validated")); | ||
|
|
||
| mockLog.awaitAllExpectationsMatched(); | ||
| } | ||
| } | ||
|
|
||
| public void testDescribeNullIssuer() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added some tests for |
||
| assertThat(authenticator.describeIssuer(null), equalTo("")); | ||
| } | ||
|
|
||
| public void testDescribeNullIssuerValue() { | ||
| final Issuer issuer = new IssuerBuilder().buildObject(); | ||
|
||
| assertThat(authenticator.describeIssuer(issuer), equalTo("")); | ||
| } | ||
|
|
||
| public void testDescribeIssuer() { | ||
| final Issuer issuer = new IssuerBuilder().buildObject(); | ||
| issuer.setValue("https://idp.saml.elastic.test/"); | ||
| assertThat( | ||
| authenticator.describeIssuer(issuer), | ||
| equalTo(" The issuer included in the SAML message was [https://idp.saml.elastic.test/]") | ||
| ); | ||
| } | ||
|
|
||
| public void testDescribeVeryLongIssuer() { | ||
| final Issuer issuer = new IssuerBuilder().buildObject(); | ||
| issuer.setValue("https://idp.saml.elastic.test/" + "a".repeat(128)); | ||
|
||
|
|
||
| final String description = authenticator.describeIssuer(issuer); | ||
| assertThat(description, hasLength(114)); | ||
| assertThat(description, endsWith("...")); | ||
| } | ||
|
|
||
| private interface CryptoTransform { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should the warning in this method also include the
issuer? https://github.com/elastic/elasticsearch/pull/126310/files#diff-19351c3f19f00d928645d11a61ef3c3dea10982b13f8f7ed47773c18d034b33dR203And maybe here too: https://github.com/elastic/elasticsearch/pull/126310/files#diff-19351c3f19f00d928645d11a61ef3c3dea10982b13f8f7ed47773c18d034b33dL234