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:&lt;url&gt;!/&lt;entry&gt;</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:&lt;url&gt;!/&lt;entry&gt;</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);
+    }
+}

Reply via email to