This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/uri-resolver in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git
commit e87655fde9a3d3eff95565612b5cf5d03dc5b708 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Wed Sep 16 11:19:00 2026 +0100 Exclude a few other things by default in DefaultURIResolver --- README.txt | 6 ++- THREAT-MODEL.md | 22 +++++++-- .../ws/commons/schema/XmlSchemaCollection.java | 4 +- .../schema/resolver/DefaultURIResolver.java | 51 +++++++++++++------- .../test/java/tests/DefaultURIResolverTest.java | 56 +++++++++++++++++++++- 5 files changed, 111 insertions(+), 28 deletions(-) diff --git a/README.txt b/README.txt index 32e1b930..9c1b8cf5 100644 --- a/README.txt +++ b/README.txt @@ -84,8 +84,10 @@ For example, set a limit with: schema locations through a URIResolver. The bundled DefaultURIResolver is a convenience for trusted, operator-controlled schema sets. It resolves http, https, file and jar locations and applies no host or - address filtering, so a schema location naming an internal host, a - cloud metadata endpoint, or a local file is fetched on request. + address filtering to the http and https targets it allows, so a schema + location naming an internal host, a cloud metadata endpoint, or a local + 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. 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 b7609303..dffe0103 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -154,7 +154,7 @@ A finding is in-model only if it reaches a row marked **yes**. | --- | --- | --- | --- | | B1 | Caller → `XmlSchemaCollection.read(InputSource | Reader | Source | Document | Element)` | none — caller is trusted | none | | B2 | `XmlSchemaCollection.read(InputSource, ...)` → hardened JDK `DocumentBuilder` | none | external DTD/entity resolution disabled unconditionally; DOCTYPE accepted | -| B3 | Schema parser → `URIResolver.resolveEntity(namespace, schemaLocation, baseUri)` | none | bundled `DefaultURIResolver` allowlists the effective scheme (`http`, `https`, `file`, `jar`, judged through any `jar:` wrapper) and refuses a location that changes the scheme of a remote base or resolves to a non-local `file:` / `jar:` authority; it does **no host filtering** on the `http(s)` targets it allows | +| B3 | Schema parser → `URIResolver.resolveEntity(namespace, schemaLocation, baseUri)` | none | bundled `DefaultURIResolver` allowlists the effective scheme (`http`, `https`, `file`, `jar`, judged through any `jar:` wrapper), and unconditionally refuses a `file:` location naming a non-local authority or a `jar:` archive fetched over the network; it also refuses a location that changes the scheme of a remote base; it does **no host filtering** on the `http(s)` targets it allows | | B4 | Resolved `InputSource` → `XmlSchemaCollection.read(InputSource, ...)` (recursive) | none | none | | B5 | `XmlSchema.write(...)` → JDK `TransformerFactory` (with `FEATURE_SECURE_PROCESSING=true` and external DTD/stylesheet access disabled where supported) | none | none | | B6 | `XmlSchemaCollection` ctor → `System.getProperty("org.apache.ws.commons.schema.extension_registry")` → `Class.forName()` | none | trusts system properties to be operator-controlled | @@ -450,9 +450,13 @@ matching disclaimer. `InputSource` pointing at it. The JDK then fetches it on parse. The resolver restricts the *scheme* it will hand back — `http`, `https`, `file` and `jar`, judged through any `jar:` wrapper — and - refuses a location that changes the scheme of a remote base or - resolves to a non-local `file:` / `jar:` authority. Within those - schemes it applies **no host or address filtering of any kind**: any + refuses a location that changes the scheme of a remote base. It also + refuses, for every location and whatever the base, a `file:` URL that + names a non-local authority (a UNC path on Windows, so an SMB + connection to a host the schema author chose) and a `jar:` URL whose + archive would be fetched over the network. Within the `http` and + `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 @@ -689,6 +693,16 @@ Revise this document when any of the following lands: held. §14 Q12 is ruled (b) in the same pass, which resolves the conditional dispositions in §5a and §11a to `BY-DESIGN: property-disclaimed`. +- **2026-09-16** — a third resolver change, again a revision trigger + under the first bullet above: the `file:` and `jar:` authority rules, + previously reachable only for *relative* locations composed against a + base, now apply to every resolved location, and a `jar:` URL whose + archive is fetched over `http`, `https` or `ftp` is refused outright. + An absolute `schemaLocation` had been skipping both checks, so + `file://host/share/x.xsd` and `jar:http://host/a.jar!/x.xsd` resolved + from a local or absent base. §4 B3 and §9 are updated. This does not + change the Q12(b) posture: absolute `http(s)` and local `file:` + locations are still followed. - **2026-09-16** — "Fix up DTD handling" (#147) changed the default parser DTD posture, a revision trigger under the second bullet above: external DTD and external entity resolution are now disabled diff --git a/xmlschema-core/src/main/java/org/apache/ws/commons/schema/XmlSchemaCollection.java b/xmlschema-core/src/main/java/org/apache/ws/commons/schema/XmlSchemaCollection.java index fdfdc056..0a367c25 100644 --- a/xmlschema-core/src/main/java/org/apache/ws/commons/schema/XmlSchemaCollection.java +++ b/xmlschema-core/src/main/java/org/apache/ws/commons/schema/XmlSchemaCollection.java @@ -71,8 +71,8 @@ import org.apache.ws.commons.schema.utils.TargetNamespaceValidator; * <code>schemaLocation</code> is dereferenced through the collection's * {@link URIResolver}. The default is {@link DefaultURIResolver}, which resolves * <code>http</code>, <code>https</code>, <code>file</code> and <code>jar</code> locations and - * applies no host or address filtering. It is a convenience for trusted, - * operator-controlled schema sets. + * applies no host or address filtering to the <code>http</code> and <code>https</code> targets it + * allows. It is a convenience for trusted, operator-controlled schema sets. * </p> * <p> * <strong>An application that reads schema or WSDL documents from an untrusted source must 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 561a268f..1f7a45f8 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 @@ -38,8 +38,9 @@ import org.xml.sax.InputSource; * <p> * This resolver is a convenience for trusted, operator-controlled schema sets. It restricts the URI * 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 or - * resolves to a non-local <code>file:</code> / <code>jar:</code> authority. Within those schemes it + * <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 * 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 @@ -108,7 +109,7 @@ public class DefaultURIResolver implements CollectionURIResolver { } if (isAbsoluteUri(schemaLocation)) { - verifyAllowedScheme(schemaLocation, schemaLocation); + verifyPermittedLocation(schemaLocation, schemaLocation); return new InputSource(schemaLocation); } if (isPlainRelativePath(schemaLocation)) { @@ -120,7 +121,7 @@ public class DefaultURIResolver implements CollectionURIResolver { private static void verifyComposedUrl(boolean remoteBase, String originalBaseUri, URL base, URL composed, String schemaLocation) { - verifyAllowedScheme(composed.toString(), schemaLocation); + verifyPermittedLocation(composed.toString(), schemaLocation); final String composedScheme = composed.getProtocol().toLowerCase(Locale.ENGLISH); if (isAbsoluteUri(schemaLocation)) { if (remoteBase && !isNetworkScheme(composedScheme)) { @@ -144,33 +145,47 @@ public class DefaultURIResolver implements CollectionURIResolver { + "\" changes the scheme of its base URI from \"" + base.getProtocol() + "\" to \"" + composed.getProtocol() + "\"."); } - if ("file".equals(composedScheme)) { - final String host = composed.getHost(); - if (host != null && host.length() > 0 && !"localhost".equalsIgnoreCase(host)) { - throw new XmlSchemaException("The schema location \"" + schemaLocation - + "\" resolves to a file URL with a non-local authority."); - } - } else if (remoteBase && "jar".equals(composedScheme) - && composed.toString().regionMatches(true, 0, "jar:file:", 0, 9) - && !isLocalFileUri(composed.toString().substring(4))) { - throw new XmlSchemaException("The schema location \"" + schemaLocation - + "\" resolves to a jar URL with a non-local file authority."); - } } /** - * Refuse a resolved location whose effective scheme is not one a schema document may use. + * Refuse a resolved location this resolver will not dereference. Three rules apply, to every + * location and whatever the base URI was: the effective scheme must be one a schema document + * may use, a <code>jar:</code> URL may not pull its archive over the network, and a + * <code>file:</code> URL may not name a remote authority. * * @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 verifyAllowedScheme(String uri, String schemaLocation) { + private static 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 + "\" resolves to the scheme \"" + scheme + "\", which is not permitted by DefaultURIResolver."); } + final String trimmed = uri.trim(); + final boolean wrapped = "jar".equals(extractScheme(trimmed)); + // A jar: URL delegates to the URL of the archive; the entry after "!/" is inside it. + final String archive = wrapped ? stripJarEntry(trimmed.substring(4)) : trimmed; + if (wrapped && isNetworkScheme(scheme)) { + throw new XmlSchemaException("The schema location \"" + schemaLocation + + "\" reads an archive fetched over the network, \"" + + archive + "\", which is not permitted by" + + " DefaultURIResolver."); + } + if ("file".equals(scheme) && !isLocalFileUri(archive)) { + throw new XmlSchemaException("The schema location \"" + schemaLocation + + "\" resolves to a file URL with a non-local authority."); + } + } + + /** + * Drop the entry part of a jar: URL, leaving the URL of the archive itself. The entry is an + * arbitrary path inside the archive and need not parse as part of a URI. + */ + private static String stripJarEntry(String uri) { + final int separator = uri.indexOf("!/"); + return separator < 0 ? uri : uri.substring(0, separator); } /** diff --git a/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java b/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java index 996fba58..6cadfa04 100644 --- a/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java +++ b/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java @@ -129,12 +129,18 @@ public class DefaultURIResolverTest extends Assert { } private static void assertSchemeRefused(String schemaLocation, String baseUri) { + assertSchemeRefused(schemaLocation, baseUri, "not permitted"); + } + + private static void assertSchemeRefused(String schemaLocation, String baseUri, + String expectedMessageFragment) { DefaultURIResolver resolver = new DefaultURIResolver(); try { resolver.resolveEntity("urn:x", schemaLocation, baseUri); - fail("The scheme of \"" + schemaLocation + "\" must be refused."); + fail("The location \"" + schemaLocation + "\" must be refused."); } catch (XmlSchemaException expected) { - assertTrue(expected.getMessage(), expected.getMessage().contains("not permitted")); + assertTrue(expected.getMessage(), + expected.getMessage().contains(expectedMessageFragment)); } } @@ -182,4 +188,50 @@ public class DefaultURIResolverTest extends Assert { assertEquals("sub/x.xsd", resolver.resolveEntity("urn:x", "sub/x.xsd", null).getSystemId()); } + + @Test + public void testFileUrlWithNonLocalAuthorityIsRefused() { + // On Windows this is a UNC path, so the JVM would make an SMB connection to a host the + // schema author chose. No base URI makes it reachable: an absolute location used to skip + // every check but the scheme allowlist. + assertSchemeRefused("file://attacker.example/share/x.xsd", localBase(), "non-local authority"); + assertSchemeRefused("file://attacker.example/share/x.xsd", null, "non-local authority"); + assertSchemeRefused("jar:file://attacker.example/share/a.jar!/x.xsd", localBase(), + "non-local authority"); + assertSchemeRefused("jar:file://attacker.example/share/a.jar!/x.xsd", null, + "non-local authority"); + } + + @Test + public void testLocalFileAuthoritiesAreStillAccepted() { + DefaultURIResolver resolver = new DefaultURIResolver(); + + assertEquals("file:///local/x.xsd", + resolver.resolveEntity("urn:x", "file:///local/x.xsd", null).getSystemId()); + assertEquals("file://localhost/local/x.xsd", + resolver.resolveEntity("urn:x", "file://localhost/local/x.xsd", null) + .getSystemId()); + } + + @Test + public void testJarOverTheNetworkIsRefused() { + // JarURLConnection would fetch and cache the whole remote archive, and the URL reads as + // protocol "jar" rather than "http". + for (String location : new String[] {"jar:http://attacker.example/a.jar!/x.xsd", + "jar:https://attacker.example/a.jar!/x.xsd"}) { + assertSchemeRefused(location, localBase(), "over the network"); + assertSchemeRefused(location, null, "over the network"); + } + } + + @Test + public void testJarEntryNeedNotParseAsAUri() { + // The archive is local; the entry after "!/" is not part of the URI that names it. + DefaultURIResolver resolver = new DefaultURIResolver(); + + InputSource result = resolver.resolveEntity("urn:x", "jar:file:///tmp/a.jar!/has space.xsd", + null); + + assertEquals("jar:file:///tmp/a.jar!/has space.xsd", result.getSystemId()); + } }
