This commit is contained in:
Stefan Grönke 2026-07-24 14:02:36 +02:00 committed by GitHub
commit 1bdf2c0568
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 95 additions and 4 deletions

View file

@ -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

View file

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