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-wss4j.git
The following commit(s) were added to refs/heads/master by this push:
new 6c3257a83 Harden Loader to only load from file + jar by default (#689)
6c3257a83 is described below
commit 6c3257a83caa9c11e4af4af6b675e26f59b921f5
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Mon Sep 14 13:34:22 2026 +0100
Harden Loader to only load from file + jar by default (#689)
---
.../java/org/apache/wss4j/common/util/Loader.java | 122 ++++++++++++++++--
.../org/apache/wss4j/common/util/LoaderTest.java | 139 +++++++++++++++++++++
2 files changed, 248 insertions(+), 13 deletions(-)
diff --git
a/ws-security-common/src/main/java/org/apache/wss4j/common/util/Loader.java
b/ws-security-common/src/main/java/org/apache/wss4j/common/util/Loader.java
index f282a22c4..3245c1f09 100644
--- a/ws-security-common/src/main/java/org/apache/wss4j/common/util/Loader.java
+++ b/ws-security-common/src/main/java/org/apache/wss4j/common/util/Loader.java
@@ -25,9 +25,12 @@ import java.lang.reflect.InvocationTargetException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.AccessController;
import java.security.PrivilegedAction;
+import java.util.Locale;
import org.apache.wss4j.common.ext.WSSecurityException;
@@ -36,6 +39,21 @@ import org.apache.wss4j.common.ext.WSSecurityException;
* <p/>
*/
public final class Loader {
+
+ /**
+ * System property holding a comma-separated list of URL schemes that
+ * {@link #loadInputStream(ClassLoader, String)} is allowed to open when a
resource
+ * string parses as a URL. The default is "file,jar": remote fetching of
configured
+ * resources (keystores, truststores, CRLs, properties files) over e.g.
http is not
+ * enabled unless explicitly configured. For a nested-URL scheme such as
"jar"
+ * (<code>jar:<url>!/<entry></code>), the embedded URL must
use an allowed
+ * scheme as well: "jar:file:..." is permitted by default,
"jar:http://..." is not.
+ */
+ public static final String ALLOWED_URL_SCHEMES_PROPERTY =
+ "org.apache.wss4j.loader.allowedUrlSchemes";
+
+ private static final String DEFAULT_ALLOWED_URL_SCHEMES = "file,jar";
+
private static final org.slf4j.Logger LOG =
org.slf4j.LoggerFactory.getLogger(Loader.class);
@@ -43,18 +61,59 @@ public final class Loader {
// complete
}
+ /**
+ * Load a resource as a stream. The resolution order is:
+ * <ol>
+ * <li>the file system - an existing file wins, so that a path configured
by the
+ * operator cannot be shadowed by a same-named classpath resource;</li>
+ * <li>a URL, if the resource string parses as one and its scheme is in
the allowed
+ * list (see {@link #ALLOWED_URL_SCHEMES_PROPERTY}; "file" and "jar" by
default) -
+ * for a nested-URL scheme such as "jar", the embedded URL's scheme must
also be in
+ * the allowed list;</li>
+ * <li>the classpath.</li>
+ * </ol>
+ * Note: prior to the introduction of this ordering, URLs (any scheme) and
the
+ * classpath were consulted before the file system.
+ */
public static InputStream loadInputStream(ClassLoader loader, String
resource)
throws WSSecurityException, IOException {
InputStream is = null;
if (resource != null) {
+ //
+ // First look on the file system
+ //
+ Path path = null;
+ try {
+ path = Paths.get(resource);
+ } catch (InvalidPathException ex) { //NOPMD
+ // skip - not a valid file system path
+ }
+ if (path != null && Files.exists(path)) {
+ try {
+ return Files.newInputStream(path);
+ } catch (Exception e) {
+ LOG.debug(e.getMessage(), e);
+ throw new WSSecurityException(
+ WSSecurityException.ErrorCode.FAILURE, e,
"resourceNotFound", new Object[] {resource}
+ );
+ }
+ }
+
+ // Next see if it's a URL with an allowed scheme
URL url = null;
- // First see if it's a URL
try {
url = new URL(resource);
} catch (MalformedURLException ex) { //NOPMD
// skip
}
- // If not a URL, then try to load the resource
+ String disallowedScheme = url == null ? null :
findDisallowedScheme(url);
+ if (disallowedScheme != null) {
+ LOG.warn("Not loading resource [" + resource + "]: URL scheme
\"" + disallowedScheme
+ + "\" is not allowed. Set the " +
ALLOWED_URL_SCHEMES_PROPERTY
+ + " system property to permit additional schemes.");
+ url = null;
+ }
+ // If not a (permitted) URL, then try to load the resource from
the classpath
if (url == null) {
url = Loader.getResource(loader, resource);
}
@@ -62,23 +121,60 @@ public final class Loader {
is = url.openStream();
}
- //
- // If we don't find it, then look on the file system.
- //
if (is == null) {
- try {
- is = Files.newInputStream(Paths.get(resource));
- } catch (Exception e) {
- LOG.debug(e.getMessage(), e);
- throw new WSSecurityException(
- WSSecurityException.ErrorCode.FAILURE, e,
"resourceNotFound", new Object[] {resource}
- );
- }
+ throw new WSSecurityException(
+ WSSecurityException.ErrorCode.FAILURE, "resourceNotFound",
new Object[] {resource}
+ );
}
}
return is;
}
+ /**
+ * Return the scheme that prevents <code>url</code> from being opened, or
null if the
+ * URL only uses allowed schemes. For a nested-URL scheme such as "jar"
+ * (<code>jar:<url>!/<entry></code>), the embedded URL is
validated
+ * recursively, so e.g. "jar:http://..." is refused unless "http" is
itself allowed.
+ * A nested part that is missing or does not parse as a URL is refused
(fail closed).
+ */
+ private static String findDisallowedScheme(URL url) {
+ String scheme = url.getProtocol();
+ if (!isAllowedUrlScheme(scheme)) {
+ return scheme;
+ }
+ if ("jar".equals(scheme.toLowerCase(Locale.ROOT))) {
+ // A jar URL nests another URL: everything after "jar:" and before
"!/" is
+ // itself a URL that a JarURLConnection would fetch (an outbound
request for
+ // e.g. jar:http://...). Validate the nested URL's scheme as well.
+ String spec = url.getFile();
+ int separator = spec.indexOf("!/");
+ if (separator < 0) {
+ return scheme;
+ }
+ URL nestedUrl;
+ try {
+ nestedUrl = new URL(spec.substring(0, separator).trim());
+ } catch (MalformedURLException ex) { //NOPMD
+ return scheme;
+ }
+ return findDisallowedScheme(nestedUrl);
+ }
+ return null;
+ }
+
+ private static boolean isAllowedUrlScheme(String scheme) {
+ if (scheme == null) {
+ return false;
+ }
+ String allowedSchemes =
System.getProperty(ALLOWED_URL_SCHEMES_PROPERTY, DEFAULT_ALLOWED_URL_SCHEMES);
+ for (String allowed : allowedSchemes.split(",")) {
+ if
(scheme.toLowerCase(Locale.ROOT).equals(allowed.trim().toLowerCase(Locale.ROOT)))
{
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* This method will search for <code>resource</code> in different
* places. The search order is as follows:
diff --git
a/ws-security-common/src/test/java/org/apache/wss4j/common/util/LoaderTest.java
b/ws-security-common/src/test/java/org/apache/wss4j/common/util/LoaderTest.java
new file mode 100644
index 000000000..615664fcc
--- /dev/null
+++
b/ws-security-common/src/test/java/org/apache/wss4j/common/util/LoaderTest.java
@@ -0,0 +1,139 @@
+/**
+ * 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 org.apache.wss4j.common.util;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import org.apache.wss4j.common.ext.WSSecurityException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests for the URL scheme allowlist enforced by
+ * {@link Loader#loadInputStream(ClassLoader, String)}. Refused schemes fail
before any
+ * connection is attempted, so no test here touches the network.
+ */
+class LoaderTest {
+
+ @TempDir
+ Path tempDir;
+
+ @AfterEach
+ void clearAllowedSchemesProperty() {
+ System.clearProperty(Loader.ALLOWED_URL_SCHEMES_PROPERTY);
+ }
+
+ @Test
+ void fileUrlIsAllowedByDefault() throws Exception {
+ Path file = Files.write(tempDir.resolve("resource.txt"),
+ "file-content".getBytes(StandardCharsets.UTF_8));
+ try (InputStream is =
+ Loader.loadInputStream(getClass().getClassLoader(),
file.toUri().toURL().toString())) {
+ assertEquals("file-content", read(is));
+ }
+ }
+
+ @Test
+ void jarFileUrlIsAllowedByDefault() throws Exception {
+ Path jar = createJar("entry.txt", "jar-content");
+ String resource = "jar:" + jar.toUri().toURL() + "!/entry.txt";
+ try (InputStream is =
Loader.loadInputStream(getClass().getClassLoader(), resource)) {
+ assertEquals("jar-content", read(is));
+ }
+ }
+
+ @Test
+ void httpUrlIsRefusedByDefault() {
+ assertThrows(WSSecurityException.class,
+ () -> Loader.loadInputStream(getClass().getClassLoader(),
+ "http://localhost:1/keystore.jks"));
+ }
+
+ @Test
+ void httpUrlIsRefusedRegardlessOfCase() {
+ assertThrows(WSSecurityException.class,
+ () -> Loader.loadInputStream(getClass().getClassLoader(),
+ "HTTP://localhost:1/keystore.jks"));
+ }
+
+ @Test
+ void jarHttpUrlIsRefusedByDefault() {
+ // The nested URL is what a JarURLConnection would fetch - it must be
validated too
+ assertThrows(WSSecurityException.class,
+ () -> Loader.loadInputStream(getClass().getClassLoader(),
+ "jar:http://localhost:1/evil.jar!/entry.txt"));
+ }
+
+ @Test
+ void doublyNestedJarHttpUrlIsRefused() {
+ assertThrows(WSSecurityException.class,
+ () -> Loader.loadInputStream(getClass().getClassLoader(),
+ "jar:jar:http://localhost:1/evil.jar!/inner.jar!/entry.txt"));
+ }
+
+ @Test
+ void emptyAllowedSchemesPropertyRefusesAllUrls() throws Exception {
+ System.setProperty(Loader.ALLOWED_URL_SCHEMES_PROPERTY, "");
+ Path file = Files.write(tempDir.resolve("resource.txt"),
+ "file-content".getBytes(StandardCharsets.UTF_8));
+
+ // Even a file: URL is refused when the allowlist is empty...
+ assertThrows(WSSecurityException.class,
+ () -> Loader.loadInputStream(getClass().getClassLoader(),
+ file.toUri().toURL().toString()));
+
+ // ...but a plain file system path is unaffected (it is not URL
loading)
+ try (InputStream is =
Loader.loadInputStream(getClass().getClassLoader(), file.toString())) {
+ assertEquals("file-content", read(is));
+ }
+ }
+
+ private Path createJar(String entryName, String content) throws
IOException {
+ Path jar = tempDir.resolve("resource.jar");
+ try (JarOutputStream jos = new
JarOutputStream(Files.newOutputStream(jar))) {
+ jos.putNextEntry(new JarEntry(entryName));
+ jos.write(content.getBytes(StandardCharsets.UTF_8));
+ jos.closeEntry();
+ }
+ return jar;
+ }
+
+ private static String read(InputStream is) throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ byte[] buffer = new byte[256];
+ int n = is.read(buffer);
+ while (n != -1) {
+ bos.write(buffer, 0, n);
+ n = is.read(buffer);
+ }
+ return new String(bos.toByteArray(), StandardCharsets.UTF_8);
+ }
+}