From a6eadc9f606bb1d3e938f5921fb12cb0dee9aabc Mon Sep 17 00:00:00 2001 From: Joakim Erdfelt Date: Wed, 22 Oct 2025 15:31:48 -0500 Subject: [PATCH 1/2] Issue #4652 - test to demonstrate the WebAppContext ClassLoader isolation Testcase uses WebAppContext.hiddenClassMatcher to set server jars (by location) that have the resource that needs protecting. --- .../ClassLoaderGetResourcesServlet.java | 46 +++++++ .../ClassLoaderProtectResourcesTest.java | 116 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 jetty-ee11/jetty-ee11-webapp/src/test/java/org/acme/webapp/ClassLoaderGetResourcesServlet.java create mode 100644 jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/ClassLoaderProtectResourcesTest.java diff --git a/jetty-ee11/jetty-ee11-webapp/src/test/java/org/acme/webapp/ClassLoaderGetResourcesServlet.java b/jetty-ee11/jetty-ee11-webapp/src/test/java/org/acme/webapp/ClassLoaderGetResourcesServlet.java new file mode 100644 index 00000000000..4565dfcc6aa --- /dev/null +++ b/jetty-ee11/jetty-ee11-webapp/src/test/java/org/acme/webapp/ClassLoaderGetResourcesServlet.java @@ -0,0 +1,46 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.acme.webapp; + +import java.io.IOException; +import java.io.PrintWriter; +import java.net.URL; +import java.util.Collections; +import java.util.List; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class ClassLoaderGetResourcesServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException + { + String resourceName = req.getParameter("resourceName"); + if (resourceName == null || resourceName.isBlank()) + throw new ServletException("Missing resourceName parameter"); + List hits = Collections.list(Thread.currentThread().getContextClassLoader().getResources(resourceName)); + resp.setStatus(200); + resp.setCharacterEncoding("utf-8"); + resp.setContentType("text/plain"); + PrintWriter out = resp.getWriter(); + out.printf("Hits: %d%n", hits.size()); + for (int i = 0; i < hits.size(); i++) + { + out.printf("[%d] %s%n", i, hits.get(i)); + } + } +} diff --git a/jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/ClassLoaderProtectResourcesTest.java b/jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/ClassLoaderProtectResourcesTest.java new file mode 100644 index 00000000000..5a5a36fa252 --- /dev/null +++ b/jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/ClassLoaderProtectResourcesTest.java @@ -0,0 +1,116 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.ee11.webapp; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.server.LocalConnector; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.toolchain.test.FS; +import org.eclipse.jetty.toolchain.test.MavenPaths; +import org.eclipse.jetty.toolchain.test.jupiter.WorkDir; +import org.eclipse.jetty.toolchain.test.jupiter.WorkDirExtension; +import org.eclipse.jetty.util.URIUtil; +import org.eclipse.jetty.util.component.LifeCycle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; + +@ExtendWith(WorkDirExtension.class) +public class ClassLoaderProtectResourcesTest +{ + public WorkDir workDir; + private Server server; + private LocalConnector connector; + + public void startServer(WebAppContext webAppContext) throws Exception + { + server = new Server(); + connector = new LocalConnector(server); + server.addConnector(connector); + + server.setHandler(webAppContext); + server.start(); + } + + @AfterEach + public void destroy() + { + LifeCycle.stop(server); + } + + @Test + public void testGetProtectedResources() throws Exception + { + // Create webapp directory + Path basePath = workDir.getEmptyPathDir(); + Path classesDir = basePath.resolve("WEB-INF/classes"); + FS.ensureDirExists(classesDir); + String pathToCopy = "org/acme/webapp/ClassLoaderGetResourcesServlet.class"; + Path servletFile = MavenPaths.targetDir().resolve("test-classes/" + pathToCopy); + Path destFile = classesDir.resolve(pathToCopy); + FS.ensureDirExists(destFile.getParent()); + Files.copy(servletFile, destFile); + WebAppContext webapp = new WebAppContext(); + webapp.setContextPath("/"); + webapp.setBaseResourceAsPath(basePath); + webapp.addServlet("org.acme.webapp.ClassLoaderGetResourcesServlet", "/lookup"); + + // The resource name we will be testing + String resourceName = "META-INF/services/org.eclipse.jetty.http.HttpFieldPreEncoder"; + + // Protect them from being discovered + ClassLoader serverClassLoader = Thread.currentThread().getContextClassLoader(); + protectServerResource(serverClassLoader, resourceName, webapp); + + startServer(webapp); + + String rawRequest = """ + GET /lookup?resourceName=%s HTTP/1.1\r + Host: localhost\r + Connection: close\r + \r + """.formatted(resourceName); + String rawResponse = connector.getResponse(rawRequest); + HttpTester.Response response = HttpTester.parseResponse(rawResponse); + assertThat(response.getContent(), containsString("Hits: 0\n")); + } + + private void protectServerResource(ClassLoader serverClassLoader, String resourceName, WebAppContext webapp) throws IOException, URISyntaxException + { + // Find resources that belong only on server side. + List urls = Collections.list(serverClassLoader.getResources(resourceName)); + assert !urls.isEmpty(); + + // Lets setup exclusions, by location ("file:///" urls), for these. + for (URL url: urls) + { + URI uri = URIUtil.unwrapContainer(url.toURI()); + // This is the key configuration to allow protecting of server resources + // even when using ClassLoader.getResource() or ClassLoader.getResources() + webapp.getHiddenClassMatcher().add(uri.toASCIIString()); + } + } +} From 7e610a746935bdd09c7b5f744b13560018a0f652 Mon Sep 17 00:00:00 2001 From: Joakim Erdfelt Date: Thu, 23 Oct 2025 14:27:18 -0500 Subject: [PATCH 2/2] Improving testcases with ServiceLoader example --- .../ClassLoaderProtectResourcesTest.java | 85 +++++++++++++++++-- .../ClassLoaderGetResourcesServlet.java | 2 +- ...tContainerInitializerDiscoveryServlet.java | 50 +++++++++++ 3 files changed, 127 insertions(+), 10 deletions(-) rename jetty-ee11/{jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp => jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/eclipse/jetty/ee11/test}/ClassLoaderProtectResourcesTest.java (56%) rename jetty-ee11/{jetty-ee11-webapp/src/test/java/org/acme => jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/example}/webapp/ClassLoaderGetResourcesServlet.java (98%) create mode 100644 jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/example/webapp/ServletContainerInitializerDiscoveryServlet.java diff --git a/jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/ClassLoaderProtectResourcesTest.java b/jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/eclipse/jetty/ee11/test/ClassLoaderProtectResourcesTest.java similarity index 56% rename from jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/ClassLoaderProtectResourcesTest.java rename to jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/eclipse/jetty/ee11/test/ClassLoaderProtectResourcesTest.java index 5a5a36fa252..2b7b1373d91 100644 --- a/jetty-ee11/jetty-ee11-webapp/src/test/java/org/eclipse/jetty/ee11/webapp/ClassLoaderProtectResourcesTest.java +++ b/jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/eclipse/jetty/ee11/test/ClassLoaderProtectResourcesTest.java @@ -11,7 +11,7 @@ // ======================================================================== // -package org.eclipse.jetty.ee11.webapp; +package org.eclipse.jetty.ee11.test; import java.io.IOException; import java.net.URI; @@ -22,6 +22,8 @@ import java.util.Collections; import java.util.List; +import jakarta.servlet.ServletContainerInitializer; +import org.eclipse.jetty.ee11.webapp.WebAppContext; import org.eclipse.jetty.http.HttpTester; import org.eclipse.jetty.server.LocalConnector; import org.eclipse.jetty.server.Server; @@ -29,14 +31,19 @@ import org.eclipse.jetty.toolchain.test.MavenPaths; import org.eclipse.jetty.toolchain.test.jupiter.WorkDir; import org.eclipse.jetty.toolchain.test.jupiter.WorkDirExtension; +import org.eclipse.jetty.util.TypeUtil; import org.eclipse.jetty.util.URIUtil; import org.eclipse.jetty.util.component.LifeCycle; +import org.example.webapp.ClassLoaderGetResourcesServlet; +import org.example.webapp.ServletContainerInitializerDiscoveryServlet; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.greaterThan; @ExtendWith(WorkDirExtension.class) public class ClassLoaderProtectResourcesTest @@ -61,22 +68,68 @@ public void destroy() LifeCycle.stop(server); } + @Test + public void testServiceLoaderVisibility() throws Exception + { + ClassLoader serverClassLoader = Thread.currentThread().getContextClassLoader(); + String resourceName = "META-INF/services/" + ServletContainerInitializer.class.getName(); + List allServiceFiles = Collections.list(serverClassLoader.getResources(resourceName)); + // Find the ee11-apache-jsp URLs + List ee11ApacheJspHits = allServiceFiles.stream() + .map(ClassLoaderProtectResourcesTest::toJarURI) + .filter(uri -> uri.toASCIIString().contains("ee11-apache-jsp")) + .toList(); + assertThat("Expecting some ee11-apache-jsp SCI", ee11ApacheJspHits.size(), greaterThan(0)); + int expectedHitsFromServlet = allServiceFiles.size() - ee11ApacheJspHits.size(); + + // Create webapp directory + Path basePath = workDir.getEmptyPathDir(); + copyTestClassIntoWebapp(ServletContainerInitializerDiscoveryServlet.class, basePath); + + WebAppContext webapp = new WebAppContext(); + webapp.setContextPath("/"); + webapp.setBaseResourceAsPath(basePath); + webapp.addServlet(ServletContainerInitializerDiscoveryServlet.class.getName(), "/lookup"); + + // Protect a specific jar's SCI from being discovered. + ee11ApacheJspHits.forEach(uri -> + webapp.getHiddenClassMatcher().add(uri.toASCIIString())); + + startServer(webapp); + + String rawRequest = """ + GET /lookup HTTP/1.1\r + Host: localhost\r + Connection: close\r + \r + """; + String rawResponse = connector.getResponse(rawRequest); + HttpTester.Response response = HttpTester.parseResponse(rawResponse); + assertThat(response.getContent(), containsString("Service Count: %s\n".formatted(expectedHitsFromServlet))); + } + + private static URI toJarURI(URL url) + { + try + { + return URIUtil.unwrapContainer(url.toURI()); + } + catch (URISyntaxException e) + { + throw new RuntimeException(e); + } + } + @Test public void testGetProtectedResources() throws Exception { // Create webapp directory Path basePath = workDir.getEmptyPathDir(); - Path classesDir = basePath.resolve("WEB-INF/classes"); - FS.ensureDirExists(classesDir); - String pathToCopy = "org/acme/webapp/ClassLoaderGetResourcesServlet.class"; - Path servletFile = MavenPaths.targetDir().resolve("test-classes/" + pathToCopy); - Path destFile = classesDir.resolve(pathToCopy); - FS.ensureDirExists(destFile.getParent()); - Files.copy(servletFile, destFile); + copyTestClassIntoWebapp(ClassLoaderGetResourcesServlet.class, basePath); WebAppContext webapp = new WebAppContext(); webapp.setContextPath("/"); webapp.setBaseResourceAsPath(basePath); - webapp.addServlet("org.acme.webapp.ClassLoaderGetResourcesServlet", "/lookup"); + webapp.addServlet(ClassLoaderGetResourcesServlet.class.getName(), "/lookup"); // The resource name we will be testing String resourceName = "META-INF/services/org.eclipse.jetty.http.HttpFieldPreEncoder"; @@ -113,4 +166,18 @@ private void protectServerResource(ClassLoader serverClassLoader, String resourc webapp.getHiddenClassMatcher().add(uri.toASCIIString()); } } + + private static void copyTestClassIntoWebapp(Class clazz, Path webappRoot) throws IOException + { + String pathToCopy = TypeUtil.toClassReference(clazz); + Path classFile = MavenPaths.targetDir().resolve("test-classes/" + pathToCopy); + Assertions.assertTrue(Files.isRegularFile(classFile), "Class should exist file: " + classFile); + + Path classesDir = webappRoot.resolve("WEB-INF/classes"); + FS.ensureDirExists(classesDir); + + Path destFile = classesDir.resolve(pathToCopy); + FS.ensureDirExists(destFile.getParent()); + Files.copy(classFile, destFile); + } } diff --git a/jetty-ee11/jetty-ee11-webapp/src/test/java/org/acme/webapp/ClassLoaderGetResourcesServlet.java b/jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/example/webapp/ClassLoaderGetResourcesServlet.java similarity index 98% rename from jetty-ee11/jetty-ee11-webapp/src/test/java/org/acme/webapp/ClassLoaderGetResourcesServlet.java rename to jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/example/webapp/ClassLoaderGetResourcesServlet.java index 4565dfcc6aa..432a14b6a1d 100644 --- a/jetty-ee11/jetty-ee11-webapp/src/test/java/org/acme/webapp/ClassLoaderGetResourcesServlet.java +++ b/jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/example/webapp/ClassLoaderGetResourcesServlet.java @@ -11,7 +11,7 @@ // ======================================================================== // -package org.acme.webapp; +package org.example.webapp; import java.io.IOException; import java.io.PrintWriter; diff --git a/jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/example/webapp/ServletContainerInitializerDiscoveryServlet.java b/jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/example/webapp/ServletContainerInitializerDiscoveryServlet.java new file mode 100644 index 00000000000..783a0ecb5a5 --- /dev/null +++ b/jetty-ee11/jetty-ee11-tests/jetty-ee11-test-integration/src/test/java/org/example/webapp/ServletContainerInitializerDiscoveryServlet.java @@ -0,0 +1,50 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.example.webapp; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.List; +import java.util.ServiceLoader; + +import jakarta.servlet.ServletContainerInitializer; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class ServletContainerInitializerDiscoveryServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException + { + ServiceLoader services = ServiceLoader.load(ServletContainerInitializer.class); + List serviceNames = services.stream() + .map(provider -> + { + return provider.get().getClass().getName(); + }) + .sorted() + .toList(); + resp.setStatus(200); + resp.setCharacterEncoding("utf-8"); + resp.setContentType("text/plain"); + PrintWriter out = resp.getWriter(); + out.printf("Service Count: %d%n", serviceNames.size()); + for (int i = 0; i < serviceNames.size(); i++) + { + out.printf("[%d] %s%n", i, serviceNames.get(i)); + } + } +}