diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 90c5e507..03a186ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,3 +30,46 @@ jobs: - name: Build run: ./mvnw -ntp -B verify + + java8-runtime: + # The library targets Java 8, but building it takes JDK 11 or newer, so + # the matrix above never loads the jar on Java 8. Package it with JDK 11, + # then run the Java 8 consumer check from src/it on a Java 8 runtime: it + # checks the jar's bytecode levels and that Java 8 picks the fallback + # shim, and sanitizes. See #515. + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Java 11 + uses: actions/setup-java@v5 + with: + java-version: '11' + distribution: 'zulu' + cache: maven + + - name: Package + run: ./mvnw -ntp -B -DskipTests -pl owasp-java-html-sanitizer -am package + + - name: Setup Java 8 + uses: actions/setup-java@v5 + with: + java-version: '8' + distribution: 'zulu' + + - name: Run the Java 8 consumer check on Java 8 + run: | + set -- owasp-java-html-sanitizer/target/owasp-java-html-sanitizer-*.jar + if [ $# -ne 1 ] || [ ! -f "$1" ]; then + echo "::error::expected one packaged jar, found: $*" + exit 1 + fi + java -version + # Java 8's javac, unlike later ones, does not create the -d directory. + mkdir -p target/java8-consumer + javac -d target/java8-consumer -classpath "$1" \ + owasp-java-html-sanitizer/src/it/java8-consumer/org/owasp/html/java8check/Java8ConsumerCheck.java + java -classpath "$1:target/java8-consumer" \ + org.owasp.html.java8check.Java8ConsumerCheck "$1" diff --git a/AGENTS.md b/AGENTS.md index 22ca0a74..c8683ec8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ OWASP Java HTML Sanitizer (`com.googlecode.owasp-java-html-sanitizer:owasp-java- This is a multi-module Maven build: -- `owasp-java-html-sanitizer/` — the library. Core code: `owasp-java-html-sanitizer/src/main/java/org/owasp/html/`. Tests: `owasp-java-html-sanitizer/src/test/java/org/owasp/html/`. `src/main/java9/module-info.java` is the JPMS module descriptor. A second compiler execution compiles it for Java 9 into `META-INF/versions/9/` of the multi-release JAR, which is how a Java 8 JAR carries one; it exports `org.owasp.html` only. `src/it/jpms-consumer/` is a small module that `./mvnw verify` compiles and runs against the packaged JAR to check that the descriptor is read and the shim stays encapsulated. Those two are the only Java 9 sources in the library module. -- `java8-shim/`, `java10-shim/` — in-repo shims that let the Java 8 library use newer JDK collection APIs when running on Java 10+. Both are inlined into the library JAR by the shade plugin and do not appear in the published POM. `java10-shim` is the only module that may use Java 9 and 10 APIs (`maven.compiler.release` 10). Nothing uses anything newer. +- `owasp-java-html-sanitizer/` — the library. Core code: `owasp-java-html-sanitizer/src/main/java/org/owasp/html/`. Tests: `owasp-java-html-sanitizer/src/test/java/org/owasp/html/`. `src/main/java9/module-info.java` is the JPMS module descriptor. A second compiler execution compiles it for Java 9 into `META-INF/versions/9/` of the multi-release JAR, which is how a Java 8 JAR carries one; it exports `org.owasp.html` only. `src/it/jpms-consumer/` is a small module that `./mvnw verify` compiles and runs against the packaged JAR to check that the descriptor is read and the shim stays encapsulated. Those two are the only Java 9 sources in the library module. `src/it/java8-consumer/` is a Java 8 program that `./mvnw verify` runs against the packaged JAR, and CI on a Java 8 runtime, to check every class's bytecode level and that each runtime picks its shim. +- `java8-shim/`, `java10-shim/` — in-repo shims that let the Java 8 library use newer JDK collection APIs when running on Java 10+. Both are inlined into the library JAR by the shade plugin and do not appear in the published POM. `java10-shim` is the only module that may use Java 9 and 10 APIs (`maven.compiler.release` 10). Nothing uses anything newer. Every class outside `META-INF/versions/` of the published JAR must be Java 8 bytecode, because Java 8 consumers reject dependencies with build rules such as `enforceBytecodeVersion`; newer bytecode goes only under `META-INF/versions/N/`, as `java10-shim`'s does. - `empiricism/` — in-browser experiments that record how real browsers nest and balance tags. They generate `HtmlElementTablesCanned.java` in the library's core package. Never hand-edit that file: change the probe or `empiricism/canned-data.json` and run `empiricism/rebuild.sh`. - `examples/`, `docs/` — samples and documentation. Not part of the shipped artifact. diff --git a/change_log.md b/change_log.md index 740bbec6..dcc9d75e 100644 --- a/change_log.md +++ b/change_log.md @@ -1,6 +1,21 @@ # OWASP Java HTML Sanitizer Change Log Most recent at top. + * Next release + * The jar passes a Java 8 build's bytecode check again. Since + 20260921.1 bundled the shims into it, its root held two classes + compiled for Java 10, `org.owasp.shim.ForJava10AndLater` and an empty + `org.owasp.shim.Notice`, so a Java 8 project that runs the Maven + Enforcer rule `enforceBytecodeVersion` with `maxJdkVersion` 1.8 failed + on this dependency unless it ignored them. `ForJava10AndLater` now + ships in `META-INF/versions/10/` of the jar, which is already + multi-release, and `Notice` is left out. That rule in its default, + non-strict mode, and Java 8 and 9, ignore the versioned directory, + while Java 10 and later find the shim there as before, so which shim + each runtime uses is unchanged. An `ignoreClasses` workaround for + these two classes can be removed. The build fails if any class in the + jar is newer than Java 8 outside `META-INF/versions/`, and CI now runs + the packaged jar on Java 8. Closes #515. * Release 20260924.2 * Follow-ups to the CSS nesting limit in 20260924.1, with no change in output. The CSS lexer keeps a bound on its search for a close diff --git a/docs/maven.md b/docs/maven.md index 17e0a1fd..a1f7b830 100644 --- a/docs/maven.md +++ b/docs/maven.md @@ -19,6 +19,14 @@ The sanitizer JAR is self-contained: the `java8-shim` and `java10-shim` artifact are bundled inside it and do **not** need to be declared as separate dependencies, including when using the JPMS module path. +In releases after 20260924.2, every class outside `META-INF/versions/` is +Java 8 bytecode, so a Java 8 build that checks its dependencies with the Maven +Enforcer rule `enforceBytecodeVersion` and a `maxJdkVersion` of 1.8 accepts +the JAR without `ignoreClasses`. The shim for Java 10 and later is in +`META-INF/versions/10/`, which Java 8 never reads and which that rule allows +unless it is set to `strict`, which holds versioned classes to `maxJdkVersion` +too. + On the module path the JAR is an explicit module named `owasp.java.html.sanitizer` that exports the `org.owasp.html` package: diff --git a/java10-shim/README.md b/java10-shim/README.md index c2303e28..1d79c659 100644 --- a/java10-shim/README.md +++ b/java10-shim/README.md @@ -16,6 +16,14 @@ and otherwise uses the Java 8 fallback. Both shim modules are shaded into the published sanitizer JAR; this module is not independently published and is not a supported consumer API. +The sanitizer JAR is a Java 8 JAR, and Java 8 builds commonly reject any +dependency with a class compiled for a later release outside +`META-INF/versions/`. So the build compiles `ForJava10AndLater` twice: into +`META-INF/versions/10/`, the only copy shaded into the multi-release sanitizer +JAR, where Java 10 and later find it and Java 8 and 9 never look; and into +the root of this module's classes, the copy the reactor's tests load. Nothing +else in this module is shaded. See issue #515. + ## Build The [build requires JDK 11 or newer](../README.md#repository-layout). diff --git a/java10-shim/pom.xml b/java10-shim/pom.xml index d85ad0fd..ed8e07dc 100644 --- a/java10-shim/pom.xml +++ b/java10-shim/pom.xml @@ -22,6 +22,41 @@ true + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + compile-versioned + compile + + compile + + + true + + org/owasp/shim/ForJava10AndLater.java + + + + + + + + com.googlecode.owasp-java-html-sanitizer diff --git a/java8-shim/src/main/java/org/owasp/shim/Java8Shim.java b/java8-shim/src/main/java/org/owasp/shim/Java8Shim.java index c949a052..181285ed 100644 --- a/java8-shim/src/main/java/org/owasp/shim/Java8Shim.java +++ b/java8-shim/src/main/java/org/owasp/shim/Java8Shim.java @@ -19,7 +19,9 @@ public abstract class Java8Shim { Object _instance; try { try { - // This is compiled with -release 1.10 in a separate project. + // java10-shim compiles this for Java 10, and the sanitizer jar + // carries it in META-INF/versions/10/, so only Java 10 and + // later find it. _instance = Class.forName("org.owasp.shim.ForJava10AndLater").newInstance(); } catch (Throwable e) { // This is co-located with this project and is a fall-back. diff --git a/owasp-java-html-sanitizer/README.md b/owasp-java-html-sanitizer/README.md index 75258cbb..f05f9ef3 100644 --- a/owasp-java-html-sanitizer/README.md +++ b/owasp-java-html-sanitizer/README.md @@ -24,6 +24,11 @@ rendering to produce sanitized HTML. See the against the packaged JAR during the `integration-test` phase, included in `verify`. It checks the descriptor and confirms that the bundled shim package remains encapsulated. +* The [Java 8 consumer](src/it/java8-consumer/) is compiled for Java 8 and run + against the packaged JAR on the class path in the same phase, and on a + Java 8 runtime in CI. It fails if any class in the JAR is newer than a + Java 8 build allows, or if the sanitizer does not pick the shim meant for + the running JDK. `HtmlElementTablesCanned.java` is generated from browser observations. Do not edit it directly; update the experiment or data in diff --git a/owasp-java-html-sanitizer/pom.xml b/owasp-java-html-sanitizer/pom.xml index c63f703d..706b7179 100644 --- a/owasp-java-html-sanitizer/pom.xml +++ b/owasp-java-html-sanitizer/pom.xml @@ -141,12 +141,16 @@ wrong directory. It is exactly where the JDK expects it, so drop that one warning and no other. --> <_fixupmessages>Classes found in the wrong directory*module-info*;is:=ignore - + true + + com.googlecode.owasp-java-html-sanitizer:java10-shim + + META-INF/versions/** + + @@ -268,6 +287,50 @@ + + + compile-java8-consumer + integration-test + + exec + + + ${java.home}/bin/javac + + --release + 8 + + -Xlint:-options + -d + ${project.build.directory}/java8-consumer + -classpath + ${project.build.directory}/${project.build.finalName}.jar + ${project.basedir}/src/it/java8-consumer/org/owasp/html/java8check/Java8ConsumerCheck.java + + + + + run-java8-consumer + integration-test + + exec + + + ${java.home}/bin/java + + -classpath + ${project.build.directory}/${project.build.finalName}.jar${path.separator}${project.build.directory}/java8-consumer + org.owasp.html.java8check.Java8ConsumerCheck + ${project.build.directory}/${project.build.finalName}.jar + + + diff --git a/owasp-java-html-sanitizer/src/it/java8-consumer/org/owasp/html/java8check/Java8ConsumerCheck.java b/owasp-java-html-sanitizer/src/it/java8-consumer/org/owasp/html/java8check/Java8ConsumerCheck.java new file mode 100644 index 00000000..2cbee7ef --- /dev/null +++ b/owasp-java-html-sanitizer/src/it/java8-consumer/org/owasp/html/java8check/Java8ConsumerCheck.java @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 OR BSD-2-Clause + +package org.owasp.html.java8check; + +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.owasp.html.Sanitizers; +import org.owasp.shim.Java8Shim; + +/** + * Checks, from the class path, that the packaged jar is usable by a consumer + * that builds for or runs on Java 8. See issue #515. + * + *

Java 8 builds commonly fail on any dependency that holds a class + * compiled for a later release, with the Maven Enforcer rule + * {@code enforceBytecodeVersion} at a {@code maxJdkVersion} of 1.8. That + * rule allows release N under {@code META-INF/versions/N/} of a + * multi-release jar, where only Java N and later look, and nothing newer + * than Java 8 anywhere else. So this checks every class in the jar against + * the same bound, then checks that the sanitizer picks the shim meant for + * the runtime it is on, which is how a mistake in the versioned directory or + * the {@code Multi-Release} manifest header would show. + * + *

The build runs this at the integration-test phase on the JDK running + * the build, and CI also runs it on a Java 8 runtime, the one place the + * Java 8 fallback shim is ever loaded. Any failure exits non-zero. It is + * compiled for Java 8, so keep to Java 8 APIs here. + */ +public final class Java8ConsumerCheck { + + private Java8ConsumerCheck() {} + + /** A versioned entry of a multi-release jar, capturing its release. */ + private static final Pattern VERSIONED = + Pattern.compile("^META-INF/versions/([0-9]+)/"); + + /** Release N compiles to class file major version N + 44. */ + private static final int MAJOR_VERSION_OFFSET = 44; + + public static void main(String[] args) throws Exception { + check(args.length == 1, "usage: Java8ConsumerCheck "); + File jar = new File(args[0]).getCanonicalFile(); + + // What a Java 8 build checks: nothing outside the versioned directories + // is newer than Java 8, and nothing inside one is newer than its release. + List tooNew = new ArrayList<>(); + int classCount = 0; + try (ZipFile zip = new ZipFile(jar)) { + for (Enumeration entries = zip.entries(); + entries.hasMoreElements();) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if (entry.isDirectory() || !name.endsWith(".class")) { + continue; + } + ++classCount; + Matcher versioned = VERSIONED.matcher(name); + boolean isVersioned = versioned.find(); + int release = isVersioned ? Integer.parseInt(versioned.group(1)) : 8; + int major = majorVersion(zip, entry); + if (major > release + MAJOR_VERSION_OFFSET) { + tooNew.add(name + " is compiled for Java " + + (major - MAJOR_VERSION_OFFSET) + " but " + + (isVersioned ? "sits in the directory for Java " + release + : "is outside META-INF/versions/")); + } + } + } + check(classCount != 0, "no classes in " + jar); + check(tooNew.isEmpty(), "a Java 8 build rejects this jar: " + tooNew); + + // Java 10 and later read the versioned directory and find the shim that + // uses their collection factories; Java 8 and 9 never see it and fall + // back. + int runtime = runtimeRelease(); + String expected = runtime >= 10 + ? "org.owasp.shim.ForJava10AndLater" : "org.owasp.shim.ForJava8"; + Class shimClass = Java8Shim.j8().getClass(); + String shim = shimClass.getName(); + check(expected.equals(shim), + "on Java " + runtime + " the sanitizer loaded " + shim + + " rather than " + expected); + + // Those classes must come from the jar under test, not from a stray shim + // or class directory earlier on the class path. + for (Class c : new Class[] { + Sanitizers.class, Java8Shim.class, shimClass }) { + File source = new File( + c.getProtectionDomain().getCodeSource().getLocation().toURI()) + .getCanonicalFile(); + check(jar.equals(source), c.getName() + " loaded from " + source); + } + + String sanitized = Sanitizers.FORMATTING.sanitize( + "safe"); + check("safe".equals(sanitized), "sanitized to " + sanitized); + + System.out.println(classCount + " classes in " + jar.getName() + + " are within a Java 8 consumer's bounds; on Java " + runtime + + " the sanitizer uses " + shim); + } + + private static int majorVersion(ZipFile zip, ZipEntry entry) + throws IOException { + try (InputStream in = zip.getInputStream(entry)) { + DataInputStream data = new DataInputStream(in); + check(data.readInt() == 0xCAFEBABE, entry.getName() + " is not a class"); + data.readUnsignedShort(); // minor version + return data.readUnsignedShort(); + } + } + + /** 8 for "1.8", and the number itself from Java 9's "9" on. */ + private static int runtimeRelease() { + String spec = System.getProperty("java.specification.version"); + return Integer.parseInt(spec.startsWith("1.") ? spec.substring(2) : spec); + } + + private static void check(boolean condition, String failure) { + if (!condition) { + throw new AssertionError(failure); + } + } +} diff --git a/owasp-java-html-sanitizer/src/it/jpms-consumer/org/owasp/html/jpmscheck/JpmsConsumerCheck.java b/owasp-java-html-sanitizer/src/it/jpms-consumer/org/owasp/html/jpmscheck/JpmsConsumerCheck.java index 312e392e..193377c4 100644 --- a/owasp-java-html-sanitizer/src/it/jpms-consumer/org/owasp/html/jpmscheck/JpmsConsumerCheck.java +++ b/owasp-java-html-sanitizer/src/it/jpms-consumer/org/owasp/html/jpmscheck/JpmsConsumerCheck.java @@ -99,6 +99,24 @@ public static void main(String[] args) { throw new AssertionError("the shim did not fail in the expected way", e); } + // On Java 10 and later the module also holds the shim compiled for them, + // which the jar carries only in META-INF/versions/10/ so that Java 8 + // builds accept it. It is as encapsulated as the rest of its package, + // so load it, uninitialized, through the module's class loader. See + // issue #515. + if (Runtime.version().major() >= 10) { + Class shim; + try { + shim = Class.forName( + "org.owasp.shim.ForJava10AndLater", false, module.getClassLoader()); + } catch (ClassNotFoundException e) { + throw new AssertionError("the module has no ForJava10AndLater on Java " + + Runtime.version().major(), e); + } + check(module.equals(shim.getModule()), + "ForJava10AndLater is in " + shim.getModule()); + } + // And the sanitizer works from the module path, which drives the shim // loader inside the module. String sanitized = Sanitizers.FORMATTING.sanitize(