This is an automated email from the ASF dual-hosted git repository.

ppkarwasz pushed a commit to branch feat/2.x/xml-factory-hardening
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git

commit 810da54997c11d22e879985c98783d80c37266b9
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Sat Jun 27 21:39:57 2026 +0200

    Harden XML configuration parsing with `copernik-xml-factory`
    
    Replace the hand-rolled `DocumentBuilderFactory` hardening in 
`XmlConfiguration`
    with the hardened JAXP factories from `eu.copernik:copernik-xml-factory`, 
and
    harden the schema-validation path the same way:
    
    * `newDocumentBuilder` uses `XmlFactories.newDocumentBuilderFactory()`,
    * `validateDocument` uses `XmlFactories.newSchemaFactory()`.
    
    By the library's contract these factories, and everything they produce, 
never
    fetch external entities, DTDs or schemas, across the JAXP implementations 
the
    library recognizes. The OSGi integration tests link the 
`eu.copernik.xml.factory`
    bundle.
    
    Assisted-By: Claude Opus 4.8 <[email protected]>
---
 log4j-core/pom.xml                                 |  5 +++
 .../log4j/core/config/xml/XmlConfiguration.java    | 33 ++++----------------
 .../log4j/osgi/tests/AbstractLoadBundleTest.java   | 36 +++++++++++++---------
 .../logging/log4j/osgi/tests/CoreOsgiTest.java     |  1 +
 .../logging/log4j/osgi/tests/DisruptorTest.java    |  1 +
 log4j-parent/pom.xml                               |  7 +++++
 src/changelog/.2.x.x/xml_factory_hardening.xml     | 11 +++++++
 7 files changed, 53 insertions(+), 41 deletions(-)

diff --git a/log4j-core/pom.xml b/log4j-core/pom.xml
index b2f34d4ab9..d18948fd9e 100644
--- a/log4j-core/pom.xml
+++ b/log4j-core/pom.xml
@@ -157,6 +157,11 @@
       <artifactId>commons-csv</artifactId>
       <optional>true</optional>
     </dependency>
+    <!-- Hardened JAXP factories for XML configuration parsing -->
+    <dependency>
+      <groupId>eu.copernik</groupId>
+      <artifactId>copernik-xml-factory</artifactId>
+    </dependency>
     <!-- Alternative implementation of BlockingQueue using Conversant 
Disruptor for AsyncAppender -->
     <dependency>
       <groupId>com.conversantmedia</groupId>
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/config/xml/XmlConfiguration.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/config/xml/XmlConfiguration.java
index 06800f991d..ee983d1355 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/config/xml/XmlConfiguration.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/config/xml/XmlConfiguration.java
@@ -16,6 +16,7 @@
  */
 package org.apache.logging.log4j.core.config.xml;
 
+import eu.copernik.xml.factory.XmlFactories;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -79,7 +80,7 @@ public class XmlConfiguration extends AbstractConfiguration 
implements Reconfigu
 
     @SuppressFBWarnings(
             value = "XXE_DOCUMENT",
-            justification = "The `newDocumentBuilder` method disables DTD 
processing.")
+            justification = "The parsers are hardened by 
`copernik-xml-factory`; SpotBugs cannot see into the library.")
     public XmlConfiguration(final LoggerContext loggerContext, final 
ConfigurationSource configSource) {
         super(loggerContext, configSource);
         byte[] buffer = null;
@@ -146,11 +147,9 @@ public class XmlConfiguration extends 
AbstractConfiguration implements Reconfigu
      * @throws ParserConfigurationException if a DocumentBuilder cannot be 
created, which satisfies the configuration requested.
      */
     static DocumentBuilder newDocumentBuilder(final boolean xIncludeAware) 
throws ParserConfigurationException {
-        final DocumentBuilderFactory factory = 
DocumentBuilderFactory.newInstance();
+        // Hardened factory: by the library's contract it and what it produces 
never fetch external resources.
+        final DocumentBuilderFactory factory = 
XmlFactories.newDocumentBuilderFactory();
         factory.setNamespaceAware(true);
-
-        disableDtdProcessing(factory);
-
         if (xIncludeAware) {
             factory.setXIncludeAware(true);
         }
@@ -163,27 +162,6 @@ public class XmlConfiguration extends 
AbstractConfiguration implements Reconfigu
         return builder;
     }
 
-    private static void disableDtdProcessing(final DocumentBuilderFactory 
factory) {
-        factory.setValidating(false);
-        factory.setExpandEntityReferences(false);
-        setFeature(factory, 
"http://xml.org/sax/features/external-general-entities";, false);
-        setFeature(factory, 
"http://xml.org/sax/features/external-parameter-entities";, false);
-        setFeature(factory, 
"http://apache.org/xml/features/nonvalidating/load-external-dtd";, false);
-    }
-
-    private static void setFeature(
-            final DocumentBuilderFactory factory, final String featureName, 
final boolean value) {
-        try {
-            factory.setFeature(featureName, value);
-        } catch (final ParserConfigurationException e) {
-            LOGGER.warn(
-                    "The DocumentBuilderFactory [{}] does not support the 
feature [{}]: {}", factory, featureName, e);
-        } catch (final AbstractMethodError err) {
-            LOGGER.warn(
-                    "The DocumentBuilderFactory [{}] is out of date and does 
not support setFeature: {}", factory, err);
-        }
-    }
-
     private static void validateDocument(final Document document, final String 
schemaLocation)
             throws ConfigurationException {
         try {
@@ -194,7 +172,8 @@ public class XmlConfiguration extends AbstractConfiguration 
implements Reconfigu
                 // a schema has its own modularity features 
(`xsd:include`/`xsd:import`).
                 final Document schemaDocument =
                         
newDocumentBuilder(false).parse(ConfigurationSourceResolver.toInputSource(schemaSource));
-                final SchemaFactory factory = 
SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
+                // Hardened factory: by the library's contract it and what it 
produces never fetch external resources.
+                final SchemaFactory factory = XmlFactories.newSchemaFactory();
                 
factory.setResourceResolver(ConfigurationSourceResolver.INSTANCE);
                 // The system id is the base URI against which the schema's 
`xsd:include`/`xsd:import` resources
                 // are resolved by the resource resolver above.
diff --git 
a/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/AbstractLoadBundleTest.java
 
b/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/AbstractLoadBundleTest.java
index 06bb63611d..9d370ccf5b 100644
--- 
a/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/AbstractLoadBundleTest.java
+++ 
b/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/AbstractLoadBundleTest.java
@@ -75,6 +75,10 @@ abstract class AbstractLoadBundleTest {
         return installBundle("org.apache.logging.log4j.api.test");
     }
 
+    private Bundle getXmlFactoryBundle() throws BundleException {
+        return installBundle("eu.copernik.xml.factory");
+    }
+
     /**
      * Tests starting, then stopping, then restarting, then stopping, and 
finally uninstalling the API and Core bundles
      */
@@ -82,19 +86,20 @@ abstract class AbstractLoadBundleTest {
     public void testApiCoreStartStopStartStop() throws BundleException {
 
         final Bundle api = getApiBundle();
+        final Bundle xmlFactory = getXmlFactoryBundle();
         final Bundle core = getCoreBundle();
         assertEquals(Bundle.INSTALLED, api.getState(), "api is not in 
INSTALLED state");
         assertEquals(Bundle.INSTALLED, core.getState(), "core is not in 
INSTALLED state");
 
         // 1st start-stop
-        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api, core);
-        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, core, api);
+        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api, 
xmlFactory, core);
+        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, core, 
xmlFactory, api);
 
         // 2nd start-stop
-        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api, core);
-        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, core, api);
+        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api, 
xmlFactory, core);
+        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, core, 
xmlFactory, api);
 
-        doOnBundlesAndVerifyState(Bundle::uninstall, Bundle.UNINSTALLED, core, 
api);
+        doOnBundlesAndVerifyState(Bundle::uninstall, Bundle.UNINSTALLED, core, 
xmlFactory, api);
     }
 
     /**
@@ -104,9 +109,10 @@ abstract class AbstractLoadBundleTest {
     public void testClassNotFoundErrorLogger() throws BundleException {
 
         final Bundle api = getApiBundle();
+        final Bundle xmlFactory = getXmlFactoryBundle();
         final Bundle core = getCoreBundle();
 
-        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api);
+        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api, 
xmlFactory);
         // fails if LOG4J2-1637 is not fixed
         try {
             core.start();
@@ -126,8 +132,8 @@ abstract class AbstractLoadBundleTest {
         }
         assertEquals(Bundle.ACTIVE, core.getState(), String.format("`%s` 
bundle state mismatch", core));
 
-        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, core, api);
-        doOnBundlesAndVerifyState(Bundle::uninstall, Bundle.UNINSTALLED, core, 
api);
+        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, core, 
xmlFactory, api);
+        doOnBundlesAndVerifyState(Bundle::uninstall, Bundle.UNINSTALLED, core, 
xmlFactory, api);
     }
 
     /**
@@ -138,10 +144,11 @@ abstract class AbstractLoadBundleTest {
     public void testLog4J12Fragement() throws BundleException, 
ReflectiveOperationException {
 
         final Bundle api = getApiBundle();
+        final Bundle xmlFactory = getXmlFactoryBundle();
         final Bundle core = getCoreBundle();
         final Bundle compat = get12ApiBundle();
 
-        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api, core);
+        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api, 
xmlFactory, core);
 
         final Class<?> coreClassFromCore = 
core.loadClass("org.apache.logging.log4j.core.Core");
         final Class<?> levelClassFrom12API = 
core.loadClass("org.apache.log4j.Level");
@@ -156,8 +163,8 @@ abstract class AbstractLoadBundleTest {
                 levelClassFromAPI.getClassLoader(),
                 "expected 1.2 API Level NOT to have the same class loader as 
API Level");
 
-        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, core, api);
-        doOnBundlesAndVerifyState(Bundle::uninstall, Bundle.UNINSTALLED, 
compat, core, api);
+        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, core, 
xmlFactory, api);
+        doOnBundlesAndVerifyState(Bundle::uninstall, Bundle.UNINSTALLED, 
compat, core, xmlFactory, api);
     }
 
     /**
@@ -166,13 +173,14 @@ abstract class AbstractLoadBundleTest {
     @Test
     public void testServiceLoader() throws BundleException, 
ReflectiveOperationException {
         final Bundle api = getApiBundle();
+        final Bundle xmlFactory = getXmlFactoryBundle();
         final Bundle core = getCoreBundle();
         final Bundle apiTests = getApiTestsBundle();
 
         final Class<?> osgiServiceLocator = 
api.loadClass("org.apache.logging.log4j.util.OsgiServiceLocator");
         assertTrue((boolean) 
osgiServiceLocator.getMethod("isAvailable").invoke(null), "OsgiServiceLocator 
is active");
 
-        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api, core, 
apiTests);
+        doOnBundlesAndVerifyState(Bundle::start, Bundle.ACTIVE, api, 
xmlFactory, core, apiTests);
 
         final Class<?> osgiServiceLocatorTest =
                 
apiTests.loadClass("org.apache.logging.log4j.test.util.OsgiServiceLocatorTest");
@@ -187,8 +195,8 @@ abstract class AbstractLoadBundleTest {
                 "org.apache.logging.log4j.core.impl.Log4jProvider",
                 services.get(0).getClass().getName());
 
-        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, apiTests, 
core, api);
-        doOnBundlesAndVerifyState(Bundle::uninstall, Bundle.UNINSTALLED, 
apiTests, core, api);
+        doOnBundlesAndVerifyState(Bundle::stop, Bundle.RESOLVED, apiTests, 
core, xmlFactory, api);
+        doOnBundlesAndVerifyState(Bundle::uninstall, Bundle.UNINSTALLED, 
apiTests, core, xmlFactory, api);
     }
 
     private static void doOnBundlesAndVerifyState(
diff --git 
a/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/CoreOsgiTest.java
 
b/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/CoreOsgiTest.java
index 493bb61c9e..77d7f54cdc 100644
--- 
a/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/CoreOsgiTest.java
+++ 
b/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/CoreOsgiTest.java
@@ -46,6 +46,7 @@ public class CoreOsgiTest {
     public Option[] config() {
         return options(
                 linkBundle("org.apache.logging.log4j.api"),
+                linkBundle("eu.copernik.xml.factory"),
                 linkBundle("org.apache.logging.log4j.core"),
                 linkBundle("org.apache.logging.log4j.1.2.api").start(false),
                 // required by Pax Exam's logging
diff --git 
a/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/DisruptorTest.java
 
b/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/DisruptorTest.java
index 813331210c..018edca9f5 100644
--- 
a/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/DisruptorTest.java
+++ 
b/log4j-osgi-test/src/test/java/org/apache/logging/log4j/osgi/tests/DisruptorTest.java
@@ -52,6 +52,7 @@ public class DisruptorTest {
     public Option[] config() {
         return options(
                 linkBundle("org.apache.logging.log4j.api"),
+                linkBundle("eu.copernik.xml.factory"),
                 linkBundle("org.apache.logging.log4j.core"),
                 linkBundle("com.lmax.disruptor"),
                 // required by Pax Exam's logging
diff --git a/log4j-parent/pom.xml b/log4j-parent/pom.xml
index 9a7b5aa2c8..5ea9c62023 100644
--- a/log4j-parent/pom.xml
+++ b/log4j-parent/pom.xml
@@ -78,6 +78,7 @@
     <commons-logging.version>1.3.5</commons-logging.version>
     <!-- `com.conversantmedia:disruptor` version 1.2.16 requires Java 9: -->
     <conversant.disruptor.version>1.2.15</conversant.disruptor.version>
+    <copernik-xml-factory.version>0.1.2</copernik-xml-factory.version>
     <disruptor.version>3.4.4</disruptor.version>
     <embedded-ldap.version>0.9.0</embedded-ldap.version>
     <error-prone-annotations.version>2.38.0</error-prone-annotations.version>
@@ -404,6 +405,12 @@
         <version>${disruptor.version}</version>
       </dependency>
 
+      <dependency>
+        <groupId>eu.copernik</groupId>
+        <artifactId>copernik-xml-factory</artifactId>
+        <version>${copernik-xml-factory.version}</version>
+      </dependency>
+
       <dependency>
         <groupId>org.zapodot</groupId>
         <artifactId>embedded-ldap-junit</artifactId>
diff --git a/src/changelog/.2.x.x/xml_factory_hardening.xml 
b/src/changelog/.2.x.x/xml_factory_hardening.xml
new file mode 100644
index 0000000000..6146a8e7b8
--- /dev/null
+++ b/src/changelog/.2.x.x/xml_factory_hardening.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<entry xmlns="https://logging.apache.org/xml/ns";
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+       xsi:schemaLocation="
+           https://logging.apache.org/xml/ns
+           https://logging.apache.org/xml/ns/log4j-changelog-0.xsd";
+       type="fixed">
+  <description format="asciidoc">
+    Harden XML configuration parsing using the Commons XML hardened JAXP 
factories.
+  </description>
+</entry>
\ No newline at end of file

Reply via email to