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 &gt;= 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 &gt;= 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());
     }
 

Reply via email to