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 4423f79e Harden default resolver (#135)
4423f79e is described below
commit 4423f79e7f6d7050dbdcc30cc88a4d83dea7bb94
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Thu Aug 27 10:19:17 2026 +0100
Harden default resolver (#135)
---
.../schema/resolver/DefaultURIResolver.java | 169 +++++++++++++++++++--
.../test/java/tests/DefaultURIResolverTest.java | 126 +++++++++++++++
2 files changed, 279 insertions(+), 16 deletions(-)
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 79747b66..4082ccc8 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,8 +21,11 @@ package org.apache.ws.commons.schema.resolver;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URI;
+import java.net.URISyntaxException;
import java.net.URL;
+import java.util.Locale;
+import org.apache.ws.commons.schema.XmlSchemaException;
import org.xml.sax.InputSource;
/**
@@ -43,36 +46,170 @@ public class DefaultURIResolver implements
CollectionURIResolver {
public InputSource resolveEntity(String namespace, String schemaLocation,
String baseUri) {
if (baseUri != null) {
+ final String originalBaseUri = baseUri;
+ final boolean remoteBase = isRemoteBase(baseUri);
try {
- File baseFile = null;
- try {
- URI uri = new URI(baseUri);
- baseFile = new File(uri);
- if (!baseFile.exists()) {
+ if (!remoteBase) {
+ File baseFile = null;
+ try {
+ URI uri = new URI(baseUri);
+ baseFile = new File(uri);
+ if (!baseFile.exists()) {
+ baseFile = new File(baseUri);
+ }
+ } catch (Throwable ex) {
baseFile = new File(baseUri);
}
- } catch (Throwable ex) {
- baseFile = new File(baseUri);
- }
- if (baseFile.exists()) {
- baseUri = baseFile.toURI().toString();
- } else if (collectionBaseURI != null) {
- baseFile = new File(collectionBaseURI);
if (baseFile.exists()) {
baseUri = baseFile.toURI().toString();
+ } else if (collectionBaseURI != null) {
+ baseFile = new File(collectionBaseURI);
+ if (baseFile.exists()) {
+ baseUri = baseFile.toURI().toString();
+ }
}
}
- String ref = new URL(new URL(baseUri),
schemaLocation).toString();
+ URL base = new URL(baseUri);
+ URL ref = new URL(base, schemaLocation);
+ verifyComposedUrl(remoteBase, originalBaseUri, base, ref,
schemaLocation);
- return new InputSource(ref);
+ return new InputSource(ref.toString());
} catch (MalformedURLException e1) {
- throw new RuntimeException(e1);
+ throw new XmlSchemaException("Unable to resolve the schema
location \"" + schemaLocation
+ + "\" against the base URI \"" +
baseUri + "\"", e1);
}
}
- return new InputSource(schemaLocation);
+ if (isAbsoluteUri(schemaLocation) ||
isPlainRelativePath(schemaLocation)) {
+ return new InputSource(schemaLocation);
+ }
+ return null;
+
+ }
+
+ private static void verifyComposedUrl(boolean remoteBase, String
originalBaseUri, URL base,
+ URL composed, String schemaLocation)
{
+ final String composedScheme =
composed.getProtocol().toLowerCase(Locale.ENGLISH);
+ if (isAbsoluteUri(schemaLocation)) {
+ if (remoteBase && !isNetworkScheme(composedScheme)) {
+ throw new XmlSchemaException("The schema location \"" +
schemaLocation
+ + "\" of the remote base URI \""
+ originalBaseUri
+ + "\" uses the non-network scheme
\"" + composedScheme
+ + "\".");
+ }
+ return;
+ }
+ if (remoteBase) {
+ final String originalScheme = extractScheme(originalBaseUri);
+ if (originalScheme != null &&
!originalScheme.equals(composedScheme)) {
+ throw new XmlSchemaException("The schema location \"" +
schemaLocation
+ + "\" changes the scheme of its
base URI from \""
+ + originalScheme + "\" to \"" +
composedScheme + "\".");
+ }
+ }
+ if (!composed.getProtocol().equalsIgnoreCase(base.getProtocol())) {
+ throw new XmlSchemaException("The schema location \"" +
schemaLocation
+ + "\" 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.");
+ }
+ }
+
+ private static boolean isAbsoluteUri(String uri) {
+ if (isWindowsDriveRootedPath(uri)) {
+ return false;
+ }
+ try {
+ return new URI(uri).isAbsolute();
+ } catch (URISyntaxException e) {
+ return false;
+ }
+ }
+
+ private static boolean isRemoteBase(String uri) {
+ final String trimmed = uri.trim();
+ if (trimmed.startsWith("//")) {
+ return true;
+ }
+ final String scheme = extractScheme(trimmed);
+ if (scheme == null) {
+ return false;
+ }
+ if ("file".equals(scheme)) {
+ return !isLocalFileUri(trimmed);
+ }
+ if ("urn".equals(scheme)) {
+ return false;
+ }
+ if ("jar".equals(scheme)) {
+ final String nested = extractScheme(trimmed.substring(4));
+ return !"file".equals(nested) ||
!isLocalFileUri(trimmed.substring(4));
+ }
+ return true;
+ }
+
+ private static boolean isLocalFileUri(String uri) {
+ try {
+ URI parsed = new URI(uri);
+ final String authority = parsed.getAuthority();
+ return "file".equalsIgnoreCase(parsed.getScheme())
+ && (authority == null || authority.length() == 0 ||
"localhost".equalsIgnoreCase(authority));
+ } catch (URISyntaxException e) {
+ return false;
+ }
+ }
+
+ private static boolean isNetworkScheme(String scheme) {
+ return "http".equals(scheme) || "https".equals(scheme) ||
"ftp".equals(scheme);
+ }
+
+ private static String extractScheme(String uri) {
+ final String trimmed = uri.trim();
+ final int colon = trimmed.indexOf(':');
+ if (colon <= 1 || !isAsciiLetter(trimmed.charAt(0))) {
+ return null;
+ }
+ for (int i = 1; i < colon; i++) {
+ final char c = trimmed.charAt(i);
+ if (!isAsciiLetter(c) && !(c >= '0' && c <= '9') && c != '+' && c
!= '-' && c != '.') {
+ return null;
+ }
+ }
+ return trimmed.substring(0, colon).toLowerCase(Locale.ENGLISH);
+ }
+
+ private static boolean isAsciiLetter(char c) {
+ return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
+ }
+
+ private static boolean isPlainRelativePath(String location) {
+ if (location.startsWith("/") || location.startsWith("\\")
+ || isWindowsDriveRootedPath(location)) {
+ return false;
+ }
+ for (String segment : location.replace('\\', '/').split("/")) {
+ if ("..".equals(segment)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ private static boolean isWindowsDriveRootedPath(String location) {
+ return location.length() >= 3 && isAsciiLetter(location.charAt(0))
+ && location.charAt(1) == ':' && (location.charAt(2) == '/' ||
location.charAt(2) == '\\');
}
/**
diff --git a/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java
b/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java
new file mode 100644
index 00000000..153a23ee
--- /dev/null
+++ b/xmlschema-core/src/test/java/tests/DefaultURIResolverTest.java
@@ -0,0 +1,126 @@
+/**
+ * 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.File;
+
+import org.apache.ws.commons.schema.XmlSchemaException;
+import org.apache.ws.commons.schema.resolver.DefaultURIResolver;
+import org.junit.Assert;
+import org.junit.Test;
+import org.xml.sax.InputSource;
+
+public class DefaultURIResolverTest extends Assert {
+
+ private static File existingDirectory() {
+ return new File(System.getProperty("java.io.tmpdir"));
+ }
+
+ @Test
+ public void testRemoteBaseIsNotRebasedOntoCollectionBase() {
+ DefaultURIResolver resolver = new DefaultURIResolver();
+ resolver.setCollectionBaseURI(existingDirectory().getAbsolutePath());
+
+ InputSource result = resolver.resolveEntity("urn:x", "sub/x.xsd",
+
"http://example.com/dir/remote.xsd");
+
+ assertEquals("http://example.com/dir/sub/x.xsd", result.getSystemId());
+ }
+
+ @Test
+ public void testAbsoluteFileLocationOnRemoteBaseIsRefused() {
+ DefaultURIResolver resolver = new DefaultURIResolver();
+
+ try {
+ resolver.resolveEntity("urn:x", "file:///etc/passwd",
+ "http://example.com/dir/remote.xsd");
+ fail("An absolute file URL from a remote base must be refused.");
+ } catch (XmlSchemaException expected) {
+ // expected
+ }
+ }
+
+ @Test
+ public void testNonLocalFileAuthorityCannotTriggerCollectionRebase() {
+ DefaultURIResolver resolver = new DefaultURIResolver();
+ resolver.setCollectionBaseURI(existingDirectory().getAbsolutePath());
+
+ try {
+ InputSource result = resolver.resolveEntity("urn:x",
"../secret.xsd",
+
"file://attacker.example/share/base.xsd");
+ assertFalse(result.getSystemId().startsWith("file:"));
+ } catch (XmlSchemaException expected) {
+ // expected
+ }
+ }
+
+ @Test
+ public void testJarFileAuthorityCannotTriggerCollectionRebase() {
+ DefaultURIResolver resolver = new DefaultURIResolver();
+ resolver.setCollectionBaseURI(existingDirectory().getAbsolutePath());
+
+ try {
+ InputSource result = resolver.resolveEntity("urn:x",
"../secret.xsd",
+
"jar:file://attacker.example/share/a.jar!/dir/base.xsd");
+ assertFalse(result.getSystemId().startsWith("file:"));
+ } catch (XmlSchemaException expected) {
+ // expected
+ }
+ }
+
+ @Test
+ public void testJarFileBaseStillResolvesInsideJar() {
+ DefaultURIResolver resolver = new DefaultURIResolver();
+ String jarBase = "jar:" + new File(existingDirectory(),
"x.zip").toURI()
+ + "!/dir/base.xsd";
+
+ InputSource result = resolver.resolveEntity("urn:x", "child.xsd",
jarBase);
+
+ assertTrue(result.getSystemId().startsWith("jar:file:"));
+ assertTrue(result.getSystemId().endsWith("/dir/child.xsd"));
+ }
+
+ @Test
+ public void testRootedWindowsLocationWithoutBaseIsRefused() {
+ DefaultURIResolver resolver = new DefaultURIResolver();
+
+ assertNull(resolver.resolveEntity("urn:x",
"C:\\Windows\\System32\\config\\SAM", null));
+ assertNull(resolver.resolveEntity("urn:x",
"C:/Windows/System32/config/SAM", null));
+ }
+
+ @Test
+ public void testPlainRelativeLocationWithoutBaseKeepsLegacyBehavior() {
+ DefaultURIResolver resolver = new DefaultURIResolver();
+
+ InputSource result = resolver.resolveEntity("urn:x", "sub/x.xsd",
null);
+
+ assertEquals("sub/x.xsd", result.getSystemId());
+ }
+
+ @Test
+ public void testOpaqueBaseStillFallsBackToCollectionBase() {
+ DefaultURIResolver resolver = new DefaultURIResolver();
+ resolver.setCollectionBaseURI(existingDirectory().getAbsolutePath());
+
+ InputSource result = resolver.resolveEntity("urn:x", "imported.xsd",
"urn:schemas0");
+
+ assertTrue(result.getSystemId().startsWith("file:"));
+ assertTrue(result.getSystemId().endsWith("imported.xsd"));
+ }
+}