Description
Breaking change and possible bug introduced somewhere between Spring Security 6.3.10 (exclusive) and 6.5.11 (inclusive)
When Spring Security processes encrypted SAML assertions, it calls XMLObjectSupport.marshall(XMLObject xmlObject) to reestablish ID attributes.
This has the unintended (?) side effect of hoisting non-visible namespace declarations from inner elements to the root response element and in the following process to the assertion element, changing the XML canonical form.
Since XML signatures are calculated over the exact canonical form, this namespace reorganization causes digest recalculation to produce a different hash, resulting in signature validation failure (due to different assertion digest values).
Root Cause
XMLObjectSupport.marshall(response) in OpenSaml4Template.OpenSaml4DecryptionConfigurer.decryptResponse(XmlObject xmlObject)
Fix
- Replace XMLObjectSupport.marshall(XMLObject xmlObject) with targeted ID attribute reestablishment that preserves original namespace declarations.
Workaround
- We commented out the following lines of code on the method OpenSaml4Template.decryptResponse(Response respone) (as well as in newer versions: OpenSaml5Template)
// This original code is currently commented out, because it leads to namespace hoisting of the xs namespace which in turn broke assertion
// if (!decrypteds.isEmpty()) {
// try {
// XMLObjectSupport.marshall(response);
// } catch (MarshallingException ex) {
// throw new Saml2Exception(ex);
// }
// }
Affected Versions: Spring Security between 6.3.10 (exclusive) and at least 6.5.11 (inclusive)
Evidence
Important to look at:
Positioning of the declaration of the xs namespace: xmlns:xs="http://www.w3.org/2001/XMLSchema" in the two different versions
Spring Security 6.3.10 (working):
Response
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_14082bfd-559c-44ab-979a-037d6289b3fc" InResponseTo="ARQ8c84f77-1b4f-4049-acd8-e8f2e204c02e" Version="2.0" IssueInstant="2026-08-17T13:55:25.094Z" Destination="https://url/saml/SSO">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">urn:issuer</saml:Issuer>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
...
</Signature>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
<saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
...
</saml:EncryptedAssertion>
</samlp:Response>
Assertion for digest value calculation
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_c1986f24-2407-4cbe-a34e-61c87e7f82ed" IssueInstant="2026-08-17T13:55:25.083Z" Version="2.0">
<saml:AttributeStatement>
<saml:Attribute Name="PrincipalType" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string"> Employee </saml:AttributeValue>
</saml:Attribute>
</saml:Assertion>
Findings
xmlns:xs="http://www.w3.org/2001/XMLSchema" at each saml:AttributeValue
- Decryption process does not change namespace positioning at all
- Digest value verification works
Spring Security 6.5.11 (NOT working):
Response after decryption
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:q1="schemas:identity" xmlns:xs="http://www.w3.org/2001/XMLSchema" Destination="https://url.de:443/saml/SSO" ID="_64439ff0-81a5-4d2d-a941-89b74c48e0d3" InResponseTo="ARQ9bb34f1-300d-4551-bff3-d1668dafbedb" IssueInstant="2026-08-17T11:27:28.092Z" Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">urn:issuer</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
...
</ds:Signature>
<samlp:Status xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
...
</samlp:Status>
<saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
...
</saml:EncryptedAssertion>
...
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_fd3c7555-b04d-4f02-a8d9-8c6c267df67b" IssueInstant="2026-08-17T11:27:28.081Z" Version="2.0">
<saml:Issuer>urn:issuer</saml:Issuer>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
...
</Signature>
<saml:Subject>
...
</saml:Subject>
<saml:Conditions NotOnOrAfter="2026-08-17T21:27:28.073Z">
...
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2026-08-17T11:27:28.073Z" SessionIndex="a2275023-023b-45d2-ae23-cebb642d8ce4">
<saml:AuthnContext>
...
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="PrincipalType" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
<saml:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string"> Employee </saml:AttributeValue>
</saml:Attribute>
</saml:Assertion>
</samlp:Response>
Assertion for digest value calculation
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xs="http://www.w3.org/2001/XMLSchema" ID="_047d1fd0-d743-4a31-81dd-10bcd69a7b08" IssueInstant="2026-08-18T06:10:37.756Z" Version="2.0">
<saml:Issuer>urn:issuer</saml:Issuer>
<saml:Subject>
...
</saml:Subject>
<saml:Conditions NotOnOrAfter="2026-08-18T16:10:37.746Z">
...
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2026-08-18T06:10:37.746Z" SessionIndex="e30d1947-8cc5-4d90-929b-71557273bdb1">
...
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="PrincipalType" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic">
<saml:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">Employee</saml:AttributeValue>
</saml:Attribute>
...
</saml:AttributeStatement>
</saml:Assertion>
Findings
xmlns:xs="http://www.w3.org/2001/XMLSchema" NOT at each saml:AttributeValue anymore but instead on the parent saml:Assertion
- After decryption process XMLObjectSupport.marshall(response) moves [in OpenSaml4Template.OpenSaml4DecryptionConfigurer.decryptResponse(XmlObject xmlObject)]
- the non-visible namespace "xs" to response and then to the assertion-tag in preparation of the digest verification
- but NOT back to the saml:AttributeValue where it is supposed to be for the correct digest value calculation
- C14N works properly with the "corrupted" assertion
- the "xs" namespace is nonVisbible, because it is declared only on the value side of an xmlns (xsi:type="xs:string") and cannot be rearranged back to the children by the C14N transformation algorithm
- The error persists in OpenSaml5Template of Spring Security (tested in 7.0.6 & 7.1.1)
Description
Breaking change and possible bug introduced somewhere between Spring Security 6.3.10 (exclusive) and 6.5.11 (inclusive)
When Spring Security processes encrypted SAML assertions, it calls XMLObjectSupport.marshall(XMLObject xmlObject) to reestablish ID attributes.
This has the unintended (?) side effect of hoisting non-visible namespace declarations from inner elements to the root response element and in the following process to the assertion element, changing the XML canonical form.
Since XML signatures are calculated over the exact canonical form, this namespace reorganization causes digest recalculation to produce a different hash, resulting in signature validation failure (due to different assertion digest values).
Root Cause
XMLObjectSupport.marshall(response) in OpenSaml4Template.OpenSaml4DecryptionConfigurer.decryptResponse(XmlObject xmlObject)
Fix
Workaround
Affected Versions: Spring Security between 6.3.10 (exclusive) and at least 6.5.11 (inclusive)
Evidence
Important to look at:
Positioning of the declaration of the xs namespace:
xmlns:xs="http://www.w3.org/2001/XMLSchema"in the two different versionsSpring Security 6.3.10 (working):
Response
Assertion for digest value calculation
Findings
xmlns:xs="http://www.w3.org/2001/XMLSchema"at each saml:AttributeValueSpring Security 6.5.11 (NOT working):
Response after decryption
Assertion for digest value calculation
Findings
xmlns:xs="http://www.w3.org/2001/XMLSchema"NOT at each saml:AttributeValue anymore but instead on the parent saml:Assertion