diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ee01f25..008524b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -65,6 +65,7 @@ jobs: build.log **/target/surefire-reports/ **/target/failsafe-reports/ + **/target/jsp-engine/ esapi-compatibility: name: ESAPI ${{ matrix.esapi-version }} diff --git a/.github/workflows/consumer-compatibility.yaml b/.github/workflows/consumer-compatibility.yaml index 7e7f8c8..f037944 100644 --- a/.github/workflows/consumer-compatibility.yaml +++ b/.github/workflows/consumer-compatibility.yaml @@ -56,6 +56,7 @@ jobs: *.log **/target/surefire-reports/ **/target/failsafe-reports/ + **/target/jsp-engine/ **/target/japicmp/ runtime: @@ -175,6 +176,7 @@ jobs: build.log **/target/surefire-reports/ **/target/failsafe-reports/ + **/target/jsp-engine/ **/target/japicmp/ gate: diff --git a/compatibility/README.md b/compatibility/README.md index 7062f07..2e23c63 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -122,3 +122,7 @@ choose a new `--directory` when rebuilding. `--maven` and `--repository` allow a explicit Maven executable and isolated dependency cache. Runtime `--directory` must point to the prepared fixture directory. CI uploads build reports, API diff reports, preparation output, and each runtime's output even when a step fails. + +The Docker-free [Jasper engine fixture](jsp-engine/README.md) also runs in normal +`verify`: it compiles and serves both packaged TLD surfaces on maintained javax +and Jakarta engines, with exact-byte and translation-rejection assertions. diff --git a/compatibility/jsp-engine/README.md b/compatibility/jsp-engine/README.md new file mode 100644 index 0000000..8deca2b --- /dev/null +++ b/compatibility/jsp-engine/README.md @@ -0,0 +1,55 @@ +# Packaged taglibs through real JSP engines + +Normal JDK 17 `mvn clean verify` runs this Docker-free fixture after each adapter +has been packaged. It deploys only the packaged core and adapter JARs into a +loopback-only temporary web application. Jasper discovers their actual TLDs, +compiles generated JSPs and serves HTTP responses. No install is necessary. + +The engine dependencies belong to the AntRun plugin realm and its forked JVM, +not to the libraries' compile/test classpaths or published runtime dependencies. +Test classes and generated JSPs live under each adapter's `target/jsp-engine`, +not its JAR. `-DskipTests` skips this integration fixture as well as unit tests. + +| Adapter | Test engine | Engine APIs | Engine JVM | +| --- | --- | --- | --- | +| `encoder-jsp` | Tomcat/Jasper 9.0.122 | Servlet 4.0 / JSP 2.3 / EL 3.0 (`javax`) | 17 | +| `encoder-jakarta-jsp` | Tomcat/Jasper 10.1.60 | Servlet 6.0 / Pages 3.1 / EL 5.0 (`jakarta`) | 17 | + +These maintained engine generations accept the adapters' older provided APIs; +this does not raise the libraries' Java 8 or API baselines. The separate +[packaged consumer matrix](../README.md) still uses JSP 2.2.1 / Servlet 3.0.1 / +EL 2.2.5 and Pages 3.0 / Servlet 5 / EL 4, including actual Java 8 execution. +The engines here demonstrate compiler/container integration, not full container +certification or a running container at every historical API floor. + +Versions and the [Tomcat support table](https://tomcat.apache.org/whichversion.html), +[9.x advisories](https://tomcat.apache.org/security-9.html) and +[10.x advisories](https://tomcat.apache.org/security-10.html) were reviewed on +2026-09-25. These versions include the September security fixes. They run only +as disposable test servers, on a random loopback port, without writable default +servlets, AJP, HTTP/2, TLS or application authentication configuration. +Dependabot reviews the plugin dependencies in the adapter POMs; recheck upstream +advisories with each update and before a release. + +The current surface is 72 bindings per adapter: 576 exact-byte assertions and +72 rejected pages on each engine (1,152 byte assertions and 144 rejections total). +Counts are derived from the packaged descriptors rather than frozen in the test. + +For both basic and advanced descriptors the fixture discovers every tag and +function and fails on empty, duplicate, missing or unexercised bindings. Each +binding renders hostile markup, controls and Unicode, a long buffer-boundary +value, empty input, missing/null EL values, numbers, booleans and scriptlet null. +The expected String/Writer facade output is compared to the complete HTTP UTF-8 +response bytes. EL null-to-empty coercion is intentionally distinct from a +scriptlet passing Java null directly to the tag's String setter. + +Every tag must also reject a nonempty body and an omitted required value at JSP +translation time. A generic HTTP 500 does not suffice: the response must identify +a Jasper exception and the expected constraint. Generated sources and engine +work directories remain under `target` for diagnosis; `mvn clean` removes them. + +This fixture proves binding, compilation, coercion and emitted bytes. It does +not prove that an arbitrary use of those bytes is safe in a browser context. +The required Docker/browser suite in `jakarta-test` retains those distinct +JavaScript grammar, HTML parser and DOM assertions. The reflection/descriptor +contracts, source parity, OSGi and JPMS consumer checks also remain in place. diff --git a/compatibility/jsp-engine/src/JspEngineTest.java b/compatibility/jsp-engine/src/JspEngineTest.java new file mode 100644 index 0000000..3adb3a8 --- /dev/null +++ b/compatibility/jsp-engine/src/JspEngineTest.java @@ -0,0 +1,191 @@ +/* Copyright (c) 2026 OWASP. All rights reserved. BSD-3-Clause. */ +package fixture; + +import java.io.StringWriter; +import java.io.Writer; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.jar.JarFile; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.catalina.Context; +import org.apache.catalina.startup.Tomcat; +import org.apache.tomcat.util.scan.StandardJarScanner; +import org.w3c.dom.Element; + +/** Compiles and serves the actual packaged TLD surface in an isolated engine JVM. */ +public final class JspEngineTest { + private record Binding(String kind, String name) { } + private record Descriptor(String uri, List bindings) { } + private record Scenario(String name, String expression, String input, String coerced) { } + private record Page(String name, byte[] expected, String rejection) { } + private static final String DIRECTIVE = "<%@page contentType=\"text/plain; charset=UTF-8\" " + + "pageEncoding=\"UTF-8\" trimDirectiveWhitespaces=\"true\"%>"; + private static final List CASES = List.of( + new Scenario("hostile", "${fixtureValue}", "&\"'`$\\\r\n\t\u0001\u0085\u2028é😀", null), + new Scenario("boundary", "${fixtureValue}", "x".repeat(2047) + "<&\"'😀" + "y".repeat(2049), null), + new Scenario("empty", "${fixtureValue}", "", ""), + new Scenario("missing", "${missingValue}", null, ""), + new Scenario("null-el", "${null}", null, ""), + new Scenario("number", "${42}", null, "42"), + new Scenario("boolean", "${true}", null, "true"), + new Scenario("null-scriptlet", "<%= (String)null %>", null, null)); + + public static void main(String[] args) throws Exception { + Path core = Path.of(args[0]).toAbsolutePath(); + Path adapter = Path.of(args[1]).toAbsolutePath(); + Path base = Files.createTempDirectory(Path.of(args[2]), "run-"); + Path web = Files.createDirectories(base.resolve("webapp")); + Path libs = Files.createDirectories(web.resolve("WEB-INF/lib")); + Files.copy(core, libs.resolve(core.getFileName())); + Files.copy(adapter, libs.resolve(adapter.getFileName())); + // Do not add reactor classes, adapters or alternate APIs to the engine/host classpath. + try (URLClassLoader oracle = new URLClassLoader(new URL[]{core.toUri().toURL()}, ClassLoader.getPlatformClassLoader())) { + Class encode = Class.forName("org.owasp.encoder.Encode", true, oracle); + List pages = new ArrayList<>(); + Set discovered = new LinkedHashSet<>(); + Set exercised = new LinkedHashSet<>(); + int renderAssertions = 0; + try (JarFile jar = new JarFile(adapter.toFile())) { + for (String file : List.of("java-encoder.tld", "java-encoder-advanced.tld")) { + Descriptor descriptor = descriptor(jar, "META-INF/" + file); + String prefix = DIRECTIVE + "<%@taglib prefix=\"e\" uri=\"" + descriptor.uri() + "\"%>"; + for (Binding binding : descriptor.bindings()) { + if (!discovered.add(file + ":" + binding)) throw new AssertionError("Duplicate binding " + binding); + } + for (Scenario scenario : CASES) { + StringBuilder jsp = new StringBuilder(prefix); + if (scenario.input() != null) { + String b64 = Base64.getEncoder().encodeToString(scenario.input().getBytes(StandardCharsets.UTF_8)); + jsp.append("<%request.setAttribute(\"fixtureValue\", new String(java.util.Base64.getDecoder().decode(\"") + .append(b64).append("\"), java.nio.charset.StandardCharsets.UTF_8));%>"); + } + StringBuilder expected = new StringBuilder(); + for (Binding binding : descriptor.bindings()) { + boolean tag = binding.kind().equals("tag"); + String marker = "[" + binding.kind() + ":" + binding.name() + "]"; + jsp.append(marker); + expected.append(marker); + if (tag) { + jsp.append(""); + } else { + String expression = scenario.name().equals("null-scriptlet") ? "null" + : scenario.expression().substring(2, scenario.expression().length() - 1); + jsp.append("${e:").append(binding.name()).append('(').append(expression).append(")}"); + } + String input = scenario.coerced() != null ? scenario.coerced() : scenario.input(); + // JSP String attributes preserve scriptlet null; EL coerces null/missing to "". + if (!tag && scenario.name().equals("null-scriptlet")) input = ""; + if (tag) { + StringWriter writer = new StringWriter(); + encode.getMethod(binding.name(), Writer.class, String.class).invoke(null, writer, input); + expected.append(writer); + } else { + expected.append(encode.getMethod(binding.name(), String.class).invoke(null, input)); + } + exercised.add(file + ":" + binding); + renderAssertions++; + } + String name = file + "-" + scenario.name() + ".jsp"; + Files.writeString(web.resolve(name), jsp, StandardCharsets.UTF_8); + pages.add(new Page(name, expected.toString().getBytes(StandardCharsets.UTF_8), null)); + } + for (Binding binding : descriptor.bindings()) { + if (!binding.kind().equals("tag")) continue; + String name = file + "-" + binding.name(); + Files.writeString(web.resolve(name + "-body.jsp"), prefix + "forbidden body"); + pages.add(new Page(name + "-body.jsp", null, "must be empty")); + Files.writeString(web.resolve(name + "-missing.jsp"), prefix + ""); + pages.add(new Page(name + "-missing.jsp", null, "attribute [value] is mandatory")); + } + } + } + if (discovered.isEmpty() || !discovered.equals(exercised)) throw new AssertionError("Coverage gap " + discovered); + Tomcat tomcat = new Tomcat(); + tomcat.setBaseDir(base.resolve("tomcat").toString()); + tomcat.setPort(0); + tomcat.getConnector().setProperty("address", "127.0.0.1"); + Context context = tomcat.addWebapp("", web.toString()); + ((StandardJarScanner) context.getJarScanner()).setScanClassPath(false); + try { + tomcat.start(); + if (!context.getState().isAvailable()) throw new AssertionError("Webapp failed to start"); + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + for (Page page : pages) { + URI uri = URI.create("http://127.0.0.1:" + tomcat.getConnector().getLocalPort() + "/" + page.name()); + HttpResponse response = client.send(HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(30)) + .build(), HttpResponse.BodyHandlers.ofByteArray()); + if (page.rejection() == null) { + if (response.statusCode() != 200 || !Arrays.equals(page.expected(), response.body())) { + throw new AssertionError(page.name() + " HTTP " + response.statusCode() + " expected bytes=" + + page.expected().length + " actual bytes=" + response.body().length + "\n" + + new String(response.body(), StandardCharsets.UTF_8)); + } + } else { + String message = new String(response.body(), StandardCharsets.UTF_8); + if (response.statusCode() != 500 || !message.contains("JasperException") + || !message.toLowerCase(java.util.Locale.ROOT).contains(page.rejection())) { + throw new AssertionError("Expected JSP translation rejection: " + page.name() + "\n" + message); + } + } + } + System.out.println("PASS " + adapter.getFileName() + ": " + discovered.size() + " TLD bindings; " + + renderAssertions + " exact-byte assertions; " + (pages.size() - CASES.size() * 2) + + " translation rejections; " + org.apache.catalina.util.ServerInfo.getServerInfo()); + } finally { + try { tomcat.stop(); } finally { tomcat.destroy(); } + } + } + // Retain generated JSPs/engine diagnostics under target for failed-run inspection. + } + + private static Descriptor descriptor(JarFile jar, String path) throws Exception { + var entry = jar.getJarEntry(path); + if (entry == null) throw new AssertionError("Missing packaged descriptor " + path); + var factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + Element root; + try (var stream = jar.getInputStream(entry)) { + root = factory.newDocumentBuilder().parse(stream).getDocumentElement(); + } + List bindings = new ArrayList<>(); + for (String kind : List.of("tag", "function")) { + var elements = root.getElementsByTagNameNS("*", kind); + if (elements.getLength() == 0) throw new AssertionError("Empty " + kind + " list in " + path); + for (int i = 0; i < elements.getLength(); i++) { + String name = text((Element) elements.item(i), "name"); + if (!name.matches("for[A-Za-z0-9]+")) throw new AssertionError(name); + bindings.add(new Binding(kind, name)); + } + } + String uri = text(root, "uri"); + if (!uri.matches("[A-Za-z0-9:/_.#-]+")) throw new AssertionError(uri); + return new Descriptor(uri, bindings); + } + + private static String text(Element element, String name) { + var nodes = element.getElementsByTagNameNS("*", name); + if (nodes.getLength() == 0) throw new AssertionError("Missing " + name); + return nodes.item(0).getTextContent().trim(); + } +} diff --git a/jakarta/pom.xml b/jakarta/pom.xml index dba37d1..413d698 100644 --- a/jakarta/pom.xml +++ b/jakarta/pom.xml @@ -96,6 +96,23 @@ + + org.apache.maven.plugins + maven-antrun-plugin + + + + org.apache.tomcat.embed + tomcat-embed-jasper + 10.1.60 + + + org.apache.tomcat + tomcat-annotations-api + 10.1.60 + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/jsp/pom.xml b/jsp/pom.xml index 8b28403..eea8540 100644 --- a/jsp/pom.xml +++ b/jsp/pom.xml @@ -96,6 +96,23 @@ + + org.apache.maven.plugins + maven-antrun-plugin + + + + org.apache.tomcat.embed + tomcat-embed-jasper + 9.0.122 + + + org.apache.tomcat + tomcat-annotations-api + 9.0.122 + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/pom.xml b/pom.xml index c2eace3..235ae97 100755 --- a/pom.xml +++ b/pom.xml @@ -155,6 +155,41 @@ + + + org.apache.maven.plugins + maven-antrun-plugin + 3.2.0 + + + packaged-jsp-engine + verify + run + + ${skipTests} + + + + + + + + + + + + + + + + + + + + + org.apache.maven.plugins maven-compiler-plugin