This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-configuration.git
The following commit(s) were added to refs/heads/master by this push:
new d14950e02 Extend scheme validation to inner schemes of jar: URLs (#636)
d14950e02 is described below
commit d14950e026bc007d4af73964dca7aeabddc9d915
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Tue May 12 13:33:30 2026 +0200
Extend scheme validation to inner schemes of jar: URLs (#636)
* Extend scheme validation to inner schemes of jar: URLs
Builds on #633 by recursively validating the inner URL of a jar: URL
against the same scheme and host allow-lists.
This deliberately changes the previous semantics: for `jar:http://host/...`
to be accepted, both `jar` and `http` must appear in the allow-list, and the
inner host must satisfy the host allow-list.
An alternative considered was the grammar documented by
[`XMLConstants`](https://docs.oracle.com/en/java/javase/25/docs/api/java.xml/javax/xml/XMLConstants.html),
where tokens like `jar:file` or `jar:http` would explicitly allow specific
inner schemes. That grammar is documented but not honored by the JDK reference
implementation: `jdk.xml.internal.SecuritySupport.checkAccess`
(verified on JDK 8, 17 and 25) strips the `jar:` prefix and matches only
the inner scheme as a bare token, so a `jar:http` entry in the allow-list never
matches anything. Aligning with the documented spec would have added
marginal expressiveness at the cost of diverging from what JDKs actually do.
* fix: use `anyMatch`
* fix: useless `String.format`
* fix: anyMatch -> noneMatch
The classical inversion bug. :wink:
* fix: import and formatting errors
* fix: no parameterized test `name`
* fix: apply review comments
---
.../ex/ConfigurationDeniedException.java | 12 ++++
.../io/AbstractFileLocationStrategy.java | 68 +++++++++++++-----
.../io/TestAbstractFileLocationStrategy.java | 84 ++++++++++++++++++++++
3 files changed, 147 insertions(+), 17 deletions(-)
diff --git
a/src/main/java/org/apache/commons/configuration2/ex/ConfigurationDeniedException.java
b/src/main/java/org/apache/commons/configuration2/ex/ConfigurationDeniedException.java
index 69ef3ca25..c31c27007 100644
---
a/src/main/java/org/apache/commons/configuration2/ex/ConfigurationDeniedException.java
+++
b/src/main/java/org/apache/commons/configuration2/ex/ConfigurationDeniedException.java
@@ -36,4 +36,16 @@ public class ConfigurationDeniedException extends
ConfigurationRuntimeException
public ConfigurationDeniedException(final String message, final Object...
args) {
super(message, args);
}
+
+ /**
+ * Constructs a new {@code ConfigurationDeniedException} with specified
detail message using {@link String#format(String,Object...)} and cause.
+ *
+ * @param cause the cause.
+ * @param message the error message.
+ * @param args arguments to the error message.
+ * @see String#format(String,Object...)
+ */
+ public ConfigurationDeniedException(final Throwable cause, final String
message, final Object... args) {
+ super(cause, message, args);
+ }
}
diff --git
a/src/main/java/org/apache/commons/configuration2/io/AbstractFileLocationStrategy.java
b/src/main/java/org/apache/commons/configuration2/io/AbstractFileLocationStrategy.java
index 1eb5716a5..5173e424f 100644
---
a/src/main/java/org/apache/commons/configuration2/io/AbstractFileLocationStrategy.java
+++
b/src/main/java/org/apache/commons/configuration2/io/AbstractFileLocationStrategy.java
@@ -17,6 +17,7 @@
package org.apache.commons.configuration2.io;
+import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import java.util.LinkedHashSet;
@@ -204,6 +205,55 @@ public abstract class AbstractFileLocationStrategy
implements FileLocationStrate
*/
private static final String KEY_SCHEMES =
"org.apache.commons.configuration2.io.FileLocationStrategy.schemes";
+ private static void checkHost(final String value, final Set<Pattern>
validSet) {
+ final String lowerCase = StringUtils.toRootLowerCase(value);
+ if (!validSet.isEmpty() && StringUtils.isNotEmpty(lowerCase) &&
validSet.stream().noneMatch(p -> p.matcher(lowerCase).matches())) {
+ throw new ConfigurationDeniedException("URL host is not enabled:
%s; must be one of %s", value, validSet);
+ }
+ }
+
+ /**
+ * Checks if the scheme is allowed.
+ *
+ * @param value A URL scheme, never empty or {@code null}.
+ * @param validSet the scheme valid-set.
+ */
+ private static void checkScheme(final String value, final Set<String>
validSet) {
+ if (!validSet.isEmpty() &&
!validSet.contains(StringUtils.toRootLowerCase(value))) {
+ throw new ConfigurationDeniedException("URL scheme \"%s\" is not
enabled, must be one of %s, override defaults with the system property \"%s\", "
+ + "complete set: \"file,http,https,jar\"", value,
validSet, KEY_SCHEMES);
+ }
+ }
+
+ /**
+ * Validates {@code url} against the scheme and host allow-lists.
+ *
+ * @param url the URL to check.
+ * @param validSchemes the scheme valid-set.
+ * @param validHosts the host valid-set.
+ * @throws ConfigurationDeniedException if the URL or any embedded URL
fails the check, or a {@code jar:} URL is malformed.
+ */
+ static void checkUrl(final URL url, final Set<String> validSchemes, final
Set<Pattern> validHosts) {
+ String scheme = url.getProtocol();
+ checkScheme(scheme, validSchemes);
+ if ("jar".equalsIgnoreCase(scheme)) {
+ try {
+ // Follows the logic of JarURLConnection#parseSpecs without
the cost of opening a connection.
+ final String spec = url.getFile();
+ final int sep = spec.lastIndexOf("!/");
+ if (sep < 0) {
+ throw new MalformedURLException("no !/ found in url spec:"
+ spec);
+ }
+ final URL inner = new URL(spec.substring(0, sep));
+ checkUrl(inner, validSchemes, validHosts);
+ } catch (final MalformedURLException e) {
+ throw new ConfigurationDeniedException(e, "Malformed 'jar:'
URL: %s", url);
+ }
+ } else {
+ checkHost(url.getHost(), validHosts);
+ }
+ }
+
private static Set<String> getSchemesProperty() {
final Set<String> set = new LinkedHashSet<>();
final String[] split = System.getProperty(KEY_SCHEMES,
DEFAULT_SCHEMES).split(",");
@@ -247,27 +297,11 @@ public abstract class AbstractFileLocationStrategy
implements FileLocationStrate
URL check(final URL url) {
if (url != null) {
- checkScheme("scheme", url, url.getProtocol(), schemes);
- checkHost("host", url, url.getHost(), hosts);
+ checkUrl(url, schemes, hosts);
}
return url;
}
- void checkHost(final String type, final URL url, final String value, final
Set<Pattern> validSet) {
- if (!validSet.isEmpty() && StringUtils.isNotEmpty(value)) {
- hosts.stream().filter(p ->
p.matcher(StringUtils.toRootLowerCase(value)).matches()).findFirst()
- .orElseThrow(() -> new
ConfigurationDeniedException(String.format("URL %s is not enabled: %s; must be
one of %s", type, value, validSet)));
- }
- }
-
- void checkScheme(final String type, final URL url, final String value,
final Set<String> validSet) {
- if (!validSet.isEmpty() && value != null &&
!validSet.contains(StringUtils.toRootLowerCase(value))) {
- throw new ConfigurationDeniedException(String.format(
- "URL %s \"%s\" is not enabled, must be one of %s, override
defaults with the system property \"%s\", complete set:
\"file,http,https,jar\"",
- type, value, validSet, KEY_SCHEMES));
- }
- }
-
/**
* Gets the enabled hosts.
*
diff --git
a/src/test/java/org/apache/commons/configuration2/io/TestAbstractFileLocationStrategy.java
b/src/test/java/org/apache/commons/configuration2/io/TestAbstractFileLocationStrategy.java
index 5ceb2e799..ac915278a 100644
---
a/src/test/java/org/apache/commons/configuration2/io/TestAbstractFileLocationStrategy.java
+++
b/src/test/java/org/apache/commons/configuration2/io/TestAbstractFileLocationStrategy.java
@@ -17,17 +17,101 @@
package org.apache.commons.configuration2.io;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import org.apache.commons.configuration2.ex.ConfigurationDeniedException;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
/**
* Tests {@link AbstractFileLocationStrategy}.
*/
public class TestAbstractFileLocationStrategy {
+ private static URL url(final String spec) throws Exception {
+ return new URL(spec);
+ }
+
+ // Bypasses the validation of the single-arg constructor
+ private static URL jarUrl(final String spec) throws Exception {
+ return new URL("jar", null, spec);
+ }
+
+ private static Set<String> schemes(final String... values) {
+ return new LinkedHashSet<>(Arrays.asList(values));
+ }
+
+ private static Set<Pattern> hosts(final String... regexes) {
+ final LinkedHashSet<Pattern> set = new LinkedHashSet<>();
+ for (final String r : regexes) {
+ set.add(Pattern.compile(r, Pattern.CASE_INSENSITIVE));
+ }
+ return set;
+ }
+
+ static Stream<Arguments> testCheckUrlAccepts() throws Exception {
+ return Stream.of(
+ // Empty scheme allows all.
+ Arguments.of(url("file:/tmp/x.properties"), schemes(),
hosts()),
+ Arguments.of(url("https://example.com/x.properties"),
schemes(), hosts()),
+ // Bare schemes that match the allow-set.
+ Arguments.of(url("file:/tmp/x.properties"), schemes("file"),
hosts()),
+ Arguments.of(url("https://example.com/x.properties"),
schemes("https"), hosts()),
+ // jar: unwraps to the inner scheme, which is in the allow-set.
+ Arguments.of(url("jar:file:/tmp/x.jar!/y.properties"),
schemes("file", "jar"), hosts()),
+
Arguments.of(url("jar:https://example.com/x.jar!/y.properties"),
schemes("https", "jar"), hosts()),
+ // Empty host allow-set means "any host".
+ Arguments.of(url("file:///tmp/x.properties"), schemes("file"),
hosts()),
+ Arguments.of(url("http://anything.example/x.properties"),
schemes("http"), hosts()),
+
Arguments.of(url("jar:https://anything.example/x.jar!/y.properties"),
schemes("https", "jar"), hosts()),
+ // Host satisfies allow-set
+ Arguments.of(url("file:///tmp/x.properties"), schemes("file"),
hosts("trusted\\.example")),
+ Arguments.of(url("https://trusted.example/x.properties"),
schemes("https", "jar"), hosts("trusted\\.example")),
+
Arguments.of(url("jar:https://trusted.example/x.jar!/y.properties"),
schemes("https", "jar"), hosts("trusted\\.example"))
+ );
+ }
+
+ static Stream<Arguments> testCheckUrlRejects() throws Exception {
+ return Stream.of(
+ // Plain scheme not in the allow-set.
+ Arguments.of(url("http://example.com/x.properties"),
schemes("file", "jar"), hosts()),
+ // jar: is allowed but the inner scheme is not.
+ Arguments.of(url("jar:file:/tmp/x.jar!/y.properties"),
schemes("jar"), hosts()),
+
Arguments.of(url("jar:https://example.com/x.jar!/y.properties"),
schemes("jar"), hosts()),
+ // Invalid jar URL
+ Arguments.of(jarUrl("file:/tmp/x.properties"), schemes("file",
"jar"), hosts()),
+ Arguments.of(jarUrl("invalid url!/y.properties"),
schemes("file", "jar"), hosts()),
+ // Host is not allowed
+ Arguments.of(url("https://evilhost/x.properties"), schemes(),
hosts("trusted\\.example")),
+ Arguments.of(url("jar:https://evilhost/x.jar!/y.properties"),
schemes(), hosts("trusted\\.example"))
+ );
+ }
+
@Test
void testBuilder() {
assertThrows(NullPointerException.class, () -> new
AbstractFileLocationStrategy.StrategyBuilder<>(null));
}
+
+ @ParameterizedTest
+ @MethodSource
+ void testCheckUrlAccepts(final URL url, final Set<String> validSchemes,
final Set<Pattern> validHosts) {
+ assertDoesNotThrow(() -> AbstractFileLocationStrategy.checkUrl(url,
validSchemes, validHosts));
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void testCheckUrlRejects(final URL url, final Set<String> validSchemes,
final Set<Pattern> validHosts) {
+ assertThrows(ConfigurationDeniedException.class, () ->
AbstractFileLocationStrategy.checkUrl(url, validSchemes, validHosts));
+ }
+
}