TikaGUIHtmlViewTest.java
/*
* 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();
}
}
}