Skip to content

Commit e5b6466

Browse files
committed
TIKA-4837 - flag extra contents in ooxml files
1 parent 26381aa commit e5b6466

6 files changed

Lines changed: 406 additions & 0 deletions

File tree

tika-core/src/main/java/org/apache/tika/metadata/Office.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,29 @@ public interface Office {
234234
// Security-relevant: Framesets can load external URLs
235235
Property HAS_FRAMESETS = Property.internalBoolean("msoffice:doc:has-framesets");
236236

237+
/**
238+
* Security-relevant, best-effort: true when the OOXML package contains one or more
239+
* declared parts that are NOT reachable from the package root through the OPC
240+
* relationship graph. Office and Tika load content by following relationships, so an
241+
* unreferenced part is carried in the file but never parsed -- a place to hide bytes
242+
* that a raw-ZIP reader (AV/DLP/CDR) can still see. (Note: a part with no declared
243+
* content type is a different case -- POI rejects the whole package at open time, so
244+
* it cannot appear on a successfully parsed file.)
245+
* <p>This is an informational signal, NOT a guarantee, and it is evadable (a payload
246+
* referenced by a relationship type Tika ignores is still reachable): per
247+
* <a href="https://tika.apache.org/security-model.html">Tika's security model</a>,
248+
* Tika is not a security boundary and does not attempt to detect parser differentials.
249+
* See {@link #UNREFERENCED_PART_NAMES} for the part names.
250+
*/
251+
Property HAS_UNREFERENCED_PARTS =
252+
Property.internalBoolean("msoffice:has-unreferenced-parts");
253+
254+
/**
255+
* The names of the parts flagged by {@link #HAS_UNREFERENCED_PARTS}.
256+
*/
257+
Property UNREFERENCED_PART_NAMES =
258+
Property.internalTextBag("msoffice:unreferenced-part-names");
259+
237260
/**
238261
* 1-based sheet number for a resource (e.g. an embedded image)
239262
* anchored to exactly one sheet of a workbook. For resources

tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@
317317
{"class":"org.apache.tika.metadata.Office","field":"HAS_POWER_QUERY","key":"msoffice:excel:has-power-query"},
318318
{"class":"org.apache.tika.metadata.Office","field":"HAS_SUBDOCUMENTS","key":"msoffice:doc:has-subdocuments"},
319319
{"class":"org.apache.tika.metadata.Office","field":"HAS_TRACK_CHANGES","key":"msoffice:has-track-changes"},
320+
{"class":"org.apache.tika.metadata.Office","field":"HAS_UNREFERENCED_PARTS","key":"msoffice:has-unreferenced-parts"},
320321
{"class":"org.apache.tika.metadata.Office","field":"HAS_VERY_HIDDEN_SHEETS","key":"msoffice:excel:has-very-hidden-sheets"},
321322
{"class":"org.apache.tika.metadata.Office","field":"HAS_VML_HYPERLINKS","key":"msoffice:has-vml-hyperlinks"},
322323
{"class":"org.apache.tika.metadata.Office","field":"HAS_WEB_QUERIES","key":"msoffice:excel:has-web-queries"},
@@ -341,6 +342,7 @@
341342
{"class":"org.apache.tika.metadata.Office","field":"SLIDE_COUNT","key":"meta:slide-count"},
342343
{"class":"org.apache.tika.metadata.Office","field":"TABLE_COUNT","key":"meta:table-count"},
343344
{"class":"org.apache.tika.metadata.Office","field":"UNLISTED_SLIDE_NAMES","key":"msoffice:ppt:unlisted-slide-names"},
345+
{"class":"org.apache.tika.metadata.Office","field":"UNREFERENCED_PART_NAMES","key":"msoffice:unreferenced-part-names"},
344346
{"class":"org.apache.tika.metadata.Office","field":"VERY_HIDDEN_SHEET_NAMES","key":"msoffice:excel:very-hidden-sheet-names"},
345347
{"class":"org.apache.tika.metadata.Office","field":"WORD_COUNT","key":"meta:word-count"},
346348
{"class":"org.apache.tika.metadata.Office","field":"WORKBOOK_CODENAME","key":"msoffice:excel:workbook-codename"},

tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@
366366
{"key":"msoffice:has-hover-hyperlinks","namespace":"msoffice","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
367367
{"key":"msoffice:has-linked-ole-objects","namespace":"msoffice","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
368368
{"key":"msoffice:has-track-changes","namespace":"msoffice","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
369+
{"key":"msoffice:has-unreferenced-parts","namespace":"msoffice","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
369370
{"key":"msoffice:has-vml-hyperlinks","namespace":"msoffice","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
370371
{"key":"msoffice:ocx-name","namespace":"msoffice","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
371372
{"key":"msoffice:ppt:has-animations","namespace":"msoffice","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
@@ -374,6 +375,7 @@
374375
{"key":"msoffice:ppt:num-unlisted-slides","namespace":"msoffice","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
375376
{"key":"msoffice:ppt:unlisted-slide-names","namespace":"msoffice","valueType":"TEXT","cardinality":"BAG","module":"tika-core"},
376377
{"key":"msoffice:prog-id","namespace":"msoffice","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
378+
{"key":"msoffice:unreferenced-part-names","namespace":"msoffice","valueType":"TEXT","cardinality":"BAG","module":"tika-core"},
377379
{"key":"multipart:boundary","namespace":"multipart","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
378380
{"key":"multipart:subtype","namespace":"multipart","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
379381
{"key":"netcdf:file-type-description","namespace":"netcdf","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-scientific-module"},

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLExtractorFactory.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,19 @@
1717
package org.apache.tika.parser.microsoft.ooxml;
1818

1919
import java.io.IOException;
20+
import java.util.ArrayDeque;
21+
import java.util.Deque;
22+
import java.util.HashSet;
2023
import java.util.Locale;
2124
import java.util.Set;
2225

2326
import org.apache.poi.openxml4j.exceptions.OpenXML4JException;
2427
import org.apache.poi.openxml4j.opc.OPCPackage;
2528
import org.apache.poi.openxml4j.opc.PackageAccess;
2629
import org.apache.poi.openxml4j.opc.PackagePart;
30+
import org.apache.poi.openxml4j.opc.PackageRelationship;
2731
import org.apache.poi.openxml4j.opc.PackageRelationshipCollection;
32+
import org.apache.poi.openxml4j.opc.TargetMode;
2833
import org.apache.poi.util.LocaleUtil;
2934
import org.apache.poi.xslf.usermodel.XSLFRelation;
3035
import org.apache.poi.xssf.usermodel.XSSFRelation;
@@ -39,6 +44,7 @@
3944
import org.apache.tika.io.TikaInputStream;
4045
import org.apache.tika.metadata.HttpHeaders;
4146
import org.apache.tika.metadata.Metadata;
47+
import org.apache.tika.metadata.Office;
4248
import org.apache.tika.metadata.TikaCoreProperties;
4349
import org.apache.tika.mime.MediaType;
4450
import org.apache.tika.parser.EmptyParser;
@@ -96,6 +102,7 @@ public static void parse(TikaInputStream tis, ContentHandler baseHandler, Metada
96102
if (prc != null && prc.size() > 0) {
97103
metadata.set(TikaCoreProperties.HAS_SIGNATURE, "true");
98104
}
105+
markUnreferencedParts(pkg, metadata);
99106
}
100107

101108
MediaType type = null;
@@ -216,6 +223,64 @@ private static boolean isVisioType(MediaType type) {
216223
return type != null && VISIO_SUBTYPES.contains(type.getSubtype());
217224
}
218225

226+
private static final String RELATIONSHIPS_CONTENT_TYPE =
227+
"application/vnd.openxmlformats-package.relationships+xml";
228+
229+
/**
230+
* Best-effort, security-relevant signal: flags declared parts that are not reachable
231+
* from the package root via the OPC relationship graph -- content carried in the file
232+
* but outside the structure Office loads by following relationships. Reuses the
233+
* already-open package (an in-memory walk of relationships POI loads anyway). Never
234+
* throws: a failure here must not break the parse.
235+
*
236+
* @see Office#HAS_UNREFERENCED_PARTS
237+
*/
238+
private static void markUnreferencedParts(OPCPackage pkg, Metadata metadata) {
239+
try {
240+
Set<String> reachable = new HashSet<>();
241+
Deque<PackagePart> queue = new ArrayDeque<>();
242+
addRelatedParts(pkg.getRelationships(), rel -> pkg.getPart(rel), reachable, queue);
243+
while (!queue.isEmpty()) {
244+
PackagePart part = queue.poll();
245+
addRelatedParts(part.getRelationships(), part::getRelatedPart, reachable, queue);
246+
}
247+
for (PackagePart part : pkg.getParts()) {
248+
// relationship parts (_rels/*.rels) are never relationship targets; skip them
249+
if (RELATIONSHIPS_CONTENT_TYPE.equals(part.getContentType())) {
250+
continue;
251+
}
252+
if (!reachable.contains(part.getPartName().getName())) {
253+
metadata.set(Office.HAS_UNREFERENCED_PARTS, true);
254+
metadata.add(Office.UNREFERENCED_PART_NAMES, part.getPartName().getName());
255+
}
256+
}
257+
} catch (Exception e) {
258+
// best-effort only; never fail the parse over this signal
259+
}
260+
}
261+
262+
@FunctionalInterface
263+
private interface PartResolver {
264+
PackagePart resolve(PackageRelationship rel) throws Exception;
265+
}
266+
267+
private static void addRelatedParts(PackageRelationshipCollection rels, PartResolver resolver,
268+
Set<String> reachable, Deque<PackagePart> queue) {
269+
for (PackageRelationship rel : rels) {
270+
if (rel.getTargetMode() != TargetMode.INTERNAL) {
271+
continue;
272+
}
273+
try {
274+
PackagePart part = resolver.resolve(rel);
275+
if (part != null && reachable.add(part.getPartName().getName())) {
276+
queue.add(part);
277+
}
278+
} catch (Exception e) {
279+
// unresolved/broken relationship target -- ignore for this best-effort signal
280+
}
281+
}
282+
}
283+
219284
private static String getCorePartContentType(OPCPackage pkg) {
220285
try {
221286
PackageRelationshipCollection rels =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.parser.microsoft.ooxml;
18+
19+
import static org.junit.jupiter.api.Assertions.assertFalse;
20+
import static org.junit.jupiter.api.Assertions.assertTrue;
21+
22+
import java.io.ByteArrayInputStream;
23+
import java.io.ByteArrayOutputStream;
24+
import java.util.List;
25+
import java.util.zip.ZipEntry;
26+
import java.util.zip.ZipInputStream;
27+
import java.util.zip.ZipOutputStream;
28+
29+
import org.junit.jupiter.api.Test;
30+
31+
import org.apache.tika.TikaTest;
32+
import org.apache.tika.io.TikaInputStream;
33+
import org.apache.tika.metadata.Metadata;
34+
import org.apache.tika.metadata.TikaCoreProperties;
35+
import org.apache.tika.parser.ParseContext;
36+
import org.apache.tika.parser.microsoft.OfficeParserConfig;
37+
38+
/**
39+
* Guards that Tika resolves the VBA project through the OPC relationship graph,
40+
* NOT by raw-zip filename-suffix / stream order.
41+
*
42+
* <p>POI's {@code VBAMacroReader.openOOXML} selects the VBA project as the first
43+
* zip entry (in stream order) whose name ends with "vbaProject.bin", bypassing
44+
* OPC. A tool built directly on that (an AV/DLP/CDR macro scanner) can be fooled
45+
* by a decoy vbaProject.bin ordered before the real one, while Office executes
46+
* the relationship-referenced part (CWE-436 parser differential; reported against
47+
* POI by n0mi1k, fixed there as a plain bug).
48+
*
49+
* <p>Tika does not use that path: it constructs {@code VBAMacroReader} only from a
50+
* {@code POIFSFileSystem} built from the OPC-resolved {@code vbaProject} part. This
51+
* test crafts an .xlsm with a decoy vbaProject.bin (a DIFFERENT, valid VBA project
52+
* lifted from testWORD_macros.docm) placed first in stream order, and asserts Tika
53+
* still extracts the real /xl/vbaProject.bin macro. If a future change routes macro
54+
* extraction through the raw-zip path, this fails.
55+
*/
56+
public class MacroPartResolutionTest extends TikaTest {
57+
58+
private static final String REAL_MACRO = "Sub Dirty()"; // testEXCEL_macro.xlsm
59+
private static final String DECOY_MACRO = "Sub Embolden()"; // testWORD_macros.docm
60+
61+
private byte[] resourceBytes(String name) throws Exception {
62+
try (TikaInputStream tis = getResourceAsStream("/test-documents/" + name)) {
63+
return tis.readAllBytes();
64+
}
65+
}
66+
67+
private byte[] entryEndingWith(byte[] zip, String suffix) throws Exception {
68+
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) {
69+
ZipEntry e;
70+
byte[] buf = new byte[8192];
71+
while ((e = zis.getNextEntry()) != null) {
72+
if (e.getName().toLowerCase(java.util.Locale.ROOT).endsWith(suffix)) {
73+
ByteArrayOutputStream bos = new ByteArrayOutputStream();
74+
int n;
75+
while ((n = zis.read(buf)) > 0) {
76+
bos.write(buf, 0, n);
77+
}
78+
return bos.toByteArray();
79+
}
80+
}
81+
}
82+
throw new IllegalStateException("no entry ending with " + suffix);
83+
}
84+
85+
private String allContent(List<Metadata> list) {
86+
StringBuilder sb = new StringBuilder();
87+
for (Metadata m : list) {
88+
String c = m.get(TikaCoreProperties.TIKA_CONTENT);
89+
if (c != null) {
90+
sb.append(c).append('\n');
91+
}
92+
}
93+
return sb.toString();
94+
}
95+
96+
private List<Metadata> parseWithMacros(byte[] bytes) throws Exception {
97+
ParseContext context = new ParseContext();
98+
OfficeParserConfig config = new OfficeParserConfig();
99+
config.setExtractMacros(true);
100+
context.set(OfficeParserConfig.class, config);
101+
return getRecursiveMetadata(TikaInputStream.get(bytes), new Metadata(), context, false);
102+
}
103+
104+
@Test
105+
public void testVbaProjectResolvedViaOpcNotStreamOrder() throws Exception {
106+
byte[] xlsm = resourceBytes("testEXCEL_macro.xlsm");
107+
// A different, valid VBA project to act as the decoy.
108+
byte[] decoyVba = entryEndingWith(resourceBytes("testWORD_macros.docm"), "vbaproject.bin");
109+
110+
// Sanity: the two projects have distinct, non-overlapping macro signatures.
111+
String realOnly = allContent(parseWithMacros(xlsm));
112+
assertTrue(realOnly.contains(REAL_MACRO), "baseline xlsm should expose real macro");
113+
assertFalse(realOnly.contains(DECOY_MACRO), "baseline xlsm must not contain decoy macro");
114+
115+
// Craft an .xlsm whose FIRST *vbaProject.bin entry (stream order) is the
116+
// undeclared decoy, with the real, relationship-referenced /xl/vbaProject.bin
117+
// kept intact later in the stream.
118+
ByteArrayOutputStream bos = new ByteArrayOutputStream();
119+
try (ZipOutputStream zos = new ZipOutputStream(bos)) {
120+
zos.putNextEntry(new ZipEntry("xl/decoy/vbaProject.bin")); // undeclared, first in stream
121+
zos.write(decoyVba);
122+
zos.closeEntry();
123+
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(xlsm))) {
124+
ZipEntry e;
125+
byte[] buf = new byte[8192];
126+
while ((e = zis.getNextEntry()) != null) {
127+
zos.putNextEntry(new ZipEntry(e.getName()));
128+
int n;
129+
while ((n = zis.read(buf)) > 0) {
130+
zos.write(buf, 0, n);
131+
}
132+
zos.closeEntry();
133+
}
134+
}
135+
}
136+
137+
String crafted = allContent(parseWithMacros(bos.toByteArray()));
138+
// Tika must extract the OPC-resolved real macro, and must NOT have been
139+
// steered to the stream-order decoy.
140+
assertTrue(crafted.contains(REAL_MACRO),
141+
"Tika must extract the OPC-resolved /xl/vbaProject.bin macro");
142+
assertFalse(crafted.contains(DECOY_MACRO),
143+
"Tika must not extract the stream-order decoy vbaProject.bin macro");
144+
}
145+
}

0 commit comments

Comments
 (0)