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 0db2a30308 TIKA-4992: follow up Tika gui html view test (#3401)
0db2a30308 is described below
commit 0db2a30308d79ec67be5f30c0274a1a406eb1513
Author: Tim Allison <[email protected]>
AuthorDate: Thu Oct 22 16:10:39 2026 -0400
TIKA-4992: follow up Tika gui html view test (#3401)
* TIKA-4992: regression test for the TikaGUI html view
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
CHANGES.txt | 4 +
.../src/main/java/org/apache/tika/gui/TikaGUI.java | 103 ++++++----
.../org/apache/tika/gui/TikaGUIHtmlViewTest.java | 217 +++++++++++++++++++++
3 files changed, 287 insertions(+), 37 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 0371aec9c4..7399f40d50 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -93,6 +93,10 @@ Release 4.2.0 - 10/22/2026
document referenced, which Tika never resolves (TIKA-4941). XFA is read
with SAX;
XMLReaderUtils.getXMLInputFactory() is deprecated (TIKA-4938).
+ * The tika-app GUI's Formatted Text view no longer lets a document make
Swing
+ construct classes or fetch URLs: only plain elements and attributes reach
it, and
+ remote images are not loaded (TIKA-4992).
+
SERVER, PIPES AND OPERATIONS
* The file-system emitter can gzip its output ("compression": "GZIP");
tika-eval reads
diff --git a/tika-app/src/main/java/org/apache/tika/gui/TikaGUI.java
b/tika-app/src/main/java/org/apache/tika/gui/TikaGUI.java
index 00cfe41fe8..aa4d9d9e68 100644
--- a/tika-app/src/main/java/org/apache/tika/gui/TikaGUI.java
+++ b/tika-app/src/main/java/org/apache/tika/gui/TikaGUI.java
@@ -41,6 +41,7 @@ import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Set;
import javax.swing.Box;
@@ -105,8 +106,14 @@ public class TikaGUI extends JFrame implements
ActionListener, HyperlinkListener
//maximum length to allow for mark for reparse to get JSON
private static final int MAX_MARK = 20 * 1024 * 1024;//20MB
- // Swing's html view instantiates the class an <object> names and fetches
<link> stylesheets
- private static final Set<String> HTML_VIEW_DROPPED = Set.of("head",
"link", "object", "param");
+ // Swing's html view builds Components from <object> and fetches frame,
stylesheet, image
+ // and background URLs, so only plain markup and attributes reach it
+ private static final Set<String> HTML_VIEW_ELEMENTS = Set.of("html",
"body", "p", "div", "span", "br",
+ "hr", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote",
"q", "address", "ul", "ol", "li",
+ "dl", "dt", "dd", "table", "caption", "thead", "tbody", "tfoot",
"tr", "th", "td", "a", "img", "b",
+ "i", "u", "em", "strong", "sub", "sup", "code", "ins", "del");
+ private static final Set<String> HTML_VIEW_ATTRIBUTES = Set.of("alt",
"title", "href", "name", "id",
+ "colspan", "rowspan");
/**
* Serial version UID.
@@ -337,7 +344,8 @@ public class TikaGUI extends JFrame implements
ActionListener, HyperlinkListener
StringBuilder metadataBuffer = new StringBuilder();
ContentHandler handler =
- new TeeContentHandler(getHtmlHandler(htmlBuffer),
getTextContentHandler(textBuffer), getTextMainContentHandler(textMainBuffer),
getXmlContentHandler(xmlBuffer));
+ new TeeContentHandler(getHtmlHandler(htmlBuffer, imageParser),
getTextContentHandler(textBuffer),
+ getTextMainContentHandler(textMainBuffer),
getXmlContentHandler(xmlBuffer));
context.set(DocumentSelector.class, new ImageDocumentSelector());
@@ -505,8 +513,10 @@ public class TikaGUI extends JFrame implements
ActionListener, HyperlinkListener
* to the given writer. The XHTML namespace is removed from the output
* to prevent the serializer from using the <tag/> empty element
* syntax that causes extra ">" characters to be displayed.
- * The <head> tags are dropped to prevent the serializer from
- * generating a <META> content type tag that makes
+ * Elements and attributes outside a small allowlist are dropped (element
+ * text is kept), so {@link JEditorPane} has nothing to fetch or
+ * instantiate. This also drops <head>, which would make the
+ * serializer generate a <META> content type tag that makes
* {@link JEditorPane} fail thinking that the document character set
* is inconsistent.
* <p>
@@ -514,10 +524,11 @@ public class TikaGUI extends JFrame implements
ActionListener, HyperlinkListener
* image links to be file:///(temporary file) so that they can be loaded.
*
* @param writer output writer
+ * @param imageParser saves the embedded images the view references
* @return HTML content handler
* @throws TransformerConfigurationException if an error occurs
*/
- private ContentHandler getHtmlHandler(Writer writer) throws
TransformerConfigurationException, TikaException {
+ static ContentHandler getHtmlHandler(Writer writer, ImageSavingParser
imageParser) throws TransformerConfigurationException, TikaException {
SAXTransformerFactory factory =
XMLReaderUtils.getSAXTransformerFactory();
TransformerHandler handler = factory.newTransformerHandler();
handler
@@ -530,38 +541,33 @@ public class TikaGUI extends JFrame implements
ActionListener, HyperlinkListener
if (XHTMLContentHandler.XHTML.equals(uri)) {
uri = null;
}
- if (!HTML_VIEW_DROPPED.contains(localName)) {
- if ("img".equals(localName)) {
- // a copy: the other tee'd handlers see the same
attributes
- AttributesImpl newAttrs = new AttributesImpl(atts);
-
- for (int i = 0; i < newAttrs.getLength(); i++) {
- if ("src".equals(newAttrs.getLocalName(i))) {
- String src = newAttrs.getValue(i);
- if (src.startsWith("embedded:")) {
- String filename =
src.substring(src.indexOf(':') + 1);
- try {
- File img =
imageParser.requestSave(filename);
- String newSrc = img
- .toURI()
- .toString();
- newAttrs.setValue(i, newSrc);
- } catch (IOException e) {
- System.err.println("Error creating
temp image file " + filename);
- // The html viewer will show a broken
image too to alert them
- }
- } else {
- // the view would fetch it on display
- newAttrs.removeAttribute(i);
- i--;
- }
+ if (!isAllowed(HTML_VIEW_ELEMENTS, localName, name)) {
+ return;
+ }
+ boolean img = isAllowed(Set.of("img"), localName, name);
+ // a copy: the other tee'd handlers see the same attributes
+ AttributesImpl newAttrs = new AttributesImpl();
+ for (int i = 0; i < atts.getLength(); i++) {
+ String attrLocal = atts.getLocalName(i);
+ String attrQName = atts.getQName(i);
+ if (img && isAllowed(Set.of("src"), attrLocal, attrQName))
{
+ String src = atts.getValue(i);
+ if (src.startsWith("embedded:")) {
+ String filename = src.substring(src.indexOf(':') +
1);
+ try {
+ File imgFile =
imageParser.requestSave(filename);
+ newAttrs.addAttribute(atts.getURI(i),
attrLocal, attrQName, atts.getType(i),
+ imgFile.toURI().toString());
+ } catch (IOException e) {
+ System.err.println("Error creating temp image
file " + filename);
+ // The html viewer will show a broken image
too to alert them
}
}
- super.startElement(uri, localName, name, newAttrs);
- } else {
- super.startElement(uri, localName, name, atts);
+ } else if (isAllowed(HTML_VIEW_ATTRIBUTES, attrLocal,
attrQName)) {
+ newAttrs.addAttribute(atts.getURI(i), attrLocal,
attrQName, atts.getType(i), atts.getValue(i));
}
}
+ super.startElement(uri, localName, name, newAttrs);
}
@Override
@@ -569,11 +575,20 @@ public class TikaGUI extends JFrame implements
ActionListener, HyperlinkListener
if (XHTMLContentHandler.XHTML.equals(uri)) {
uri = null;
}
- if (!HTML_VIEW_DROPPED.contains(localName)) {
+ if (isAllowed(HTML_VIEW_ELEMENTS, localName, name)) {
super.endElement(uri, localName, name);
}
}
+ // the serializer writes the data raw and Swing ends a PI at the
first '>'
+ @Override
+ public void processingInstruction(String target, String data) {
+ }
+
+ @Override
+ public void skippedEntity(String name) {
+ }
+
@Override
public void startPrefixMapping(String prefix, String uri) {
}
@@ -584,6 +599,20 @@ public class TikaGUI extends JFrame implements
ActionListener, HyperlinkListener
};
}
+ // the serializer writes the qName and Swing ignores case, so both names
must pass
+ private static boolean isAllowed(Set<String> allowed, String localName,
String qName) {
+ boolean named = false;
+ for (String n : new String[]{localName, qName}) {
+ if (n != null && !n.isEmpty()) {
+ if (!allowed.contains(n.toLowerCase(Locale.ROOT))) {
+ return false;
+ }
+ named = true;
+ }
+ }
+ return named;
+ }
+
private ContentHandler getTextContentHandler(Writer writer) {
return new BodyContentHandler(writer);
}
@@ -617,12 +646,12 @@ public class TikaGUI extends JFrame implements
ActionListener, HyperlinkListener
* directory, and delegates everything else to another downstream
* parser.
*/
- private static class ImageSavingParser implements Parser {
+ static class ImageSavingParser implements Parser {
private Map<String, File> wanted = new HashMap<>();
private Parser downstreamParser;
private File tmpDir;
- private ImageSavingParser(Parser downstreamParser) {
+ ImageSavingParser(Parser downstreamParser) {
this.downstreamParser = downstreamParser;
try {
diff --git
a/tika-app/src/test/java/org/apache/tika/gui/TikaGUIHtmlViewTest.java
b/tika-app/src/test/java/org/apache/tika/gui/TikaGUIHtmlViewTest.java
new file mode 100644
index 0000000000..7937919474
--- /dev/null
+++ b/tika-app/src/test/java/org/apache/tika/gui/TikaGUIHtmlViewTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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.gui;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.StringWriter;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.swing.JEditorPane;
+import javax.swing.SwingUtilities;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.helpers.AttributesImpl;
+
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.html.HtmlMapper;
+import org.apache.tika.parser.html.IdentityHtmlMapper;
+import org.apache.tika.sax.TeeContentHandler;
+import org.apache.tika.sax.ToXMLContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+public class TikaGUIHtmlViewTest {
+
+ private static final String HTML = "<html><head><title>t</title>"
+ + "<link rel=\"stylesheet\"
href=\"http://attacker.invalid/head.css\"></head><body>"
+ + "<p>before</p>"
+ + "<link rel=\"stylesheet\"
href=\"http://attacker.invalid/body.css\">"
+ + "<object classid=\"javax.swing.JLabel\"><param name=\"text\"
value=\"evil\"></object>"
+ + "<img src=\"http://attacker.invalid/remote.png\" alt=\"remote\">"
+ + "<img src=\"embedded:image1.png\" alt=\"local\">"
+ + "<p>after</p></body></html>";
+
+ // every construct here makes Swing's html view request BASE or build a
Component
+ private static final String ACTIVE = "<html><head><link rel=\"stylesheet\"
href=\"BASE/link.css\"></head>"
+ + "<body background=\"BASE/body-bg\"><p
style=\"background-image:url(BASE/style)\">styled</p>"
+ + "<table background=\"BASE/table-bg\"><tr><td
background=\"BASE/td-bg\">cell</td></tr></table>"
+ + "<input type=\"image\" src=\"BASE/input\">"
+ + "<img src=\"BASE/img.png\" alt=\"remote\">"
+ + "<object classid=\"javax.swing.JLabel\"><param name=\"text\"
value=\"evil\"></object>"
+ + "<frameset rows=\"50%,50%\"><frame
src=\"BASE/frame\"></frameset>"
+ + "<p>visible</p></body></html>";
+
+ @Test
+ public void testHtmlViewDropsActiveContent() throws Exception {
+ StringWriter view = new StringWriter();
+ ToXMLContentHandler xhtml = new ToXMLContentHandler();
+ TeeContentHandler handler = new TeeContentHandler(
+ TikaGUI.getHtmlHandler(view, new
TikaGUI.ImageSavingParser(null)), xhtml);
+ Metadata metadata = new Metadata();
+ try (TikaInputStream tis = TikaInputStream.get(HTML.getBytes(UTF_8))) {
+ new AutoDetectParser().parse(tis, handler, metadata, new
ParseContext());
+ }
+ String html = view.toString();
+ try {
+ assertTrue(html.contains("before") && html.contains("after"),
html);
+ assertFalse(html.contains("<object"), html);
+ assertFalse(html.contains("classid"), html);
+ assertFalse(html.contains("<param"), html);
+ assertFalse(html.contains("<link"), html);
+ assertFalse(html.contains("attacker.invalid"), html);
+ assertTrue(html.contains("alt=\"remote\""), html);
+ assertTrue(html.contains("src=\"file:"), html);
+
+ // the tee'd handlers still see the parser's own attributes
+ String xml = xhtml.toString();
+
assertTrue(xml.contains("src=\"http://attacker.invalid/remote.png\""), xml);
+ assertTrue(xml.contains("src=\"embedded:image1.png\""), xml);
+ } finally {
+ deleteSavedImages(html);
+ }
+ }
+
+ @Test
+ public void testHtmlViewMatchesSerializedName() throws Exception {
+ StringWriter view = new StringWriter();
+ ContentHandler handler = TikaGUI.getHtmlHandler(view, new
TikaGUI.ImageSavingParser(null));
+ AttributesImpl objectAtts = new AttributesImpl();
+ objectAtts.addAttribute("", "", "classid", "CDATA",
"javax.swing.JLabel");
+ AttributesImpl imgAtts = new AttributesImpl();
+ imgAtts.addAttribute("", "", "SRC", "CDATA",
"http://attacker.invalid/remote.png");
+ handler.startDocument();
+ handler.startElement(XHTMLContentHandler.XHTML, "body", "body", new
AttributesImpl());
+ handler.startElement("", "", "OBJECT", objectAtts);
+ handler.endElement("", "", "OBJECT");
+ handler.startElement("", "", "IMG", imgAtts);
+ handler.endElement("", "", "IMG");
+ handler.processingInstruction("x", "a><object
classid=\"javax.swing.JLabel\"></object><x ");
+ char[] text = "<object classid=\"javax.swing.JLabel\">".toCharArray();
+ handler.characters(text, 0, text.length);
+ handler.endElement(XHTMLContentHandler.XHTML, "body", "body");
+ handler.endDocument();
+ String html = view.toString();
+ assertFalse(html.toLowerCase(Locale.ROOT).contains("<object"), html);
+ assertTrue(html.contains("<object"), html);
+ assertFalse(html.contains("attacker.invalid"), html);
+ assertTrue(html.contains("<IMG"), html);
+ }
+
+ @Test
+ public void testSwingRendersNothingActive() throws Exception {
+ // calibration: the unfiltered markup does reach the listener and
build a Component
+ try (Listener raw = new Listener()) {
+ JEditorPane pane = render(ACTIVE.replace("BASE", raw.base()));
+ raw.awaitAtLeast(5);
+ assertTrue(pane.getComponentCount() > 0);
+ }
+
+ // IdentityHtmlMapper passes through whatever the document has, as a
custom parser could
+ ParseContext context = new ParseContext();
+ context.set(HtmlMapper.class, IdentityHtmlMapper.INSTANCE);
+ try (Listener filtered = new Listener()) {
+ StringWriter view = new StringWriter();
+ try (TikaInputStream tis =
TikaInputStream.get(ACTIVE.replace("BASE", filtered.base()).getBytes(UTF_8))) {
+ new AutoDetectParser().parse(tis, TikaGUI.getHtmlHandler(view,
new TikaGUI.ImageSavingParser(null)),
+ new Metadata(), context);
+ }
+ String html = view.toString();
+ assertTrue(html.contains("visible"), html);
+ JEditorPane pane = render(html);
+ Thread.sleep(1000);
+ assertEquals(List.of(), filtered.requests, html);
+ assertEquals(0, pane.getComponentCount(), html);
+ }
+ }
+
+ private static JEditorPane render(String html) throws Exception {
+ JEditorPane[] pane = new JEditorPane[1];
+ SwingUtilities.invokeAndWait(() -> {
+ pane[0] = new JEditorPane();
+ pane[0].setContentType("text/html");
+ pane[0].setText(html);
+ pane[0].setSize(800, 600);
+ pane[0].getPreferredSize();
+ });
+ return pane[0];
+ }
+
+ private static void deleteSavedImages(String html) throws Exception {
+ Matcher m = Pattern.compile("src=\"(file:[^\"]+)\"").matcher(html);
+ while (m.find()) {
+ Files.deleteIfExists(Path.of(URI.create(m.group(1))));
+ }
+ }
+
+ private static class Listener implements AutoCloseable {
+ private final ServerSocket server = new ServerSocket(0, 50,
InetAddress.getLoopbackAddress());
+ private final List<String> requests = new CopyOnWriteArrayList<>();
+
+ Listener() throws Exception {
+ Thread t = new Thread(() -> {
+ while (true) {
+ try (Socket s = server.accept()) {
+ requests.add(new BufferedReader(new
InputStreamReader(s.getInputStream(), UTF_8)).readLine());
+ s.getOutputStream().write("HTTP/1.0 404 Not
Found\r\nContent-Length: 0\r\n\r\n".getBytes(UTF_8));
+ } catch (SocketException e) {
+ return;
+ } catch (Exception e) {
+ //swallow
+ }
+ }
+ });
+ t.setDaemon(true);
+ t.start();
+ }
+
+ String base() {
+ return "http://127.0.0.1:" + server.getLocalPort();
+ }
+
+ void awaitAtLeast(int n) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 10_000;
+ while (requests.size() < n && System.currentTimeMillis() <
deadline) {
+ Thread.sleep(50);
+ }
+ assertTrue(requests.size() >= n, requests.toString());
+ }
+
+ @Override
+ public void close() throws Exception {
+ server.close();
+ }
+ }
+}