This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/network-resolution in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git
commit a2d1fde27cfd5c8ce27c13f853e6f22ce03eae4c Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Thu Sep 17 11:19:02 2026 +0100 Add a switch to allow network resolution to be turned off --- README.txt | 16 ++++++ THREAT-MODEL.md | 21 +++++++- .../schema/resolver/DefaultURIResolver.java | 53 +++++++++++++++++-- .../test/java/tests/DefaultURIResolverTest.java | 59 ++++++++++++++++++++++ 4 files changed, 144 insertions(+), 5 deletions(-) diff --git a/README.txt b/README.txt index 0d002be6..28643daa 100644 --- a/README.txt +++ b/README.txt @@ -91,6 +91,16 @@ adjust the per-document limits: Maximum bytes accepted from one remote schema fetch. The default is 67108864 (64 MB). + org.apache.ws.commons.schema.remote.allowNetwork + Whether a schema location may be fetched over the network at all. The + default is true. Set it to false in a deployment whose schema sets are + entirely local: an xs:import naming an http or https location is then + refused rather than fetched, and no resolver has to be supplied to get + that. Only true and false are recognised, so a typo leaves resolution + working rather than quietly turning it off. Local file: and jar: reads + are unaffected either way, so this is not on its own a defence against + an untrusted schema document - see the Security section below. + file: and jar: locations are read as before, without buffering. The collections returned by the "read-only" accessors on the schema model @@ -126,6 +136,12 @@ For example, set a limit with: file is fetched on request. It does refuse a file: location that names a non-local authority, and a jar: archive fetched over the network. + Where a deployment needs no remote schemas at all, setting + org.apache.ws.commons.schema.remote.allowNetwork to false refuses http and + https locations outright, which closes the remote-fetch half of this + without any code. It does not restrict local file: reads, so it does not + replace a restricting resolver for untrusted input. + Applications that parse schema or WSDL documents from an untrusted source must install a restricting resolver before reading them: diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 99f3410e..2cb0be48 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -285,6 +285,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.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.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 | @@ -482,7 +483,11 @@ matching disclaimer. is not enforceable at the `resolveEntity` boundary. The caller is responsible for installing a restricting `URIResolver` if the input schema is attacker-controlled *(documented: `DefaultURIResolver.java`; - ratified — §14 Q12)*. + ratified — §14 Q12)*. An operator with no remote schema sets can set + `org.apache.ws.commons.schema.remote.allowNetwork=false` to refuse + `http` and `https` locations outright (§5a); that removes the SSRF + reach but not the local `file:` read, so it narrows this disclaimer + rather than retiring it. - **No guarantee that external DTD or external entity content is ever resolved.** XMLSchema accepts a DOCTYPE declaration, but never fetches an external DTD subset or an external entity; a schema that depends on @@ -559,7 +564,11 @@ The embedding Java application **must**: is not a supported production posture for untrusted schema bytes. A resolver that returns `null` declines the location (the collection falls back to any schema already registered for that namespace); one - that throws rejects the read outright. + that throws rejects the read outright. A deployment that simply never + needs a remote schema can instead set + `org.apache.ws.commons.schema.remote.allowNetwork=false` (§5a), which + needs no code but still leaves local `file:` reads open, so it is not + a substitute for a restricting resolver on untrusted input. 2. When passing a pre-parsed `Document` / `Element` into `XmlSchemaCollection.read(...)`, use a `DocumentBuilderFactory` hardened against XXE — specifically with `disallow-doctype-decl=true` @@ -731,6 +740,14 @@ 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** — a new `org.apache.ws.commons.schema.remote.allowNetwork` + system property lets an operator refuse `http` and `https` schema + locations outright, defaulting to `true` so nothing changes for an + existing deployment. It is a revision trigger under the first bullet + above and is recorded in §5a, §9 and §10 item 1. It does not disturb + the §14 Q12(b) ruling: the shipped default still resolves remote + locations, and the opt-out does not restrict local `file:` reads, so a + report against the default remains `BY-DESIGN: property-disclaimed`. - **2026-09-17** — "Place default limits on read timeouts + size on remote schemas" (#152) is a revision trigger under the first bullet above: a network location is now fetched through a stream the resolver 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 91558b9a..ff8d6b83 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 @@ -45,7 +45,9 @@ import org.xml.sax.InputSource; * schemes it will resolve to <code>http</code>, <code>https</code>, <code>file</code> and * <code>jar</code>, and refuses a schema location that changes the scheme of a remote base URI, * names a non-local authority with the <code>file:</code> scheme, or reads a <code>jar:</code> - * archive fetched over the network. Within the schemes it does allow it + * archive fetched over the network. A deployment with no remote schema sets can turn network + * resolution off altogether with the {@link #ALLOW_NETWORK_PROPERTY} system property, without + * supplying its own resolver. Within the schemes it does allow it * applies no host or address filtering, so any reachable host or readable file a schema location * names is fetched. An application that parses untrusted schema documents must install a restricting * resolver instead; see @@ -78,6 +80,18 @@ public class DefaultURIResolver implements CollectionURIResolver { public static final String MAX_BYTES_PROPERTY = "org.apache.ws.commons.schema.remote.maxBytes"; + /** + * 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 + * <code>xs:import</code> naming an <code>http</code> or <code>https</code> location is then + * refused instead of fetched, without the deployment having to supply its own + * {@link URIResolver}. It defaults to <code>true</code>, which is the behaviour this resolver + * has always had. Only "true" and "false" are recognised, so a typo leaves resolution working + * rather than silently turning it off. + */ + public static final String ALLOW_NETWORK_PROPERTY = + "org.apache.ws.commons.schema.remote.allowNetwork"; + 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; @@ -90,6 +104,7 @@ 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); + private final boolean allowNetwork = getBooleanProperty(ALLOW_NETWORK_PROPERTY, true); private String collectionBaseURI; @@ -272,6 +287,33 @@ public class DefaultURIResolver implements CollectionURIResolver { return millis > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int)millis; } + /** + * Reads a boolean system property. Only "true" and "false" count, so an unparseable value + * leaves the default in place rather than being read as <code>false</code> the way + * {@link Boolean#parseBoolean} would. + */ + private static boolean getBooleanProperty(final String name, boolean defaultValue) { + try { + String value = AccessController.doPrivileged(new PrivilegedAction<String>() { + public String run() { + return System.getProperty(name); + } + }); + if (value != null) { + String trimmed = value.trim(); + if ("true".equalsIgnoreCase(trimmed)) { + return true; + } + if ("false".equalsIgnoreCase(trimmed)) { + return false; + } + } + } catch (RuntimeException e) { + // fall through to the default + } + return defaultValue; + } + private static long getLongProperty(final String name, long defaultValue) { try { String value = AccessController.doPrivileged(new PrivilegedAction<String>() { @@ -291,7 +333,7 @@ public class DefaultURIResolver implements CollectionURIResolver { return defaultValue; } - private static void verifyComposedUrl(boolean remoteBase, String originalBaseUri, URL base, + private void verifyComposedUrl(boolean remoteBase, String originalBaseUri, URL base, URL composed, String schemaLocation) { verifyPermittedLocation(composed.toString(), schemaLocation); final String composedScheme = composed.getProtocol().toLowerCase(Locale.ENGLISH); @@ -328,7 +370,7 @@ public class DefaultURIResolver implements CollectionURIResolver { * @param uri the resolved location that would be handed to the parser. * @param schemaLocation the original schema location, for the error message. */ - private static void verifyPermittedLocation(String uri, String schemaLocation) { + private void verifyPermittedLocation(String uri, String schemaLocation) { final String scheme = effectiveScheme(uri); if (scheme == null || !ALLOWED_SCHEMES.contains(scheme)) { throw new XmlSchemaException("The schema location \"" + schemaLocation @@ -350,6 +392,11 @@ public class DefaultURIResolver implements CollectionURIResolver { + archive + "\", which is not permitted by" + " DefaultURIResolver."); } + if (!allowNetwork && isNetworkScheme(scheme)) { + throw new XmlSchemaException("The schema location \"" + schemaLocation + + "\" would be fetched over the network, which " + + ALLOW_NETWORK_PROPERTY + " has turned off."); + } if ("file".equals(scheme) && !isLocalFileUri(archive)) { throw new XmlSchemaException("The schema location \"" + schemaLocation + "\" resolves to a file URL with a non-local authority."); diff --git a/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java b/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java index 4e394092..a07f991b 100644 --- a/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java +++ b/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java @@ -294,4 +294,63 @@ public class DefaultURIResolverTest extends Assert { assertEquals("dir/a:b.xsd", resolver.resolveEntity("urn:x", "dir/a:b.xsd", null).getSystemId()); } + + /** + * A deployment whose schema sets are all local can turn remote fetching off without writing + * its own resolver. The property is read when the resolver is constructed, as the per-fetch + * bounds are, so each case builds its resolver after setting it. + */ + @Test + public void testNetworkResolutionCanBeTurnedOff() { + System.setProperty(DefaultURIResolver.ALLOW_NETWORK_PROPERTY, "false"); + try { + DefaultURIResolver resolver = new DefaultURIResolver(); + for (String location : new String[] {"http://example.com/x.xsd", + "https://example.com/x.xsd"}) { + for (String base : new String[] {null, localBase()}) { + try { + resolver.resolveEntity("urn:x", location, base); + fail("network resolution is off, so \"" + location + + "\" must be refused."); + } catch (XmlSchemaException expected) { + assertTrue(expected.getMessage(), + expected.getMessage().contains("turned off")); + } + } + } + // Local resolution is unaffected: that is the point of the switch. + assertEquals("file:///legit/local.xsd", + resolver.resolveEntity("urn:x", "file:///legit/local.xsd", null) + .getSystemId()); + assertEquals("sub/x.xsd", + resolver.resolveEntity("urn:x", "sub/x.xsd", null).getSystemId()); + assertTrue(resolver.resolveEntity("urn:x", "jar:file:///a.jar!/x.xsd", null) + .getSystemId().startsWith("jar:file:")); + } finally { + System.clearProperty(DefaultURIResolver.ALLOW_NETWORK_PROPERTY); + } + } + + @Test + public void testNetworkResolutionIsAllowedByDefault() { + assertNull(System.getProperty(DefaultURIResolver.ALLOW_NETWORK_PROPERTY)); + + assertEquals("http://example.com/x.xsd", + new DefaultURIResolver() + .resolveEntity("urn:x", "http://example.com/x.xsd", null).getSystemId()); + } + + @Test + public void testUnparseableAllowNetworkValueLeavesResolutionOn() { + // Boolean.parseBoolean would read this as false and quietly break the deployment. + System.setProperty(DefaultURIResolver.ALLOW_NETWORK_PROPERTY, "no"); + try { + assertEquals("http://example.com/x.xsd", + new DefaultURIResolver() + .resolveEntity("urn:x", "http://example.com/x.xsd", null) + .getSystemId()); + } finally { + System.clearProperty(DefaultURIResolver.ALLOW_NETWORK_PROPERTY); + } + } }
