This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 0a72858971ec CAMEL-24444: camel-xmlsecurity - option to require the 
verified output to be covered by a Reference
0a72858971ec is described below

commit 0a72858971ec2d581b41a5d58b129906d34a6c14
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Sep 28 17:23:45 2026 +0200

    CAMEL-24444: camel-xmlsecurity - option to require the verified output to 
be covered by a Reference
    
    Core XML signature validation only proves that each Reference's digest
    matches what it resolves to. An attacker can embed a legitimately signed
    fragment inside a larger document of their own, and validation still
    passes while the default output node search hands the whole attacker
    document downstream as verified content (XML signature wrapping).
    
    The new enforceReferenceCoverage option on DefaultXmlSignature2Message
    rejects a signature whose same-document References do not cover the
    document element. It recognises URI="", #xpointer(/), Id/ID/id, xml:id
    and schema/DTD-declared IDs; a namespaced id such as wsu:Id is not
    matched by name, so use an outputNodeSearch for those. An empty
    identifier and a Reference without a URI never count as coverage.
    
    Covered by unit tests of the check and end-to-end tests through
    xmlsecurity-verify for both the rejection and the acceptance path.
    
    Closes #26726
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
    Co-authored-by: Guillaume Nodet - AI Bot <[email protected]>
---
 .../catalog/docs/xmlsecurity-verify-component.adoc |  43 ++++
 .../main/docs/xmlsecurity-verify-component.adoc    |  43 ++++
 .../api/DefaultXmlSignature2Message.java           | 152 +++++++++++-
 .../component/xmlsecurity/XmlSignatureTest.java    |  77 +++++-
 ...tXmlSignature2MessageReferenceCoverageTest.java | 266 +++++++++++++++++++++
 5 files changed, 579 insertions(+), 2 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/xmlsecurity-verify-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/xmlsecurity-verify-component.adoc
index 547bdde0386f..0c70b632e130 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/xmlsecurity-verify-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/xmlsecurity-verify-component.adoc
@@ -182,6 +182,49 @@ Elements):
 ----
 
  
+== Correlating the Verified Output with the Signed Content
+
+Signature validation proves that each `Reference` digest matches the content 
that
+`Reference` resolves to. It proves nothing about the rest of the document.
+
+For an enveloped or detached signature the default output node search emits 
the whole
+document element. Where the signature covers only a sub-element, an attacker 
who obtains
+such a signed document can embed the signed fragment unchanged inside a 
document of their
+own: the same-document `Reference` URI still resolves to that fragment, 
validation still
+passes, and the verifier hands the whole surrounding document downstream as 
verified
+content. This is XML signature wrapping.
+
+Whether that matters depends on what the route expects. A detached signature 
that
+deliberately covers a sub-element and emits the whole document — the pattern 
described in
+_Detached XML Signatures as Siblings of the Signed Elements_ below — is doing 
exactly this
+on purpose, and nothing in the document distinguishes it from the wrapping 
case. Only the
+route knows which it is, so there is no safe default.
+
+Two ways to correlate, in increasing order of specificity:
+
+* Set `enforceReferenceCoverage` on `DefaultXmlSignature2Message` when the 
signature is
+expected to cover the document element — a plain enveloped signature with 
`URI=""`, or with
+the document element's own id. Verification then fails if the validated 
References cover
+only sub-elements:
++
+[source,java]
+----
+DefaultXmlSignature2Message mapper = new DefaultXmlSignature2Message();
+mapper.setEnforceReferenceCoverage(true);
+// ... to("xmlsecurity-verify:verify?xmlSignature2Message=#mapper")
+----
++
+A same-document reference is matched against the document element's own id — 
an `Id`, `ID` or
+`id` attribute, an `xml:id` attribute, or an attribute a DTD or schema 
declared to be of type ID.
+A namespaced id from another convention, notably WS-Security's `wsu:Id`, is 
deliberately not
+matched by attribute name (matching it across any namespace would let an 
attacker put a matching
+id on their wrapper element), so select the signed content with an 
`outputNodeSearch` for those.
+* Configure an `outputNodeSearch` (by element name or XPath), so the output is 
the signed
+node rather than the document element, or supply an `xmlSignatureChecker` that 
inspects the
+References against what the route expects.
+
+Leaving all three unset means the verified body may contain content no 
`Reference` covered.
+
 == Basic Example
 
 The following example shows the basic usage of the component.
diff --git 
a/components/camel-xmlsecurity/src/main/docs/xmlsecurity-verify-component.adoc 
b/components/camel-xmlsecurity/src/main/docs/xmlsecurity-verify-component.adoc
index 547bdde0386f..0c70b632e130 100644
--- 
a/components/camel-xmlsecurity/src/main/docs/xmlsecurity-verify-component.adoc
+++ 
b/components/camel-xmlsecurity/src/main/docs/xmlsecurity-verify-component.adoc
@@ -182,6 +182,49 @@ Elements):
 ----
 
  
+== Correlating the Verified Output with the Signed Content
+
+Signature validation proves that each `Reference` digest matches the content 
that
+`Reference` resolves to. It proves nothing about the rest of the document.
+
+For an enveloped or detached signature the default output node search emits 
the whole
+document element. Where the signature covers only a sub-element, an attacker 
who obtains
+such a signed document can embed the signed fragment unchanged inside a 
document of their
+own: the same-document `Reference` URI still resolves to that fragment, 
validation still
+passes, and the verifier hands the whole surrounding document downstream as 
verified
+content. This is XML signature wrapping.
+
+Whether that matters depends on what the route expects. A detached signature 
that
+deliberately covers a sub-element and emits the whole document — the pattern 
described in
+_Detached XML Signatures as Siblings of the Signed Elements_ below — is doing 
exactly this
+on purpose, and nothing in the document distinguishes it from the wrapping 
case. Only the
+route knows which it is, so there is no safe default.
+
+Two ways to correlate, in increasing order of specificity:
+
+* Set `enforceReferenceCoverage` on `DefaultXmlSignature2Message` when the 
signature is
+expected to cover the document element — a plain enveloped signature with 
`URI=""`, or with
+the document element's own id. Verification then fails if the validated 
References cover
+only sub-elements:
++
+[source,java]
+----
+DefaultXmlSignature2Message mapper = new DefaultXmlSignature2Message();
+mapper.setEnforceReferenceCoverage(true);
+// ... to("xmlsecurity-verify:verify?xmlSignature2Message=#mapper")
+----
++
+A same-document reference is matched against the document element's own id — 
an `Id`, `ID` or
+`id` attribute, an `xml:id` attribute, or an attribute a DTD or schema 
declared to be of type ID.
+A namespaced id from another convention, notably WS-Security's `wsu:Id`, is 
deliberately not
+matched by attribute name (matching it across any namespace would let an 
attacker put a matching
+id on their wrapper element), so select the signed content with an 
`outputNodeSearch` for those.
+* Configure an `outputNodeSearch` (by element name or XPath), so the output is 
the signed
+node rather than the document element, or supply an `xmlSignatureChecker` that 
inspects the
+References against what the route expects.
+
+Leaving all three unset means the verified body may contain content no 
`Reference` covered.
+
 == Basic Example
 
 The following example shows the basic usage of the component.
diff --git 
a/components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java
 
b/components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java
index aae5855be436..9bff7be67307 100644
--- 
a/components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java
+++ 
b/components/camel-xmlsecurity/src/main/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2Message.java
@@ -20,6 +20,7 @@ import java.io.ByteArrayOutputStream;
 import java.util.ArrayList;
 import java.util.List;
 
+import javax.xml.XMLConstants;
 import javax.xml.crypto.XMLStructure;
 import javax.xml.crypto.dom.DOMStructure;
 import javax.xml.crypto.dsig.Manifest;
@@ -142,8 +143,48 @@ 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" };
+
     private static final Logger LOG = 
LoggerFactory.getLogger(DefaultXmlSignature2Message.class);
 
+    private boolean enforceReferenceCoverage;
+
+    /**
+     * Whether the default output node search requires a validated Reference 
to cover the document element before
+     * emitting it. Off by default; see {@link 
#setEnforceReferenceCoverage(boolean)}.
+     */
+    public boolean isEnforceReferenceCoverage() {
+        return enforceReferenceCoverage;
+    }
+
+    /**
+     * Requires, for an enveloped or detached signature handled by the default 
output node search, that one of the
+     * validated References covered the document element being emitted.
+     * <p>
+     * Core signature validation proves only that each Reference's digest 
matches the content that Reference resolves
+     * to; it says nothing about the rest of the document. An attacker can 
therefore take a legitimately signed
+     * fragment, embed it unchanged inside a document of their own, and 
validation still passes - the same-document URI
+     * resolves to that fragment exactly as before - while the default search 
hands the whole surrounding document
+     * downstream as verified content. That is XML signature wrapping.
+     * <p>
+     * This is off by default because the framework cannot tell that shape 
apart from the component's documented
+     * detached-signature flow, where a Reference deliberately covers a 
sub-element and the whole document is emitted on
+     * purpose. Only the route knows which it is. Turn this on when the 
signature is expected to cover the document
+     * element - a plain enveloped signature with {@code URI=""} or with the 
document element's own id - and use an
+     * output node search or an {@link XmlSignatureChecker} instead when it is 
not.
+     * <p>
+     * A same-document reference is matched against the document element's own 
id: an {@code Id}, {@code ID} or
+     * {@code id} attribute (the XML DSig 1.0 convention), an {@code xml:id} 
attribute (XML DSig 1.1), or an attribute a
+     * DTD or schema declared to be of type ID. A namespaced id from another 
convention - notably WS-Security's
+     * {@code wsu:Id} - is deliberately not matched by attribute name: 
matching it by local name across any namespace
+     * would let an attacker put a matching id on their wrapper element and 
defeat the check, so only the id mechanisms
+     * the signature processor itself resolves references through are 
honoured. When such a reference legitimately
+     * covers the document element but is not recognised here, select the 
signed content with an output node search.
+     */
+    public void setEnforceReferenceCoverage(boolean enforceReferenceCoverage) {
+        this.enforceReferenceCoverage = enforceReferenceCoverage;
+    }
+
     @Override
     public void mapToMessage(Input input, Message output) throws Exception {
 
@@ -155,7 +196,11 @@ public class DefaultXmlSignature2Message implements 
XmlSignature2Message {
                 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);
+                }
+                node = documentElement;
                 removeSignatureElements = true;
             }
         } else if 
(OUTPUT_NODE_SEARCH_TYPE_ELEMENT_NAME.equals(input.getOutputNodeSearchType())) {
@@ -314,6 +359,111 @@ public class DefaultXmlSignature2Message implements 
XmlSignature2Message {
         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) {
+                // Absent URI (getURI() == null per JSR-105) identifies the 
whole document per the XML Signature
+                // spec, the same as URI="". However, treating it as 
whole-document coverage here would let an
+                // attacker bypass the check by attaching a null-URI 
reference, so we skip it conservatively:
+                // a lone absent-URI reference leaves 
sameDocumentReferenceSeen false and the document is rejected.
+                continue;
+            }
+            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;
+
+        if (id.isEmpty()) {
+            // An empty identifier names nothing. Without this guard it would 
fall through to the getAttribute
+            // comparison below, where Element.getAttribute returns "" for a 
missing attribute, so "".equals("")
+            // would match any element and accept the whole document 
(reachable via URI="#" and URI="#xpointer(id(''))").
+            return false;
+        }
+
+        for (String attribute : ID_ATTRIBUTE_NAMES) {
+            if (id.equals(documentElement.getAttribute(attribute))) {
+                return true;
+            }
+        }
+        // xml:id (XML DSig 1.1 / the W3C xml:id spec) is a standardised ID 
attribute the signature processor also
+        // resolves references through, so a document element carrying it is 
genuinely covered
+        if (id.equals(documentElement.getAttributeNS(XMLConstants.XML_NS_URI, 
"id"))) {
+            return true;
+        }
+        // In case an ID attribute was declared for the document, ask the DOM 
as well
+        Element byId = documentElement.getOwnerDocument().getElementById(id);
+        return byId != null && byId == documentElement;
+    }
+
+    /**
+     * Extracts {@code x} out of the {@code xpointer(id('x'))} and {@code 
xpointer(id("x"))} forms, returning null when
+     * the identifier is not one of them.
+     */
+    private static String getXPointerId(String identifier) {
+        String prefix = "xpointer(id(";
+        if (!identifier.startsWith(prefix) || !identifier.endsWith("))")) {
+            return null;
+        }
+        String value = identifier.substring(prefix.length(), 
identifier.length() - 2).trim();
+        if (value.length() > 1
+                && (value.charAt(0) == '\'' && value.charAt(value.length() - 
1) == '\''
+                        || value.charAt(0) == '"' && 
value.charAt(value.length() - 1) == '"')) {
+            return value.substring(1, value.length() - 1);
+        }
+        return null;
+    }
+
     /**
      * Removes the Signature elements from the document.
      */
diff --git 
a/components/camel-xmlsecurity/src/test/java/org/apache/camel/component/xmlsecurity/XmlSignatureTest.java
 
b/components/camel-xmlsecurity/src/test/java/org/apache/camel/component/xmlsecurity/XmlSignatureTest.java
index f6c7301f86cd..d7aa212b4626 100644
--- 
a/components/camel-xmlsecurity/src/test/java/org/apache/camel/component/xmlsecurity/XmlSignatureTest.java
+++ 
b/components/camel-xmlsecurity/src/test/java/org/apache/camel/component/xmlsecurity/XmlSignatureTest.java
@@ -74,6 +74,7 @@ import org.apache.camel.ProducerTemplate;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.xmlsecurity.api.DefaultXmlSignature2Message;
 import org.apache.camel.component.xmlsecurity.api.KeyAccessor;
 import org.apache.camel.component.xmlsecurity.api.ValidationFailedHandler;
 import org.apache.camel.component.xmlsecurity.api.XmlSignature2Message;
@@ -145,6 +146,10 @@ public class XmlSignatureTest extends CamelTestSupport {
         registry.bind("xpathsToIdAttributes", xpaths);
 
         registry.bind("parentXpathBean", getParentXPathBean());
+
+        DefaultXmlSignature2Message enforceCoverageMapper = new 
DefaultXmlSignature2Message();
+        enforceCoverageMapper.setEnforceReferenceCoverage(true);
+        registry.bind("enforceCoverageMapper", enforceCoverageMapper);
     }
 
     @Override
@@ -350,7 +355,8 @@ public class XmlSignatureTest extends CamelTestSupport {
                         .to(
                                 "mock:result");
             }
-        }, createDetachedRoute(), createRouteForEnvelopedWithParentXpath() };
+        }, createDetachedRoute(), createRouteForEnvelopedWithParentXpath(), 
createEnforceReferenceCoverageRoute(),
+                createEnforceReferenceCoverageAcceptanceRoute() };
     }
 
     RouteBuilder createDetachedRoute() {
@@ -382,6 +388,75 @@ public class XmlSignatureTest extends CamelTestSupport {
         };
     }
 
+    RouteBuilder createEnforceReferenceCoverageRoute() {
+        return new RouteBuilder() {
+            public void configure() {
+                
onException(XmlSignatureException.class).handled(true).to("mock:enforceCoverageException");
+                from("direct:enforceCoverage")
+                        
.to("xmlsecurity-sign:enforceCoverage?keyAccessor=#keyAccessorDefault"
+                            + "&xpathsToIdAttributes=#xpathsToIdAttributes"
+                            + 
"&schemaResourceUri=org/apache/camel/component/xmlsecurity/Test.xsd&signatureId=&clearHeaders=false")
+                        
.to("xmlsecurity-verify:enforceCoverage?keySelector=#keySelectorDefault"
+                            + 
"&schemaResourceUri=org/apache/camel/component/xmlsecurity/Test.xsd"
+                            + "&xmlSignature2Message=#enforceCoverageMapper")
+                        .to("mock:enforceCoverageResult");
+            }
+        };
+    }
+
+    RouteBuilder createEnforceReferenceCoverageAcceptanceRoute() {
+        return new RouteBuilder() {
+            public void configure() {
+                
onException(XmlSignatureException.class).handled(true).to("mock:enforceCoverageException");
+                // enveloped signature whose reference covers the whole 
document (URI="" with the enveloped-signature
+                // transform), verified with enforceReferenceCoverage on - the 
document element is covered, so it passes
+                from("direct:enforceCoverageAccept")
+                        
.to("xmlsecurity-sign:enforceCoverageAccept?keyAccessor=#accessor"
+                            + 
"&parentLocalName=root&parentNamespace=http://test/test";)
+                        
.to("xmlsecurity-verify:enforceCoverageAccept?keySelector=#selector"
+                            + "&xmlSignature2Message=#enforceCoverageMapper")
+                        .to("mock:enforceCoverageResult");
+            }
+        };
+    }
+
+    @Test
+    void enforceReferenceCoverageRejectsASignatureCoveringOnlyASubElement() 
throws Exception {
+        // a detached signature legitimately covers a sub-element while the 
whole document is emitted - the same shape
+        // as an XML signature wrapping attack. With enforceReferenceCoverage 
on, the default output-node search must
+        // refuse to emit the uncovered document element. This drives the 
check through mapToMessage, not in isolation.
+        String detachedPayload = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+                                 + "<ns:root xmlns:ns=\"http://test\";><a 
ID=\"myID\"><b>bValue</b></a></ns:root>";
+
+        MockEndpoint exceptionMock = 
getMockEndpoint("mock:enforceCoverageException");
+        exceptionMock.expectedMessageCount(1);
+        MockEndpoint resultMock = 
getMockEndpoint("mock:enforceCoverageResult");
+        resultMock.expectedMessageCount(0);
+
+        TestSupport.sendBody(this.template, "direct:enforceCoverage", 
detachedPayload,
+                
Collections.singletonMap(XmlSignatureConstants.HEADER_CONTENT_REFERENCE_URI, 
(Object) "#myID"));
+
+        MockEndpoint.assertIsSatisfied(context);
+    }
+
+    @Test
+    void enforceReferenceCoverageAcceptsAWholeDocumentReference() throws 
Exception {
+        // the acceptance counterpart of the rejection test: an enveloped 
signature references the whole document
+        // (URI="" with the enveloped-signature transform), so the emitted 
document element is signed and
+        // enforceReferenceCoverage must let it through the full sign -> 
verify -> mapToMessage pipeline. Without this,
+        // only the rejection path is exercised end-to-end, and a regression 
that inverted the enforceReferenceCoverage
+        // guard while leaving the call wired would go unnoticed. Uses a 
dedicated enveloped route because the detached
+        // route above signs a sub-element by id and cannot produce a 
whole-document reference.
+        MockEndpoint exceptionMock = 
getMockEndpoint("mock:enforceCoverageException");
+        exceptionMock.expectedMessageCount(0);
+        MockEndpoint resultMock = 
getMockEndpoint("mock:enforceCoverageResult");
+        resultMock.expectedMessageCount(1);
+
+        TestSupport.sendBody(this.template, "direct:enforceCoverageAccept", 
payload);
+
+        MockEndpoint.assertIsSatisfied(context);
+    }
+
     @Test
     public void testEnvelopingSignature() throws Exception {
         setupMock();
diff --git 
a/components/camel-xmlsecurity/src/test/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2MessageReferenceCoverageTest.java
 
b/components/camel-xmlsecurity/src/test/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2MessageReferenceCoverageTest.java
new file mode 100644
index 000000000000..fbfc06bf968e
--- /dev/null
+++ 
b/components/camel-xmlsecurity/src/test/java/org/apache/camel/component/xmlsecurity/api/DefaultXmlSignature2MessageReferenceCoverageTest.java
@@ -0,0 +1,266 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.xmlsecurity.api;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import javax.xml.crypto.Data;
+import javax.xml.crypto.dsig.DigestMethod;
+import javax.xml.crypto.dsig.Reference;
+import javax.xml.crypto.dsig.XMLObject;
+import javax.xml.crypto.dsig.XMLSignatureException;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import org.apache.camel.component.xmlsecurity.api.XmlSignature2Message.Input;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The default output node search emits the whole document element for an 
enveloped or detached signature. Validation
+ * only proves that each Reference's digest matches what that Reference 
resolves to, so a signed fragment embedded in a
+ * larger document still validates while the surrounding, unsigned content 
goes downstream as verified content.
+ * <p>
+ * {@code enforceReferenceCoverage} closes that for routes where the signature 
is expected to cover the document
+ * element. It cannot be the default: the component's detached-signature flow 
deliberately covers a sub-element and
+ * emits the whole document, and nothing in the document distinguishes the two.
+ */
+class DefaultXmlSignature2MessageReferenceCoverageTest {
+
+    private static final String WRAPPED = "<attacker><signed 
ID=\"myID\"><b>bValue</b></signed></attacker>";
+    private static final String PLAIN = "<signed 
ID=\"myID\"><b>bValue</b></signed>";
+
+    @Test
+    void offByDefault() {
+        assertFalse(new 
DefaultXmlSignature2Message().isEnforceReferenceCoverage());
+    }
+
+    @Test
+    void aFragmentReferenceDoesNotCoverTheDocumentElement() {
+        XmlSignatureException e = assertThrows(XmlSignatureException.class, () 
-> check(WRAPPED, "#myID"));
+        assertTrue(e.getMessage().contains("None of the validated References 
covers the document element"),
+                "unexpected message: " + e.getMessage());
+    }
+
+    @Test
+    void anEmptyReferenceUriCoversTheWholeDocument() throws Exception {
+        check(WRAPPED, "");
+    }
+
+    @Test
+    void aReferenceToTheDocumentElementsOwnIdIsAccepted() throws Exception {
+        check(PLAIN, "#myID");
+    }
+
+    @Test
+    void anXPointerToTheDocumentElementsOwnIdIsAccepted() throws Exception {
+        check(PLAIN, "#xpointer(id('myID'))");
+    }
+
+    @Test
+    void aWholeDocumentXPointerIsAccepted() throws Exception {
+        check(WRAPPED, "#xpointer(/)");
+    }
+
+    @Test
+    void anExternalReferenceIsLeftAlone() throws Exception {
+        // The signature says nothing about this document either way, so there 
is nothing to correlate
+        check(WRAPPED, "http://example.org/other.xml";);
+    }
+
+    @Test
+    void aBareHashReferenceDoesNotCoverAnything() {
+        // URI="#" yields an empty identifier; without the empty-id guard, 
getAttribute returning "" for a missing
+        // attribute would make "".equals("") match any element and accept the 
whole document
+        XmlSignatureException e = assertThrows(XmlSignatureException.class, () 
-> check(WRAPPED, "#"));
+        assertTrue(e.getMessage().contains("None of the validated References 
covers the document element"),
+                "unexpected message: " + e.getMessage());
+    }
+
+    @Test
+    void anXPointerWithAnEmptyIdDoesNotCoverAnything() {
+        XmlSignatureException e = assertThrows(XmlSignatureException.class, () 
-> check(WRAPPED, "#xpointer(id(''))"));
+        assertTrue(e.getMessage().contains("None of the validated References 
covers the document element"),
+                "unexpected message: " + e.getMessage());
+    }
+
+    @Test
+    void aNullReferenceUriDoesNotDisableTheCheckForLaterReferences() {
+        // An absent URI tells us nothing, but it must not short-circuit the 
whole check: the #myID reference after it
+        // still has to be examined, and it does not cover the <attacker> 
document element
+        XmlSignatureException e = assertThrows(XmlSignatureException.class,
+                () -> check(WRAPPED, Arrays.asList(null, "#myID")));
+        assertTrue(e.getMessage().contains("None of the validated References 
covers the document element"),
+                "unexpected message: " + e.getMessage());
+    }
+
+    @Test
+    void aReferenceToTheDocumentElementsXmlIdIsAccepted() throws Exception {
+        // xml:id is a standardised ID attribute (XML DSig 1.1); a reference 
to it covers the element
+        check("<signed xml:id=\"myID\"><b>bValue</b></signed>", "#myID");
+    }
+
+    private static void check(String xml, String referenceUri) throws 
Exception {
+        check(xml, Collections.singletonList(referenceUri));
+    }
+
+    private static void check(String xml, List<String> referenceUris) throws 
Exception {
+        DefaultXmlSignature2Message mapper = new DefaultXmlSignature2Message();
+        mapper.setEnforceReferenceCoverage(true);
+
+        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+        // namespace-aware so xml:id resolves to the XML namespace, matching 
how the signature processor parses
+        dbf.setNamespaceAware(true);
+        Document document = dbf.newDocumentBuilder()
+                .parse(new 
ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
+        Element documentElement = document.getDocumentElement();
+
+        mapper.checkDocumentElementIsCoveredByAReference(new 
TestInput(referenceUris), documentElement);
+    }
+
+    /**
+     * Only getReferences() is consulted by the check under test.
+     */
+    private static final class TestInput implements Input {
+
+        private final List<String> uris;
+
+        private TestInput(List<String> uris) {
+            this.uris = uris;
+        }
+
+        @Override
+        public List<Reference> getReferences() {
+            List<Reference> references = new ArrayList<>();
+            for (String uri : uris) {
+                references.add(new TestReference(uri));
+            }
+            return references;
+        }
+
+        @Override
+        public List<XMLObject> getObjects() {
+            return Collections.emptyList();
+        }
+
+        @Override
+        public Document getMessageBodyDocument() {
+            return null;
+        }
+
+        @Override
+        public Object getOutputNodeSearch() {
+            return null;
+        }
+
+        @Override
+        public String getOutputNodeSearchType() {
+            return DefaultXmlSignature2Message.OUTPUT_NODE_SEARCH_TYPE_DEFAULT;
+        }
+
+        @Override
+        public Boolean getRemoveSignatureElements() {
+            return Boolean.FALSE;
+        }
+
+        @Override
+        public Boolean omitXmlDeclaration() {
+            return Boolean.FALSE;
+        }
+
+        @Override
+        public String getOutputXmlEncoding() {
+            return null;
+        }
+    }
+
+    private static final class TestReference implements Reference {
+
+        private final String uri;
+
+        private TestReference(String uri) {
+            this.uri = uri;
+        }
+
+        @Override
+        public String getURI() {
+            return uri;
+        }
+
+        @Override
+        public String getType() {
+            return null;
+        }
+
+        @Override
+        public String getId() {
+            return null;
+        }
+
+        @Override
+        public byte[] getDigestValue() {
+            return new byte[0];
+        }
+
+        @Override
+        public byte[] getCalculatedDigestValue() {
+            return new byte[0];
+        }
+
+        @Override
+        public boolean validate(javax.xml.crypto.dsig.XMLValidateContext 
validateContext) throws XMLSignatureException {
+            return true;
+        }
+
+        @Override
+        public DigestMethod getDigestMethod() {
+            return null;
+        }
+
+        @Override
+        @SuppressWarnings("rawtypes")
+        public List getTransforms() {
+            return Collections.emptyList();
+        }
+
+        @Override
+        public Data getDereferencedData() {
+            return null;
+        }
+
+        @Override
+        public InputStream getDigestInputStream() {
+            return null;
+        }
+
+        @Override
+        public boolean isFeatureSupported(String feature) {
+            return false;
+        }
+    }
+}

Reply via email to