SAML 2.0 for Enterprise: Spring Security Configuration and Signature Wrapping Attacks
SAML 2.0 for Enterprise
The Assumption
SAML is legacy but straightforward. Parse the XML, validate the signature, extract the user. The assumption: XML signature validation in your library is correct and complete.
SAML signature wrapping attacks exploit the gap between which XML element is signed and which XML element the application reads. The signature is valid. The assertion the application processes is forged. The library validates correctly. The integration is wrong.
The Attack
XML Signature Wrapping (XSW). A SAML Response contains a signed Assertion with user attributes. The attack moves the signed Assertion to a different location in the XML tree and inserts a forged Assertion at the original location.
<!-- XSW Attack Pattern -->
<samlp:Response>
<saml:Assertion> <!-- FORGED, unsigned -->
<saml:Subject>
<saml:NameID>[email protected]</saml:NameID>
</saml:Subject>
</saml:Assertion>
<ds:Signature>
<ds:Reference URI="#_abc123"/>
<ds:Object>
<saml:Assertion ID="_abc123"> <!-- Original, signed, hidden -->
<saml:Subject>
<saml:NameID>[email protected]</saml:NameID>
</saml:Subject>
</saml:Assertion>
</ds:Object>
</ds:Signature>
</samlp:Response>
The signature library validates the element with ID="_abc123" (correct). The application reads the first <saml:Assertion> in the DOM (the forged one). The attacker authenticates as [email protected].
Eight variants exist (XSW1 through XSW8), each relocating the signed element differently. A robust defense must handle all of them.
The Spec or Mechanism
Two properties prevent XSW:
- After signature validation, process the validated element directly (do not re-query the DOM).
- Reject responses with more than one Assertion.
Spring Security’s OpenSaml4AuthenticationProvider satisfies both properties by default. It validates the signature and returns the validated assertion object, not a re-queried DOM element.
The Implementation
Spring Security SAML2 Configuration
@Configuration
@EnableWebSecurity
public class SamlSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.saml2Login(saml2 -> saml2
.authenticationManager(samlAuthenticationManager())
)
.saml2Metadata(Customizer.withDefaults())
.build();
}
@Bean
public AuthenticationManager samlAuthenticationManager() {
OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider();
provider.setResponseValidator(responseToken -> {
Saml2ResponseValidatorResult result =
OpenSaml4AuthenticationProvider
.createDefaultResponseValidator()
.convert(responseToken);
Response response = responseToken.getResponse();
// Reject multiple assertions
if (response.getAssertions().size() != 1) {
return result.concat(Saml2ResponseValidatorResult.failure(
new Saml2Error("invalid_assertion_count",
"Expected exactly one assertion, got " +
response.getAssertions().size())));
}
// Verify issuer matches configured IdP
String expectedIssuer = responseToken.getToken()
.getRelyingPartyRegistration()
.getAssertingPartyDetails()
.getEntityId();
String actualIssuer = response.getAssertions().get(0)
.getIssuer().getValue();
if (!expectedIssuer.equals(actualIssuer)) {
return result.concat(Saml2ResponseValidatorResult.failure(
new Saml2Error("invalid_issuer",
"Assertion issuer does not match expected IdP")));
}
return result;
});
provider.setAssertionValidator(assertionToken ->
OpenSaml4AuthenticationProvider
.createDefaultAssertionValidatorWithParameters(params -> {
params.put(SAML2AssertionValidationParameters
.VALID_AUDIENCES, Set.of(
"https://app.saas.example/saml/metadata"));
})
.convert(assertionToken));
return new ProviderManager(provider);
}
}
Relying Party Registration
spring:
security:
saml2:
relyingparty:
registration:
adfs-globex:
assertingparty:
metadata-uri: https://adfs.globex-inc.com/FederationMetadata/2007-06/FederationMetadata.xml
entity-id: https://app.saas.example/saml/metadata
acs:
location: https://app.saas.example/login/saml2/sso/{registrationId}
signing:
credentials:
- private-key-location: classpath:saml/sp-private-key.pem
certificate-location: classpath:saml/sp-certificate.pem
SAML to Internal User Mapping
@Component
public class SamlResponseAuthenticationConverter
implements Converter<ResponseToken, AbstractAuthenticationToken> {
private final UserRepository userRepository;
private final TenantProviderMapping tenantMapping;
@Override
public AbstractAuthenticationToken convert(ResponseToken responseToken) {
Response response = responseToken.getResponse();
Assertion assertion = response.getAssertions().get(0);
String nameId = assertion.getSubject().getNameID().getValue();
String registrationId = responseToken.getToken()
.getRelyingPartyRegistration().getRegistrationId();
Map<String, List<Object>> attributes = extractAttributes(assertion);
String email = getFirstAttribute(attributes,
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress");
String tenantId = tenantMapping.resolveTenant(registrationId);
User user = userRepository
.findByFederatedIdentity(registrationId, nameId)
.orElseGet(() -> createUser(email, nameId, registrationId, tenantId));
return new TenantAwareAuthentication(user, tenantId);
}
private Map<String, List<Object>> extractAttributes(Assertion assertion) {
Map<String, List<Object>> result = new LinkedHashMap<>();
for (AttributeStatement stmt : assertion.getAttributeStatements()) {
for (Attribute attr : stmt.getAttributes()) {
List<Object> values = attr.getAttributeValues().stream()
.map(XMLObject::getDOM)
.map(Element::getTextContent)
.collect(Collectors.toList());
result.put(attr.getName(), values);
}
}
return result;
}
}
Vulnerable vs Hardened
// VULNERABLE: Manual XML parsing
public String extractUser(String samlXml) {
Document doc = parseXml(samlXml);
validateXmlSignature(doc); // Validates the signed element
// Re-queries DOM: may find the forged assertion
NodeList assertions = doc.getElementsByTagNameNS(
"urn:oasis:names:tc:SAML:2.0:assertion", "Assertion");
return extractNameId((Element) assertions.item(0));
}
// HARDENED: Spring Security OpenSAML4
// No manual XML parsing. The framework:
// 1. Validates the signature
// 2. Returns the VALIDATED assertion object (not re-queried from DOM)
// 3. Checks assertion count, audience, timestamps, conditions
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class SamlXswDefenseTest {
@Autowired
private MockMvc mockMvc;
@Test
void legitimateSamlResponseIsAccepted() throws Exception {
String samlResponse = createValidSamlResponse(
"[email protected]", "adfs-globex");
mockMvc.perform(post("/login/saml2/sso/adfs-globex")
.param("SAMLResponse", Base64.getEncoder()
.encodeToString(samlResponse.getBytes())))
.andExpect(status().is3xxRedirection());
}
@Test
void xswAttackIsRejected() throws Exception {
String xswResponse = createXswAttackResponse(
"[email protected]", // Forged
"[email protected]", // Original signed
"adfs-globex");
mockMvc.perform(post("/login/saml2/sso/adfs-globex")
.param("SAMLResponse", Base64.getEncoder()
.encodeToString(xswResponse.getBytes())))
.andExpect(status().isUnauthorized());
}
@Test
void multipleAssertionsRejected() throws Exception {
String multiResponse = createMultiAssertionResponse(
"[email protected]", "[email protected]", "adfs-globex");
mockMvc.perform(post("/login/saml2/sso/adfs-globex")
.param("SAMLResponse", Base64.getEncoder()
.encodeToString(multiResponse.getBytes())))
.andExpect(status().isUnauthorized());
}
@Test
void wrongIssuerRejected() throws Exception {
String wrongIssuer = createSamlResponseWithIssuer(
"[email protected]",
"https://evil-idp.example/metadata",
"adfs-globex");
mockMvc.perform(post("/login/saml2/sso/adfs-globex")
.param("SAMLResponse", Base64.getEncoder()
.encodeToString(wrongIssuer.getBytes())))
.andExpect(status().isUnauthorized());
}
}
The second test validates XSW resistance. If someone introduces a custom SAML processor that re-queries the DOM after validation, this test catches the regression before the vulnerability reaches production.