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;
+ }
+ }
+}