This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git
The following commit(s) were added to refs/heads/master by this push:
new e13dd509 Add a bound on the number of redirects the DefaultURIResolver
can do (#164)
e13dd509 is described below
commit e13dd5094df227a4f8310da67e6f40bbdc30c49d
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Thu Sep 24 07:10:20 2026 +0100
Add a bound on the number of redirects the DefaultURIResolver can do (#164)
---
README.txt | 9 ++
THREAT-MODEL.md | 20 +++-
.../schema/resolver/DefaultURIResolver.java | 101 ++++++++++++++++++-
.../src/test/java/tests/RemoteFetchBoundsTest.java | 108 +++++++++++++++++++++
4 files changed, 230 insertions(+), 8 deletions(-)
diff --git a/README.txt b/README.txt
index c5153d9b..e7a2834a 100644
--- a/README.txt
+++ b/README.txt
@@ -112,6 +112,15 @@ stack. The following JVM system property adjusts the limit:
are unaffected either way, so this is not on its own a defence against
an untrusted schema document - see the Security section below.
+ org.apache.ws.commons.schema.remote.maxRedirects
+ How many HTTP redirects one remote schema fetch may follow. The default
+ is 5. Redirects are followed by the resolver rather than by the JDK, so
+ the chain is bounded, every hop goes through the same checks as the
+ location the schema named, and the whole chain counts against the one
+ fetch deadline above. A redirect that changes scheme is refused either
+ way, so an http location cannot become a file read. Set it to 0 to
+ refuse a redirected schema location outright.
+
org.apache.ws.commons.schema.local.allowFileSystem
Whether a schema location may be read from the filesystem at all. The
default is true. Set it to false where schema documents are expected
diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md
index 9bda3e77..d94559c8 100644
--- a/THREAT-MODEL.md
+++ b/THREAT-MODEL.md
@@ -291,6 +291,7 @@ points*:
| `org.apache.ws.commons.schema.maxSchemaResolutions` system property | `1000`
*(documented: `README.txt`)* | operator-tunable per-process limit | maximum
schema documents resolved during one top-level read |
| `org.apache.ws.commons.schema.maxNestingDepth` system property | `512`
*(documented: `README.txt`)* | operator-tunable per-process limit | maximum
structural nesting depth while building the schema model, including nested
include/import/redefine document resolutions |
| `org.apache.ws.commons.schema.remote.allowNetwork` system property | `true`
*(documented: `README.txt`)* | operator opt-out for deployments with no remote
schema sets | when `false`, `DefaultURIResolver` refuses a location whose
effective scheme is `http` or `https`; local `file:` / `jar:` reads are
unaffected, so it closes the remote-fetch half of §9's SSRF disclaimer but not
the local-read half |
+| `org.apache.ws.commons.schema.remote.maxRedirects` system property | `5`
*(documented: `README.txt`)* | operator-tunable bound on one fetch's redirect
chain | `DefaultURIResolver` follows redirects itself rather than leaving them
to the JDK, so the chain is bounded, each hop is re-checked against the scheme
and authority rules, and the chain shares one fetch deadline; `0` refuses a
redirected location. A hop that changes scheme is refused |
| `org.apache.ws.commons.schema.local.allowFileSystem` system property |
`true` *(documented: `README.txt`)* | operator opt-out for deployments whose
schema documents stand alone | when `false`, `DefaultURIResolver` refuses a
`file:` location, a `jar:file:` one, and a relative location with no base URI;
with `remote.allowNetwork=false` it leaves the resolver with nothing to fetch,
which is the nearest the shipped resolver comes to the catalog-only default §14
Q12(b) declined to make the [...]
| `org.apache.ws.commons.schema.remote.connectTimeoutMillis` /
`.readTimeoutMillis` / `.maxFetchMillis` / `.maxBytes` system properties |
`5000` / `10000` / `30000` / `67108864` *(documented: `README.txt`)* |
operator-tunable per-fetch bounds | bound one remote `DefaultURIResolver` fetch
in wall-clock time and bytes; without them the JDK opens a `schemaLocation`
with no timeout and no size limit, and a single import can hold a thread or its
heap indefinitely |
| `org.apache.ws.commons.schema.protectReadOnlyCollections` system property |
`false` *(documented: `README.txt`, `CollectionFactory.java` lines 37-48)* |
in-process convenience, not a trust boundary | when false, the "read-only"
model accessors return the **live internal collections**, not unmodifiable
views; §7 places the in-process caller outside the attacker model, so this is a
correctness guard rather than a security control |
@@ -489,9 +490,10 @@ matching disclaimer.
`https` targets it does allow, it applies **no host or address
filtering of any kind**: any
`http(s)` host is fetched on request, including loopback, link-local
- (`169.254.169.254`) and RFC1918 addresses, and the JDK follows HTTP
- redirects without consulting the resolver again — so a host allowlist
- is not enforceable at the `resolveEntity` boundary. The caller is
+ (`169.254.169.254`) and RFC1918 addresses. Redirects are now followed by
+ the resolver rather than the JDK and each hop is re-checked, so a
+ destination rule *could* be enforced across a chain — but none is
+ applied, by host or by address, so the reach is unchanged. The caller is
responsible for installing a restricting `URIResolver` if the input
schema is attacker-controlled *(documented: `DefaultURIResolver.java`;
ratified — §14 Q12)*. An operator with no remote schema sets can set
@@ -759,6 +761,18 @@ Revise this document when any of the following lands:
rule as first written: it tested only the URI authority, so
`file:////host/share/x.xsd`, which parses with no authority and
carries the host in its path instead, was not caught.
+- **2026-09-17** — `DefaultURIResolver` now follows HTTP redirects itself
+ instead of leaving them to the JDK, bounded by a new
+ `org.apache.ws.commons.schema.remote.maxRedirects` property (default
+ `5`, `0` to refuse a redirected location). A revision trigger under the
+ first bullet above; recorded in §5a. Each hop is re-checked against the
+ scheme and authority rules and a scheme-changing hop is refused, so the
+ location that is fetched is one these checks have passed — which the
+ JDK's own following did not give. The whole chain shares one fetch
+ deadline, so a redirect chain cannot buy time. §9 is corrected: it said a
+ destination rule was unenforceable at this boundary because redirects
+ escaped it, which is no longer the reason — none is applied, but one now
+ could be.
- **2026-09-17** — a companion
`org.apache.ws.commons.schema.local.allowFileSystem` system property
refuses `file:` and `jar:file:` locations, and a relative location with
diff --git
a/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java
b/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java
index 079235c0..2272cebc 100644
---
a/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java
+++
b/xmlschema-core/src/main/java/org/apache/ws/commons/schema/resolver/DefaultURIResolver.java
@@ -21,6 +21,7 @@ package org.apache.ws.commons.schema.resolver;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
+import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
@@ -81,6 +82,15 @@ public class DefaultURIResolver implements
CollectionURIResolver {
public static final String MAX_BYTES_PROPERTY =
"org.apache.ws.commons.schema.remote.maxBytes";
+ /**
+ * How many HTTP redirects one fetch may follow. Redirects are followed by
this resolver
+ * rather than by the JDK, so that the chain is bounded, every hop is
checked the way the
+ * location the schema named was checked, and the whole chain counts
against one fetch
+ * deadline. Set it to <code>0</code> to refuse a redirected schema
location outright.
+ */
+ public static final String MAX_REDIRECTS_PROPERTY =
+ "org.apache.ws.commons.schema.remote.maxRedirects";
+
/**
* Whether a schema location may be fetched over the network at all. Set
it to
* <code>false</code> in a deployment whose schema sets are entirely
local: an
@@ -111,6 +121,7 @@ public class DefaultURIResolver implements
CollectionURIResolver {
private static final long DEFAULT_READ_TIMEOUT_MILLIS = 10L * 1000L;
private static final long DEFAULT_MAX_FETCH_MILLIS = 30L * 1000L;
private static final long DEFAULT_MAX_BYTES = 64L * 1024L * 1024L;
+ private static final long DEFAULT_MAX_REDIRECTS = 5L;
private final long connectTimeoutMillis =
getLongProperty(CONNECT_TIMEOUT_PROPERTY,
DEFAULT_CONNECT_TIMEOUT_MILLIS);
@@ -119,6 +130,9 @@ public class DefaultURIResolver implements
CollectionURIResolver {
private final long maxFetchMillis =
getLongProperty(MAX_FETCH_MILLIS_PROPERTY, DEFAULT_MAX_FETCH_MILLIS);
private final long maxBytes = getLongProperty(MAX_BYTES_PROPERTY,
DEFAULT_MAX_BYTES);
+ // Zero is meaningful here -- it refuses a redirect outright -- so the
minimum is 0, not 1.
+ private final long maxRedirects =
+ getLongProperty(MAX_REDIRECTS_PROPERTY, DEFAULT_MAX_REDIRECTS, 0L);
private final boolean allowNetwork =
getBooleanProperty(ALLOW_NETWORK_PROPERTY, true);
private final boolean allowFileSystem =
getBooleanProperty(ALLOW_FILE_SYSTEM_PROPERTY, true);
@@ -247,11 +261,42 @@ public class DefaultURIResolver implements
CollectionURIResolver {
if (closed) {
throw new IOException("The schema location \"" + systemId +
"\" is closed.");
}
- URLConnection connection = url.openConnection();
- connection.setDoInput(true);
- connection.setConnectTimeout(toIntMillis(connectTimeoutMillis));
- connection.setReadTimeout(toIntMillis(readTimeoutMillis));
+ // One deadline for the whole fetch, so a redirect chain cannot
buy more time.
deadlineNanos = System.nanoTime() + maxFetchMillis * 1000000L;
+ URL target = url;
+ long hops = 0;
+ URLConnection connection = null;
+ while (connection == null) {
+ checkDeadline();
+ URLConnection candidate = target.openConnection();
+ candidate.setDoInput(true);
+ candidate.setConnectTimeout(toIntMillis(connectTimeoutMillis));
+ candidate.setReadTimeout(toIntMillis(readTimeoutMillis));
+ String redirectedTo = null;
+ if (candidate instanceof HttpURLConnection) {
+ HttpURLConnection http = (HttpURLConnection)candidate;
+ // Followed here rather than by the JDK: that bounds the
chain, puts every hop
+ // through the same checks as the location the schema
named, and keeps the
+ // whole chain inside one deadline.
+ http.setInstanceFollowRedirects(false);
+ if (isRedirect(http.getResponseCode())) {
+ redirectedTo = http.getHeaderField("Location");
+ http.disconnect();
+ if (hops >= maxRedirects) {
+ throw new IOException("The schema location \"" +
systemId
+ + "\" redirected more than "
+ maxRedirects
+ + " times, the maximum set
by "
+ + MAX_REDIRECTS_PROPERTY +
".");
+ }
+ hops++;
+ }
+ }
+ if (redirectedTo == null) {
+ connection = candidate;
+ } else {
+ target = nextHop(target, redirectedTo);
+ }
+ }
// A declared length is a courtesy: it is absent for a chunked
response and is in any
// case whatever the host chose to claim. The running count below
is the real limit.
if (connection.getContentLengthLong() > maxBytes) {
@@ -262,6 +307,48 @@ public class DefaultURIResolver implements
CollectionURIResolver {
delegate = connection.getInputStream();
}
+ private boolean isRedirect(int code) {
+ return code == HttpURLConnection.HTTP_MOVED_PERM
+ || code == HttpURLConnection.HTTP_MOVED_TEMP
+ || code == HttpURLConnection.HTTP_SEE_OTHER
+ || code == 307
+ || code == 308;
+ }
+
+ /**
+ * The next URL in a redirect chain, or an exception if it is one this
resolver will not
+ * fetch. A redirect that changes scheme is refused, which is what the
JDK does when it
+ * follows redirects itself, so an http location cannot become a file
read or an https one
+ * be downgraded.
+ */
+ private URL nextHop(URL from, String location) throws IOException {
+ if (location == null || location.trim().length() == 0) {
+ throw new IOException("The schema location \"" + systemId
+ + "\" redirected without saying where
to.");
+ }
+ URL next;
+ try {
+ next = new URL(from, location.trim());
+ } catch (MalformedURLException e) {
+ throw new IOException("The schema location \"" + systemId
+ + "\" redirected to \"" + location.trim()
+ + "\", which is not a usable URL.", e);
+ }
+ if (!next.getProtocol().equalsIgnoreCase(from.getProtocol())) {
+ throw new IOException("The schema location \"" + systemId
+ + "\" redirected from the scheme \"" +
from.getProtocol()
+ + "\" to \"" + next.getProtocol() +
"\".");
+ }
+ try {
+ verifyPermittedLocation(next.toString(), systemId);
+ } catch (XmlSchemaException e) {
+ // Surface it as an IOException: this runs inside a read(),
where the parser
+ // expects I/O failures.
+ throw new IOException(e.getMessage(), e);
+ }
+ return next;
+ }
+
private void checkDeadline() throws IOException {
if (System.nanoTime() - deadlineNanos >= 0) {
throw new IOException("Fetching the schema location \"" +
systemId
@@ -337,6 +424,10 @@ public class DefaultURIResolver implements
CollectionURIResolver {
}
private static long getLongProperty(final String name, long defaultValue) {
+ return getLongProperty(name, defaultValue, 1L);
+ }
+
+ private static long getLongProperty(final String name, long defaultValue,
long minimum) {
try {
String value = AccessController.doPrivileged(new
PrivilegedAction<String>() {
public String run() {
@@ -345,7 +436,7 @@ public class DefaultURIResolver implements
CollectionURIResolver {
});
if (value != null && value.trim().length() > 0) {
long parsed = Long.parseLong(value.trim());
- if (parsed > 0) {
+ if (parsed >= minimum) {
return parsed;
}
}
diff --git a/xmlschema-core/src/test/java/tests/RemoteFetchBoundsTest.java
b/xmlschema-core/src/test/java/tests/RemoteFetchBoundsTest.java
index ae38b252..41bc91d8 100644
--- a/xmlschema-core/src/test/java/tests/RemoteFetchBoundsTest.java
+++ b/xmlschema-core/src/test/java/tests/RemoteFetchBoundsTest.java
@@ -54,6 +54,17 @@ public class RemoteFetchBoundsTest extends Assert {
private static final int MODE_TRICKLE = 1;
/** Sends a well-formed but endless body. */
private static final int MODE_FLOOD = 2;
+ /** Redirects to a sibling path on the same server, once per connection. */
+ private static final int MODE_REDIRECT = 3;
+ /** Serves a small valid schema for namespace urn:b. */
+ private static final int MODE_SCHEMA = 4;
+ /** Redirects for ever, so the hop cap is what stops it. */
+ private static final int MODE_REDIRECT_LOOP = 5;
+ /** Redirects to a file: URL, changing scheme. */
+ private static final int MODE_REDIRECT_TO_FILE = 6;
+
+ private static final String SCHEMA_BODY =
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"
targetNamespace=\"urn:b\"/>";
private void startServer(final int mode) throws IOException {
server = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
@@ -79,6 +90,13 @@ public class RemoteFetchBoundsTest extends Assert {
return;
}
OutputStream out = socket.getOutputStream();
+ if (mode == MODE_REDIRECT || mode == MODE_SCHEMA || mode ==
MODE_REDIRECT_LOOP
+ || mode == MODE_REDIRECT_TO_FILE) {
+ out.write(oneShotResponse(mode).getBytes(StandardCharsets.UTF_8));
+ out.flush();
+ socket.close();
+ return;
+ }
out.write("HTTP/1.1 200 OK\r\nContent-Type:
text/xml\r\n\r\n".getBytes(StandardCharsets.UTF_8));
out.flush();
byte[] chunk = mode == MODE_TRICKLE
@@ -97,6 +115,41 @@ public class RemoteFetchBoundsTest extends Assert {
}
}
+ private String oneShotResponse(int mode) {
+ if (mode == MODE_SCHEMA) {
+ return "HTTP/1.1 200 OK\r\nContent-Type:
text/xml\r\nContent-Length: "
+ + SCHEMA_BODY.getBytes(StandardCharsets.UTF_8).length
+ + "\r\nConnection: close\r\n\r\n" + SCHEMA_BODY;
+ }
+ String location = mode == MODE_REDIRECT_TO_FILE
+ ? "file:///etc/passwd"
+ : "http://127.0.0.1:" + server.getLocalPort() + "/next.xsd";
+ return "HTTP/1.1 302 Found\r\nLocation: " + location
+ + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
+ }
+
+ /** First connection answers with {@code first}, every later one with
{@code rest}. */
+ private void startServer(final int first, final int rest) throws
IOException {
+ server = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
+ running = true;
+ Thread thread = new Thread(new Runnable() {
+ public void run() {
+ boolean isFirst = true;
+ while (running) {
+ try {
+ Socket socket = server.accept();
+ serve(socket, isFirst ? first : rest);
+ isFirst = false;
+ } catch (IOException e) {
+ return;
+ }
+ }
+ }
+ });
+ thread.setDaemon(true);
+ thread.start();
+ }
+
private String importing() {
return "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"
targetNamespace=\"urn:a\">"
+ "<xs:import namespace=\"urn:b\"
schemaLocation=\"http://127.0.0.1:"
@@ -129,6 +182,14 @@ public class RemoteFetchBoundsTest extends Assert {
}
private void assertRefusedWithin(long millis) throws IOException {
+ assertRefusedWithin(millis, null);
+ }
+
+ /**
+ * @param messageFragment when given, the reason the fetch was refused
must mention it, so a
+ * test for one bound cannot pass because a different bound happened
to fire first.
+ */
+ private void assertRefusedWithin(long millis, String messageFragment)
throws IOException {
long start = System.currentTimeMillis();
try {
new XmlSchemaCollection().read(new StringReader(importing()));
@@ -136,6 +197,10 @@ public class RemoteFetchBoundsTest extends Assert {
} catch (XmlSchemaException expected) {
long elapsed = System.currentTimeMillis() - start;
assertTrue("refused, but only after " + elapsed + "ms", elapsed <
millis);
+ if (messageFragment != null) {
+ assertTrue("refused for the wrong reason: " +
expected.getMessage(),
+ expected.getMessage().contains(messageFragment));
+ }
}
}
@@ -158,6 +223,49 @@ public class RemoteFetchBoundsTest extends Assert {
assertRefusedWithin(30000);
}
+ /**
+ * A schema that has simply moved must still resolve. Bounding a fetch is
not a reason to stop
+ * following a redirect, and schemas do get reorganised behind one.
+ */
+ @Test(timeout = 60000)
+ public void testMovedSchemaIsStillFollowed() throws Exception {
+ startServer(MODE_REDIRECT, MODE_SCHEMA);
+ XmlSchemaCollection collection = new XmlSchemaCollection();
+ collection.read(new StringReader(importing()));
+ assertNotNull("a schema behind a redirect must still resolve",
+ collection.schemaForNamespace("urn:b"));
+ }
+
+ @Test(timeout = 60000)
+ public void testEndlessRedirectChainIsCutOffAtTheHopCap() throws Exception
{
+ System.setProperty(DefaultURIResolver.MAX_REDIRECTS_PROPERTY, "3");
+ try {
+ startServer(MODE_REDIRECT_LOOP, MODE_REDIRECT_LOOP);
+ assertRefusedWithin(30000, "redirected more than 3 times");
+ } finally {
+ System.clearProperty(DefaultURIResolver.MAX_REDIRECTS_PROPERTY);
+ }
+ }
+
+ /** Zero hops is meaningful: it refuses a redirected location outright. */
+ @Test(timeout = 60000)
+ public void testZeroHopsRefusesARedirect() throws Exception {
+ System.setProperty(DefaultURIResolver.MAX_REDIRECTS_PROPERTY, "0");
+ try {
+ startServer(MODE_REDIRECT, MODE_SCHEMA);
+ assertRefusedWithin(30000, "redirected more than 0 times");
+ } finally {
+ System.clearProperty(DefaultURIResolver.MAX_REDIRECTS_PROPERTY);
+ }
+ }
+
+ /** A redirect may not turn a network fetch into a local read. */
+ @Test(timeout = 60000)
+ public void testRedirectChangingSchemeIsRefused() throws Exception {
+ startServer(MODE_REDIRECT_TO_FILE, MODE_SCHEMA);
+ assertRefusedWithin(30000, "redirected from the scheme");
+ }
+
/** Local schemas keep the system-id-only path: no buffering, no behaviour
change. */
@Test
public void testLocalImportStillResolves() throws Exception {