Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/changelog/126310.yaml
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
Expand Up @@ -93,7 +93,7 @@ private SamlAttributes authenticateResponse(Element element, Collection<String>
}
final boolean requireSignedAssertions;
if (response.isSigned()) {
validateSignature(response.getSignature());
validateSignature(response.getSignature(), response.getIssuer());
requireSignedAssertions = false;
} else {
requireSignedAssertions = true;
Expand Down Expand Up @@ -199,7 +199,7 @@ private List<Attribute> processAssertion(Assertion assertion, boolean requireSig
}
// Do not further process unsigned Assertions
if (assertion.isSigned()) {
validateSignature(assertion.getSignature());
validateSignature(assertion.getSignature(), assertion.getIssuer());
} else if (requireSignature) {
throw samlException("Assertion [{}] is not signed, but a signature is required", assertion.getElementQName());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ private Result parseLogout(LogoutRequest logoutRequest, boolean requireSignature
throw samlException("Logout request is not signed");
}
} else {
validateSignature(signature);
validateSignature(signature, logoutRequest.getIssuer());
}

checkIssuer(logoutRequest.getIssuer(), logoutRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public void handle(boolean httpRedirect, String payload, Collection<String> allo
if (logoutResponse.getSignature() == null) {
throw samlException("LogoutResponse is not signed, but is required for HTTP-Post binding");
}
validateSignature(logoutResponse.getSignature());
validateSignature(logoutResponse.getSignature(), logoutResponse.getIssuer());
}
checkInResponseTo(logoutResponse, allowedSamlRequestIds);
checkStatus(logoutResponse.getStatus());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

}

// package private for testing
String describeIssuer(@Nullable Issuer issuer) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this could be static.

if (issuer == null || issuer.getValue() == null) {
return "";
}
final String msg = " The issuer included in the SAML message was [%s]";
if (issuer.getValue().length() > 64) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issuer.getValue() can return null, as I discovered writing the tests - how realistic do we think this is? Should I guard against it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, lets guard against null here 👍

Copy link
Contributor

@n1v0lg n1v0lg Apr 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd move the 64 into a constant and make the threshold higher -- 512 is still negligible in terms of a logging payload but less likely to truncate useful info

return Strings.format(msg + "...", Strings.cleanTruncate(issuer.getValue(), 64));
}
return Strings.format(msg, issuer.getValue());
}

private static String describeCredentials(List<Credential> credentials) {
Expand Down Expand Up @@ -423,7 +440,7 @@ private void validateSignature(String inputString, String signatureAlgorithm, St
);
return false;
}
}, signatureText);
}, signatureText, null);
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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())) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update existing tests to check for log message

Copy link
Contributor

Choose a reason for hiding this comment

The 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 {
Expand Down Expand Up @@ -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() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added some tests for describeIssuer since it's getting a little complicated

assertThat(authenticator.describeIssuer(null), equalTo(""));
}

public void testDescribeNullIssuerValue() {
final Issuer issuer = new IssuerBuilder().buildObject();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: testDescribeNullIssuer and this one can be combined using randomFrom(null, 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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: there is a utility function called randomAlphaOfLength(128) that could be used here.


final String description = authenticator.describeIssuer(issuer);
assertThat(description, hasLength(114));
assertThat(description, endsWith("..."));
}

private interface CryptoTransform {
Expand Down
Loading