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 af61c6f4 Place default limits on read timeouts + size on remote
schemas (#152)
af61c6f4 is described below
commit af61c6f4eb2a1ed9413f597248f60564909c26e5
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Thu Sep 17 07:51:19 2026 +0100
Place default limits on read timeouts + size on remote schemas (#152)
---
README.txt | 27 ++++
THREAT-MODEL.md | 21 ++-
.../schema/resolver/DefaultURIResolver.java | 177 ++++++++++++++++++++-
.../src/test/java/tests/RemoteFetchBoundsTest.java | 170 ++++++++++++++++++++
4 files changed, 385 insertions(+), 10 deletions(-)
diff --git a/README.txt b/README.txt
index 0cb6324c..4c780598 100644
--- a/README.txt
+++ b/README.txt
@@ -68,6 +68,31 @@ adjust the per-document limits:
as an entity in one - and FEATURE_SECURE_PROCESSING bounds entity
expansion by both count and accumulated size.
+ When the DefaultURIResolver fetches a schema over http or https, the fetch
+ is bounded: left to the JDK it has no timeout and no size limit, so one
+ schemaLocation naming a slow or endless host can hold a parsing thread or
+ its heap indefinitely. The import and resolution limits above bound the
+ shape of the import graph, not the cost of a single fetch within it. The
+ following JVM system properties adjust the bounds:
+
+ org.apache.ws.commons.schema.remote.connectTimeoutMillis
+ Connect timeout for a remote schema fetch. The default is 5000.
+
+ org.apache.ws.commons.schema.remote.readTimeoutMillis
+ Per-read timeout for a remote schema fetch. The default is 10000.
+
+ org.apache.ws.commons.schema.remote.maxFetchMillis
+ Maximum total wall-clock time for one remote schema fetch. The
+ per-read timeout above bounds each blocking read separately, so this
+ is what stops a host that trickles bytes below that interval. The
+ default is 30000.
+
+ org.apache.ws.commons.schema.remote.maxBytes
+ Maximum bytes accepted from one remote schema fetch. The default is
+ 67108864 (64 MB).
+
+ file: and jar: locations are read as before, without buffering.
+
The collections returned by the "read-only" accessors on the schema model
are, by default, the live internal collections rather than unmodifiable
views. To wrap them so that modification throws instead, set:
@@ -86,6 +111,8 @@ For example, set a limit with:
-Dorg.apache.ws.commons.schema.protectReadOnlyCollections=true
+ -Dorg.apache.ws.commons.schema.remote.maxFetchMillis=10000
+
===================
Security
===================
diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md
index a5fa6dc4..a55da09b 100644
--- a/THREAT-MODEL.md
+++ b/THREAT-MODEL.md
@@ -270,6 +270,7 @@ points*:
| `org.apache.ws.commons.schema.maxImportDepth` system property | `64`
*(documented: `README.txt`)* | operator-tunable per-process limit | maximum
import/include resolution depth for one schema read |
| `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.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 |
| `DocumentBuilderFactory` provider | JDK default (typically Xerces fork)
*(inferred — §14 Q6)* | depends on the JDK | shape of XML parsing for
`read(InputSource)` / stream-shaped `read(Source)` paths |
@@ -573,8 +574,10 @@ defense-in-depth controls:
3. Supplement XMLSchema's import/include depth, per-read resolution, and
structural nesting limits with caller-boundary budgets for total imported
bytes and fetch rate per top-level parse.
-4. Use connect/read timeouts for import fetches and fail closed on
- timeout or policy-check errors.
+4. Tune, or tighten beyond, the default per-fetch bounds of §5a, and
+ fail closed on timeout or policy-check errors. The defaults bound a
+ remote fetch; an aggregate budget across the whole import graph is
+ still a caller responsibility.
5. Log import-resolution decisions (requested URI, normalized target,
allow/deny result, reason) for incident response and triage.
6. Prefer integrity-controlled schema sources (pinned internal mirror
@@ -644,11 +647,13 @@ model, the section that licenses the call.
reachable from input *(documented: `XmlSchema.java`)*. →
`KNOWN-NON-FINDING`.
- **"`URLConnection.getInputStream()` without timeout."** True;
- XMLSchema does no read-timeout on fetched imports *(maintainer —
- §14 Q12)*. The resolver returns a system ID and the JDK opens the
- connection, so a timeout cannot be imposed without changing the
- resolver's contract. → `BY-DESIGN: property-disclaimed`;
- connect/read timeouts are a §10 item 4 caller responsibility.
+ `DefaultURIResolver` now opens `http`/`https` fetches itself and
+ bounds them by connect timeout, per-read timeout, total wall-clock
+ deadline and byte count (see §5a); `file:` and `jar:` locations are
+ still returned as a system ID for the parser to open. A report that
+ an unbounded remote fetch holds a thread or its heap is `VALID` if it
+ shows a bypass of those bounds. Callers wanting tighter budgets, or
+ bounds on local reads, still install their own resolver.
- **"Path traversal via `XmlSchemaCollection.setBaseUri()`."** Caller-
supplied trusted string per §6. → `OUT-OF-MODEL: trusted-input`.
- **"Schemas in `w3c-testcases/` contain wide-open DTDs."** W3C
@@ -730,7 +735,7 @@ A report against XMLSchema receives exactly one of the
following:
| `OUT-OF-MODEL: unsupported-component` | Lands in `w3c-testcases/`,
`*/src/test/`, `etc/`, `xmlschema-bundle-test/`. | §3 items 4, 8 |
| `OUT-OF-MODEL: non-default-build` | Only manifests under a §5a configuration
the maintainer rules dev/test (e.g. an unsafe custom `URIResolver`). | §5a |
| `OUT-OF-MODEL: out-of-layer` | Concerns a *document* validation step
delegated to `javax.xml.validation.Validator`, or a WSDL parser upstream. | §3
items 1–3 |
-| `BY-DESIGN: property-disclaimed` | Concerns a §9 property the project
explicitly does not provide (no SSRF defense, no guarantee of default DTD
acceptance, no schema-size, imported-byte, or fetch-rate ceiling). | §9 |
+| `BY-DESIGN: property-disclaimed` | Concerns a §9 property the project
explicitly does not provide (no SSRF defense, no guarantee that external DTD or
entity content is resolved, no aggregate schema-size, imported-byte, or
fetch-rate ceiling across a whole import graph). | §9 |
| `KNOWN-NON-FINDING` | Matches a §11a recurring false positive. | §11a |
| `MODEL-GAP` | Cannot be cleanly routed to any of the above — triggers §12
model revision. | §12 |
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 5a645cf9..06d6f2c5 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
@@ -19,10 +19,16 @@
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;
import java.net.URL;
+import java.net.URLConnection;
+import java.security.AccessController;
+import java.security.PrivilegedAction;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
@@ -57,6 +63,35 @@ public class DefaultURIResolver implements
CollectionURIResolver {
private static final Set<String> ALLOWED_SCHEMES =
Collections.unmodifiableSet(
new HashSet<String>(Arrays.asList("http", "https", "file", "jar")));
+ /**
+ * Bounds on a single network fetch. Left to the JDK, a schema location
naming a slow or
+ * silent host holds the parsing thread for as long as that host keeps the
socket open, and
+ * one that keeps sending holds as much heap as it cares to send. The
import depth and
+ * resolution limits bound the shape of the import graph, not the cost of
one fetch within
+ * it, so they never come into play: a single import is enough.
+ */
+ public static final String CONNECT_TIMEOUT_PROPERTY =
+ "org.apache.ws.commons.schema.remote.connectTimeoutMillis";
+ public static final String READ_TIMEOUT_PROPERTY =
+ "org.apache.ws.commons.schema.remote.readTimeoutMillis";
+ public static final String MAX_FETCH_MILLIS_PROPERTY =
+ "org.apache.ws.commons.schema.remote.maxFetchMillis";
+ public static final String MAX_BYTES_PROPERTY =
+ "org.apache.ws.commons.schema.remote.maxBytes";
+
+ private static final long DEFAULT_CONNECT_TIMEOUT_MILLIS = 5L * 1000L;
+ 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 final long connectTimeoutMillis =
+ getLongProperty(CONNECT_TIMEOUT_PROPERTY,
DEFAULT_CONNECT_TIMEOUT_MILLIS);
+ private final long readTimeoutMillis =
+ getLongProperty(READ_TIMEOUT_PROPERTY, DEFAULT_READ_TIMEOUT_MILLIS);
+ private final long maxFetchMillis =
+ getLongProperty(MAX_FETCH_MILLIS_PROPERTY, DEFAULT_MAX_FETCH_MILLIS);
+ private final long maxBytes = getLongProperty(MAX_BYTES_PROPERTY,
DEFAULT_MAX_BYTES);
+
private String collectionBaseURI;
/**
@@ -101,7 +136,7 @@ public class DefaultURIResolver implements
CollectionURIResolver {
URL ref = new URL(base, schemaLocation);
verifyComposedUrl(remoteBase, originalBaseUri, base, ref,
schemaLocation);
- return new InputSource(ref.toString());
+ return toInputSource(ref, ref.toString());
} catch (MalformedURLException e1) {
throw new XmlSchemaException("Unable to resolve the schema
location \"" + schemaLocation
+ "\" against the base URI \"" +
baseUri + "\"", e1);
@@ -113,7 +148,11 @@ public class DefaultURIResolver implements
CollectionURIResolver {
// rejects is not thereby harmless.
if (isAbsoluteUri(schemaLocation) || extractScheme(schemaLocation) !=
null) {
verifyPermittedLocation(schemaLocation, schemaLocation);
- return new InputSource(schemaLocation);
+ try {
+ return toInputSource(new URL(schemaLocation), schemaLocation);
+ } catch (MalformedURLException e) {
+ return new InputSource(schemaLocation);
+ }
}
if (isPlainRelativePath(schemaLocation)) {
return new InputSource(schemaLocation);
@@ -122,6 +161,140 @@ public class DefaultURIResolver implements
CollectionURIResolver {
}
+ /**
+ * Hands the parser an InputSource for a resolved location. A
<code>file:</code> or
+ * <code>jar:</code> location keeps the system-id-only form: those reads
are local, and the
+ * parser opens them as it always has. A network location gets a byte
stream that the parser
+ * opens the same way it would have, except that it is bounded - left to
the JDK the fetch has
+ * no timeout and no size limit.
+ * <p>
+ * The system id is set either way, and the stream is opened lazily on
first read, so this
+ * method performs no I/O: resolving a location stays a pure URL
composition, as callers of
+ * {@link URIResolver#resolveEntity} expect. Schema documents also carry
relative
+ * <code>schemaLocation</code>s resolved against the system id, so a
document that arrives as
+ * bytes still needs its own URL recorded or its own imports cannot be
resolved.
+ * </p>
+ */
+ private InputSource toInputSource(URL url, String systemId) {
+ InputSource source = new InputSource(systemId);
+ if (isNetworkScheme(url.getProtocol().toLowerCase(Locale.ENGLISH))) {
+ source.setByteStream(new BoundedUrlInputStream(url, systemId));
+ }
+ return source;
+ }
+
+ /**
+ * A stream over a remote schema document that opens on first read and
enforces the per-fetch
+ * bounds as it goes. The connect and read timeouts bound each blocking
operation separately,
+ * so they are not on their own enough: a host trickling bytes below the
read-timeout interval
+ * resets that timer indefinitely. The total deadline checked on every
read is what bounds
+ * that, and the running byte count bounds a host that simply keeps
sending.
+ */
+ private final class BoundedUrlInputStream extends InputStream {
+
+ private final URL url;
+ private final String systemId;
+ private InputStream delegate;
+ private long deadlineNanos;
+ private long total;
+ private boolean closed;
+
+ BoundedUrlInputStream(URL url, String systemId) {
+ this.url = url;
+ this.systemId = systemId;
+ }
+
+ private void ensureOpen() throws IOException {
+ if (delegate != null) {
+ return;
+ }
+ 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));
+ if (connection instanceof HttpURLConnection) {
+
((HttpURLConnection)connection).setInstanceFollowRedirects(false);
+ }
+ deadlineNanos = System.nanoTime() + maxFetchMillis * 1000000L;
+ // 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) {
+ throw new IOException("The schema location \"" + systemId
+ + "\" declared a length above the
maximum of "
+ + maxBytes + " bytes.");
+ }
+ delegate = connection.getInputStream();
+ }
+
+ private void checkDeadline() throws IOException {
+ if (System.nanoTime() - deadlineNanos >= 0) {
+ throw new IOException("Fetching the schema location \"" +
systemId
+ + "\" took longer than the maximum of "
+ + maxFetchMillis + " ms.");
+ }
+ }
+
+ private int count(int read) throws IOException {
+ if (read > 0) {
+ total += read;
+ if (total > maxBytes) {
+ throw new IOException("The schema location \"" + systemId
+ + "\" returned more than the maximum
of "
+ + maxBytes + " bytes.");
+ }
+ }
+ return read;
+ }
+
+ public int read() throws IOException {
+ ensureOpen();
+ checkDeadline();
+ int value = delegate.read();
+ count(value == -1 ? -1 : 1);
+ return value;
+ }
+
+ public int read(byte[] buffer, int offset, int length) throws
IOException {
+ ensureOpen();
+ checkDeadline();
+ return count(delegate.read(buffer, offset, length));
+ }
+
+ public void close() throws IOException {
+ closed = true;
+ if (delegate != null) {
+ delegate.close();
+ delegate = null;
+ }
+ }
+ }
+
+ private static int toIntMillis(long millis) {
+ return millis > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int)millis;
+ }
+
+ private static long getLongProperty(final String name, long defaultValue) {
+ try {
+ String value = AccessController.doPrivileged(new
PrivilegedAction<String>() {
+ public String run() {
+ return System.getProperty(name);
+ }
+ });
+ if (value != null && value.trim().length() > 0) {
+ long parsed = Long.parseLong(value.trim());
+ if (parsed > 0) {
+ return parsed;
+ }
+ }
+ } catch (RuntimeException e) {
+ // fall through to the default
+ }
+ return defaultValue;
+ }
+
private static void verifyComposedUrl(boolean remoteBase, String
originalBaseUri, URL base,
URL composed, String schemaLocation)
{
verifyPermittedLocation(composed.toString(), schemaLocation);
diff --git a/xmlschema-core/src/test/java/tests/RemoteFetchBoundsTest.java
b/xmlschema-core/src/test/java/tests/RemoteFetchBoundsTest.java
new file mode 100644
index 00000000..ae38b252
--- /dev/null
+++ b/xmlschema-core/src/test/java/tests/RemoteFetchBoundsTest.java
@@ -0,0 +1,170 @@
+/**
+ * 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 tests;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.StringReader;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.ws.commons.schema.XmlSchema;
+import org.apache.ws.commons.schema.XmlSchemaCollection;
+import org.apache.ws.commons.schema.XmlSchemaException;
+import org.apache.ws.commons.schema.resolver.DefaultURIResolver;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.xml.sax.InputSource;
+
+/**
+ * A schema location naming a hostile host must not be able to hold the
parsing thread or its
+ * heap. The import depth and resolution limits bound the shape of the import
graph, not the
+ * cost of one fetch within it, so a single import reaches none of them.
+ */
+public class RemoteFetchBoundsTest extends Assert {
+
+ private ServerSocket server;
+ private volatile boolean running;
+
+ /** Accepts, then sends nothing and never closes. */
+ private static final int MODE_SILENT = 0;
+ /** Sends a byte at a time forever, resetting the per-read timeout on each
one. */
+ private static final int MODE_TRICKLE = 1;
+ /** Sends a well-formed but endless body. */
+ private static final int MODE_FLOOD = 2;
+
+ private void startServer(final int mode) throws IOException {
+ server = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
+ running = true;
+ Thread thread = new Thread(new Runnable() {
+ public void run() {
+ while (running) {
+ try {
+ Socket socket = server.accept();
+ serve(socket, mode);
+ } catch (IOException e) {
+ return;
+ }
+ }
+ }
+ });
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ private void serve(Socket socket, int mode) throws IOException {
+ if (mode == MODE_SILENT) {
+ return;
+ }
+ OutputStream out = socket.getOutputStream();
+ 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
+ ? new byte[] {' '}
+ : new byte[64 * 1024];
+ while (running) {
+ try {
+ out.write(chunk);
+ out.flush();
+ if (mode == MODE_TRICKLE) {
+ Thread.sleep(50);
+ }
+ } catch (Exception e) {
+ return;
+ }
+ }
+ }
+
+ 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:"
+ + server.getLocalPort() + "/b.xsd\"/>"
+ + "</xs:schema>";
+ }
+
+ @After
+ public void stopServer() throws IOException {
+ running = false;
+ if (server != null) {
+ server.close();
+ }
+ }
+
+ @Before
+ public void shortenBounds() {
+ System.setProperty(DefaultURIResolver.CONNECT_TIMEOUT_PROPERTY,
"2000");
+ System.setProperty(DefaultURIResolver.READ_TIMEOUT_PROPERTY, "1000");
+ System.setProperty(DefaultURIResolver.MAX_FETCH_MILLIS_PROPERTY,
"2000");
+ System.setProperty(DefaultURIResolver.MAX_BYTES_PROPERTY, "1048576");
+ }
+
+ @After
+ public void restoreBounds() {
+ System.clearProperty(DefaultURIResolver.CONNECT_TIMEOUT_PROPERTY);
+ System.clearProperty(DefaultURIResolver.READ_TIMEOUT_PROPERTY);
+ System.clearProperty(DefaultURIResolver.MAX_FETCH_MILLIS_PROPERTY);
+ System.clearProperty(DefaultURIResolver.MAX_BYTES_PROPERTY);
+ }
+
+ private void assertRefusedWithin(long millis) throws IOException {
+ long start = System.currentTimeMillis();
+ try {
+ new XmlSchemaCollection().read(new StringReader(importing()));
+ fail("expected the fetch to be refused");
+ } catch (XmlSchemaException expected) {
+ long elapsed = System.currentTimeMillis() - start;
+ assertTrue("refused, but only after " + elapsed + "ms", elapsed <
millis);
+ }
+ }
+
+ @Test(timeout = 60000)
+ public void testSilentHostDoesNotHoldTheThread() throws IOException {
+ startServer(MODE_SILENT);
+ assertRefusedWithin(30000);
+ }
+
+ /** The case a per-read timeout alone does not catch. */
+ @Test(timeout = 60000)
+ public void testTricklingHostIsCutOffAtTheTotalDeadline() throws
IOException {
+ startServer(MODE_TRICKLE);
+ assertRefusedWithin(30000);
+ }
+
+ @Test(timeout = 60000)
+ public void testOversizedResponseIsRefused() throws IOException {
+ startServer(MODE_FLOOD);
+ assertRefusedWithin(30000);
+ }
+
+ /** Local schemas keep the system-id-only path: no buffering, no behaviour
change. */
+ @Test
+ public void testLocalImportStillResolves() throws Exception {
+ XmlSchemaCollection collection = new XmlSchemaCollection();
+ collection.setBaseUri(Resources.TEST_RESOURCES);
+ XmlSchema schema = collection.read(new
InputSource(Resources.asURI("importBase.xsd")));
+ assertNotNull(schema);
+
assertNotNull(collection.schemaForNamespace("http://soapinterop.org/xsd2"));
+ }
+}