oscerd commented on code in PR #26726:
URL: https://github.com/apache/camel/pull/26726#discussion_r4080562504


##########
components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java:
##########
@@ -314,6 +350,96 @@ protected Node getNodeForMessageBodyInEnvelopingCase(Input 
input) throws Excepti
         return node;
     }
 
+    /**
+     * Checks that a validated Reference actually covered the document element 
the default search is about to emit.
+     * <p>
+     * Core signature validation only proves that each Reference's digest 
matches the content that Reference resolves
+     * to. It says nothing about the rest of the document. So an attacker can 
take a legitimately signed fragment, embed
+     * it unchanged inside a larger document of their own, and validation 
still passes - the same-document URI resolves
+     * to that fragment exactly as before - while this method would hand the 
whole attacker document downstream as
+     * verified content. That is XML signature wrapping.
+     * <p>
+     * The check is deliberately narrow, so that it rejects that shape and 
nothing else. It only complains when the
+     * signature carries same-document references and none of them covers the 
document element. A Reference with an
+     * empty URI covers the whole document, and a signature whose References 
are all external says nothing about this
+     * document either way, so both are left alone.
+     *
+     * @param input           the verification input, carrying the validated 
References
+     * @param documentElement the element the default search would emit
+     */
+    protected void checkDocumentElementIsCoveredByAReference(Input input, 
Element documentElement) throws Exception {
+        List<Reference> references = getReferencesForMessageMapping(input);
+        if (references == null || references.isEmpty()) {
+            return;
+        }
+
+        boolean sameDocumentReferenceSeen = false;
+        for (Reference reference : references) {
+            String uri = reference.getURI();
+            if (uri == null) {

Review Comment:
   Fixed in 1eadf1f — changed the `uri == null` branch from `return` to 
`continue`, matching the external-reference branch below it. A lone absent-URI 
reference now leaves `sameDocumentReferenceSeen` false, so it is still 
correctly rejected, while a following `#myID` reference is no longer skipped. 
Covered by `aNullReferenceUriDoesNotDisableTheCheckForLaterReferences`, which 
fails against the un-fixed code (verified).
   
   _Claude Code on behalf of oscerd_
   



##########
components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java:
##########
@@ -314,6 +350,96 @@ protected Node getNodeForMessageBodyInEnvelopingCase(Input 
input) throws Excepti
         return node;
     }
 
+    /**
+     * Checks that a validated Reference actually covered the document element 
the default search is about to emit.
+     * <p>
+     * Core signature validation only proves that each Reference's digest 
matches the content that Reference resolves
+     * to. It says nothing about the rest of the document. So an attacker can 
take a legitimately signed fragment, embed
+     * it unchanged inside a larger document of their own, and validation 
still passes - the same-document URI resolves
+     * to that fragment exactly as before - while this method would hand the 
whole attacker document downstream as
+     * verified content. That is XML signature wrapping.
+     * <p>
+     * The check is deliberately narrow, so that it rejects that shape and 
nothing else. It only complains when the
+     * signature carries same-document references and none of them covers the 
document element. A Reference with an
+     * empty URI covers the whole document, and a signature whose References 
are all external says nothing about this
+     * document either way, so both are left alone.
+     *
+     * @param input           the verification input, carrying the validated 
References
+     * @param documentElement the element the default search would emit
+     */
+    protected void checkDocumentElementIsCoveredByAReference(Input input, 
Element documentElement) throws Exception {
+        List<Reference> references = getReferencesForMessageMapping(input);
+        if (references == null || references.isEmpty()) {
+            return;
+        }
+
+        boolean sameDocumentReferenceSeen = false;
+        for (Reference reference : references) {
+            String uri = reference.getURI();
+            if (uri == null) {
+                // Nothing to correlate against
+                return;
+            }
+            if (uri.isEmpty()) {
+                // The whole document is covered
+                return;
+            }
+            if (!uri.startsWith("#")) {
+                // External reference - it tells us nothing about the document 
we are emitting
+                continue;
+            }
+            sameDocumentReferenceSeen = true;
+            String identifier = uri.substring(1);
+            if (identifier.startsWith("xpointer(/)")) {
+                // #xpointer(/) is the whole document
+                return;
+            }
+            if (coversElement(identifier, documentElement)) {
+                return;
+            }
+        }
+
+        if (sameDocumentReferenceSeen) {
+            throw new XmlSignatureException(
+                    "Cannot extract the root node for the output document from 
the XML signature document. "
+                                            + "None of the validated 
References covers the document element, so the "
+                                            + "document contains content which 
was not signed. Configure an output node "
+                                            + "search, or an 
XmlSignatureChecker, which selects the signed content.");
+        }
+    }
+
+    private static boolean coversElement(String identifier, Element 
documentElement) {
+        String xpointerId = getXPointerId(identifier);
+        String id = xpointerId != null ? xpointerId : identifier;
+
+        for (String attribute : ID_ATTRIBUTE_NAMES) {
+            if (id.equals(documentElement.getAttribute(attribute))) {

Review Comment:
   Fixed in 1eadf1f — `coversElement` now returns false immediately for an 
empty identifier, before the `getAttribute` comparison where `""` for a missing 
attribute would `"".equals("")` and match anything. Covered by 
`aBareHashReferenceDoesNotCoverAnything` (`URI="#"`) and 
`anXPointerWithAnEmptyIdDoesNotCoverAnything` (`#xpointer(id(''))`), both of 
which fail against the un-fixed code.
   
   _Claude Code on behalf of oscerd_
   



##########
components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java:
##########
@@ -314,6 +350,96 @@ protected Node getNodeForMessageBodyInEnvelopingCase(Input 
input) throws Excepti
         return node;
     }
 
+    /**
+     * Checks that a validated Reference actually covered the document element 
the default search is about to emit.
+     * <p>
+     * Core signature validation only proves that each Reference's digest 
matches the content that Reference resolves
+     * to. It says nothing about the rest of the document. So an attacker can 
take a legitimately signed fragment, embed
+     * it unchanged inside a larger document of their own, and validation 
still passes - the same-document URI resolves
+     * to that fragment exactly as before - while this method would hand the 
whole attacker document downstream as
+     * verified content. That is XML signature wrapping.
+     * <p>
+     * The check is deliberately narrow, so that it rejects that shape and 
nothing else. It only complains when the
+     * signature carries same-document references and none of them covers the 
document element. A Reference with an
+     * empty URI covers the whole document, and a signature whose References 
are all external says nothing about this
+     * document either way, so both are left alone.
+     *
+     * @param input           the verification input, carrying the validated 
References
+     * @param documentElement the element the default search would emit
+     */
+    protected void checkDocumentElementIsCoveredByAReference(Input input, 
Element documentElement) throws Exception {
+        List<Reference> references = getReferencesForMessageMapping(input);
+        if (references == null || references.isEmpty()) {
+            return;
+        }
+
+        boolean sameDocumentReferenceSeen = false;
+        for (Reference reference : references) {
+            String uri = reference.getURI();
+            if (uri == null) {
+                // Nothing to correlate against
+                return;

Review Comment:
   The comment is gone — the `null` branch now `continue`s (1eadf1f). On the 
"null means whole-document coverage" reading: I went the other way 
deliberately, because treating an absent URI as "accept the whole document" 
reintroduces a bypass @davsclaus probed in the adjacent thread — `[null, 
"#evil"]` would accept even when no reference covers the document element. A 
same-document reference with `URI=""` is the reliable "whole document" signal 
and still returns; an absent URI is treated as "tells us nothing", so a lone 
one fails closed.
   
   _Claude Code on behalf of oscerd_
   



##########
components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java:
##########
@@ -314,6 +350,96 @@ protected Node getNodeForMessageBodyInEnvelopingCase(Input 
input) throws Excepti
         return node;
     }
 
+    /**
+     * Checks that a validated Reference actually covered the document element 
the default search is about to emit.
+     * <p>
+     * Core signature validation only proves that each Reference's digest 
matches the content that Reference resolves
+     * to. It says nothing about the rest of the document. So an attacker can 
take a legitimately signed fragment, embed
+     * it unchanged inside a larger document of their own, and validation 
still passes - the same-document URI resolves
+     * to that fragment exactly as before - while this method would hand the 
whole attacker document downstream as
+     * verified content. That is XML signature wrapping.
+     * <p>
+     * The check is deliberately narrow, so that it rejects that shape and 
nothing else. It only complains when the
+     * signature carries same-document references and none of them covers the 
document element. A Reference with an
+     * empty URI covers the whole document, and a signature whose References 
are all external says nothing about this
+     * document either way, so both are left alone.
+     *
+     * @param input           the verification input, carrying the validated 
References
+     * @param documentElement the element the default search would emit
+     */
+    protected void checkDocumentElementIsCoveredByAReference(Input input, 
Element documentElement) throws Exception {
+        List<Reference> references = getReferencesForMessageMapping(input);
+        if (references == null || references.isEmpty()) {
+            return;
+        }
+
+        boolean sameDocumentReferenceSeen = false;
+        for (Reference reference : references) {
+            String uri = reference.getURI();
+            if (uri == null) {
+                // Nothing to correlate against
+                return;
+            }
+            if (uri.isEmpty()) {
+                // The whole document is covered
+                return;
+            }
+            if (!uri.startsWith("#")) {
+                // External reference - it tells us nothing about the document 
we are emitting
+                continue;
+            }
+            sameDocumentReferenceSeen = true;
+            String identifier = uri.substring(1);
+            if (identifier.startsWith("xpointer(/)")) {
+                // #xpointer(/) is the whole document
+                return;
+            }
+            if (coversElement(identifier, documentElement)) {
+                return;
+            }
+        }
+
+        if (sameDocumentReferenceSeen) {
+            throw new XmlSignatureException(
+                    "Cannot extract the root node for the output document from 
the XML signature document. "
+                                            + "None of the validated 
References covers the document element, so the "
+                                            + "document contains content which 
was not signed. Configure an output node "
+                                            + "search, or an 
XmlSignatureChecker, which selects the signed content.");
+        }
+    }
+
+    private static boolean coversElement(String identifier, Element 
documentElement) {
+        String xpointerId = getXPointerId(identifier);
+        String id = xpointerId != null ? xpointerId : identifier;
+
+        for (String attribute : ID_ATTRIBUTE_NAMES) {
+            if (id.equals(documentElement.getAttribute(attribute))) {

Review Comment:
   Fixed in 1eadf1f — `coversElement` now also checks 
`getAttributeNS("http://www.w3.org/XML/1998/namespace";, "id")` (via 
`XMLConstants.XML_NS_URI`), so an `xml:id` on the document element is 
recognised and a reference to it is no longer falsely rejected. Covered by 
`aReferenceToTheDocumentElementsXmlIdIsAccepted` (the coverage test now parses 
namespace-aware, matching the signature processor).
   
   _Claude Code on behalf of oscerd_
   



##########
components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java:
##########
@@ -142,8 +142,40 @@ public class DefaultXmlSignature2Message implements 
XmlSignature2Message {
      */
     public static final String OUTPUT_NODE_SEARCH_TYPE_XPATH = "XPath";
 
+    private static final String[] ID_ATTRIBUTE_NAMES = { "Id", "ID", "id" };

Review Comment:
   I went with documenting the limitation rather than broadening the match. A 
namespace-agnostic local-name scan would reintroduce a wrapping bypass: the 
check runs *after* validation, so it must agree with how the signature 
processor resolved the reference. `xml:id` and DTD/schema-declared IDs are 
mechanisms the processor itself resolves through (added `xml:id` in 1eadf1f). 
But an arbitrary `*:Id` matched by local name is not — an attacker can place 
`wsu:Id="myID"` on their wrapper root while the validated `#myID` actually 
resolved to the original fragment via a real ID, and the scan would wrongly 
accept.
   
   So the setter javadoc and `xmlsecurity-verify-component.adoc` now state that 
`enforceReferenceCoverage` matches `Id`/`ID`/`id`, `xml:id`, and 
schema/DTD-declared IDs, and that a namespaced `wsu:Id` is not matched by name 
— use an `outputNodeSearch` for those. Happy to revisit if you'd prefer a 
WS-Security-namespace-specific allowance, but a blanket local-name scan I think 
is unsafe here.
   
   _Claude Code on behalf of oscerd_
   



##########
components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java:
##########
@@ -155,7 +187,11 @@ public void mapToMessage(Input input, Message output) 
throws Exception {
                 node = getNodeForMessageBodyInEnvelopingCase(input);
             } else {
                 // enveloped or detached XML signature  --> remove signature 
element
-                node = input.getMessageBodyDocument().getDocumentElement();
+                Element documentElement = 
input.getMessageBodyDocument().getDocumentElement();
+                if (enforceReferenceCoverage) {
+                    checkDocumentElementIsCoveredByAReference(input, 
documentElement);

Review Comment:
   The check itself is now comprehensively unit-tested, including both bypasses 
you found — `aNullReferenceUriDoesNotDisableTheCheckForLaterReferences`, 
`aBareHashReferenceDoesNotCoverAnything`, 
`anXPointerWithAnEmptyIdDoesNotCoverAnything` — each verified to fail against 
the un-fixed code, plus `xml:id` acceptance.
   
   You're right that the wiring in `mapToMessage` (the flag check + the call) 
is not yet exercised end to end. I held off on the full sign-a-sub-element / 
wrap / verify test in this round because building a correct XSW fixture (a real 
enveloped signature plus the DOM surgery to wrap it) is easy to get subtly 
wrong, and a fragile test on a security feature is worse than none. I'd like to 
add it as a focused follow-up so it demonstrates the attack is actually 
stopped, rather than rush it here. Let me know if you'd rather it block this PR.
   
   _Claude Code on behalf of oscerd_
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to