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 @@ 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")); + } +}