This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-text.git
The following commit(s) were added to refs/heads/master by this push:
new ae3d36b8 Harden XML parsing via commons-secure-xml (#766)
ae3d36b8 is described below
commit ae3d36b8cea5d1fa5eaf3bd28f07bb111d1890e0
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Wed Sep 9 15:43:13 2026 +0200
Harden XML parsing via commons-secure-xml (#766)
* Harden XML parsing via commons-secure-xml
Create XmlStringLookup's document builder and XPath factories through
org.apache.commons:commons-secure-xml. The secure factories enable
FEATURE_SECURE_PROCESSING and install a non-removable entity-resolver
floor on every parser they produce: external DTD and entity lookups are
resolved to empty content instead of being fetched, and internal entity
expansion is bounded, regardless of the JAXP implementation on the
classpath.
Changes:
- Add the commons-secure-xml dependency (1.0.0-SNAPSHOT until its first
release).
- Route factory creation through SecureDocumentBuilderFactory and
SecureXPathFactory in XmlStringLookup when the instance's feature map
enables secure processing. The documented opt-outs keep their
meaning: feature maps without secure processing, and the standard
javax.xml.accessExternalDTD system property (which the secure
factory's resolver floor would otherwise ignore), keep using the
plain JAXP factories, so external entity resolution can be restored
where it is wanted.
- Adapt the secure-path tests to the secure contract: a parser may
either reject a document with an external reference or parse it with
the reference resolved to empty content; the tests now assert that
the external content does not leak into the result instead of
expecting one fixed failure mode.
- Run the CI and CodeQL builds with -Puse-apache-snapshots (inherited
from the org.apache:apache parent POM) so the commons-secure-xml
SNAPSHOT resolves; CodeQL's autobuild receives the profile through
MAVEN_ARGS.
Assisted-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MHgnMnGWHQoH2zD2jFdoMT
* Use the Commons Secure XML 1.0.0 release candidate
Bump org.apache.commons:commons-secure-xml from 1.0.0-SNAPSHOT to 1.0.0
and add the temporary staging repository
https://repository.apache.org/content/repositories/orgapachecommons-1962/
after Central, so the vote gets downstream CI results. Drop the
-Puse-apache-snapshots profile from the CI workflows, which the release
version no longer needs. Remove the staging repository once 1.0.0 is
released.
Assisted-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_0167e29ScPEdfzJnEFm95imK
* Bump Apache Commons Secure XML from 1.0.0-SNAPSHOT to 1.0.0
* Drop the insecure XmlStringLookup fallback
Commons Secure XML blocks external resource fetching with an entity
resolver rather than with parser features, so the opt-outs the previous
commit documented never worked: neither a feature map without secure
processing nor the javax.xml.accessExternalDTD system property reaches
the resolver floor. Always create the factories through
SecureDocumentBuilderFactory and SecureXPathFactory instead.
Changes:
- Remove the plain-JAXP fallback from XmlStringLookup.lookup(String),
along with the DEFAULT_XML_FEATURES and DEFAULT_XPATH_FEATURES maps
that only existed to select it.
- Document the two aspects Commons Secure XML secures separately:
FEATURE_SECURE_PROCESSING governs processing limits and remains
settable, while external DTD subsets and entities are blocked outright
and cannot be re-enabled.
- Remove the references to the "XmlStringLookup.secure" system property,
which was never introduced.
- Disable the tests that expect an external entity to resolve, and
tighten the leak assertion now that the parser no longer rejects such
documents.
- Shorten the changelog entry and state the behavior change.
Assisted-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y7VfssZLkK11bBY2kVyyY4
* Add missing tests
* Fence external XML references in XmlStringLookup
Commons Secure XML blocks external DTD subsets and entities with a
non-removable entity-resolver floor, and lets a caller opt specific
resources back in by installing a resolver of their own. Reuse the Path
fences the lookup already accepts for that: a caller who declared
trusted roots for the document gets its follow-up resources resolved
from those same roots, and from nowhere else.
Changes:
- Add PathFenceResolver, a package-private EntityResolver that resolves
a system identifier only when it lands inside the fence and refuses
anything else, including any non-file reference. XmlStringLookup
installs it only when the fence has roots, so an unfenced lookup
resolves no external resource at all.
- Parse with the document's own URI, so relative system identifiers
resolve against the document as XML requires, rather than against the
process working directory.
- Add PathFence.isEmpty() to tell an empty fence from a real allow.
- Reword the XmlStringLookup and StringLookupFactory Javadoc: secure
processing covers two separate aspects, the FEATURE_SECURE_PROCESSING
limits that callers can still turn off and the external references
that only a fence can opt back in. Drop the references to the
XmlStringLookup.secure system property, which was never implemented.
- Move the external-reference fixtures to
src/test/resources/XmlStringLookup, with the referenced files in the
root and the documents that reference them one directory below, so
one pair of files covers both the allowed and the refused case
depending on where the fence points.
Assisted-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QoqFgJRAAHAHhvJTfDRfUt
* Add a changelog entry for the base URI fix
XmlStringLookup used to parse through DocumentBuilder.parse(InputStream),
which supplies no base URI, so the parser expanded a relative system
identifier against the process working directory rather than against the
document. That defect shipped in 1.5 and was reachable through
StringLookupFactory.xmlStringLookup(Map) with a feature map that left
external references enabled, so record it as a fix of its own rather
than folding it into the Commons Secure XML entries.
Assisted-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QoqFgJRAAHAHhvJTfDRfUt
* Cover the malformed system identifier branch of PathFenceResolver
PathFenceResolver.toPath catches URISyntaxException,
IllegalArgumentException
and FileSystemNotFoundException, but every refusal the tests exercised went
through the preceding scheme check instead, leaving that catch untested.
- testMalformedSystemIdIsRefused covers the URISyntaxException: an
unencoded space, the realistic way a system identifier goes malformed.
- testUnconvertibleFileUrlIsRefused covers the IllegalArgumentException
from Paths.get on a 'file:' URI that parses and passes the scheme check
but names no path: an opaque one, and one carrying a fragment.
Both assert on the cause, so a change that made the scheme check reject
these earlier would not silently keep the tests green.
Assisted-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QoqFgJRAAHAHhvJTfDRfUt
* Drop the unreachable FileSystemNotFoundException catch
Paths.get(URI) raises FileSystemNotFoundException only for a scheme whose
provider is not installed, and PathFenceResolver.toPath rejects every
scheme but 'file' before it gets there, so that catch type could never
fire.
Assisted-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QoqFgJRAAHAHhvJTfDRfUt
---------
Co-authored-by: Gary Gregory <[email protected]>
---
pom.xml | 5 +
src/changes/changes.xml | 3 +
.../org/apache/commons/text/lookup/PathFence.java | 11 +-
.../commons/text/lookup/PathFenceResolver.java | 107 ++++++++++++++
.../commons/text/lookup/StringLookupFactory.java | 59 +++++---
.../commons/text/lookup/XmlStringLookup.java | 67 +++++----
.../commons/text/lookup/PathFenceResolverTest.java | 164 +++++++++++++++++++++
.../text/lookup/StringLookupFactoryTest.java | 54 ++++++-
.../commons/text/lookup/XmlStringLookupTest.java | 109 ++++++++++----
.../document.dtd} | 5 +-
.../documents}/document-entity-ref.xml | 6 +-
.../documents}/document-external-dtd.xml | 4 +-
.../documents/document-remote-entity.xml} | 6 +-
.../text => XmlStringLookup}/xml-entity.txt | 0
.../apache/commons/text/document-external-dtd.dtd | 20 ---
15 files changed, 512 insertions(+), 108 deletions(-)
diff --git a/pom.xml b/pom.xml
index 150a58e9..7e37f37b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -87,6 +87,11 @@
<artifactId>commons-lang3</artifactId>
<version>${commons.lang3.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-secure-xml</artifactId>
+ <version>1.0.0</version>
+ </dependency>
<!-- testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 043e37d2..c456633a 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -72,7 +72,10 @@ The <action> type attribute can be add,update,fix,remove.
<action type="fix" dev="ggregory" due-to="Javid Khan, Gary Gregory">Fix
ArrayIndexOutOfBoundsException in DnsStringLookup for '|' key (#765).</action>
<action type="fix" dev="ggregory" due-to="Weiki, Gary Gregory">Compare
characters by value in DamerauLevenshteinDistance (#770).</action>
<action type="fix" dev="ggregory" due-to="Jeff Lenamon, Gary
Gregory">StringMatcher.isMatch(CharSequence, int, int, int) forwards bufferEnd
as bufferStart (#768).</action>
+ <action type="fix" dev="pkarwasz" due-to="Piotr P. Karwasz, Gary
Gregory">XmlStringLookup resolves relative system identifiers against the XML
document, instead of the current working directory.</action>
<!-- ADD -->
+ <action type="add" dev="pkarwasz" due-to="Piotr P. Karwasz, Gary
Gregory">XmlStringLookup creates its XML parser and XPath factories through
Commons Secure XML.</action>
+ <action type="add" dev="pkarwasz" due-to="Piotr P. Karwasz, Gary
Gregory">XmlStringLookup resolves external DTD subsets and entities from its
Path fences.</action>
<!-- UPDATE -->
<action type="update" dev="ggregory" due-to="Gary Gregory">Bump
org.apache.commons:commons-parent from 93 to 105.</action>
<action type="update" dev="ggregory" due-to="Gary Gregory">Bump the
level of test coverage checks.</action>
diff --git a/src/main/java/org/apache/commons/text/lookup/PathFence.java
b/src/main/java/org/apache/commons/text/lookup/PathFence.java
index 0bd884a5..c914fa1d 100644
--- a/src/main/java/org/apache/commons/text/lookup/PathFence.java
+++ b/src/main/java/org/apache/commons/text/lookup/PathFence.java
@@ -94,7 +94,7 @@ final class PathFence {
*/
Path apply(final String fileName) {
final Path path = Paths.get(fileName);
- if (roots.isEmpty()) {
+ if (isEmpty()) {
return path;
}
final Path pathAbs = normalize(path);
@@ -105,6 +105,15 @@ final class PathFence {
throw new IllegalArgumentException(String.format("[%s] -> [%s] not in
the fence %s", fileName, pathAbs, roots));
}
+ /**
+ * Tests whether this fence has no roots, in which case {@link
#apply(String)} lets every path through.
+ *
+ * @return whether this fence has no roots.
+ */
+ boolean isEmpty() {
+ return roots.isEmpty();
+ }
+
private Path normalize(final Path path) {
return path.toAbsolutePath().normalize();
}
diff --git
a/src/main/java/org/apache/commons/text/lookup/PathFenceResolver.java
b/src/main/java/org/apache/commons/text/lookup/PathFenceResolver.java
new file mode 100644
index 00000000..3458017d
--- /dev/null
+++ b/src/main/java/org/apache/commons/text/lookup/PathFenceResolver.java
@@ -0,0 +1,107 @@
+/*
+ * 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
+ *
+ * https://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.commons.text.lookup;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Objects;
+
+import org.apache.commons.lang3.StringUtils;
+import org.xml.sax.EntityResolver;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXParseException;
+
+/**
+ * Resolves the external resources of an XML document from within a {@link
PathFence}.
+ * <p>
+ * A document fetched from a fence may legitimately reference a follow-up
resource, an external DTD subset or an external entity, living in the same
roots.
+ * </p>
+ *
+ * Keep package-private.
+ */
+final class PathFenceResolver implements EntityResolver {
+
+ /**
+ * Converts a system identifier to a local Path.
+ *
+ * @param systemId An absolute 'file:' URI, may be null.
+ * @return A Path, or null if the system identifier is empty.
+ * @throws SAXException If the system identifier is not a valid `file:`
URI.
+ */
+ private static Path toPath(final String systemId) throws SAXException {
+ if (StringUtils.isEmpty(systemId)) {
+ return null;
+ }
+ try {
+ final URI uri = new URI(systemId);
+ if (!"file".equals(uri.getScheme())) {
+ throw new SAXParseException("Failed to read external document
'" + systemId + "', because only 'file' access is allowed.", null, systemId, -1,
+ -1);
+ }
+ return Paths.get(uri);
+ } catch (final URISyntaxException | IllegalArgumentException e) {
+ throw new SAXParseException("Failed to read external document '" +
systemId + "'.", null, systemId, -1, -1, e);
+ }
+ }
+
+ /**
+ * A fence is made of Paths guarding Path resolution.
+ */
+ private final PathFence fence;
+
+ /**
+ * Constructs a new instance.
+ *
+ * @param fence The fence guarding Path resolution.
+ */
+ PathFenceResolver(final PathFence fence) {
+ this.fence = Objects.requireNonNull(fence, "fence");
+ }
+
+ /**
+ * Resolves an external resource, opting it in when it resolves within our
fence.
+ *
+ * @param publicId The public identifier, may be null.
+ * @param systemId The system identifier, already absolutized by the
caller, may be null.
+ * @return An InputSource on the resource.
+ * @throws SAXException if the system identifier names a file outside our
fence.
+ * @throws IOException if the resource cannot be read.
+ */
+ @Override
+ public InputSource resolveEntity(final String publicId, final String
systemId) throws SAXException, IOException {
+ final Path path = toPath(systemId);
+ if (path == null) {
+ return null;
+ }
+ final Path fenced;
+ try {
+ fenced = fence.apply(path.toString());
+ } catch (final IllegalArgumentException e) {
+ throw new SAXException(e);
+ }
+ final InputSource inputSource = new
InputSource(Files.newInputStream(fenced));
+ inputSource.setPublicId(publicId);
+ inputSource.setSystemId(systemId);
+ return inputSource;
+ }
+}
diff --git
a/src/main/java/org/apache/commons/text/lookup/StringLookupFactory.java
b/src/main/java/org/apache/commons/text/lookup/StringLookupFactory.java
index 3feb4fe5..af41f0cc 100644
--- a/src/main/java/org/apache/commons/text/lookup/StringLookupFactory.java
+++ b/src/main/java/org/apache/commons/text/lookup/StringLookupFactory.java
@@ -30,6 +30,7 @@ import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;
+import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPathFactory;
@@ -1614,8 +1615,19 @@ public final class StringLookupFactory {
/**
* Returns an XML StringLookup instance.
* <p>
- * If this factory was built using {@link Builder#setFences(Path...)},
then the string lookup is fenced and will throw an {@link
IllegalArgumentException}
- * if a lookup causes a path to resolve outside of these fences.
Otherwise, the result is unfenced to preserved behavior from previous versions.
+ * XML files are parsed using Commons Secure XML, which enables the {@link
XMLConstants#FEATURE_SECURE_PROCESSING} processing limits and ignores external
+ * DTD subsets and entities by default.
+ * </p>
+ * <p>
+ * If this factory was built using {@link Builder#setFences}, then the
string lookup is <strong>fenced</strong> and additional features are available:
+ * </p>
+ * <ul>
+ * <li>External DTD subsets and entities are enabled.</li>
+ * <li>The document and any external DTD subset or entity it references
are read from within those fences, and a path resolving outside them throws an
+ * {@link IllegalArgumentException}.</li>
+ * </ul>
+ * <p>
+ * Every resource inside the fences is considered trusted, so fence only
directories whose contents you control.
* </p>
* <p>
* We looks up values in an XML document in the format {@code
"DocumentPath:XPath"}.
@@ -1627,10 +1639,6 @@ public final class StringLookupFactory {
* <li>{@code "com/domain/document.xml:/path/to/node"}</li>
* </ul>
* <p>
- * Secure processing is enabled by default and can be overridden with the
system property {@code "XmlStringLookup.secure"} set to {@code false}. The
secure
- * boolean String parsing follows the syntax defined by {@link
Boolean#parseBoolean(String)}.
- * </p>
- * <p>
* Using a {@link StringLookup} from the {@link StringLookupFactory}:
* </p>
*
@@ -1652,14 +1660,25 @@ public final class StringLookupFactory {
* @since 1.5
*/
public StringLookup xmlStringLookup() {
- return fences != null ?
xmlStringLookup(XmlStringLookup.DEFAULT_XPATH_FEATURES, fences) :
XmlStringLookup.INSTANCE;
+ return fences != null ? xmlStringLookup(Collections.emptyMap(),
fences) : XmlStringLookup.INSTANCE;
}
/**
* Returns an XML StringLookup instance.
* <p>
- * If this factory was built using {@link Builder#setFences(Path...)},
then the string lookup is fenced and will throw an {@link
IllegalArgumentException}
- * if a lookup causes a path to resolve outside of these fences.
Otherwise, the result is unfenced to preserved behavior from previous versions.
+ * XML files are parsed using Commons Secure XML, which enables the {@link
XMLConstants#FEATURE_SECURE_PROCESSING} processing limits, which
+ * {@code factoryFeatures} can turn back off, and ignores external DTD
subsets and entities by default.
+ * </p>
+ * <p>
+ * If this factory was built using {@link Builder#setFences}, then the
string lookup is <strong>fenced</strong> and additional features are available:
+ * </p>
+ * <ul>
+ * <li>External DTD subsets and entities are enabled.</li>
+ * <li>The document and any external DTD subset or entity it references
are read from within those fences, and a path resolving outside them throws an
+ * {@link IllegalArgumentException}.</li>
+ * </ul>
+ * <p>
+ * Every resource inside the fences is considered trusted, so fence only
directories whose contents you control.
* </p>
* <p>
* We looks up values in an XML document in the format {@code
"]DocumentPath:XPath"}.
@@ -1671,10 +1690,6 @@ public final class StringLookupFactory {
* <li>{@code "com/domain/document.xml:/path/to/node"}</li>
* </ul>
* <p>
- * Secure processing is enabled by default and can be overridden with the
system property {@code "XmlStringLookup.secure"} set to {@code false}. The
secure
- * boolean String parsing follows the syntax defined by {@link
Boolean#parseBoolean(String)}.
- * </p>
- * <p>
* Using a {@link StringLookup} from the {@link StringLookupFactory}:
* </p>
*
@@ -1705,8 +1720,19 @@ public final class StringLookupFactory {
/**
* Returns a fenced XML StringLookup instance.
* <p>
- * If this factory was built using {@link Builder#setFences(Path...)},
then the string lookup is fenced and will throw an {@link
IllegalArgumentException}
- * if a lookup causes a path to resolve outside of these fences.
Otherwise, the result is unfenced to preserved behavior from previous versions.
+ * XML files are parsed using Commons Secure XML, which enables the {@link
XMLConstants#FEATURE_SECURE_PROCESSING} processing limits, which
+ * {@code factoryFeatures} can turn back off, and ignores external DTD
subsets and entities by default.
+ * </p>
+ * <p>
+ * If the {@code fences} argument is not empty, then the string lookup is
<strong>fenced</strong> and additional features are available:
+ * </p>
+ * <ul>
+ * <li>External DTD subsets and entities are enabled.</li>
+ * <li>The document and any external DTD subset or entity it references
are read from within those fences, and a path resolving outside them throws an
+ * {@link IllegalArgumentException}.</li>
+ * </ul>
+ * <p>
+ * Every resource inside the fences is considered trusted, so fence only
directories whose contents you control.
* </p>
* <p>
* We looks up values in an XML document in the format {@code
"DocumentPath:XPath"}.
@@ -1718,9 +1744,6 @@ public final class StringLookupFactory {
* <li>{@code "com/domain/document.xml:/path/to/node"}</li>
* </ul>
* <p>
- * Secure processing is enabled by default and can be overridden with this
constructor.
- * </p>
- * <p>
* Using a {@link StringLookup} from the {@link StringLookupFactory}
fenced by the current directory ({@code Paths.get("")}):
* </p>
*
diff --git a/src/main/java/org/apache/commons/text/lookup/XmlStringLookup.java
b/src/main/java/org/apache/commons/text/lookup/XmlStringLookup.java
index 624c7d01..1fb3f0c6 100644
--- a/src/main/java/org/apache/commons/text/lookup/XmlStringLookup.java
+++ b/src/main/java/org/apache/commons/text/lookup/XmlStringLookup.java
@@ -20,16 +20,19 @@ package org.apache.commons.text.lookup;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.HashMap;
+import java.util.Collections;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPathFactory;
import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.xml.secure.SecureDocumentBuilderFactory;
+import org.apache.commons.xml.secure.SecureXPathFactory;
import org.w3c.dom.Document;
/**
@@ -41,7 +44,19 @@ import org.w3c.dom.Document;
* <li>{@code "com/domain/document.xml:/path/to/node"}</li>
* </ul>
* <p>
- * Secure processing is enabled by default and can be overridden with {@link
StringLookupFactory#xmlStringLookup(Map, Path...)}.
+ * DOM parser and XPath factory features can be set with {@link
StringLookupFactory#xmlStringLookup(Map)}.
+ * </p>
+ * <p>
+ * Documents are parsed through Apache Commons Secure XML, which secures two
separate aspects:
+ * </p>
+ * <ul>
+ * <li>Processing limits, such as the number of entity expansions, come from
{@link XMLConstants#FEATURE_SECURE_PROCESSING}. That feature is enabled by
+ * default, and the feature maps above can turn it off.</li>
+ * <li>External resource fetching, that is external DTD subsets and external
entities, is ignored by default and can <strong>not</strong> be enabled via the
+ * feature map. To enable external resource fetching, provide a non-empty list
of <strong>fences</strong>.</li>
+ * </ul>
+ * <p>
+ * Every resource inside the fences is considered trusted, so fence only
directories whose contents you control.
* </p>
*
* @since 1.5
@@ -54,28 +69,12 @@ final class XmlStringLookup extends
AbstractPathFencedLookup {
private static final int KEY_PARTS_LEN = 2;
/**
- * Defines default XPath factory features.
- */
- static final Map<String, Boolean> DEFAULT_XPATH_FEATURES;
-
- /**
- * Defines default XML factory features.
- */
- static final Map<String, Boolean> DEFAULT_XML_FEATURES;
- static {
- DEFAULT_XPATH_FEATURES = new HashMap<>(1);
- DEFAULT_XPATH_FEATURES.put(XMLConstants.FEATURE_SECURE_PROCESSING,
Boolean.TRUE);
- DEFAULT_XML_FEATURES = new HashMap<>(1);
- DEFAULT_XML_FEATURES.put(XMLConstants.FEATURE_SECURE_PROCESSING,
Boolean.TRUE);
- }
-
- /**
- * Defines the singleton for this class with secure processing enabled by
default.
+ * Defines the singleton for this class, which sets no parser or XPath
factory feature and has no fence.
* <p>
- * Secure processing is enabled by default and can be overridden with
{@link StringLookupFactory#xmlStringLookup(Map, Path...)}.
+ * Use {@link StringLookupFactory#xmlStringLookup(Map, Path...)} to set
features and fences; without any fence, external resources are ignored.
* </p>
*/
- static final XmlStringLookup INSTANCE = new
XmlStringLookup(DEFAULT_XML_FEATURES, DEFAULT_XPATH_FEATURES, (Path[]) null);
+ static final XmlStringLookup INSTANCE = new
XmlStringLookup(Collections.emptyMap(), Collections.emptyMap(), (Path[]) null);
/**
* Defines XPath factory features.
@@ -98,7 +97,7 @@ final class XmlStringLookup extends AbstractPathFencedLookup {
XmlStringLookup(final Map<String, Boolean> xmlFactoryFeatures, final
Map<String, Boolean> xPathFactoryFeatures, final Path... fences) {
super(fences);
this.xmlFactoryFeatures = Objects.requireNonNull(xmlFactoryFeatures,
"xmlFactoryFeatures");
- this.xPathFactoryFeatures =
Objects.requireNonNull(xPathFactoryFeatures, "xPathFfactoryFeatures");
+ this.xPathFactoryFeatures =
Objects.requireNonNull(xPathFactoryFeatures, "xPathFactoryFeatures");
}
/**
@@ -110,7 +109,14 @@ final class XmlStringLookup extends
AbstractPathFencedLookup {
* <li>{@code "com/domain/document.xml:/path/to/node"}</li>
* </ul>
* <p>
- * Secure processing is enabled by default and can be overridden with
{@link StringLookupFactory#xmlStringLookup(Map, Path...)}.
+ * The document is parsed through Apache Commons Secure XML:
+ * </p>
+ * <ul>
+ * <li>Processing limits are governed by {@link
XMLConstants#FEATURE_SECURE_PROCESSING}, which is enabled by default.</li>
+ * <li>External DTD subsets and external entities are resolved only from
the fences guarding this lookup if these are not empty.</li>
+ * </ul>
+ * <p>
+ * Every resource inside the fences is considered trusted, so fence only
directories whose contents you control.
* </p>
*
* @param key The key to be looked up, may be null.
@@ -128,14 +134,21 @@ final class XmlStringLookup extends
AbstractPathFencedLookup {
}
final String documentPath = keys[0];
final String xpath = StringUtils.substringAfterLast(key, SPLIT_CH);
- final DocumentBuilderFactory dbFactory =
DocumentBuilderFactory.newInstance();
+ final DocumentBuilderFactory dbFactory =
SecureDocumentBuilderFactory.newInstance();
try {
for (final Entry<String, Boolean> p :
xmlFactoryFeatures.entrySet()) {
dbFactory.setFeature(p.getKey(), p.getValue());
}
- try (InputStream inputStream =
Files.newInputStream(getPath(documentPath))) {
- final Document doc =
dbFactory.newDocumentBuilder().parse(inputStream);
- final XPathFactory xpFactory = XPathFactory.newInstance();
+ final Path documentFile = getPath(documentPath);
+ try (InputStream inputStream = Files.newInputStream(documentFile))
{
+ final DocumentBuilder documentBuilder =
dbFactory.newDocumentBuilder();
+ // If the fence is not empty, opt-in follow-up resources
fetched from the fence.
+ if (!fence.isEmpty()) {
+ documentBuilder.setEntityResolver(new
PathFenceResolver(fence));
+ }
+ // Parsing with the document's own URI gives relative system
identifiers a base URI to resolve against, as XML requires.
+ final Document doc = documentBuilder.parse(inputStream,
documentFile.toUri().toString());
+ final XPathFactory xpFactory =
SecureXPathFactory.newInstance();
for (final Entry<String, Boolean> p :
xPathFactoryFeatures.entrySet()) {
xpFactory.setFeature(p.getKey(), p.getValue());
}
diff --git
a/src/test/java/org/apache/commons/text/lookup/PathFenceResolverTest.java
b/src/test/java/org/apache/commons/text/lookup/PathFenceResolverTest.java
new file mode 100644
index 00000000..51f1e6d7
--- /dev/null
+++ b/src/test/java/org/apache/commons/text/lookup/PathFenceResolverTest.java
@@ -0,0 +1,164 @@
+/*
+ * 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
+ *
+ * https://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.commons.text.lookup;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+/**
+ * Tests {@link PathFenceResolver}.
+ */
+class PathFenceResolverTest {
+
+ private static final String DATA = "Hello World!";
+ private static final Path CURRENT_PATH = Paths.get(StringUtils.EMPTY); //
NOT "."
+
+ /**
+ * Reads an InputSource byte stream as UTF-8.
+ */
+ private static String read(final InputSource inputSource) throws
IOException {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (InputStream in = inputSource.getByteStream()) {
+ final byte[] buffer = new byte[1024];
+ for (int len = in.read(buffer); len != -1; len = in.read(buffer)) {
+ out.write(buffer, 0, len);
+ }
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ }
+
+ private static PathFenceResolver resolver(final Path... roots) {
+ return new
PathFenceResolver(PathFence.builder().setRoots(roots).get());
+ }
+
+ /**
+ * Writes a file and gives back its path relative to the working directory.
+ */
+ private static Path write(final Path path, final String content) throws
IOException {
+ Files.createDirectories(path.getParent());
+ Files.write(path, content.getBytes(StandardCharsets.UTF_8));
+ return CURRENT_PATH.toAbsolutePath().relativize(path.toAbsolutePath());
+ }
+
+ @Test
+ void testAbsentFileInFence(@TempDir final Path tempDir) {
+ assertThrows(IOException.class, () ->
resolver(tempDir).resolveEntity(null,
tempDir.resolve("absent.txt").toUri().toString()));
+ }
+
+ @Test
+ void testDrivePathIsRefused(@TempDir final Path tempDir) {
+ // A bare Windows drive path is not a 'file:' URI, so it is refused
like any other non-file system identifier.
+ assertThrows(SAXException.class, () ->
resolver(tempDir).resolveEntity(null, "C:/does-not-matter.txt"));
+ }
+
+ @Test
+ void testEmptySystemId(@TempDir final Path tempDir) throws Exception {
+ assertNull(resolver(tempDir).resolveEntity(null, StringUtils.EMPTY));
+ }
+
+ @Test
+ void testFileUrlInFence(@TempDir final Path tempDir) throws Exception {
+ final Path target = write(tempDir.resolve("entity.txt"), DATA);
+ final InputSource inputSource =
resolver(tempDir).resolveEntity("publicId", target.toUri().toString());
+ assertNotNull(inputSource);
+ assertEquals("publicId", inputSource.getPublicId());
+ assertEquals(DATA, read(inputSource));
+ }
+
+ @Test
+ void testFileUrlOutsideFence(@TempDir final Path tempDir) throws Exception
{
+ final Path target = write(tempDir.resolve("out/entity.txt"), DATA);
+ final SAXException e = assertThrows(SAXException.class, () ->
resolver(tempDir.resolve("in")).resolveEntity(null, target.toUri().toString()));
+ assertInstanceOf(IllegalArgumentException.class, e.getCause());
+ }
+
+ @Test
+ void testMalformedSystemIdIsRefused(@TempDir final Path tempDir) {
+ // An unencoded space is illegal in a URI, so the identifier never
becomes a path.
+ final SAXException e = assertThrows(SAXException.class, () ->
resolver(tempDir).resolveEntity(null, "file:/a b/entity.txt"));
+ assertInstanceOf(URISyntaxException.class, e.getCause());
+ }
+
+ @Test
+ void testNullFence() {
+ assertThrows(NullPointerException.class, () -> new
PathFenceResolver(null));
+ }
+
+ @Test
+ void testNullSystemId(@TempDir final Path tempDir) throws Exception {
+ assertNull(resolver(tempDir).resolveEntity(null, null));
+ }
+
+ @Test
+ void testPercentEncodedFileUrlInFence(@TempDir final Path tempDir) throws
Exception {
+ final Path target = write(tempDir.resolve("na me.txt"), DATA);
+ final InputSource inputSource = resolver(tempDir).resolveEntity(null,
target.toUri().toString());
+ assertNotNull(inputSource);
+ assertEquals(DATA, read(inputSource));
+ }
+
+ @Test
+ void testRelativeSystemIdIsRefused(@TempDir final Path tempDir) throws
Exception {
+ // XmlStringLookup parses with the document's own URI, so a relative
system identifier always reaches us absolutized.
+ final Path target = write(tempDir.resolve("entity.txt"), DATA);
+ assertThrows(SAXException.class, () ->
resolver(tempDir).resolveEntity(null, target.toString().replace('\\', '/')));
+ }
+
+ @Test
+ void testRemoteSystemIdIsRefused(@TempDir final Path tempDir) {
+ // A remote reference names no path, so the fence can never opt it in.
+ assertThrows(SAXException.class, () ->
resolver(tempDir).resolveEntity(null, "http://localhost:1/entity.txt"));
+ assertThrows(SAXException.class, () ->
resolver(tempDir).resolveEntity(null, "https://localhost:1/entity.txt"));
+ assertThrows(SAXException.class, () ->
resolver(tempDir).resolveEntity(null, "ftp://localhost:1/entity.txt"));
+ assertThrows(SAXException.class, () ->
resolver(tempDir).resolveEntity(null, "jar:file:/lib.jar!/entity.txt"));
+ }
+
+ @Test
+ void testSystemIdIsEchoed(@TempDir final Path tempDir) throws Exception {
+ final Path target = write(tempDir.resolve("entity.txt"), DATA);
+ final String systemId = target.toUri().toString();
+ assertEquals(systemId, resolver(tempDir).resolveEntity(null,
systemId).getSystemId());
+ }
+
+ @Test
+ void testUnconvertibleFileUrlIsRefused(@TempDir final Path tempDir) {
+ // A well-formed 'file:' URI that names no path: opaque, and carrying
a fragment.
+ final SAXException opaque = assertThrows(SAXException.class, () ->
resolver(tempDir).resolveEntity(null, "file:entity.txt"));
+ assertInstanceOf(IllegalArgumentException.class, opaque.getCause());
+ final SAXException fragment = assertThrows(SAXException.class, () ->
resolver(tempDir).resolveEntity(null, "file:/entity.txt#frag"));
+ assertInstanceOf(IllegalArgumentException.class, fragment.getCause());
+ }
+}
diff --git
a/src/test/java/org/apache/commons/text/lookup/StringLookupFactoryTest.java
b/src/test/java/org/apache/commons/text/lookup/StringLookupFactoryTest.java
index e0fdf8ab..67ab3cb8 100644
--- a/src/test/java/org/apache/commons/text/lookup/StringLookupFactoryTest.java
+++ b/src/test/java/org/apache/commons/text/lookup/StringLookupFactoryTest.java
@@ -22,6 +22,9 @@ import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
@@ -33,7 +36,6 @@ import javax.xml.XMLConstants;
import org.junit.jupiter.api.Test;
import org.junitpioneer.jupiter.DefaultLocale;
-import org.junitpioneer.jupiter.SetSystemProperty;
/**
* Tests {@link StringLookupFactory}.
@@ -291,16 +293,56 @@ class StringLookupFactoryTest {
XmlStringLookupTest.assertLookup(stringLookupFactory.xmlStringLookup(new
HashMap<>()));
}
+ @Test
+ void testXmlStringLookupEmptyPaths() {
+
XmlStringLookupTest.assertLookup(StringLookupFactory.INSTANCE.xmlStringLookup(XmlStringLookupTest.EMPTY_MAP,
new Path[0]));
+ }
+
@Test
void testXmlStringLookupExternalEntityOff() {
- assertThrows(IllegalArgumentException.class,
- () ->
StringLookupFactory.INSTANCE.xmlStringLookup().apply(XmlStringLookupTest.DOC_DIR
+ "document-entity-ref.xml:/document/content"));
+ XmlStringLookupTest.assertDoesNotLeak(
+ () ->
StringLookupFactory.INSTANCE.xmlStringLookup().apply(XmlStringLookupTest.FENCE_DOCS
+ "document-entity-ref.xml:/document/content"),
+ XmlStringLookupTest.DATA);
}
@Test
- @SetSystemProperty(key = "XmlStringLookup.secure", value = "false")
void testXmlStringLookupExternalEntityOn() {
- final String key = XmlStringLookupTest.DOC_DIR +
"document-entity-ref.xml:/document/content";
- assertEquals(XmlStringLookupTest.DATA,
StringLookupFactory.INSTANCE.xmlStringLookup(XmlStringLookupTest.EMPTY_MAP).apply(key).trim());
+ // A fence opts the external entity in: it resolves inside the fence,
one directory above the document.
+ final String key = XmlStringLookupTest.FENCE_DOCS +
"document-entity-ref.xml:/document/content";
+ final StringLookup lookup =
StringLookupFactory.INSTANCE.xmlStringLookup(XmlStringLookupTest.EMPTY_MAP,
XmlStringLookupTest.FENCE_ROOT);
+ assertEquals(XmlStringLookupTest.DATA, lookup.apply(key).trim());
+ }
+
+ @Test
+ void testXmlStringLookupMultiplePaths() {
+ final Path documentPath = Paths.get(XmlStringLookupTest.DOC_DIR);
+ final Path otherPath = Paths.get("src/main");
+
XmlStringLookupTest.assertLookup(StringLookupFactory.INSTANCE.xmlStringLookup(XmlStringLookupTest.EMPTY_MAP,
otherPath, documentPath));
+
XmlStringLookupTest.assertLookup(StringLookupFactory.INSTANCE.xmlStringLookup(XmlStringLookupTest.EMPTY_MAP,
documentPath, otherPath));
}
+
+ @Test
+ void testXmlStringLookupNullFeatures() {
+ assertThrows(NullPointerException.class, () ->
StringLookupFactory.INSTANCE.xmlStringLookup(null,
Paths.get(XmlStringLookupTest.DOC_DIR)));
+ }
+
+ @Test
+ void testXmlStringLookupNullPaths() {
+
XmlStringLookupTest.assertLookup(StringLookupFactory.INSTANCE.xmlStringLookup(XmlStringLookupTest.EMPTY_MAP,
(Path[]) null));
+ }
+
+ @Test
+ void testXmlStringLookupOutsidePaths() {
+ final StringLookup lookup =
StringLookupFactory.INSTANCE.xmlStringLookup(XmlStringLookupTest.EMPTY_MAP,
Paths.get("src/main"));
+ assertThrows(IllegalArgumentException.class, () ->
lookup.apply(XmlStringLookupTest.DOC_DIR + "document.xml:/root/path/to/node"));
+ }
+
+ @Test
+ void testXmlStringLookupPaths() {
+ final Path documentPath = Paths.get(XmlStringLookupTest.DOC_DIR);
+ final Map<String, Boolean> features =
Collections.singletonMap(XMLConstants.FEATURE_SECURE_PROCESSING, Boolean.TRUE);
+
XmlStringLookupTest.assertLookup(StringLookupFactory.INSTANCE.xmlStringLookup(features,
documentPath));
+
XmlStringLookupTest.assertLookup(StringLookupFactory.INSTANCE.xmlStringLookup(XmlStringLookupTest.EMPTY_MAP,
documentPath));
+ }
+
}
diff --git
a/src/test/java/org/apache/commons/text/lookup/XmlStringLookupTest.java
b/src/test/java/org/apache/commons/text/lookup/XmlStringLookupTest.java
index 862fdf81..13c3ac24 100644
--- a/src/test/java/org/apache/commons/text/lookup/XmlStringLookupTest.java
+++ b/src/test/java/org/apache/commons/text/lookup/XmlStringLookupTest.java
@@ -23,19 +23,21 @@ import static
org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
+import java.util.function.Supplier;
import javax.xml.XMLConstants;
import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.text.StringSubstitutor;
import org.junit.jupiter.api.Test;
-import org.junitpioneer.jupiter.SetSystemProperty;
/**
* Tests {@link XmlStringLookup}.
@@ -44,11 +46,46 @@ class XmlStringLookupTest {
static final String DATA = "Hello World!";
static final Map<String, Boolean> EMPTY_MAP = Collections.emptyMap();
+ static final String DOC_DIR =
"src/test/resources/org/apache/commons/text/";
private static final Path CURRENT_PATH = Paths.get(StringUtils.EMPTY); //
NOT "."
private static final Path ABSENT_PATH = Paths.get("does not exist at all");
- static final String DOC_DIR =
"src/test/resources/org/apache/commons/text/";
private static final String DOC_RELATIVE = DOC_DIR + "document.xml";
private static final String DOC_ROOT = "/document.xml";
+ private static final String DTD_DATA = "This is an external entity.";
+ /** Holds the files the fixture documents reference. */
+ private static final String FENCE_DIR =
"src/test/resources/XmlStringLookup/";
+ static final Path FENCE_ROOT = Paths.get(FENCE_DIR);
+ /** Holds the documents themselves, one directory below the files they
reference. */
+ static final String FENCE_DOCS = FENCE_DIR + "documents/";
+ private static final Path FENCE_DOCS_PATH = Paths.get(FENCE_DOCS);
+
+ /**
+ * Asserts external content does not leak
+ */
+ static void assertDoesNotLeak(final Supplier<String> lookup, final String
external) {
+ final String result = lookup.get();
+ assertNotNull(result, "lookup returned null");
+ assertFalse(result.contains(external), () -> "external content leaked:
" + result);
+ }
+
+ /**
+ * Builds a substitutor whose {@code xml} lookup is fenced. {@link
StringSubstitutor#createInterpolator()} shares one
+ * {@link InterpolatorStringLookup} instance JVM-wide, so its lookup map
must not be mutated here.
+ */
+ private static StringSubstitutor fencedInterpolator(final Path... fences) {
+ final Map<String, StringLookup> stringLookupMap = new HashMap<>(1);
+ stringLookupMap.put(StringLookupFactory.KEY_XML,
StringLookupFactory.INSTANCE.xmlStringLookup(EMPTY_MAP, fences));
+ return new
StringSubstitutor(StringLookupFactory.INSTANCE.interpolatorStringLookup(stringLookupMap,
null, false));
+ }
+
+ /**
+ * Asserts the lookup is refused because {@code fileName}, not the
document itself, resolves outside the fence.
+ */
+ static void assertRefusesOutsideFence(final Supplier<String> lookup, final
String fileName) {
+ final IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, lookup::get);
+ final String message = ExceptionUtils.getRootCauseMessage(e);
+ assertTrue(message.contains(fileName) && message.contains("not in the
fence"), () -> "unexpected refusal: " + message);
+ }
static void assertLookup(final StringLookup xmlStringLookup) {
assertNotNull(xmlStringLookup);
@@ -62,62 +99,80 @@ class XmlStringLookupTest {
assertThrows(IllegalArgumentException.class, () ->
XmlStringLookup.INSTANCE.apply("docName"));
}
+ @Test
+ void testExternalDtdOff() {
+ assertDoesNotLeak(
+ () -> new XmlStringLookup(EMPTY_MAP,
EMPTY_MAP).apply(FENCE_DOCS + "document-external-dtd.xml:/document/content"),
DTD_DATA);
+ }
+
@Test
void testExternalEntityOff() {
- assertThrows(IllegalArgumentException.class,
- () -> new
XmlStringLookup(XmlStringLookup.DEFAULT_XML_FEATURES, EMPTY_MAP).apply(DOC_DIR
+ "document-entity-ref.xml:/document/content"));
+ assertDoesNotLeak(
+ () -> new XmlStringLookup(EMPTY_MAP,
EMPTY_MAP).apply(FENCE_DOCS + "document-entity-ref.xml:/document/content"),
DATA);
}
@Test
- void testExternalEntityOn() {
- final String key = DOC_DIR +
"document-entity-ref.xml:/document/content";
- assertEquals(DATA, new XmlStringLookup(EMPTY_MAP,
EMPTY_MAP).apply(key).trim());
- assertEquals(DATA, new XmlStringLookup(EMPTY_MAP,
XmlStringLookup.DEFAULT_XPATH_FEATURES).apply(key).trim());
+ void testFenceAllowsExternalDtd() {
+ // The fence covers the document and the DTD it references in the
parent directory.
+ assertEquals(DTD_DATA,
+ new XmlStringLookup(EMPTY_MAP, EMPTY_MAP,
FENCE_ROOT).apply(FENCE_DOCS +
"document-external-dtd.xml:/document/content").trim());
}
@Test
- void testInterpolatorExternalDtdOff() {
- final StringSubstitutor stringSubstitutor =
StringSubstitutor.createInterpolator();
- assertThrows(IllegalArgumentException.class, () ->
stringSubstitutor.replace("${xml:" + DOC_DIR +
"document-external-dtd.xml:/document/content}"));
+ void testFenceAllowsExternalEntity() {
+ // The fence covers the document and the entity it references in the
parent directory.
+ assertEquals(DATA, new XmlStringLookup(EMPTY_MAP, EMPTY_MAP,
FENCE_ROOT).apply(FENCE_DOCS +
"document-entity-ref.xml:/document/content").trim());
}
@Test
- @SetSystemProperty(key = "javax.xml.accessExternalDTD", value = "file")
- void testInterpolatorExternalDtdOn() {
- final StringSubstitutor stringSubstitutor =
StringSubstitutor.createInterpolator();
- assertEquals("This is an external entity.",
stringSubstitutor.replace("${xml:" + DOC_DIR +
"document-external-dtd.xml:/document/content}").trim());
+ void testFenceBlocksExternalDtdOutsideFence() {
+ // The fence covers the document only, so its DTD in the parent
directory is out of reach.
+ final XmlStringLookup lookup = new XmlStringLookup(EMPTY_MAP,
EMPTY_MAP, FENCE_DOCS_PATH);
+ assertRefusesOutsideFence(() -> lookup.apply(FENCE_DOCS +
"document-external-dtd.xml:/document/content"), "document.dtd");
}
@Test
- void testInterpolatorExternalEntityOff() {
- final StringSubstitutor stringSubstitutor =
StringSubstitutor.createInterpolator();
- assertThrows(IllegalArgumentException.class, () ->
stringSubstitutor.replace("${xml:" + DOC_DIR +
"document-entity-ref.xml:/document/content}"));
+ void testFenceBlocksExternalEntityOutsideFence() {
+ // The fence covers the document only, so its entity in the parent
directory is out of reach.
+ final XmlStringLookup lookup = new XmlStringLookup(EMPTY_MAP,
EMPTY_MAP, FENCE_DOCS_PATH);
+ assertRefusesOutsideFence(() -> lookup.apply(FENCE_DOCS +
"document-entity-ref.xml:/document/content"), "xml-entity.txt");
}
@Test
- @SetSystemProperty(key = "javax.xml.accessExternalDTD", value = "file")
- void testInterpolatorExternalEntityOffOverride() {
- final StringSubstitutor stringSubstitutor =
StringSubstitutor.createInterpolator();
- assertEquals(DATA, stringSubstitutor.replace("${xml:" + DOC_DIR +
"document-entity-ref.xml:/document/content}").trim());
+ void testFenceBlocksRemoteEntity() {
+ // A remote reference names no path, so no fence can ever opt it in.
+ final XmlStringLookup lookup = new XmlStringLookup(EMPTY_MAP,
EMPTY_MAP, FENCE_ROOT);
+ assertThrows(IllegalArgumentException.class, () ->
lookup.apply(FENCE_DOCS + "document-remote-entity.xml:/document/content"));
+ }
+
+ @Test
+ void testFencedInterpolatorExternalDtdOn() {
+ final StringSubstitutor stringSubstitutor =
fencedInterpolator(FENCE_ROOT);
+ assertEquals(DTD_DATA, stringSubstitutor.replace("${xml:" + FENCE_DOCS
+ "document-external-dtd.xml:/document/content}").trim());
}
@Test
- void testInterpolatorExternalEntityOn() {
+ void testFencedInterpolatorExternalEntityOn() {
+ final StringSubstitutor stringSubstitutor =
fencedInterpolator(FENCE_ROOT);
+ assertEquals(DATA, stringSubstitutor.replace("${xml:" + FENCE_DOCS +
"document-entity-ref.xml:/document/content}").trim());
+ }
+
+ @Test
+ void testInterpolatorExternalDtdOff() {
final StringSubstitutor stringSubstitutor =
StringSubstitutor.createInterpolator();
- assertThrows(IllegalArgumentException.class, () ->
stringSubstitutor.replace("${xml:" + DOC_DIR +
"document-entity-ref.xml:/document/content}"));
+ assertDoesNotLeak(() -> stringSubstitutor.replace("${xml:" +
FENCE_DOCS + "document-external-dtd.xml:/document/content}"), DTD_DATA);
}
@Test
- void testInterpolatorExternalEntityOnOverride() {
+ void testInterpolatorExternalEntityOff() {
final StringSubstitutor stringSubstitutor =
StringSubstitutor.createInterpolator();
- assertThrows(IllegalArgumentException.class, () ->
stringSubstitutor.replace("${xml:" + DOC_DIR +
"document-entity-ref.xml:/document/content}"));
+ assertDoesNotLeak(() -> stringSubstitutor.replace("${xml:" +
FENCE_DOCS + "document-entity-ref.xml:/document/content}"), DATA);
}
@Test
void testInterpolatorSecureOnBla() {
final StringSubstitutor stringSubstitutor =
StringSubstitutor.createInterpolator();
assertThrows(IllegalArgumentException.class, () ->
stringSubstitutor.replace("${xml:" + DOC_DIR + "bla.xml:/document/content}"));
- // Using XmlStringLookup.secure=false allows the BLA to occur.
}
@Test
diff --git a/src/test/resources/org/apache/commons/text/xml-entity.txt
b/src/test/resources/XmlStringLookup/document.dtd
similarity index 84%
copy from src/test/resources/org/apache/commons/text/xml-entity.txt
copy to src/test/resources/XmlStringLookup/document.dtd
index 4f2fa758..f652db2d 100644
--- a/src/test/resources/org/apache/commons/text/xml-entity.txt
+++ b/src/test/resources/XmlStringLookup/document.dtd
@@ -14,4 +14,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-Hello World!
+<!ENTITY externalEntity "This is an external entity.">
+<!ELEMENT document (title, content)>
+<!ELEMENT title (#PCDATA)>
+<!ELEMENT content (#PCDATA)>
diff --git a/src/test/resources/org/apache/commons/text/document-entity-ref.xml
b/src/test/resources/XmlStringLookup/documents/document-entity-ref.xml
similarity index 86%
copy from src/test/resources/org/apache/commons/text/document-entity-ref.xml
copy to src/test/resources/XmlStringLookup/documents/document-entity-ref.xml
index bfd36098..37050d62 100644
--- a/src/test/resources/org/apache/commons/text/document-entity-ref.xml
+++ b/src/test/resources/XmlStringLookup/documents/document-entity-ref.xml
@@ -16,11 +16,11 @@
limitations under the License.
-->
<!DOCTYPE document [
- <!ENTITY ext SYSTEM
"src/test/resources/org/apache/commons/text/xml-entity.txt">
+ <!ENTITY ext SYSTEM "../xml-entity.txt">
]>
<document>
- <title>Example of an External Entity</title>
+ <title>External entity in the parent directory</title>
<content>
- &ext;
+ &ext;
</content>
</document>
diff --git
a/src/test/resources/org/apache/commons/text/document-external-dtd.xml
b/src/test/resources/XmlStringLookup/documents/document-external-dtd.xml
similarity index 86%
rename from src/test/resources/org/apache/commons/text/document-external-dtd.xml
rename to src/test/resources/XmlStringLookup/documents/document-external-dtd.xml
index 158f5d83..12fb1345 100644
--- a/src/test/resources/org/apache/commons/text/document-external-dtd.xml
+++ b/src/test/resources/XmlStringLookup/documents/document-external-dtd.xml
@@ -15,9 +15,9 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-<!DOCTYPE document SYSTEM
"src/test/resources/org/apache/commons/text/document-external-dtd.dtd">
+<!DOCTYPE document SYSTEM "../document.dtd">
<document>
- <title>Example of an External Entity</title>
+ <title>External DTD subset in the parent directory</title>
<content>
&externalEntity;
</content>
diff --git a/src/test/resources/org/apache/commons/text/document-entity-ref.xml
b/src/test/resources/XmlStringLookup/documents/document-remote-entity.xml
similarity index 86%
rename from src/test/resources/org/apache/commons/text/document-entity-ref.xml
rename to
src/test/resources/XmlStringLookup/documents/document-remote-entity.xml
index bfd36098..98bc5aa2 100644
--- a/src/test/resources/org/apache/commons/text/document-entity-ref.xml
+++ b/src/test/resources/XmlStringLookup/documents/document-remote-entity.xml
@@ -16,11 +16,11 @@
limitations under the License.
-->
<!DOCTYPE document [
- <!ENTITY ext SYSTEM
"src/test/resources/org/apache/commons/text/xml-entity.txt">
+ <!ENTITY ext SYSTEM "http://localhost:1/secret.txt">
]>
<document>
- <title>Example of an External Entity</title>
+ <title>Remote external entity</title>
<content>
- &ext;
+ &ext;
</content>
</document>
diff --git a/src/test/resources/org/apache/commons/text/xml-entity.txt
b/src/test/resources/XmlStringLookup/xml-entity.txt
similarity index 100%
rename from src/test/resources/org/apache/commons/text/xml-entity.txt
rename to src/test/resources/XmlStringLookup/xml-entity.txt
diff --git
a/src/test/resources/org/apache/commons/text/document-external-dtd.dtd
b/src/test/resources/org/apache/commons/text/document-external-dtd.dtd
deleted file mode 100644
index dbc9ca74..00000000
--- a/src/test/resources/org/apache/commons/text/document-external-dtd.dtd
+++ /dev/null
@@ -1,20 +0,0 @@
-<!--
- 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
-
- https://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.
--->
-<!ENTITY externalEntity "This is an external entity.">
-<!ELEMENT document (title, content)>
-<!ELEMENT title (#PCDATA)>
-<!ELEMENT content (#PCDATA)>