From e77667215ea55e55aaa82560d0064ced0f170249 Mon Sep 17 00:00:00 2001 From: Carsten Ziegeler Date: Tue, 4 Aug 2026 09:59:25 +0200 Subject: [PATCH] FELIX-6854 Normalize web console resource paths --- .../webconsole/SimpleWebConsolePlugin.java | 40 ++++++++++++++- .../webconsole/servlet/AbstractServlet.java | 34 ++++++++++++- .../AbstractWebConsolePluginTest.java | 23 +++++++++ .../servlet/AbstractServletTest.java | 49 +++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 webconsole/src/test/java/org/apache/felix/webconsole/servlet/AbstractServletTest.java diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/SimpleWebConsolePlugin.java b/webconsole/src/main/java/org/apache/felix/webconsole/SimpleWebConsolePlugin.java index e2a44b227b..ff0cbeed7f 100644 --- a/webconsole/src/main/java/org/apache/felix/webconsole/SimpleWebConsolePlugin.java +++ b/webconsole/src/main/java/org/apache/felix/webconsole/SimpleWebConsolePlugin.java @@ -20,6 +20,8 @@ import java.net.URL; +import java.util.ArrayDeque; +import java.util.Deque; import java.util.Dictionary; import java.util.HashMap; import java.util.Hashtable; @@ -202,6 +204,9 @@ protected final String[] getCssReferences() * resources are accessed like /system/console/abc/res/logo.gif, * the code here will try load resource /res/logo.gif from the * bundle, providing the plugin. + * Path segments are normalized before the resource is loaded, so a request + * cannot escape the plugin's resource directory using .. + * segments. * * * @param path the path to read. @@ -209,12 +214,43 @@ protected final String[] getCssReferences() */ protected URL getResource( String path ) { - return ( path != null && path.startsWith( labelRes ) ) ? // - getClass().getResource( path.substring( labelResLen ) ) + final String normalizedPath = path == null ? null : normalizePath(path); + return ( normalizedPath != null && normalizedPath.startsWith( labelRes ) ) ? // + getClass().getResource( normalizedPath.substring( labelResLen ) ) : null; } + /** + * Normalizes an absolute resource path without applying host filesystem + * semantics. Resource names always use forward slashes, irrespective of + * the operating system hosting the web console. + */ + private static String normalizePath(final String path) + { + final Deque segments = new ArrayDeque<>(); + for (final String segment : path.substring(1).split("/")) + { + if (segment.isEmpty() || ".".equals(segment)) + { + continue; + } + if ("..".equals(segment)) + { + if (!segments.isEmpty()) + { + segments.removeLast(); + } + } + else + { + segments.addLast(segment); + } + } + return '/' + String.join("/", segments); + } + + // -- begin methods for plugin registration/unregistration /** * This is an utility method. It is used to register the plugin service. Don't diff --git a/webconsole/src/main/java/org/apache/felix/webconsole/servlet/AbstractServlet.java b/webconsole/src/main/java/org/apache/felix/webconsole/servlet/AbstractServlet.java index 9bf9853453..61668f7128 100644 --- a/webconsole/src/main/java/org/apache/felix/webconsole/servlet/AbstractServlet.java +++ b/webconsole/src/main/java/org/apache/felix/webconsole/servlet/AbstractServlet.java @@ -25,6 +25,8 @@ import java.io.OutputStream; import java.net.URL; import java.net.URLConnection; +import java.util.ArrayDeque; +import java.util.Deque; import org.apache.felix.webconsole.internal.Util; @@ -74,19 +76,47 @@ public abstract class AbstractServlet extends HttpServlet { * Called to identify resources. * By default, if the path starts with "/res/" this is treated as a resource * and the URL to the resource is tried to be loaded via the class loader. + * Path segments are normalized before this check, so a request cannot escape + * the {@code /res/} directory using {@code ..} segments. * @param path the path * @return the URL of the resource or null if not found. */ protected URL getResource( final String path ) { + if (path == null) { + return null; + } final int index = path.indexOf( '/', 1 ); if (index != -1) { - if (path.substring(index).startsWith("/res/") ) { - return getClass().getResource( path.substring(index) ); + final String resourcePath = normalizePath(path.substring(index)); + if (resourcePath.startsWith("/res/") ) { + return getClass().getResource(resourcePath); } } return null; } + /** + * Normalizes an absolute resource path without applying host filesystem + * semantics. Resource names always use forward slashes, irrespective of + * the operating system hosting the web console. + */ + private static String normalizePath(final String path) { + final Deque segments = new ArrayDeque<>(); + for (final String segment : path.substring(1).split("/")) { + if (segment.isEmpty() || ".".equals(segment)) { + continue; + } + if ("..".equals(segment)) { + if (!segments.isEmpty()) { + segments.removeLast(); + } + } else { + segments.addLast(segment); + } + } + return '/' + String.join("/", segments); + } + /** * Handle get requests. This method can be used to return resources, like JSON responses etc. * If the plugin is serving a resource, this method call {@link HttpServletResponse#setStatus(int)}. diff --git a/webconsole/src/test/java/org/apache/felix/webconsole/AbstractWebConsolePluginTest.java b/webconsole/src/test/java/org/apache/felix/webconsole/AbstractWebConsolePluginTest.java index d4f811a020..0a02b3af04 100644 --- a/webconsole/src/test/java/org/apache/felix/webconsole/AbstractWebConsolePluginTest.java +++ b/webconsole/src/test/java/org/apache/felix/webconsole/AbstractWebConsolePluginTest.java @@ -120,6 +120,15 @@ public void test_getGetResourceMethod_extension_class() throws Exception assertNull( getGetResourceMethod.invoke( testPrivate, (Object[]) null ) ); } + + public void test_getResource_normalizesPath() { + final SimpleTestPlugin plugin = new SimpleTestPlugin(); + + assertNotNull(plugin.getResource("/test/res/ui/webconsole.css")); + assertNotNull(plugin.getResource("/test/res/ui/../ui/webconsole.css")); + assertNull(plugin.getResource("/test/res/../../META-INF/MANIFEST.MF")); + } + private static class PrivateTestPlugin extends TestPlugin { @SuppressWarnings("unused") @@ -175,4 +184,18 @@ protected void renderContent( HttpServletRequest req, HttpServletResponse res ) } } + + private static class SimpleTestPlugin extends SimpleWebConsolePlugin + { + SimpleTestPlugin() + { + super("test", "Test", null); + } + + @Override + protected void renderContent(final HttpServletRequest req, final HttpServletResponse res) + { + // nothing to render + } + } } diff --git a/webconsole/src/test/java/org/apache/felix/webconsole/servlet/AbstractServletTest.java b/webconsole/src/test/java/org/apache/felix/webconsole/servlet/AbstractServletTest.java new file mode 100644 index 0000000000..d9fab3336a --- /dev/null +++ b/webconsole/src/test/java/org/apache/felix/webconsole/servlet/AbstractServletTest.java @@ -0,0 +1,49 @@ +/* + * 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.felix.webconsole.servlet; + +import java.net.URL; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import junit.framework.TestCase; + +public class AbstractServletTest extends TestCase { + + public void testGetResourceNormalizesPath() { + final TestServlet servlet = new TestServlet(); + + assertNotNull(servlet.getResource("/test/res/ui/webconsole.css")); + assertNotNull(servlet.getResource("/test/res/ui/../ui/webconsole.css")); + assertNull(servlet.getResource("/test/res/../../META-INF/MANIFEST.MF")); + } + + private static final class TestServlet extends AbstractServlet { + + @Override + public void renderContent(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException { + // nothing to render + } + + @Override + protected URL getResource(final String path) { + return super.getResource(path); + } + } +}