mirror of
https://github.com/elastic/elasticsearch.git
synced 2025-04-24 23:27:25 -04:00
* Add Issuer to failed SAML Signature validation logs when available
* [CI] Auto commit changes from spotless
* Fix tests
* Update docs/changelog/126310.yaml
* address review comments
* replace String.format call
* update formatIssuer to describeIssuer
* [CI] Auto commit changes from spotless
* truncate long issuers in log messages
* [CI] Auto commit changes from spotless
* handle null issuer value
* address review comments
---------
Co-authored-by: elasticsearchmachine <infra-root+elasticsearchmachine@elastic.co>
(cherry picked from commit ceaa01a538
)
This commit is contained in:
parent
b2529edd50
commit
cbf36bf2e2
6 changed files with 146 additions and 46 deletions
6
docs/changelog/126310.yaml
Normal file
6
docs/changelog/126310.yaml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
pr: 126310
|
||||||
|
summary: Add Issuer to failed SAML Signature validation logs when available
|
||||||
|
area: Security
|
||||||
|
type: enhancement
|
||||||
|
issues:
|
||||||
|
- 111022
|
|
@ -93,7 +93,7 @@ class SamlAuthenticator extends SamlResponseHandler {
|
||||||
}
|
}
|
||||||
final boolean requireSignedAssertions;
|
final boolean requireSignedAssertions;
|
||||||
if (response.isSigned()) {
|
if (response.isSigned()) {
|
||||||
validateSignature(response.getSignature());
|
validateSignature(response.getSignature(), response.getIssuer());
|
||||||
requireSignedAssertions = false;
|
requireSignedAssertions = false;
|
||||||
} else {
|
} else {
|
||||||
requireSignedAssertions = true;
|
requireSignedAssertions = true;
|
||||||
|
@ -199,7 +199,7 @@ class SamlAuthenticator extends SamlResponseHandler {
|
||||||
}
|
}
|
||||||
// Do not further process unsigned Assertions
|
// Do not further process unsigned Assertions
|
||||||
if (assertion.isSigned()) {
|
if (assertion.isSigned()) {
|
||||||
validateSignature(assertion.getSignature());
|
validateSignature(assertion.getSignature(), assertion.getIssuer());
|
||||||
} else if (requireSignature) {
|
} else if (requireSignature) {
|
||||||
throw samlException("Assertion [{}] is not signed, but a signature is required", assertion.getElementQName());
|
throw samlException("Assertion [{}] is not signed, but a signature is required", assertion.getElementQName());
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,7 +73,7 @@ public class SamlLogoutRequestHandler extends SamlObjectHandler {
|
||||||
throw samlException("Logout request is not signed");
|
throw samlException("Logout request is not signed");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
validateSignature(signature);
|
validateSignature(signature, logoutRequest.getIssuer());
|
||||||
}
|
}
|
||||||
|
|
||||||
checkIssuer(logoutRequest.getIssuer(), logoutRequest);
|
checkIssuer(logoutRequest.getIssuer(), logoutRequest);
|
||||||
|
|
|
@ -45,7 +45,7 @@ public class SamlLogoutResponseHandler extends SamlResponseHandler {
|
||||||
if (logoutResponse.getSignature() == null) {
|
if (logoutResponse.getSignature() == null) {
|
||||||
throw samlException("LogoutResponse is not signed, but is required for HTTP-Post binding");
|
throw samlException("LogoutResponse is not signed, but is required for HTTP-Post binding");
|
||||||
}
|
}
|
||||||
validateSignature(logoutResponse.getSignature());
|
validateSignature(logoutResponse.getSignature(), logoutResponse.getIssuer());
|
||||||
}
|
}
|
||||||
checkInResponseTo(logoutResponse, allowedSamlRequestIds);
|
checkInResponseTo(logoutResponse, allowedSamlRequestIds);
|
||||||
checkStatus(logoutResponse.getStatus());
|
checkStatus(logoutResponse.getStatus());
|
||||||
|
|
|
@ -95,6 +95,8 @@ public class SamlObjectHandler {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private static final int ISSUER_VALUE_MAX_LENGTH = 512;
|
||||||
|
|
||||||
protected final Logger logger = LogManager.getLogger(getClass());
|
protected final Logger logger = LogManager.getLogger(getClass());
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
|
@ -161,13 +163,13 @@ public class SamlObjectHandler {
|
||||||
return credentials.stream().map(credential -> describe(credential.getEntityCertificate())).collect(Collectors.joining(","));
|
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);
|
final String signatureText = text(signature, 32);
|
||||||
SAMLSignatureProfileValidator profileValidator = new SAMLSignatureProfileValidator();
|
SAMLSignatureProfileValidator profileValidator = new SAMLSignatureProfileValidator();
|
||||||
try {
|
try {
|
||||||
profileValidator.validate(signature);
|
profileValidator.validate(signature);
|
||||||
} catch (SignatureException e) {
|
} catch (SignatureException e) {
|
||||||
throw samlSignatureException(idp.getSigningCredentials(), signatureText, e);
|
throw samlSignatureException(issuer, idp.getSigningCredentials(), signatureText, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkIdpSignature(credential -> {
|
checkIdpSignature(credential -> {
|
||||||
|
@ -200,21 +202,21 @@ public class SamlObjectHandler {
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (PrivilegedActionException e) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (PrivilegedActionException e) {
|
} catch (PrivilegedActionException e) {
|
||||||
throw new SecurityException("SecurityException while attempting to validate SAML signature", 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.
|
* 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 -> {
|
final Predicate<Credential> predicate = credential -> {
|
||||||
try {
|
try {
|
||||||
return check.apply(credential);
|
return check.apply(credential);
|
||||||
|
@ -231,35 +233,52 @@ public class SamlObjectHandler {
|
||||||
logger.trace("SAML Signature failure caused by", e);
|
logger.trace("SAML Signature failure caused by", e);
|
||||||
return false;
|
return false;
|
||||||
} catch (Exception e) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
final List<Credential> credentials = idp.getSigningCredentials();
|
final List<Credential> credentials = idp.getSigningCredentials();
|
||||||
if (credentials.stream().anyMatch(predicate) == false) {
|
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
|
* 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(
|
logger.warn(
|
||||||
"The XML Signature of this SAML message cannot be validated. Please verify that the saml realm uses the correct SAML"
|
"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"
|
+ "metadata file/URL for this Identity Provider.{}",
|
||||||
|
describeIssuer(issuer)
|
||||||
);
|
);
|
||||||
final String msg = "SAML Signature [{}] could not be validated against [{}]";
|
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) {
|
private ElasticsearchSecurityException samlSignatureException(Issuer issuer, List<Credential> credentials, String signature) {
|
||||||
logger.warn(
|
return samlSignatureException(issuer, credentials, signature, null);
|
||||||
"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"
|
|
||||||
);
|
// package private for testing
|
||||||
final String msg = "SAML Signature [{}] could not be validated against [{}]";
|
static String describeIssuer(@Nullable Issuer issuer) {
|
||||||
return samlException(msg, signature, describeCredentials(credentials));
|
if (issuer == null || issuer.getValue() == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
final String msg = " The issuer included in the SAML message was [%s]";
|
||||||
|
if (issuer.getValue().length() > ISSUER_VALUE_MAX_LENGTH) {
|
||||||
|
return Strings.format(msg + "...", Strings.cleanTruncate(issuer.getValue(), ISSUER_VALUE_MAX_LENGTH));
|
||||||
|
}
|
||||||
|
return Strings.format(msg, issuer.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String describeCredentials(List<Credential> credentials) {
|
private static String describeCredentials(List<Credential> credentials) {
|
||||||
|
@ -423,7 +442,7 @@ public class SamlObjectHandler {
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, signatureText);
|
}, signatureText, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected byte[] decodeBase64(String content) {
|
protected byte[] decodeBase64(String content) {
|
||||||
|
|
|
@ -39,6 +39,7 @@ import org.opensaml.saml.saml2.core.Subject;
|
||||||
import org.opensaml.saml.saml2.core.SubjectConfirmation;
|
import org.opensaml.saml.saml2.core.SubjectConfirmation;
|
||||||
import org.opensaml.saml.saml2.core.SubjectConfirmationData;
|
import org.opensaml.saml.saml2.core.SubjectConfirmationData;
|
||||||
import org.opensaml.saml.saml2.core.impl.AuthnStatementBuilder;
|
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.saml.saml2.encryption.Encrypter;
|
||||||
import org.opensaml.security.credential.BasicCredential;
|
import org.opensaml.security.credential.BasicCredential;
|
||||||
import org.opensaml.security.credential.Credential;
|
import org.opensaml.security.credential.Credential;
|
||||||
|
@ -83,7 +84,9 @@ import static org.hamcrest.CoreMatchers.not;
|
||||||
import static org.hamcrest.Matchers.contains;
|
import static org.hamcrest.Matchers.contains;
|
||||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||||
import static org.hamcrest.Matchers.containsString;
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.hamcrest.Matchers.endsWith;
|
||||||
import static org.hamcrest.Matchers.equalTo;
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
|
import static org.hamcrest.Matchers.hasLength;
|
||||||
import static org.hamcrest.Matchers.instanceOf;
|
import static org.hamcrest.Matchers.instanceOf;
|
||||||
import static org.hamcrest.Matchers.is;
|
import static org.hamcrest.Matchers.is;
|
||||||
import static org.hamcrest.Matchers.iterableWithSize;
|
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. "
|
+ "Attributes with a name clash may prevent authentication or interfere will role mapping. "
|
||||||
+ "Change your IdP configuration to use a different attribute *"
|
+ "Change your IdP configuration to use a different attribute *"
|
||||||
+ " that will not clash with any of [*]";
|
+ " 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;
|
private SamlAuthenticator authenticator;
|
||||||
|
|
||||||
|
@ -741,16 +747,29 @@ public class SamlAuthenticatorTests extends SamlResponseHandlerTests {
|
||||||
// check that the content is valid when signed by the correct key-pair
|
// check that the content is valid when signed by the correct key-pair
|
||||||
assertThat(authenticator.authenticate(token(signer.transform(xml, idpSigningCertificatePair))), notNullValue());
|
assertThat(authenticator.authenticate(token(signer.transform(xml, idpSigningCertificatePair))), notNullValue());
|
||||||
|
|
||||||
// check is rejected when signed by a different key-pair
|
try (var mockLog = MockLog.capture(authenticator.getClass())) {
|
||||||
final Tuple<X509Certificate, PrivateKey> wrongKey = readKeyPair("RSA_4096_updated");
|
mockLog.addExpectation(
|
||||||
final ElasticsearchSecurityException exception = expectThrows(
|
new MockLog.SeenEventExpectation(
|
||||||
ElasticsearchSecurityException.class,
|
"Invalid Signature",
|
||||||
() -> authenticator.authenticate(token(signer.transform(xml, wrongKey)))
|
authenticator.getClass().getName(),
|
||||||
);
|
Level.WARN,
|
||||||
assertThat(exception.getMessage(), containsString("SAML Signature"));
|
SIGNATURE_VALIDATION_FAILED_LOG_MESSAGE
|
||||||
assertThat(exception.getMessage(), containsString("could not be validated"));
|
)
|
||||||
assertThat(exception.getCause(), nullValue());
|
);
|
||||||
assertThat(SamlUtils.isSamlException(exception), is(true));
|
|
||||||
|
// 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 {
|
public void testSigningKeyIsReloadedForEachRequest() throws Exception {
|
||||||
|
@ -1301,24 +1320,80 @@ public class SamlAuthenticatorTests extends SamlResponseHandlerTests {
|
||||||
authenticator = buildAuthenticator(() -> emptyList(), emptyList());
|
authenticator = buildAuthenticator(() -> emptyList(), emptyList());
|
||||||
final String xml = getSimpleResponseAsString(clock.instant());
|
final String xml = getSimpleResponseAsString(clock.instant());
|
||||||
final SamlToken token = token(signResponse(xml));
|
final SamlToken token = token(signResponse(xml));
|
||||||
final ElasticsearchSecurityException exception = expectSamlException(() -> authenticator.authenticate(token));
|
|
||||||
assertThat(exception.getCause(), nullValue());
|
try (var mockLog = MockLog.capture(authenticator.getClass())) {
|
||||||
assertThat(exception.getMessage(), containsString("SAML Signature"));
|
mockLog.addExpectation(
|
||||||
assertThat(exception.getMessage(), containsString("could not be validated"));
|
new MockLog.SeenEventExpectation(
|
||||||
// Restore the authenticator with credentials for the rest of the test cases
|
"Invalid signature",
|
||||||
authenticator = buildAuthenticator(() -> buildOpenSamlCredential(idpSigningCertificatePair), emptyList());
|
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 {
|
public void testFailureWhenIdPCredentialsAreNull() throws Exception {
|
||||||
authenticator = buildAuthenticator(() -> singletonList(null), emptyList());
|
authenticator = buildAuthenticator(() -> singletonList(null), emptyList());
|
||||||
final String xml = getSimpleResponseAsString(clock.instant());
|
final String xml = getSimpleResponseAsString(clock.instant());
|
||||||
final SamlToken token = token(signResponse(xml));
|
final SamlToken token = token(signResponse(xml));
|
||||||
final ElasticsearchSecurityException exception = expectSamlException(() -> authenticator.authenticate(token));
|
|
||||||
assertThat(exception.getCause(), nullValue());
|
try (var mockLog = MockLog.capture(authenticator.getClass())) {
|
||||||
assertThat(exception.getMessage(), containsString("SAML Signature"));
|
mockLog.addExpectation(
|
||||||
assertThat(exception.getMessage(), containsString("could not be validated"));
|
new MockLog.SeenEventExpectation(
|
||||||
// Restore the authenticator with credentials for the rest of the test cases
|
"Invalid signature",
|
||||||
authenticator = buildAuthenticator(() -> buildOpenSamlCredential(idpSigningCertificatePair), emptyList());
|
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() {
|
||||||
|
final Issuer issuer = randomFrom(new IssuerBuilder().buildObject(), null);
|
||||||
|
assertThat(SamlAuthenticator.describeIssuer(issuer), equalTo(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void testDescribeIssuer() {
|
||||||
|
final Issuer issuer = new IssuerBuilder().buildObject();
|
||||||
|
issuer.setValue("https://idp.saml.elastic.test/");
|
||||||
|
assertThat(
|
||||||
|
SamlAuthenticator.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/" + randomAlphaOfLength(512));
|
||||||
|
|
||||||
|
final String description = SamlAuthenticator.describeIssuer(issuer);
|
||||||
|
assertThat(description, hasLength(562));
|
||||||
|
assertThat(description, endsWith("..."));
|
||||||
}
|
}
|
||||||
|
|
||||||
private interface CryptoTransform {
|
private interface CryptoTransform {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue