diff --git a/src/main/java/de/kosit/validationtool/impl/xml/RelativeUriResolver.java b/src/main/java/de/kosit/validationtool/impl/xml/RelativeUriResolver.java index 132ed7e..4c9deae 100644 --- a/src/main/java/de/kosit/validationtool/impl/xml/RelativeUriResolver.java +++ b/src/main/java/de/kosit/validationtool/impl/xml/RelativeUriResolver.java @@ -19,6 +19,9 @@ package de.kosit.validationtool.impl.xml; import java.io.IOException; import java.io.Reader; import java.net.URI; +import java.nio.file.FileSystemNotFoundException; +import java.nio.file.Path; +import java.nio.file.Paths; import javax.xml.transform.Source; import javax.xml.transform.TransformerException; @@ -81,12 +84,67 @@ public class RelativeUriResolver implements URIResolver, UnparsedTextURIResolver } private static boolean isUnderBaseUri(final URI resolved, final URI baseUri) { - if (resolved == null || baseUri == null) { + if (resolved == null || baseUri == null || resolved.getScheme() == null || baseUri.getScheme() == null + || !resolved.getScheme().equalsIgnoreCase(baseUri.getScheme())) { return false; } - final String base = baseUri.toASCIIString().replaceAll("file:/+", ""); - final String r = resolved.toASCIIString().replaceAll("file:/+", ""); - return r.startsWith(base); + if ("file".equalsIgnoreCase(resolved.getScheme())) { + return isUnderBasePath(resolved, baseUri); + } + if (isJarURI(resolved)) { + return isUnderBaseJarUri(resolved, baseUri); + } + // remaining schemes (e.g. http repositories configured via the API): normalized comparison on a path + // segment boundary. URI.normalize() does not decode percent-encoded dot segments, but for non-local + // schemes decoding happens server-side and can not be validated here. + final String base = ensureTrailingSlash(baseUri.normalize().toASCIIString()); + return resolved.normalize().toASCIIString().startsWith(base); + } + + /** + * Compares file uris as decoded and normalized paths. {@link Paths#get(URI)} decodes percent-encoded characters, so + * encoded dot segments (e.g. {@code %2e%2e}) are resolved by {@link Path#normalize()} before the segment-aware + * {@link Path#startsWith(Path)} comparison. Fails closed for uris that can not be mapped to a local path (e.g. + * non-local {@code file://host/path} uris). + */ + private static boolean isUnderBasePath(final URI resolved, final URI baseUri) { + try { + final Path base = Paths.get(baseUri).normalize(); + return Paths.get(resolved).normalize().startsWith(base); + } catch (final IllegalArgumentException | FileSystemNotFoundException e) { + return false; + } + } + + /** + * Compares jar uris by their jar file location and their entry path. The jar file must be identical, since a + * relative resolution can never legitimately reach into a different jar. Entries are confined to the same jar file + * in any case, so the entry comparison is a plain normalized prefix check. + */ + private static boolean isUnderBaseJarUri(final URI resolved, final URI baseUri) { + final String resolvedPart = resolved.getSchemeSpecificPart(); + final String basePart = baseUri.getSchemeSpecificPart(); + final int resolvedSeparator = resolvedPart.indexOf("!/"); + final int baseSeparator = basePart.indexOf("!/"); + if (resolvedSeparator < 0 || baseSeparator < 0) { + return false; + } + try { + final Path resolvedJar = Paths.get(URI.create(resolvedPart.substring(0, resolvedSeparator))).normalize(); + final Path baseJar = Paths.get(URI.create(basePart.substring(0, baseSeparator))).normalize(); + if (!resolvedJar.equals(baseJar)) { + return false; + } + } catch (final IllegalArgumentException | FileSystemNotFoundException e) { + return false; + } + final Path resolvedEntry = Paths.get("/", resolvedPart.substring(resolvedSeparator + 2)).normalize(); + final Path baseEntry = Paths.get("/", basePart.substring(baseSeparator + 2)).normalize(); + return resolvedEntry.startsWith(baseEntry); + } + + private static String ensureTrailingSlash(final String uri) { + return uri.endsWith("/") ? uri : uri + "/"; } // from UnparsedTextURIResolver diff --git a/src/test/java/de/kosit/validationtool/impl/RelativeUriResolverTest.java b/src/test/java/de/kosit/validationtool/impl/RelativeUriResolverTest.java index 4a5f937..dd270fd 100644 --- a/src/test/java/de/kosit/validationtool/impl/RelativeUriResolverTest.java +++ b/src/test/java/de/kosit/validationtool/impl/RelativeUriResolverTest.java @@ -17,6 +17,7 @@ package de.kosit.validationtool.impl; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.net.URI; import java.net.URISyntaxException; @@ -28,6 +29,8 @@ import javax.xml.transform.URIResolver; import org.junit.Test; +import net.sf.saxon.trans.XPathException; + import de.kosit.validationtool.impl.xml.RelativeUriResolver; /** @@ -80,4 +83,34 @@ public class RelativeUriResolverTest { final Source resolved = this.resolver.resolve("./resources/reference.xsd", moz.toURI().toASCIIString()); assertThat(resolved).isNotNull(); } + + @Test + public void testPercentEncodedTraversalRejected() { + // %2e%2e survives URI.resolve() undecoded but is decoded to '..' when the file is opened. + assertThatThrownBy(() -> this.resolver.resolve("x/%2e%2e/%2e%2e/simple/scenarios.xml", BASE.toASCIIString())) + .isInstanceOf(TransformerException.class).hasMessageContaining("not within the configured repository"); + } + + @Test + public void testPercentEncodedTraversalRejectedForUnparsedText() throws URISyntaxException { + final RelativeUriResolver r = new RelativeUriResolver(BASE); + final URI escaped = new URI(BASE.toASCIIString() + "x/%2e%2e/%2e%2e/simple/scenarios.xml"); + assertThatThrownBy(() -> r.resolve(escaped, "UTF-8", null)).isInstanceOf(XPathException.class) + .hasMessageContaining("not within the configured repository"); + } + + @Test + public void testSiblingDirectoryWithCommonPrefixRejected() throws URISyntaxException { + // a base uri without trailing slash must not match sibling directories sharing a name prefix + final URI baseWithoutSlash = new URI(BASE.toASCIIString().replaceFirst("/$", "")); + final URIResolver r = new RelativeUriResolver(baseWithoutSlash); + assertThatThrownBy(() -> r.resolve("../assertions-sibling/file.xml", baseWithoutSlash.toASCIIString())) + .isInstanceOf(TransformerException.class).hasMessageContaining("not within the configured repository"); + } + + @Test + public void testDifferentSchemeRejected() { + assertThatThrownBy(() -> this.resolver.resolve("http://example.org/evil.xsl", BASE.toASCIIString())) + .isInstanceOf(TransformerException.class).hasMessageContaining("not within the configured repository"); + } }