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

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 21b7ba5a6c TIKA-4941: record the external DTDs and entities a 
document's XML referenced (#3271)
21b7ba5a6c is described below

commit 21b7ba5a6c4673d742dbcb5e631cc9eac66faf9f
Author: Tim Allison <[email protected]>
AuthorDate: Mon Sep 28 16:46:02 2026 -0400

    TIKA-4941: record the external DTDs and entities a document's XML 
referenced (#3271)
---
 CHANGES.txt                                        |   5 +
 .../apache/tika/metadata/TikaCoreProperties.java   |  18 +++
 .../org/apache/tika/parser/CompositeParser.java    |   5 +-
 .../java/org/apache/tika/parser/ParseRecord.java   |  39 +++++-
 .../org/apache/tika/sax/OfflineContentHandler.java |  16 ++-
 ...tentHandler.java => OfflineEntityResolver.java} |  29 ++---
 .../java/org/apache/tika/utils/XMLReaderUtils.java |  13 +-
 .../parser/ParseRecordExternalReferenceTest.java   |  77 ++++++++++++
 .../tika/parser/XmlExternalReferenceTest.java      | 138 +++++++++++++++++++++
 .../apache/tika/sax/OfflineEntityResolverTest.java |  80 ++++++++++++
 .../apache/tika/metadata/metadata-key-fields.json  |   3 +
 .../org/apache/tika/metadata/metadata-keys.json    |   3 +
 .../parser/XmlExternalReferenceMetadataTest.java   | 104 ++++++++++++++++
 .../parser/odf/NSNormalizerContentHandler.java     |  10 +-
 14 files changed, 508 insertions(+), 32 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 1f75ba7f27..9d213462a5 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -7,6 +7,11 @@ Release 4.2.0 - unreleased
    * Retire Tika's entity expansion limit (default 20) in SAX, DOM and StAX
      parsing in favor of standard Java configuration methods (TIKA-4940).
    
+   * Report the external DTDs and entities a document's XML referenced, which 
Tika never
+     resolves: tk:xml-external-reference (up to 20 system ids) and
+     tk:xml-external-reference-count on that document's metadata, and
+     tk:xml-external-reference-embedded on a container whose embedded document 
had any.
+     Every resolver Tika installs answers with an empty stream, never null 
(TIKA-4941).
    * Deprecate XMLReaderUtils.getXMLInputFactory() and use SAX for XFA 
(TIKA-4938).
 
 Release 4.1.0 - 9/26/2026
diff --git 
a/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java 
b/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
index c528f3f34d..d1587b1873 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
@@ -202,6 +202,24 @@ public interface TikaCoreProperties {
     Property EMBEDDED_DEPTH_LIMIT_REACHED =
             Property.reservedInternalBoolean(TIKA_META_EXCEPTION_PREFIX + 
"embedded-depth-limit-reached");
 
+    /**
+     * External DTDs and entities this document's XML referenced, by system id 
(public id
+     * when there is none). Tika never resolves them; this records what the 
document would
+     * have fetched. At most {@link 
org.apache.tika.parser.ParseRecord#MAX_EXTERNAL_REFERENCES}
+     * are listed; {@link #XML_EXTERNAL_REFERENCE_COUNT} is the full count. 
XML that a
+     * third-party library parses with its own parser (OOXML package parts, 
plists, feeds) is
+     * not seen.
+     */
+    Property XML_EXTERNAL_REFERENCE =
+            Property.reservedInternalTextBag(TIKA_META_PREFIX + 
"xml-external-reference");
+
+    Property XML_EXTERNAL_REFERENCE_COUNT =
+            Property.reservedInternalInteger(TIKA_META_PREFIX + 
"xml-external-reference-count");
+
+    //on the container: an embedded document recorded an XML_EXTERNAL_REFERENCE
+    Property XML_EXTERNAL_REFERENCE_EMBEDDED =
+            Property.reservedInternalBoolean(TIKA_META_PREFIX + 
"xml-external-reference-embedded");
+
     //total timeout exhausted mid-parse; remaining embedded docs were skipped, 
not attempted
     Property TASK_DEADLINE_REACHED =
             Property.reservedInternalBoolean(TIKA_META_EXCEPTION_PREFIX + 
"task-deadline-reached");
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/CompositeParser.java 
b/tika-core/src/main/java/org/apache/tika/parser/CompositeParser.java
index ba7a47f800..96cd5fa42b 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/CompositeParser.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/CompositeParser.java
@@ -354,7 +354,7 @@ public class CompositeParser implements Parser {
             String parserClassname = ParserUtils.getParserClassname(parser);
             parserRecord.addParserClass(parserClassname);
             ParserUtils.recordParserDetails(parserClassname, metadata);
-            parserRecord.beforeParse();
+            parserRecord.beforeParse(metadata);
             try {
                 parser.parse(tis, taggedHandler, metadata, context);
             } catch (SecurityException e) {
@@ -410,6 +410,9 @@ public class CompositeParser implements Parser {
         if (record.isTaskDeadlineReached()) {
             metadata.set(TikaCoreProperties.TASK_DEADLINE_REACHED, true);
         }
+        if (record.isExternalReferenceInEmbedded()) {
+            metadata.set(TikaCoreProperties.XML_EXTERNAL_REFERENCE_EMBEDDED, 
true);
+        }
 
         for (Metadata m : record.getMetadataList()) {
             for (String n : m.names()) {
diff --git a/tika-core/src/main/java/org/apache/tika/parser/ParseRecord.java 
b/tika-core/src/main/java/org/apache/tika/parser/ParseRecord.java
index ed5e57c3ea..a99f3e92f5 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/ParseRecord.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/ParseRecord.java
@@ -16,7 +16,9 @@
  */
 package org.apache.tika.parser;
 
+import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Deque;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
@@ -25,6 +27,7 @@ import org.apache.tika.config.EmbeddedLimits;
 import org.apache.tika.config.TimeoutLimits;
 import org.apache.tika.config.TransientParseState;
 import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
 
 /**
  * Use this class to store exceptions, warnings and other information
@@ -46,6 +49,8 @@ public class ParseRecord implements TransientParseState {
     private static final int MAX_WARNINGS = 100;
 
     private static final int MAX_METADATA_LIST_SIZE = 100;
+    public static final int MAX_EXTERNAL_REFERENCES = 20;
+    private static final int MAX_EXTERNAL_REFERENCE_LENGTH = 1000;
 
     private int depth = 0;
     private final Set<String> parsers = new LinkedHashSet<>();
@@ -55,6 +60,9 @@ public class ParseRecord implements TransientParseState {
     private final List<String> warnings = new ArrayList<>();
 
     private final List<Metadata> metadataList = new ArrayList<>();
+    //metadata of the documents being parsed, innermost on top
+    private final Deque<Metadata> documents = new ArrayDeque<>();
+    private boolean externalReferenceInEmbedded = false;
 
     private boolean writeLimitReached = false;
 
@@ -90,12 +98,41 @@ public class ParseRecord implements TransientParseState {
         return record;
     }
 
-    void beforeParse() {
+    void beforeParse(Metadata metadata) {
         depth++;
+        documents.push(metadata);
     }
 
     void afterParse() {
         depth--;
+        documents.pop();
+    }
+
+    /**
+     * Records an external DTD or entity reference Tika refused, on the 
metadata of the
+     * document being parsed. No-op outside a {@link CompositeParser} parse.
+     */
+    public void addExternalReference(String reference) {
+        Metadata metadata = documents.peek();
+        if (metadata == null || reference == null) {
+            return;
+        }
+        Integer seen = 
metadata.getInt(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT);
+        int count = seen == null ? 0 : seen;
+        metadata.set(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT, count + 
1);
+        if (count < MAX_EXTERNAL_REFERENCES) {
+            String value = reference.length() > MAX_EXTERNAL_REFERENCE_LENGTH ?
+                    reference.substring(0, MAX_EXTERNAL_REFERENCE_LENGTH) : 
reference;
+            metadata.add(TikaCoreProperties.XML_EXTERNAL_REFERENCE, value);
+        }
+        //composite delegation pushes the same metadata again; embedded 
documents bring their own
+        if (metadata != documents.peekLast()) {
+            externalReferenceInEmbedded = true;
+        }
+    }
+
+    public boolean isExternalReferenceInEmbedded() {
+        return externalReferenceInEmbedded;
     }
 
     public int getDepth() {
diff --git 
a/tika-core/src/main/java/org/apache/tika/sax/OfflineContentHandler.java 
b/tika-core/src/main/java/org/apache/tika/sax/OfflineContentHandler.java
index 6461e0946a..39a9c96edb 100644
--- a/tika-core/src/main/java/org/apache/tika/sax/OfflineContentHandler.java
+++ b/tika-core/src/main/java/org/apache/tika/sax/OfflineContentHandler.java
@@ -16,10 +16,11 @@
  */
 package org.apache.tika.sax;
 
-import org.apache.commons.io.input.ClosedInputStream;
 import org.xml.sax.ContentHandler;
 import org.xml.sax.InputSource;
 
+import org.apache.tika.parser.ParseContext;
+
 /**
  * Content handler decorator that always returns an empty stream from the
  * {@link #resolveEntity(String, String)} method to prevent potential
@@ -29,8 +30,19 @@ import org.xml.sax.InputSource;
  */
 public class OfflineContentHandler extends ContentHandlerDecorator {
 
+    private final OfflineEntityResolver resolver;
+
     public OfflineContentHandler(ContentHandler handler) {
+        this(handler, null);
+    }
+
+    /**
+     * @param context records refused external references on the document's 
metadata
+     *                when it holds a {@link 
org.apache.tika.parser.ParseRecord}; may be null
+     */
+    public OfflineContentHandler(ContentHandler handler, ParseContext context) 
{
         super(handler);
+        this.resolver = new OfflineEntityResolver(context);
     }
 
     /**
@@ -39,7 +51,7 @@ public class OfflineContentHandler extends 
ContentHandlerDecorator {
      */
     @Override
     public InputSource resolveEntity(String publicId, String systemId) {
-        return new InputSource(new ClosedInputStream());
+        return resolver.resolveEntity(publicId, systemId);
     }
 
 }
diff --git 
a/tika-core/src/main/java/org/apache/tika/sax/OfflineContentHandler.java 
b/tika-core/src/main/java/org/apache/tika/sax/OfflineEntityResolver.java
similarity index 61%
copy from tika-core/src/main/java/org/apache/tika/sax/OfflineContentHandler.java
copy to tika-core/src/main/java/org/apache/tika/sax/OfflineEntityResolver.java
index 6461e0946a..c2f0411e18 100644
--- a/tika-core/src/main/java/org/apache/tika/sax/OfflineContentHandler.java
+++ b/tika-core/src/main/java/org/apache/tika/sax/OfflineEntityResolver.java
@@ -17,29 +17,30 @@
 package org.apache.tika.sax;
 
 import org.apache.commons.io.input.ClosedInputStream;
-import org.xml.sax.ContentHandler;
+import org.xml.sax.EntityResolver;
 import org.xml.sax.InputSource;
 
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.ParseRecord;
+
 /**
- * Content handler decorator that always returns an empty stream from the
- * {@link #resolveEntity(String, String)} method to prevent potential
- * network or other external resources from being accessed by an XML parser.
- *
- * @see <a href="https://issues.apache.org/jira/browse/TIKA-185";>TIKA-185</a>
+ * Answers every external DTD or entity with an empty stream, and records what 
was asked
+ * for on the document's metadata when a {@link ParseRecord} is in the context.
  */
-public class OfflineContentHandler extends ContentHandlerDecorator {
+public class OfflineEntityResolver implements EntityResolver {
 
-    public OfflineContentHandler(ContentHandler handler) {
-        super(handler);
+    private final ParseContext context;
+
+    public OfflineEntityResolver(ParseContext context) {
+        this.context = context;
     }
 
-    /**
-     * Returns an empty stream. This will make an XML parser silently
-     * ignore any external entities.
-     */
     @Override
     public InputSource resolveEntity(String publicId, String systemId) {
+        ParseRecord record = context == null ? null : 
context.get(ParseRecord.class);
+        if (record != null) {
+            record.addExternalReference(systemId != null ? systemId : 
publicId);
+        }
         return new InputSource(new ClosedInputStream());
     }
-
 }
diff --git a/tika-core/src/main/java/org/apache/tika/utils/XMLReaderUtils.java 
b/tika-core/src/main/java/org/apache/tika/utils/XMLReaderUtils.java
index 5570fbf8d1..0b5e502517 100644
--- a/tika-core/src/main/java/org/apache/tika/utils/XMLReaderUtils.java
+++ b/tika-core/src/main/java/org/apache/tika/utils/XMLReaderUtils.java
@@ -61,6 +61,7 @@ import org.xml.sax.helpers.DefaultHandler;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.sax.OfflineContentHandler;
+import org.apache.tika.sax.OfflineEntityResolver;
 
 
 /**
@@ -373,8 +374,8 @@ public class XMLReaderUtils implements Serializable {
             }
         }
 
-        //a supplied builder never brings its own resolver along
-        builder.setEntityResolver(IGNORING_SAX_ENTITY_RESOLVER);
+        //never the builder's own resolver, and record what was refused
+        builder.setEntityResolver(new OfflineEntityResolver(context));
         try {
             return builder.parse(is);
         } finally {
@@ -411,8 +412,8 @@ public class XMLReaderUtils implements Serializable {
             }
         }
 
-        //a supplied builder never brings its own resolver along
-        builder.setEntityResolver(IGNORING_SAX_ENTITY_RESOLVER);
+        //never the builder's own resolver, and record what was refused
+        builder.setEntityResolver(new OfflineEntityResolver(context));
         try {
             return builder.parse(new InputSource(reader));
         } finally {
@@ -535,7 +536,7 @@ public class XMLReaderUtils implements Serializable {
             }
         }
         try {
-            saxParser.parse(is, new OfflineContentHandler(contentHandler));
+            saxParser.parse(is, new OfflineContentHandler(contentHandler, 
context));
         } finally {
             releaseParser(poolSAXParser);
         }
@@ -573,7 +574,7 @@ public class XMLReaderUtils implements Serializable {
             }
         }
         try {
-            saxParser.parse(new InputSource(reader), new 
OfflineContentHandler(contentHandler));
+            saxParser.parse(new InputSource(reader), new 
OfflineContentHandler(contentHandler, context));
         } finally {
             releaseParser(poolSAXParser);
         }
diff --git 
a/tika-core/src/test/java/org/apache/tika/parser/ParseRecordExternalReferenceTest.java
 
b/tika-core/src/test/java/org/apache/tika/parser/ParseRecordExternalReferenceTest.java
new file mode 100644
index 0000000000..d9c6ac4607
--- /dev/null
+++ 
b/tika-core/src/test/java/org/apache/tika/parser/ParseRecordExternalReferenceTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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
+ *
+ *     http://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.tika.parser;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+public class ParseRecordExternalReferenceTest {
+
+    @Test
+    public void testCapAndCount() {
+        ParseRecord record = ParseRecord.newInstance(new ParseContext());
+        Metadata m = new Metadata();
+        record.beforeParse(m);
+        for (int i = 0; i < ParseRecord.MAX_EXTERNAL_REFERENCES + 5; i++) {
+            record.addExternalReference("http://127.0.0.1:9/"; + i);
+        }
+        record.afterParse();
+        assertEquals(ParseRecord.MAX_EXTERNAL_REFERENCES,
+                m.getValues(TikaCoreProperties.XML_EXTERNAL_REFERENCE).length);
+        assertEquals(ParseRecord.MAX_EXTERNAL_REFERENCES + 5,
+                m.getInt(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT));
+        assertFalse(record.isExternalReferenceInEmbedded());
+    }
+
+    @Test
+    public void testLongReferenceTruncated() {
+        ParseRecord record = ParseRecord.newInstance(new ParseContext());
+        Metadata m = new Metadata();
+        record.beforeParse(m);
+        record.addExternalReference("http://127.0.0.1:9/"; + "x".repeat(5000));
+        assertEquals(1000, 
m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE).length());
+    }
+
+    @Test
+    public void testOutsideAParseIsANoOp() {
+        ParseRecord record = ParseRecord.newInstance(new ParseContext());
+        record.addExternalReference("http://127.0.0.1:9/x";);
+        assertFalse(record.isExternalReferenceInEmbedded());
+    }
+
+    @Test
+    public void testNestedGoesToInnerAndFlagsEmbedded() {
+        ParseRecord record = ParseRecord.newInstance(new ParseContext());
+        Metadata outer = new Metadata();
+        Metadata inner = new Metadata();
+        record.beforeParse(outer);
+        record.beforeParse(inner);
+        record.addExternalReference("http://127.0.0.1:9/x";);
+        record.afterParse();
+        record.afterParse();
+        assertNull(outer.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+        assertEquals("http://127.0.0.1:9/x";, 
inner.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+        assertTrue(record.isExternalReferenceInEmbedded());
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/parser/XmlExternalReferenceTest.java 
b/tika-core/src/test/java/org/apache/tika/parser/XmlExternalReferenceTest.java
new file mode 100644
index 0000000000..99014c175f
--- /dev/null
+++ 
b/tika-core/src/test/java/org/apache/tika/parser/XmlExternalReferenceTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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
+ *
+ *     http://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.tika.parser;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.mime.MediaTypeRegistry;
+import org.apache.tika.utils.XMLReaderUtils;
+
+/**
+ * Refused external references land on the metadata of the document whose XML 
made them,
+ * and the container is flagged when an embedded document made any.
+ */
+public class XmlExternalReferenceTest {
+
+    private static final String DTD = "file:///couldnt_possibly_exist/a.dtd";
+    private static final String ENTITY = "http://127.0.0.1:9/e.txt";;
+    private static final String XML = "<!DOCTYPE r SYSTEM \"" + DTD + "\" 
[<!ENTITY e SYSTEM \"" +
+            ENTITY + "\">]><r>&e;</r>";
+
+    // parses XML through the two chokepoints, and can parse a nested 
"embedded" document
+    private static class XmlParser implements Parser {
+        private final boolean dom;
+        private Parser embedded;
+        private byte[] embeddedBytes;
+        private Metadata innerMetadata;
+
+        XmlParser(boolean dom) {
+            this.dom = dom;
+        }
+
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return Collections.singleton(MediaType.APPLICATION_XML);
+        }
+
+        @Override
+        public void parse(TikaInputStream stream, ContentHandler handler, 
Metadata metadata,
+                          ParseContext context) throws IOException, 
SAXException, TikaException {
+            if (dom) {
+                XMLReaderUtils.buildDOM(stream, context);
+            } else {
+                XMLReaderUtils.parseSAX(stream, new DefaultHandler(), context);
+            }
+            if (embedded != null) {
+                innerMetadata = new Metadata();
+                innerMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, 
"inner.xml");
+                innerMetadata.set(HttpHeaders.CONTENT_TYPE, "application/xml");
+                try (TikaInputStream tis = TikaInputStream.get(embeddedBytes)) 
{
+                    embedded.parse(tis, new DefaultHandler(), innerMetadata, 
context);
+                }
+            }
+        }
+    }
+
+    private static Metadata parse(XmlParser xmlParser, String xml) throws 
Exception {
+        CompositeParser composite = new 
CompositeParser(MediaTypeRegistry.getDefaultRegistry(),
+                List.of(xmlParser));
+        Metadata metadata = new Metadata();
+        metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, "outer.xml");
+        metadata.set(HttpHeaders.CONTENT_TYPE, "application/xml");
+        try (TikaInputStream tis = 
TikaInputStream.get(xml.getBytes(StandardCharsets.UTF_8))) {
+            composite.parse(tis, new DefaultHandler(), metadata, new 
ParseContext());
+        }
+        return metadata;
+    }
+
+    @Test
+    public void testSaxRecordsOnDocument() throws Exception {
+        Metadata m = parse(new XmlParser(false), XML);
+        assertArrayEquals(new String[]{DTD, ENTITY},
+                m.getValues(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+        assertEquals(2, 
m.getInt(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT));
+        assertNull(m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE_EMBEDDED));
+    }
+
+    @Test
+    public void testDomRecordsOnDocument() throws Exception {
+        Metadata m = parse(new XmlParser(true), XML);
+        assertTrue(m.getInt(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT) 
>= 1);
+        assertEquals(DTD, 
m.getValues(TikaCoreProperties.XML_EXTERNAL_REFERENCE)[0]);
+    }
+
+    @Test
+    public void testCleanDocumentRecordsNothing() throws Exception {
+        Metadata m = parse(new XmlParser(false), "<r>plain</r>");
+        assertNull(m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+        assertNull(m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT));
+    }
+
+    @Test
+    public void testEmbeddedRecordsOnInnerAndFlagsOuter() throws Exception {
+        XmlParser outer = new XmlParser(false);
+        outer.embedded = new 
CompositeParser(MediaTypeRegistry.getDefaultRegistry(),
+                List.of(new XmlParser(false)));
+        outer.embeddedBytes = XML.getBytes(StandardCharsets.UTF_8);
+        Metadata m = parse(outer, "<r>plain outer</r>");
+        assertNull(m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+        assertEquals("true", 
m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE_EMBEDDED));
+        assertArrayEquals(new String[]{DTD, ENTITY},
+                
outer.innerMetadata.getValues(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+        
assertNull(outer.innerMetadata.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE_EMBEDDED));
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/sax/OfflineEntityResolverTest.java 
b/tika-core/src/test/java/org/apache/tika/sax/OfflineEntityResolverTest.java
new file mode 100644
index 0000000000..814cdf0c5f
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/sax/OfflineEntityResolverTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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
+ *
+ *     http://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.tika.sax;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.EntityResolver;
+import org.xml.sax.InputSource;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.utils.XMLReaderUtils;
+
+/**
+ * A resolver that returns null, or a source with no stream, hands the id back 
to the
+ * parser to open (CVE-2025-66516). Every resolver Tika installs must answer 
every
+ * input shape with a source that carries an empty stream.
+ */
+public class OfflineEntityResolverTest {
+
+    private static final String[][] IDS = {
+            {null, null},
+            {"-//W3C//DTD XHTML 1.0//EN", null},
+            {null, "http://127.0.0.1:9/x.dtd"},
+            {null, "file:///etc/passwd"},
+            {null, "relative.dtd"},
+            {null, ""},
+            {"-//X//Y//EN", "https://example.invalid/y.dtd"},
+    };
+
+    @Test
+    public void testInstalledResolversAlwaysAnswerWithAnEmptyStream() throws 
Exception {
+        List<EntityResolver> resolvers = List.of(
+                new OfflineEntityResolver(null),
+                new OfflineEntityResolver(new ParseContext()),
+                new OfflineContentHandler(new DefaultHandler()),
+                new OfflineContentHandler(new DefaultHandler(), new 
ParseContext()),
+                XMLReaderUtils.getXMLReader().getEntityResolver());
+        for (EntityResolver resolver : resolvers) {
+            for (String[] id : IDS) {
+                InputSource source = resolver.resolveEntity(id[0], id[1]);
+                String label = resolver.getClass().getSimpleName() + " for " + 
id[0] + ", " + id[1];
+                assertNotNull(source, label + " returned null");
+                assertNull(source.getSystemId(), label + " carries a system 
id");
+                assertTrue(source.getByteStream() != null || 
source.getCharacterStream() != null,
+                        label + " carries no stream");
+                assertEquals(-1, source.getByteStream() != null ? 
source.getByteStream().read() :
+                        source.getCharacterStream().read(), label + " stream 
is not empty");
+            }
+        }
+    }
+
+    @Test
+    public void testEmptyStreamTwiceOverIsStillEmpty() throws IOException {
+        InputSource source = new 
OfflineEntityResolver(null).resolveEntity(null, "x.dtd");
+        assertEquals(-1, source.getByteStream().read());
+        assertEquals(-1, source.getByteStream().read(new byte[16]));
+    }
+}
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
index e1633faa21..3f57665d29 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
@@ -580,6 +580,9 @@
   
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"VERSION_COUNT","key":"tk:version-count"},
   
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"VERSION_NUMBER","key":"tk:version-number"},
   
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"WRITE_LIMIT_REACHED","key":"tk:exception:write-limit-reached"},
+  
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"XML_EXTERNAL_REFERENCE","key":"tk:xml-external-reference"},
+  
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"XML_EXTERNAL_REFERENCE_COUNT","key":"tk:xml-external-reference-count"},
+  
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"XML_EXTERNAL_REFERENCE_EMBEDDED","key":"tk:xml-external-reference-embedded"},
   
{"class":"org.apache.tika.metadata.TikaPagedText","field":"PAGE_NUMBER","key":"tk:page:number"},
   
{"class":"org.apache.tika.metadata.TikaPagedText","field":"PAGE_NUMBERS","key":"tk:page:numbers"},
   
{"class":"org.apache.tika.metadata.TikaPagedText","field":"PAGE_ROTATION","key":"tk:page:rotation"},
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
index 284fed451e..4dc423e946 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
@@ -635,6 +635,9 @@
   
{"key":"tk:version-count","namespace":"tk","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"tk:version-number","namespace":"tk","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"tk:warn:truncated-metadata","namespace":"tk","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"tk:xml-external-reference","namespace":"tk","valueType":"TEXT","cardinality":"BAG","module":"tika-core"},
+  
{"key":"tk:xml-external-reference-count","namespace":"tk","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"tk:xml-external-reference-embedded","namespace":"tk","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"video:bitrate","namespace":"video","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"video:fourcc","namespace":"video","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"video:frame-rate","namespace":"video","valueType":"REAL","cardinality":"SIMPLE","module":"tika-core"},
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/XmlExternalReferenceMetadataTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/XmlExternalReferenceMetadataTest.java
new file mode 100644
index 0000000000..e13d87e841
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/XmlExternalReferenceMetadataTest.java
@@ -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
+ *
+ *     http://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.tika.parser;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+/**
+ * The document parsers report the external DTDs and entities a file would 
have fetched.
+ */
+public class XmlExternalReferenceMetadataTest extends XMLTestBase {
+
+    private static final String DTD = "file:///couldnt_possibly_exist/xxe.dtd";
+    private static final byte[] DOCTYPE =
+            ("<!DOCTYPE roottag SYSTEM \"" + DTD + 
"\">").getBytes(StandardCharsets.UTF_8);
+
+    @Test
+    public void testXmlFile() throws Exception {
+        byte[] injected = injectXML("<?xml 
version=\"1.0\"?><r>text</r>".getBytes(StandardCharsets.UTF_8),
+                DOCTYPE);
+        Metadata m = getXML(TikaInputStream.get(injected), AUTO_DETECT_PARSER, 
new Metadata()).metadata;
+        assertEquals(DTD, m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+        assertEquals(1, 
m.getInt(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT));
+        assertNull(m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE_EMBEDDED));
+    }
+
+    @Test
+    public void testCleanXmlFile() throws Exception {
+        Metadata m = 
getXML(TikaInputStream.get("<r>text</r>".getBytes(StandardCharsets.UTF_8)),
+                AUTO_DETECT_PARSER, new Metadata()).metadata;
+        assertNull(m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+        assertNull(m.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT));
+    }
+
+    // EPUB parts are parsed inline, so the container itself carries the ids
+    @Test
+    public void testEpubPartsLandOnContainer() throws Exception {
+        Path injected;
+        try (TikaInputStream tis = TikaInputStream.get(
+                
getClass().getResourceAsStream("/test-documents/testEPUB.epub"))) {
+            injected = injectZippedXMLs(tis.getPath(), DOCTYPE);
+        }
+        try {
+            Metadata container = getRecursiveMetadata(injected).get(0);
+            assertEquals(DTD, 
container.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+            
assertTrue(container.getInt(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT) >= 
1);
+            
assertNull(container.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE_EMBEDDED));
+        } finally {
+            Files.delete(injected);
+        }
+    }
+
+    // an XML file inside a zip is an embedded document: it carries the ids, 
the zip is flagged
+    @Test
+    public void testEmbeddedXmlFlagsContainer() throws Exception {
+        Path zip = Files.createTempFile("tika-xxe-", ".zip");
+        try (ZipOutputStream out = new 
ZipOutputStream(Files.newOutputStream(zip))) {
+            out.putNextEntry(new ZipEntry("inner.xml"));
+            out.write(injectXML("<?xml 
version=\"1.0\"?><r>text</r>".getBytes(StandardCharsets.UTF_8),
+                    DOCTYPE));
+            out.closeEntry();
+        }
+        try {
+            List<Metadata> all = getRecursiveMetadata(zip);
+            assertEquals(2, all.size());
+            Metadata container = all.get(0);
+            Metadata inner = all.get(1);
+            assertEquals("true", 
container.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE_EMBEDDED));
+            
assertNull(container.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+            assertEquals(DTD, 
inner.get(TikaCoreProperties.XML_EXTERNAL_REFERENCE));
+            assertEquals(1, 
inner.getInt(TikaCoreProperties.XML_EXTERNAL_REFERENCE_COUNT));
+        } finally {
+            Files.delete(zip);
+        }
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/NSNormalizerContentHandler.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/NSNormalizerContentHandler.java
index 48cf14b2e8..1496f71e33 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/NSNormalizerContentHandler.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/NSNormalizerContentHandler.java
@@ -18,7 +18,6 @@ package org.apache.tika.parser.odf;
 
 import java.io.IOException;
 import java.io.StringReader;
-import java.util.Locale;
 
 import org.xml.sax.Attributes;
 import org.xml.sax.ContentHandler;
@@ -40,7 +39,6 @@ public class NSNormalizerContentHandler extends 
ContentHandlerDecorator {
 
     private static final String NEW_NS = 
"urn:oasis:names:tc:opendocument:xmlns:";
 
-    private static final String DTD_PUBLIC_ID = "-//OpenOffice.org//DTD 
OfficeDocument 1.0//EN";
 
     public NSNormalizerContentHandler(ContentHandler handler) {
         super(handler);
@@ -83,12 +81,8 @@ public class NSNormalizerContentHandler extends 
ContentHandlerDecorator {
     @Override
     public InputSource resolveEntity(String publicId, String systemId)
             throws IOException, SAXException {
-        if ((systemId != null && 
systemId.toLowerCase(Locale.ROOT).endsWith(".dtd")) ||
-                DTD_PUBLIC_ID.equals(publicId)) {
-            return new InputSource(new StringReader(""));
-        } else {
-            return super.resolveEntity(publicId, systemId);
-        }
+        //never null: a null answer lets the parser resolve the id itself
+        return new InputSource(new StringReader(""));
     }
 
 }

Reply via email to