From b3466359edccb8905eb327ea2984acc24a74e344 Mon Sep 17 00:00:00 2001 From: Peter Hill Date: Mon, 27 Apr 2026 16:16:57 -0700 Subject: [PATCH] Harden RPC reply parsing for 2.2.1.1 Validate required load-config reply fields up front, defensively copy DOM-backed reply documents, and add regression coverage for malformed and mutable inputs. Also bumps the published project version to 2.2.1.1 and keeps spotbugsTest clean. --- build.gradle | 2 +- pom.xml | 2 +- .../element/AbstractNetconfElement.java | 19 ++- .../net/juniper/netconf/element/RpcReply.java | 35 +++-- .../element/RpcReplyLoadConfigResults.java | 78 +++++++---- .../java/net/juniper/netconf/XMLTest.java | 4 +- .../RpcReplyLoadConfigResultsTest.java | 122 +++++++++++++++++- .../juniper/netconf/element/RpcReplyTest.java | 18 +++ 8 files changed, 238 insertions(+), 42 deletions(-) diff --git a/build.gradle b/build.gradle index f33ed71..c5a8d07 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'net.juniper.netconf' -version = '2.2.1.0' +version = '2.2.1.1' description = 'An API For NetConf client' java { diff --git a/pom.xml b/pom.xml index 9ae8483..5fc4491 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ net.juniper.netconf netconf-java - 2.2.1.0 + 2.2.1.1 jar diff --git a/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java b/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java index b41e587..a15eb23 100644 --- a/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java +++ b/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java @@ -14,6 +14,7 @@ import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import java.io.StringWriter; +import java.util.Objects; import static java.lang.String.format; @@ -46,8 +47,9 @@ public abstract class AbstractNetconfElement { * @throws NullPointerException if {@code document} is {@code null} */ protected AbstractNetconfElement(final Document document) { - this.document = document; - this.xml = createXml(document); + final Document documentCopy = Objects.requireNonNull(copyDocument(document), "document"); + this.document = documentCopy; + this.xml = createXml(documentCopy); } /** @@ -57,7 +59,18 @@ protected AbstractNetconfElement(final Document document) { * @return a cloned {@link Document} representing this element */ public Document getDocument() { - return (Document) document.cloneNode(true); // deep copy + return copyDocument(document); + } + + /** + * Returns a defensive deep copy of the supplied DOM {@link Document}. + * + * @param document source document; may be {@code null} + * @return deep copy of {@code document}, or {@code null} when the input is + * {@code null} + */ + protected static Document copyDocument(final Document document) { + return document == null ? null : (Document) document.cloneNode(true); } /** diff --git a/src/main/java/net/juniper/netconf/element/RpcReply.java b/src/main/java/net/juniper/netconf/element/RpcReply.java index 287a8dd..c0923be 100644 --- a/src/main/java/net/juniper/netconf/element/RpcReply.java +++ b/src/main/java/net/juniper/netconf/element/RpcReply.java @@ -123,11 +123,12 @@ private Builder() { } /** * Sets the original {@link Document} this reply was parsed from. * - * @param originalDocument the source DOM {@link Document}; may be {@code null} + * @param originalDocument the source DOM {@link Document}; may be {@code null}. + * A defensive copy is taken immediately. * @return this {@code Builder} instance for method chaining */ public Builder originalDocument(Document originalDocument) { - this.originalDocument = originalDocument; + this.originalDocument = copyDocument(originalDocument); return this; } @@ -318,6 +319,27 @@ private static String stripEomDelimiter(String xml) { return xml.replaceFirst("\\Q]]>]]>\\E\\s*$", ""); } + /** + * Parses a NETCONF rpc-reply document after applying the common wire-format + * hygiene checks shared by all rpc-reply variants. + * + * @param xml raw NETCONF XML, optionally terminated with the RFC 6242 + * end-of-message delimiter + * @return parsed DOM document + * @throws ParserConfigurationException if a parser cannot be configured + * @throws IOException if the input cannot be read + * @throws SAXException if the XML is not well-formed + */ + protected static Document parseRpcReplyDocument(final String xml) + throws ParserConfigurationException, IOException, SAXException { + final String cleaned = stripEomDelimiter(xml); + if (cleaned.contains(" T from(final String xml) throws ParserConfigurationException, IOException, SAXException, XPathExpressionException { - String cleaned = stripEomDelimiter(xml); - if (cleaned.contains(" element"); + final Element loadConfigResultsElement = requireElement( + (Element) xPath.evaluate(RpcReplyLoadConfigResults.XPATH_RPC_REPLY_LOAD_CONFIG_RESULT, document, XPathConstants.NODE), + "Missing required element"); final Element rpcReplyOkElement = (Element) xPath.evaluate(XPATH_RPC_REPLY_LOAD_CONFIG_RESULT_OK, document, XPathConstants.NODE); final List errorList = getRpcErrors(document, xPath, XPATH_RPC_REPLY_LOAD_CONFIG_RESULT_ERROR); - return RpcReplyLoadConfigResults.loadConfigResultsBuilder() - .originalDocument(document) - .namespacePrefix(null) - .messageId(getAttribute(rpcReplyElement, "message-id")) - .action(getAttribute(loadConfigResultsElement, "action")) - .ok(rpcReplyOkElement != null) - .errors(errorList) - .build(); + return new RpcReplyLoadConfigResults( + null, + document, + requireAttribute(rpcReplyElement, "message-id", "rpc-reply"), + requireAttribute(loadConfigResultsElement, "action", "load-configuration-results"), + rpcReplyOkElement != null, + errorList + ); } private RpcReplyLoadConfigResults( @@ -121,12 +126,12 @@ public Builder() { /** * Sets the original XML Document for the reply. - * @param originalDocument the XML Document, must not be null + * @param originalDocument the XML Document, may be null. A defensive + * copy is taken immediately. * @return this Builder - * @throws NullPointerException if originalDocument is null */ public Builder originalDocument(Document originalDocument) { - this.originalDocument = Objects.requireNonNull(originalDocument, "originalDocument must not be null"); + this.originalDocument = copyDocument(originalDocument); return this; } @@ -174,12 +179,11 @@ public Builder ok(boolean ok) { /** * Sets the list of errors for the reply. - * @param errors the list of errors, must not be null + * @param errors the list of errors, or null to clear them * @return this Builder - * @throws NullPointerException if errors is null */ public Builder errors(List errors) { - this.errors = new java.util.ArrayList<>(Objects.requireNonNull(errors, "errors list must not be null")); + this.errors = errors == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(errors); return this; } @@ -233,7 +237,9 @@ private static Document createDocument( final List errors) { final Document createdDocument = createBlankDocument(); final Element rpcReplyElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "rpc-reply"); - rpcReplyElement.setPrefix(namespacePrefix); + if (namespacePrefix != null) { + rpcReplyElement.setPrefix(namespacePrefix); + } rpcReplyElement.setAttribute("message-id", messageId); createdDocument.appendChild(rpcReplyElement); final Element loadConfigResultsElement = createdDocument.createElement("load-configuration-results"); @@ -242,13 +248,33 @@ private static Document createDocument( appendErrors(namespacePrefix, errors, createdDocument, loadConfigResultsElement); if (ok) { final Element okElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "ok"); - okElement.setPrefix(namespacePrefix); + if (namespacePrefix != null) { + okElement.setPrefix(namespacePrefix); + } loadConfigResultsElement.appendChild(okElement); } return createdDocument; } + private static Element requireElement(final Element element, final String message) + throws XPathExpressionException { + if (element == null) { + throw new XPathExpressionException(message); + } + return element; + } + + private static String requireAttribute(final Element element, final String attributeName, final String elementName) + throws XPathExpressionException { + final String attributeValue = trim(getAttribute(element, attributeName)); + if (attributeValue == null || attributeValue.isEmpty()) { + throw new XPathExpressionException( + String.format("Missing required @%s attribute on <%s>", attributeName, elementName)); + } + return attributeValue; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -276,8 +302,8 @@ public String toString() { * * @return copy of the error list */ - @SuppressWarnings("unchecked") // parent class returns raw List + @Override public List getErrors() { - return new java.util.ArrayList<>((List) super.getErrors()); + return new java.util.ArrayList<>(super.getErrors()); } -} \ No newline at end of file +} diff --git a/src/test/java/net/juniper/netconf/XMLTest.java b/src/test/java/net/juniper/netconf/XMLTest.java index 3d61bcb..cba8d85 100644 --- a/src/test/java/net/juniper/netconf/XMLTest.java +++ b/src/test/java/net/juniper/netconf/XMLTest.java @@ -133,10 +133,12 @@ public void GIVEN_multiSegmentPath_WHEN_addPath_THEN_createNestedHierarchy() thr XMLBuilder builder = new XMLBuilder(); XML xml = builder.createNewXML("parent"); - xml.addPath("level1/level2/leaf"); + XML nested = xml.addPath("level1/level2/leaf"); assertThat(xml.toString()) .containsIgnoringWhitespaces(""); + assertThat(nested.toString()) + .containsIgnoringWhitespaces(""); } @Test diff --git a/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java b/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java index 2a49778..d9dd0ff 100644 --- a/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java +++ b/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java @@ -1,9 +1,11 @@ package net.juniper.netconf.element; import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; import org.xmlunit.assertj.XmlAssert; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; public class RpcReplyLoadConfigResultsTest { @@ -27,6 +29,9 @@ public class RpcReplyLoadConfigResultsTest { """; + private static final String LOAD_CONFIG_RESULTS_OK_NO_NAMESPACE_FRAMED = + LOAD_CONFIG_RESULTS_OK_NO_NAMESPACE + "]]>]]>"; + private static final String LOAD_CONFIG_RESULTS_ERROR_NO_NAMESPACE = "" + " + ]> + + + + + + """; + + assertThatThrownBy(() -> RpcReplyLoadConfigResults.from(withDtd)) + .isInstanceOf(Exception.class) + .hasMessageContaining("DOCTYPE"); + } + + @Test + public void willThrowWhenMessageIdIsMissingFromLoadConfigurationResultsReply() { + String withoutMessageId = """ + + + + + + """; + + assertThatThrownBy(() -> RpcReplyLoadConfigResults.from(withoutMessageId)) + .isInstanceOf(javax.xml.xpath.XPathExpressionException.class) + .hasMessageContaining("message-id"); + } + + @Test + public void willThrowWhenActionIsMissingFromLoadConfigurationResultsReply() { + String withoutAction = """ + + + + + + """; + + assertThatThrownBy(() -> RpcReplyLoadConfigResults.from(withoutAction)) + .isInstanceOf(javax.xml.xpath.XPathExpressionException.class) + .hasMessageContaining("action"); + } + @Test public void willCreateXmlOkWithoutNamespace() { @@ -247,4 +315,56 @@ public void willCreateXmlErrorWithNamespace() { .areIdentical(); } -} \ No newline at end of file + @Test + public void builderWillTreatNullErrorsAsEmptyList() { + + final RpcReplyLoadConfigResults rpcReply = RpcReplyLoadConfigResults.loadConfigResultsBuilder() + .messageId("7") + .action("set") + .errors(null) + .build(); + + assertThat(rpcReply.getErrors()) + .isEmpty(); + assertThat(rpcReply.hasErrorsOrWarnings()) + .isFalse(); + } + + @Test + public void builderWillAllowExplicitNullOriginalDocument() { + + final RpcReplyLoadConfigResults rpcReply = RpcReplyLoadConfigResults.loadConfigResultsBuilder() + .originalDocument(null) + .messageId("8") + .action("set") + .ok(true) + .build(); + + assertThat(rpcReply.getMessageId()) + .isEqualTo("8"); + assertThat(rpcReply.getAction()) + .isEqualTo("set"); + assertThat(rpcReply.isOK()) + .isTrue(); + } + + @Test + public void builderWillDefensivelyCopyOriginalDocument() throws Exception { + + final Document originalDocument = RpcReply.parseRpcReplyDocument(LOAD_CONFIG_RESULTS_OK_NO_NAMESPACE); + + final RpcReplyLoadConfigResults.Builder builder = RpcReplyLoadConfigResults.loadConfigResultsBuilder() + .originalDocument(originalDocument) + .messageId("3") + .action("set") + .ok(true); + + originalDocument.getDocumentElement().setAttribute("message-id", "999"); + + final RpcReplyLoadConfigResults rpcReply = builder.build(); + + assertThat(rpcReply.getDocument().getDocumentElement().getAttribute("message-id")) + .isEqualTo("3"); + } + +} diff --git a/src/test/java/net/juniper/netconf/element/RpcReplyTest.java b/src/test/java/net/juniper/netconf/element/RpcReplyTest.java index b1bea4e..a2fae54 100644 --- a/src/test/java/net/juniper/netconf/element/RpcReplyTest.java +++ b/src/test/java/net/juniper/netconf/element/RpcReplyTest.java @@ -1,6 +1,7 @@ package net.juniper.netconf.element; import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; import org.xmlunit.assertj.XmlAssert; import java.util.Arrays; @@ -237,6 +238,23 @@ public void willCreateXmlWithErrors() { .areIdentical(); } + @Test + public void builderWillDefensivelyCopyOriginalDocument() throws Exception { + final Document originalDocument = RpcReply.parseRpcReplyDocument(RPC_REPLY_WITH_OK); + + final RpcReply.Builder builder = RpcReply.builder() + .originalDocument(originalDocument) + .messageId("5") + .ok(true); + + originalDocument.getDocumentElement().setAttribute("message-id", "999"); + + final RpcReply rpcReply = builder.build(); + + assertThat(rpcReply.getDocument().getDocumentElement().getAttribute("message-id")) + .isEqualTo("5"); + } + /** * RFC 6241 §4.3: a peer SHOULD respond with * if the incoming message is not well‑formed XML. In our client-side parser we