This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/loader in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git
commit b586952774ca114a71649b2a71b6a6c09906b995 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Mon Sep 14 12:18:23 2026 +0100 Harden Loader to only load from file + jar by default --- .../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); + } +}
