This is an automated email from the ASF dual-hosted git repository. ppkarwasz pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/commons-xml.git
commit bb1610ab52a9cfc2c9162e5f645621d62f6a84fd Author: Piotr P. Karwasz <[email protected]> AuthorDate: Wed Apr 29 16:27:20 2026 +0200 Adds support for Android (#9) * Adds support for Android This change adds support for DOM and SAX on Android. Android's XML stack is rather secure out-of-the-box, but it does not accept most security-related features from the Java stack, so the `AndroidProvider` ensures that the lack of hardening calls is by design, not omission. The stack uses: - **DOM**: a wrapper around the [KXml2](https://github.com/kobjects/kxml2) mini pull parser, which does not support entities, which solves the security problem at the beginning. - **SAX**: a wrapper around native `libexpat2`, which, since version `2.4.0` does protect against entity expansion attacks, but provides no way to enable its support in earlier versions. `AndroidProvider` only slightly improves the resolution protection (by default the parser ignores external entities) by throwing whenever an external entity is declared, but ignoring external subsets. * fix: add `SchemaFactory` tests --- README.md | 34 ++- android-tests/.gitignore | 4 + android-tests/README.md | 60 +++++ android-tests/build.gradle.kts | 104 +++++++++ android-tests/gradle.properties | 18 ++ android-tests/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 21 ++ android-tests/gradlew | 252 +++++++++++++++++++++ android-tests/gradlew.bat | 94 ++++++++ android-tests/settings.gradle.kts | 33 +++ android-tests/src/main/AndroidManifest.xml | 18 ++ .../commons/xml/factory/AndroidProvider.java | 155 +++++++++++++ .../apache/commons/xml/factory/XmlFactories.java | 6 + .../commons/xml/factory/AttackTestSupport.java | 73 +++++- .../commons/xml/factory/BillionLaughsTest.java | 122 +++++++--- .../commons/xml/factory/ExternalDtdTest.java | 5 + .../xml/factory/ExternalGeneralEntityTest.java | 5 + .../xml/factory/ExternalParameterEntityTest.java | 84 +++++++ 18 files changed, 1048 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 3fd7b92..689eede 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,16 @@ stylesheet) is blocked, and DOCTYPE input is rejected wherever the underlying im ### Supported implementations -Out of the box the library recognises the stock JDK JAXP implementations, Apache Xerces 2.x, Woodstox, and Saxon-HE. If -a factory resolves to an implementation not covered by any bundled hardening recipe, every `XmlFactories` method throws +The library recognises: + +- the stock JDK JAXP implementations, +- Android, +- Saxon-HE, +- Apache Xalan, +- Apache Xerces, +- Woodstox. + +If a factory resolves to an implementation not covered by any bundled hardening recipe, every `XmlFactories` method throws `IllegalStateException` with a message naming the unsupported class. Adding support for a new JAXP implementation requires a code change to this library. @@ -138,6 +146,28 @@ the implementation picks internally, and that parser may not be hardened (Saxon' below). Treat stylesheets and schemas as trusted input, or pre-parse them through a hardened `XmlFactories` parser and pass the result as a `DOMSource` or `SAXSource`. +### Android compatibility + +The library is compiled to Java 8 bytecode and runs on any Android version that supports a Java 8 runtime (API 19 and +above). What ships with the platform and what the application has to add varies by JAXP API: + +- **DOM** (`DocumentBuilderFactory`) and **SAX** (`SAXParserFactory`) ship in `android.jar` since API 1. DOM is backed by + KXmlParser (a kxml2 pull parser); SAX is a wrapper around the system `libexpat`. The hardened factories returned by + `XmlFactories` route through these built-in implementations. +- **TrAX** (`TransformerFactory`) and **XPath** (`XPathFactory`) ship as Apache Xalan since Android 1.0. The hardened + factories receive the same JDK-style entity-expansion limits and deny-all resolver that the standalone Xalan recipe + applies. +- **W3C XML Schema** (`SchemaFactory`) is **declared** in `android.jar` (the `javax.xml.validation.*` API is present) + but the platform ships **no implementation**. +- **StAX** (`XMLInputFactory`) is **not** part of `android.jar` at any API level. + +The SAX path's native billion-laughs amplification protection lives in `libexpat` 2.4 (March 2022), which AOSP first +shipped in **Android 13 (API 33)**. On API 33 and above the platform SAX parser blocks billion-laughs payloads natively; +on older Android releases this specific defence could be unavailable, and a hostile internal-entity payload could +amplify without bound. If your minimum-supported Android level is below 33 and you parse SAX input that you do not +control, sanitise upstream of `XmlFactories`, or pre-parse with Apache Xerces (which carries its own entity-expansion +limit) once you have added it to the classpath for schema support. + ### Caching and thread-safety There is no caching or pooling inside `XmlFactories`; callers on a hot path are responsible for their own caching. The diff --git a/android-tests/.gitignore b/android-tests/.gitignore new file mode 100644 index 0000000..92a94e2 --- /dev/null +++ b/android-tests/.gitignore @@ -0,0 +1,4 @@ +# Android Gradle build outputs +.gradle +build +local.properties diff --git a/android-tests/README.md b/android-tests/README.md new file mode 100644 index 0000000..7c86b79 --- /dev/null +++ b/android-tests/README.md @@ -0,0 +1,60 @@ +<!--- + 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 + + https://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. +--> + +# Android instrumented tests + +Runs the attack-test suite from `../src/test/java` against the Android runtime, exercising +the harmony based DOM and SAX factories that ship with Android. The Maven build does not include this +module; it is a standalone Gradle build kept separate so the default `mvn` goal stays JVM only. + +## Prerequisites + +- JDK 17 on `PATH` (AGP 8.x requires it). +- Android SDK with `platforms/android-34` and `build-tools/34.0.0` installed; + export `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) to point at it. +- Either an attached emulator/device (`adb devices` shows it) or the AGP managed device + bundled into this build (`api31`, AOSP system image). +- The library JAR built by the parent Maven build: + + ``` + cd .. && mvn -DskipTests package + ``` + +## Running + +Against an attached emulator/device: + +``` +./gradlew connectedAndroidTest +``` + +Against the bundled AGP managed device (downloads the AOSP API 31 system image on first +run, then provisions and tears down a headless emulator for each invocation): + +``` +./gradlew api31DebugAndroidTest +``` + +## Excluded test groups + +The build excludes JUnit 5 tags for JAXP types Android does not ship: + +- `stax`: there is no `XMLInputFactory` on Android. +- `schema`: there is no `SchemaFactory` on Android. +- `xpath3`: relies on Saxon, which is not on the Android classpath. + +DOM, SAX, TrAX and XPath 1.0 paths are exercised in full. diff --git a/android-tests/build.gradle.kts b/android-tests/build.gradle.kts new file mode 100644 index 0000000..18a7c15 --- /dev/null +++ b/android-tests/build.gradle.kts @@ -0,0 +1,104 @@ +/* + * 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 + * + * https://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. + */ +import com.android.build.api.dsl.ManagedVirtualDevice + +plugins { + id("com.android.library") version "8.6.1" + id("de.mannodermaus.android-junit") version "2.0.1" +} + +val libraryVersion = "0.1.0-SNAPSHOT" +val libraryJar = rootProject.file("../target/commons-xml-factory-${libraryVersion}.jar") + +android { + namespace = "org.apache.commons.xml.factory.androidtests" + compileSdk = 34 + + defaultConfig { + minSdk = 19 + // androidx.test runner; Mannodermaus's android-junit5 plugin slots a JUnit 5 RunnerBuilder under it so AndroidJUnitRunner picks up Jupiter tests. + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + sourceSets { + getByName("androidTest") { + java.srcDirs("../src/test/java") + resources.srcDirs("../src/test/resources") + } + } + + @Suppress("UnstableApiUsage") + testOptions { + managedDevices { + devices { + // API 33 is the first AOSP release shipping libexpat >= 2.4, which has the built-in billion-laughs check. + // Earlier images (e.g. API 31 with libexpat 2.3.0) carry no native amplification protection. + maybeCreate<ManagedVirtualDevice>("api33").apply { + device = "Pixel 6a" + apiLevel = 33 + systemImageSource = "aosp" + } + } + } + } +} + +// Skip JAXP groups whose factories Android does not ship +junitPlatform { + filters { + // Pass single tag expression + includeTags("dom | sax | schema") + } +} + +dependencies { + if (libraryJar.exists()) { + implementation(files(libraryJar)) + } else { + // Helpful failure: tell the user to build the jar before running androidTest tasks. + configurations.named("implementation").configure { + dependencies.add( + project.dependencies.create( + files(libraryJar).builtBy( + tasks.register("missingLibraryJar") { + doFirst { + throw GradleException( + "Library JAR not found at ${libraryJar}. Run `mvn -DskipTests package` from the project root first." + ) + } + } + ) + ) + ) + } + } + + // Apache Xerces: android.jar ships javax.xml.validation but no SchemaFactory implementation. + androidTestImplementation("xerces:xercesImpl:2.12.2") + + androidTestImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2") + androidTestImplementation("de.mannodermaus.junit5:android-test-core:1.4.0") + androidTestRuntimeOnly("de.mannodermaus.junit5:android-test-runner:1.4.0") + androidTestRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2") + androidTestImplementation("androidx.test:runner:1.5.2") +} + diff --git a/android-tests/gradle.properties b/android-tests/gradle.properties new file mode 100644 index 0000000..69bbe55 --- /dev/null +++ b/android-tests/gradle.properties @@ -0,0 +1,18 @@ +# 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 +# +# https://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. + +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/android-tests/gradle/wrapper/gradle-wrapper.jar b/android-tests/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/android-tests/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-tests/gradle/wrapper/gradle-wrapper.properties b/android-tests/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..de501da --- /dev/null +++ b/android-tests/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,21 @@ +# 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 +# +# https://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. +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android-tests/gradlew b/android-tests/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/android-tests/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android-tests/gradlew.bat b/android-tests/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/android-tests/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android-tests/settings.gradle.kts b/android-tests/settings.gradle.kts new file mode 100644 index 0000000..675ef5a --- /dev/null +++ b/android-tests/settings.gradle.kts @@ -0,0 +1,33 @@ +/* + * 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 + * + * https://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. + */ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "commons-xml-factory-android-tests" diff --git a/android-tests/src/main/AndroidManifest.xml b/android-tests/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6b39031 --- /dev/null +++ b/android-tests/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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 + + https://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. +--> +<manifest /> diff --git a/src/main/java/org/apache/commons/xml/factory/AndroidProvider.java b/src/main/java/org/apache/commons/xml/factory/AndroidProvider.java new file mode 100644 index 0000000..f2863ae --- /dev/null +++ b/src/main/java/org/apache/commons/xml/factory/AndroidProvider.java @@ -0,0 +1,155 @@ +/* + * 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 + * + * https://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.commons.xml.factory; + +import java.util.Objects; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParserFactory; + +import org.xml.sax.EntityResolver; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.XMLReader; +import org.xml.sax.ext.LexicalHandler; + +/** + * Hardening recipes for Android's Apache Harmony based DOM and SAX implementation. + * + * <p>Factory classes live in the {@code org.apache.harmony.xml.parsers.*} package, but DOM and SAX are backed by two different engines:</p> + * <ul> + * <li>{@link SAXParserFactory} produces {@code org.apache.harmony.xml.ExpatReader}, a SAX wrapper around the platform's native Expat parser.</li> + * <li>{@link DocumentBuilderFactory} produces {@code DocumentBuilderImpl}, which builds the DOM tree on top of {@code com.android.org.kxml2.io.KXmlParser} + * (a kxml2 pull parser); it does not use Expat at all.</li> + * </ul> + * + * <p>What the SAX/Expat surface exposes:</p> + * <ul> + * <li>SAX features: only {@code namespaces}, {@code namespace-prefixes}, {@code string-interning}, {@code validation}, + * {@code external-general-entities} and {@code external-parameter-entities}. The last three are read-only and cannot be enabled.</li> + * <li>SAX properties: only {@code lexical-handler}.</li> + * <li>{@link XMLConstants#FEATURE_SECURE_PROCESSING} and JAXP 1.5 {@code ACCESS_EXTERNAL_*} are not recognised.</li> + * <li>Entity expansion: native libexpat enforces a built-in Billion Laughs check (compiled-in activation threshold and amplification factor), so internal + * entity expansion is already bounded below us.</li> + * <li>Every external fetch (DTD subset, DOCTYPE {@code SYSTEM}, general/parameter entity) flows through the 2-arg + * {@link EntityResolver#resolveEntity}; {@code EntityResolver2.getExternalSubset} is never called.</li> + * </ul> + * + * <p>What the DOM/KXmlParser surface exposes:</p> + * <ul> + * <li>{@link DocumentBuilderFactory#setFeature} only recognises {@code namespaces} and {@code validation}.</li> + * <li>{@link DocumentBuilderFactory#setAttribute} always throws {@code IllegalArgumentException}.</li> + * <li>{@link XMLConstants#FEATURE_SECURE_PROCESSING} and JAXP 1.5 {@code ACCESS_EXTERNAL_*} are not recognised.</li> + * <li>KXmlParser already drops external DTD subsets and external general entities silently (no HTTP fetch), and aborts on recursive internal entity + * declarations.</li> + * </ul> + * + * <p>The SAX path installs a {@link DtdAwareDenyResolver} as both {@link EntityResolver} and {@link LexicalHandler}: it allows the external subset to load + * silently (so a DOCTYPE that names an external DTD but does not use it parses) and throws on every external general or parameter entity reference. The DOM + * path needs no resolver: KXmlParser silently drops external DTDs and external general entities and does not consult the resolver.</p> + */ +final class AndroidProvider { + + /** + * Resolver that denies every external resource lookup, except the external DTD subset declared by the DOCTYPE + * + * <p>Merely <em>declaring</em> an external subset does not cause the parse to throw.</p> + */ + private static final class DtdAwareDenyResolver implements EntityResolver, LexicalHandler { + + private boolean inDtd; + private String dtdPublicId; + private String dtdSystemId; + + private static String forbiddenMessage(final String publicId, final String systemId) { + return String.format("External Entity: failed to read external entity (publicId='%s', systemId='%s'); external entity access is denied.", + publicId, systemId); + } + + @Override + public void comment(final char[] ch, final int start, final int length) { + // no-op + } + + @Override + public void endCDATA() { + // no-op + } + + @Override + public void endDTD() { + inDtd = false; + } + + @Override + public void endEntity(final String name) { + // no-op + } + + @Override + public InputSource resolveEntity(final String publicId, final String systemId) throws SAXException { + if (inDtd && Objects.equals(publicId, dtdPublicId) && Objects.equals(systemId, dtdSystemId)) { + return null; + } + throw new SAXException(forbiddenMessage(publicId, systemId)); + } + + @Override + public void startCDATA() { + // no-op + } + + @Override + public void startDTD(final String name, final String publicId, final String systemId) { + inDtd = true; + dtdPublicId = publicId; + dtdSystemId = systemId; + } + + @Override + public void startEntity(final String name) { + // no-op + } + } + + private static final String LEXICAL_HANDLER_PROPERTY = "http://xml.org/sax/properties/lexical-handler"; + + static DocumentBuilderFactory configure(final DocumentBuilderFactory factory) { + return factory; + } + + static SAXParserFactory configure(final SAXParserFactory factory) { + return new HardeningSAXParserFactory(factory, AndroidProvider::configure); + } + + static XMLReader configure(final XMLReader reader) { + final DtdAwareDenyResolver resolver = new DtdAwareDenyResolver(); + reader.setEntityResolver(resolver); + try { + reader.setProperty(LEXICAL_HANDLER_PROPERTY, resolver); + } catch (final SAXException e) { + // ExpatReader recognises the lexical-handler property; if a future replacement does not, fall through and lose subset-vs-entity discrimination. + } + return reader; + } + + private AndroidProvider() { + } +} \ No newline at end of file diff --git a/src/main/java/org/apache/commons/xml/factory/XmlFactories.java b/src/main/java/org/apache/commons/xml/factory/XmlFactories.java index 3ce0e0b..00dd1b0 100644 --- a/src/main/java/org/apache/commons/xml/factory/XmlFactories.java +++ b/src/main/java/org/apache/commons/xml/factory/XmlFactories.java @@ -69,6 +69,8 @@ static DocumentBuilderFactory dispatch(final DocumentBuilderFactory factory) { switch (factory.getClass().getName()) { case "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl": return StockJdkProvider.configure(factory); + case "org.apache.harmony.xml.parsers.DocumentBuilderFactoryImpl": + return AndroidProvider.configure(factory); case "org.apache.xerces.jaxp.DocumentBuilderFactoryImpl": return XercesProvider.configure(factory); default: @@ -80,6 +82,8 @@ private static SAXParserFactory dispatch(final SAXParserFactory factory) { switch (factory.getClass().getName()) { case "com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl": return StockJdkProvider.configure(factory); + case "org.apache.harmony.xml.parsers.SAXParserFactoryImpl": + return AndroidProvider.configure(factory); case "org.apache.xerces.jaxp.SAXParserFactoryImpl": return XercesProvider.configure(factory); default: @@ -150,6 +154,8 @@ public static XMLReader harden(final XMLReader reader) { switch (reader.getClass().getName()) { case "com.sun.org.apache.xerces.internal.jaxp.SAXParserImpl$JAXPSAXParser": return StockJdkProvider.configure(reader); + case "org.apache.harmony.xml.ExpatReader": + return AndroidProvider.configure(reader); case "org.apache.xerces.jaxp.SAXParserImpl$JAXPSAXParser": return XercesProvider.configure(reader); default: diff --git a/src/test/java/org/apache/commons/xml/factory/AttackTestSupport.java b/src/test/java/org/apache/commons/xml/factory/AttackTestSupport.java index a35ad0a..0ce93c6 100644 --- a/src/test/java/org/apache/commons/xml/factory/AttackTestSupport.java +++ b/src/test/java/org/apache/commons/xml/factory/AttackTestSupport.java @@ -50,6 +50,7 @@ import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.api.function.ThrowingSupplier; import org.w3c.dom.Document; +import org.w3c.dom.Element; import org.xml.sax.InputSource; import org.xml.sax.SAXException; import org.xml.sax.XMLReader; @@ -65,9 +66,14 @@ * layer is expected to reject the attack outright.</li> * <li>{@code assert*DoesNotLeak(...)} runs the payload through a hardened factory and asserts the {@link #LEAKED_MARKER} string does not appear in the * output. The parse may either throw or succeed silently. Used where the JAXP API does not give a clean throw hook (SAX content stream, transformer - * output) or where the XML spec relaxes the constraint that would otherwise force a throw (DOM with an external DTD subset).</li> + * output) or where the XML spec lets the parser silently skip undeclared entities once the external DTD subset has been refused (DOM with an external + * DTD subset).</li> * </ul> * + * <p>DOM tests that depend on user-defined entity machinery should gate themselves with {@link org.junit.jupiter.api.Assumptions#assumeTrue} on + * {@link #DOM_RESOLVES_INTERNAL_ENTITIES} so they skip on platforms (such as Android with KXmlParser) whose DOM parser does not surface the entity events that + * the strict {@link #assertDomBlocks} assertion expects.</p> + * * <p>The unconfigured-side positive controls keep verbs that match the JAXP type they exercise: {@code assert*Resolves(payload)} for direct parsing, * {@code assert*Compiles(...)} for {@link SchemaFactory} / {@link TransformerFactory} compilation, {@code assertTransformerSucceeds(payload)} for * {@code Transformer.transform}, {@code assertValidatorAccepts(payload)} for {@code Validator.validate}.</p> @@ -97,11 +103,28 @@ final class AttackTestSupport { + " </xs:element>\n" + "</xs:schema>\n"; + /** + * Benign payload used to probe whether the DOM parser inlines a user-defined internal general entity into the tree. + */ + private static final String DOM_INTERNAL_ENTITY_PROBE = + "<?xml version=\"1.0\"?>\n" + + "<!DOCTYPE root [<!ENTITY foo \"bar\">]>\n" + + "<root>&foo;</root>"; + + /** + * Set to {@code true} when the platform's DOM parser supports user-defined internal entities. + * + * <p>Android's {@code KXmlParser} currently fails this test.</p> + */ + static final boolean DOM_RESOLVES_INTERNAL_ENTITIES = probeDomResolvesInternalEntities(); + + /** {@code true} when running on Android (Dalvik / ART), {@code false} on any standard JVM. Probed once via {@code Class.forName} on {@code android.os.Build}. */ + static final boolean IS_ANDROID = probeAndroid(); + /** * URL form of the JDK's entity-expansion limit property. */ private static final String JDK_ENTITY_EXPANSION_LIMIT = "http://www.oracle.com/xml/jaxp/properties/entityExpansionLimit"; - /** * Text planted in every fixture under {@code src/test/resources/leaked/}. * @@ -128,7 +151,12 @@ static void assertDomBlocks(final String payload) { static void assertDomDoesNotLeak(final String payload) { assertNoLeak(() -> { final Document doc = XmlFactories.newDocumentBuilderFactory().newDocumentBuilder().parse(inputSource(payload)); - return doc.getDocumentElement() == null ? "" : doc.getDocumentElement().getTextContent(); + if (doc.getDocumentElement() == null) { + return ""; + } + // Harmony's DOM returns null from getTextContent() on an element whose only children are unresolved EntityReference nodes. + final String text = doc.getDocumentElement().getTextContent(); + return text == null ? "" : text; }, "DOM", SAXException.class); } @@ -150,7 +178,9 @@ static void assertDomParses(final String payload) { static void assertDomResolves(final String payload) { assertParseSucceeds(() -> { final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + if (!IS_ANDROID) { + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + } suppressException(() -> factory.setAttribute(JDK_ENTITY_EXPANSION_LIMIT, "0")); factory.newDocumentBuilder().parse(inputSource(payload)); }, "DOM"); @@ -258,7 +288,9 @@ static void assertSaxParses(final String payload) { static void assertSaxResolves(final String payload) { assertParseSucceeds(() -> { final SAXParserFactory factory = SAXParserFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + if (!IS_ANDROID) { + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + } final XMLReader reader = factory.newSAXParser().getXMLReader(); suppressException(() -> reader.setProperty(JDK_ENTITY_EXPANSION_LIMIT, "0")); parseQuietly(reader, payload); @@ -283,7 +315,9 @@ static void assertSchemaBlocks(final Source xsd) { static void assertSchemaCompiles(final Source xsd) { assertParseSucceeds(() -> { final SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + if (!IS_ANDROID) { + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + } suppressException(() -> factory.setProperty(JDK_ENTITY_EXPANSION_LIMIT, "0")); factory.newSchema(xsd); }, "Schema compile"); @@ -532,16 +566,39 @@ private static void parseQuietly(final XMLReader reader, final String payload) t /** Builds a {@link SAXSource} wrapping the payload, parsed by a deliberately permissive SAX parser; used by the unconfigured-side TrAX controls. */ private static SAXSource permissiveSaxSource(final String xml) throws ParserConfigurationException, org.xml.sax.SAXException { final SAXParserFactory parserFactory = SAXParserFactory.newInstance(); - parserFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + if (!IS_ANDROID) { + parserFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + } final XMLReader reader = parserFactory.newSAXParser().getXMLReader(); suppressException(() -> reader.setProperty(JDK_ENTITY_EXPANSION_LIMIT, "0")); return new SAXSource(reader, new InputSource(new StringReader(xml))); } + private static boolean probeAndroid() { + try { + Class.forName("android.os.Build"); + return true; + } catch (final ClassNotFoundException e) { + return false; + } + } + + private static boolean probeDomResolvesInternalEntities() { + try { + final Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputSource(DOM_INTERNAL_ENTITY_PROBE)); + final Element root = doc.getDocumentElement(); + return root != null && "bar".equals(root.getTextContent()); + } catch (final Exception e) { + return false; + } + } + /** Builds a raw {@link XMLReader} from a deliberately permissive {@link SAXParserFactory} and hardens it via {@link XmlFactories#harden(XMLReader)}. */ private static XMLReader rawHardenedReader() throws Exception { final SAXParserFactory factory = SAXParserFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + if (!IS_ANDROID) { + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, false); + } return XmlFactories.harden(factory.newSAXParser().getXMLReader()); } diff --git a/src/test/java/org/apache/commons/xml/factory/BillionLaughsTest.java b/src/test/java/org/apache/commons/xml/factory/BillionLaughsTest.java index 4e74cb4..accae35 100644 --- a/src/test/java/org/apache/commons/xml/factory/BillionLaughsTest.java +++ b/src/test/java/org/apache/commons/xml/factory/BillionLaughsTest.java @@ -16,21 +16,46 @@ */ package org.apache.commons.xml.factory; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; /** * Checks whether parsers reject a Billion Laughs payload (nested entity expansion in the internal DTD subset). * - * <p>The payload nests three levels of 16x expansion ({@code lol1} through {@code lol3}), so resolving {@code &lol3;} produces - * {@code 16 * 16 * 16 = 4096} leaf {@code &lol;} expansions and a total of {@code 1 + 16 + 256 + 4096 = 4369} entity-expansion events. That is comfortably over - * the {@code entityExpansionLimit = 2500} this library pins on every JAXP factory it returns (mirroring JDK 25's secure value), so the hardened side trips on - * every JDK from 8 to 25. The unconfigured side disables the limit explicitly via {@code setAttribute/setProperty} on the JDK property name, so it parses the - * ~4 KB of expanded {@code "A"} text and finishes immediately.</p> + * <p>Two fixtures are used:</p> + * + * <ul> + * <li>The <strong>medium</strong> fixture nests three levels of 16x expansion ({@code lol1} through {@code lol3}), so resolving {@code &lol3;} produces + * {@code 16 * 16 * 16 = 4096} leaf {@code &lol;} expansions and a total of {@code 1 + 16 + 256 + 4096 = 4369} entity-expansion events. That is + * comfortably over the {@code entityExpansionLimit = 2500} this library pins on every JAXP factory it returns (mirroring JDK 25's secure value), so the + * hardened side trips on every JDK from 8 to 25. The unconfigured side disables the limit explicitly via {@code setAttribute/setProperty} on the JDK + * property name, so it parses the ~4 KB of expanded {@code "A"} text and finishes immediately.</li> + * <li>The <strong>large</strong> fixture nests seven levels of 10x expansion ({@code lol1} through {@code lol7}), so resolving {@code &lol7;} produces + * {@code 10^7 = 10 000 000} leaf expansions, ~10 MB of {@code "A"} text, and an amplification factor in the tens of thousands. Sized to trip + * libexpat >= 2.4's built-in billion-laughs check (8 MiB activation threshold and 100x amplification factor); used by the hardened DOM/SAX/XmlReader + * tests on Android, where the JDK count limit is unavailable and only libexpat's native check stands between the parser and the expansion.</li> + * </ul> * * <p>Why a single character {@code "A"}: XSLTC compiles a stylesheet's expanded text into a JVM string constant, which is capped at 65535 bytes. A larger - * expansion makes {@code newTransformer(stylesheet)} fail with a misleading "GregorSamsa" stub-class error even when entity limits are disabled, so the payload - * is sized to stay well under that ceiling.</p> + * expansion makes {@code newTransformer(stylesheet)} fail with a misleading "GregorSamsa" stub-class error even when entity limits are disabled. The medium + * fixture is sized to stay well under that ceiling. The large fixture cannot be used for {@code Templates} / {@code Transformer} compilation for the same + * reason.</p> + * + * <p>Which fixture each test uses:</p> + * + * <ul> + * <li>{@code hardenedSaxBlocks}, {@code hardenedXmlReaderBlocks}: large fixture on Android (libexpat is the only defence), medium fixture on JDK + * (entity-expansion count limit is sufficient). Selected by {@link AttackTestSupport#IS_ANDROID}.</li> + * <li>{@code hardenedDomBlocks} and {@code unconfiguredDomResolves}: gated on {@link AttackTestSupport#DOM_RESOLVES_INTERNAL_ENTITIES}. They run with the + * medium fixture on platforms whose DOM parser resolves user-defined entities (every JDK), and skip on platforms where it does not (Android with + * KXmlParser, where custom internal entities become unresolved {@code EntityReference} nodes and amplification cannot grow). When the probe lights up + * on a future Android the assertions will start running again without further changes.</li> + * <li>Every other test: medium fixture. {@code unconfigured*} positive controls cannot use the large fixture because libexpat's protection cannot be + * disabled from Java. {@code Templates} and {@code Transformer} tests cannot use it because of the XSLTC 60 KB cap. {@code Schema}, {@code StAX} and + * {@code Validator} hardened tests do not run on Android (no harmony {@code SchemaFactory} or {@code XMLInputFactory}), so the JDK count limit is the + * only relevant defence for them and the medium fixture is enough.</li> + * </ul> * * <p>Each parser type is exercised twice as a pair (unconfigured factory, expected to parse; hardened factory, expected to throw):</p> * @@ -44,41 +69,76 @@ */ class BillionLaughsTest { - private static final String INSERTION = "&lol3;"; + private static final String LARGE_DTD = + " <!ENTITY lol \"A\">\n" + + " <!ENTITY lol1 \"&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;\">\n" + + " <!ENTITY lol2 \"&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;\">\n" + + " <!ENTITY lol3 \"&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;\">\n" + + " <!ENTITY lol4 \"&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;\">\n" + + " <!ENTITY lol5 \"&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;\">\n" + + " <!ENTITY lol6 \"&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;\">\n" + + " <!ENTITY lol7 \"&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;\">\n"; + + private static final String LARGE_CONTENT = "&lol7;"; + + private static final String MEDIUM_DTD = + " <!ENTITY lol \"A\">\n" + + " <!ENTITY lol1 \"&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;\">\n" + + " <!ENTITY lol2 \"&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;\">\n" + + " <!ENTITY lol3 \"&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;\">\n"; - private static String withDoctype(final String rootQName, final String body) { + private static final String MEDIUM_CONTENT = "&lol3;"; + + /** + * Hardened-side payload for DOM/SAX/XmlReader: large on Android (libexpat amplification check), medium on JDK (entity-expansion count limit). + */ + private static String hardenedXmlPayload() { + return AttackTestSupport.IS_ANDROID ? largeXmlPayload() : mediumXmlPayload(); + } + + /** + * ~10 MB of expanded {@code "A"}; trips libexpat >= 2.4's billion-laughs check. Not usable for Templates/Transformer (XSLTC 60 KB cap) or for any + * unconfigured positive control. + */ + private static String largeXmlPayload() { + return withDoctype("root", LARGE_DTD, AttackTestSupport.xmlBody(LARGE_CONTENT)); + } + + /** + * ~4 KB of expanded {@code "A"}; trips JDK 25's {@code entityExpansionLimit = 2500}. Universal payload that stays under XSLTC's 60 KB constant-pool cap. + */ + private static String mediumXmlPayload() { + return withDoctype("root", MEDIUM_DTD, AttackTestSupport.xmlBody(MEDIUM_CONTENT)); + } + + private static String withDoctype(final String rootQName, final String dtd, final String body) { return "<?xml version=\"1.0\"?>\n" + "<!DOCTYPE " + rootQName + " [\n" - + " <!ENTITY lol \"A\">\n" - + " <!ENTITY lol1 \"&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;\">\n" - + " <!ENTITY lol2 \"&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;\">\n" - + " <!ENTITY lol3 \"&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;\">\n" + + dtd + "]>\n" + body + "\n"; } - private static String xmlPayload() { - return withDoctype("root", AttackTestSupport.xmlBody(INSERTION)); - } - private static String xsdPayload() { - return withDoctype("xs:schema", AttackTestSupport.xsdBody(INSERTION)); + return withDoctype("xs:schema", MEDIUM_DTD, AttackTestSupport.xsdBody(MEDIUM_CONTENT)); } private static String xsltPayload() { - return withDoctype("xsl:stylesheet", AttackTestSupport.xsltBody(INSERTION)); + return withDoctype("xsl:stylesheet", MEDIUM_DTD, AttackTestSupport.xsltBody(MEDIUM_CONTENT)); } @Test @Tag("dom") void hardenedDomBlocks() { - AttackTestSupport.assertDomBlocks(xmlPayload()); + Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, + "Skipped: platform DOM does not resolve user-defined entities"); + AttackTestSupport.assertDomBlocks(hardenedXmlPayload()); } @Test @Tag("sax") void hardenedSaxBlocks() { - AttackTestSupport.assertSaxBlocks(xmlPayload()); + AttackTestSupport.assertSaxBlocks(hardenedXmlPayload()); } @Test @@ -90,7 +150,7 @@ void hardenedSchemaBlocks() { @Test @Tag("stax") void hardenedStaxBlocks() { - AttackTestSupport.assertStaxBlocks(xmlPayload()); + AttackTestSupport.assertStaxBlocks(mediumXmlPayload()); } @Test @@ -102,31 +162,33 @@ void hardenedTemplatesDoesNotLeak() { @Test @Tag("trax") void hardenedTransformerDoesNotLeak() { - AttackTestSupport.assertTransformerDoesNotLeak(xmlPayload()); + AttackTestSupport.assertTransformerDoesNotLeak(mediumXmlPayload()); } @Test @Tag("schema") void hardenedValidatorBlocks() { - AttackTestSupport.assertValidatorBlocks(xmlPayload()); + AttackTestSupport.assertValidatorBlocks(mediumXmlPayload()); } @Test @Tag("sax") void hardenedXmlReaderBlocks() { - AttackTestSupport.assertXmlReaderBlocks(xmlPayload()); + AttackTestSupport.assertXmlReaderBlocks(hardenedXmlPayload()); } @Test @Tag("dom") void unconfiguredDomResolves() { - AttackTestSupport.assertDomResolves(xmlPayload()); + Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, + "Skipped: platform DOM does not resolve user-defined entities"); + AttackTestSupport.assertDomResolves(mediumXmlPayload()); } @Test @Tag("sax") void unconfiguredSaxResolves() { - AttackTestSupport.assertSaxResolves(xmlPayload()); + AttackTestSupport.assertSaxResolves(mediumXmlPayload()); } @Test @@ -138,7 +200,7 @@ void unconfiguredSchemaCompiles() { @Test @Tag("stax") void unconfiguredStaxResolves() { - AttackTestSupport.assertStaxResolves(xmlPayload()); + AttackTestSupport.assertStaxResolves(mediumXmlPayload()); } @Test @@ -150,12 +212,12 @@ void unconfiguredTemplatesCompiles() { @Test @Tag("trax") void unconfiguredTransformerSucceeds() { - AttackTestSupport.assertTransformerSucceeds(xmlPayload()); + AttackTestSupport.assertTransformerSucceeds(mediumXmlPayload()); } @Test @Tag("schema") void unconfiguredValidatorAccepts() { - AttackTestSupport.assertValidatorAccepts(xmlPayload()); + AttackTestSupport.assertValidatorAccepts(mediumXmlPayload()); } } diff --git a/src/test/java/org/apache/commons/xml/factory/ExternalDtdTest.java b/src/test/java/org/apache/commons/xml/factory/ExternalDtdTest.java index 8662032..fd5e3cb 100644 --- a/src/test/java/org/apache/commons/xml/factory/ExternalDtdTest.java +++ b/src/test/java/org/apache/commons/xml/factory/ExternalDtdTest.java @@ -16,6 +16,7 @@ */ package org.apache.commons.xml.factory; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -61,6 +62,8 @@ private static String xsltPayload() { @Test @Tag("dom") void hardenedDomDoesNotLeak() { + Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, + "Skipped: platform DOM does not resolve user-defined entities"); AttackTestSupport.assertDomDoesNotLeak(xmlPayload()); } @@ -109,6 +112,8 @@ void hardenedXmlReaderDoesNotLeak() { @Test @Tag("dom") void unconfiguredDomResolves() { + Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, + "Skipped: platform DOM does not resolve user-defined entities"); AttackTestSupport.assertDomResolves(xmlPayload()); } diff --git a/src/test/java/org/apache/commons/xml/factory/ExternalGeneralEntityTest.java b/src/test/java/org/apache/commons/xml/factory/ExternalGeneralEntityTest.java index b0dc708..08c13f5 100644 --- a/src/test/java/org/apache/commons/xml/factory/ExternalGeneralEntityTest.java +++ b/src/test/java/org/apache/commons/xml/factory/ExternalGeneralEntityTest.java @@ -16,6 +16,7 @@ */ package org.apache.commons.xml.factory; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -63,6 +64,8 @@ private static String xsltPayload() { @Test @Tag("dom") void hardenedDomBlocks() { + Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, + "Skipped: platform DOM does not resolve user-defined entities"); AttackTestSupport.assertDomBlocks(xmlPayload()); } @@ -111,6 +114,8 @@ void hardenedXmlReaderBlocks() { @Test @Tag("dom") void unconfiguredDomResolves() { + Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, + "Skipped: platform DOM does not resolve user-defined entities"); AttackTestSupport.assertDomResolves(xmlPayload()); } diff --git a/src/test/java/org/apache/commons/xml/factory/ExternalParameterEntityTest.java b/src/test/java/org/apache/commons/xml/factory/ExternalParameterEntityTest.java index 0d4e6cc..353011f 100644 --- a/src/test/java/org/apache/commons/xml/factory/ExternalParameterEntityTest.java +++ b/src/test/java/org/apache/commons/xml/factory/ExternalParameterEntityTest.java @@ -16,8 +16,20 @@ */ package org.apache.commons.xml.factory; +import java.io.StringReader; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.SAXParserFactory; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.xml.sax.InputSource; +import org.xml.sax.XMLReader; +import org.xml.sax.helpers.DefaultHandler; /** * Checks whether parsers can pull in an external DTD via a parameter-entity reference inside the internal subset. @@ -39,8 +51,72 @@ */ class ExternalParameterEntityTest { + /** + * Benign payload used to probe whether the DOM parser tolerates a parameter-entity reference inside the internal subset. + */ + private static final String DOM_PARAMETER_ENTITY_PROBE = + "<?xml version=\"1.0\"?>\n" + + "<!DOCTYPE root [\n" + + " <!ENTITY % p \"<!ENTITY child 'A'>\">\n" + + " %p;\n" + + "]>\n" + + "<root>&child;</root>"; + + /** + * Set to {@code true} when the platform's DOM parser supports parameter-entity references. + * + * <p>Android's {@code KXmlParser} currently fails this test.</p> + */ + private static final boolean DOM_ACCEPTS_PARAMETER_ENTITIES = probeDomAcceptsParameterEntities(); + private static final String INSERTION = "&leaked;"; + /** + * Benign payload used to probe whether the SAX parser invokes the {@link org.xml.sax.EntityResolver} for an external parameter-entity reference. The + * payload references an external parameter entity by an unfetchable {@code about:invalid} URL; the probe's resolver returns an empty {@code InputSource} + * to let parsing complete without a network call, and reports whether it was consulted at all. + */ + private static final String SAX_PARAMETER_ENTITY_PROBE = + "<?xml version=\"1.0\"?>\n" + + "<!DOCTYPE root [\n" + + " <!ENTITY % p SYSTEM \"about:invalid\">\n" + + " %p;\n" + + "]>\n" + + "<root/>"; + + /** + * Set to {@code true} when the platform's SAX parser invokes the entity resolver for an external parameter-entity reference. False on Android because + * libexpat's default leaves {@code XML_SetParamEntityParsing} disabled and the harmony native bridge does not enable it, so {@code %p;} is silently + * skipped without consulting the resolver. + */ + private static final boolean SAX_RESOLVES_PARAMETER_ENTITIES = probeSaxResolvesParameterEntities(); + + private static boolean probeDomAcceptsParameterEntities() { + try { + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(AttackTestSupport.inputSource(DOM_PARAMETER_ENTITY_PROBE)); + return true; + } catch (final Exception e) { + return false; + } + } + + private static boolean probeSaxResolvesParameterEntities() { + final AtomicBoolean called = new AtomicBoolean(); + Assertions.assertDoesNotThrow(() -> { + final XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader(); + reader.setEntityResolver((publicId, systemId) -> { + if ("about:invalid".equals(systemId)) { + called.set(true); + } + return new InputSource(new StringReader("")); + }); + reader.setContentHandler(new DefaultHandler()); + reader.setErrorHandler(new DefaultHandler()); + reader.parse(AttackTestSupport.inputSource(SAX_PARAMETER_ENTITY_PROBE)); + }); + return called.get(); + } + private static String withDoctype(final String rootQName, final String body) { return "<?xml version=\"1.0\"?>\n" + "<!DOCTYPE " + rootQName + " [\n" @@ -65,12 +141,16 @@ private static String xsltPayload() { @Test @Tag("dom") void hardenedDomBlocks() { + Assumptions.assumeTrue(DOM_ACCEPTS_PARAMETER_ENTITIES, + "Skipped: platform DOM does not accept parameter entities"); AttackTestSupport.assertDomBlocks(xmlPayload()); } @Test @Tag("sax") void hardenedSaxBlocks() { + Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES, + "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities"); AttackTestSupport.assertSaxBlocks(xmlPayload()); } @@ -107,12 +187,16 @@ void hardenedValidatorBlocks() { @Test @Tag("sax") void hardenedXmlReaderBlocks() { + Assumptions.assumeTrue(SAX_RESOLVES_PARAMETER_ENTITIES, + "Skipped: platform SAX parser does not invoke the entity resolver for parameter entities"); AttackTestSupport.assertXmlReaderBlocks(xmlPayload()); } @Test @Tag("dom") void unconfiguredDomResolves() { + Assumptions.assumeTrue(DOM_ACCEPTS_PARAMETER_ENTITIES, + "Skipped: platform DOM does not accept parameter entities"); AttackTestSupport.assertDomResolves(xmlPayload()); }
