Skip to content

Commit a49480c

Browse files
authored
TIKA-4814 -- OneNote follow-ups: fall back to legacy string dump when… (#3045)
1 parent cbfeb51 commit a49480c

22 files changed

Lines changed: 934 additions & 183 deletions

.skills/oss-fuzz/SKILL.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,26 @@ and is safe — it runs a single testcase, not a corpus dir, so the
312312
- **New target:** add `FooParserFuzzer.java` next to the others following the
313313
`ParserFuzzer.parseOne` + swallow-expected-exceptions pattern.
314314

315+
## Cleanup — the container leaves root-owned files in your working tree
316+
317+
`build_fuzzers` with a local `--mount_path` compiles your working tree *inside
318+
the container as root*, so `target/` dirs (and other build outputs) in the
319+
mounted checkout come back **root-owned** — a later host-side `./mvnw clean`
320+
then fails with permission errors. When you are done fuzzing, run the clean
321+
**from the container** (root can delete its own files) against the same mount:
322+
323+
```bash
324+
docker run --rm --platform linux/amd64 \
325+
-v /path/to/tika:/src/project-parent/tika \
326+
gcr.io/oss-fuzz/apache-tika \
327+
bash -c 'cd /src/project-parent/tika && ./mvnw clean -Pfast -Dmaven.repo.local=/tmp/m2'
328+
```
329+
330+
Use a throwaway in-container repo path for `-Dmaven.repo.local` (as above) so
331+
the clean itself does not write root-owned files into the host `.local_m2_repo`.
332+
Verify nothing is left behind: `find /path/to/tika -user root | head` should
333+
print nothing.
334+
315335
## Disclosure caveat (read before touching the public project)
316336

317337
The `apache-tika` project on Google's infra **auto-files bugs and discloses on

CHANGES.txt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ Release 4.1.0 - unreleased
3030
CONTENT_TYPE_USER_OVERRIDE key is deliberately not carried, so the hint
3131
cannot force an unrelated type (TIKA-4825).
3232

33+
* OneNote extraction now follows document order, omits superseded page
34+
revisions, sorts author metadata, extracts embedded object BLOBs, and
35+
bounds malformed-input recursion and file-derived allocations. Parse
36+
warnings and embedded relationship IDs are exposed in metadata. Malformed
37+
or truncated files that cannot be fully parsed, and files whose walk
38+
yields no content, now fall back to the legacy string dump instead of
39+
failing or returning empty output. The legacy MS-ONESTORE walker bounds
40+
its recursion (depth caps plus file-node-list and fragment-chain cycle
41+
guards) and now honors shouldParseEmbedded for embedded file data
42+
(TIKA-4814).
43+
3344
* RawTiffParser extracts the camera-generated JPEG previews embedded in
3445
TIFF-based raw images (Nikon NEF/NRW, Sony ARW/SRF/SR2, Pentax PEF/PTX,
3546
Adobe DNG and Canon CR2, including BigTIFF DNG containers) as thumbnail
@@ -510,12 +521,6 @@ Release 4.0.0 - 8/18/2026
510521

511522
OTHER CHANGES
512523

513-
* OneNote extraction now follows document order, omits superseded page
514-
revisions, sorts author metadata, extracts embedded object BLOBs, and
515-
bounds malformed-input recursion and per-file-node-list property allocation.
516-
Malformed truncated property arrays now fail rather than return partial data;
517-
parse warnings and embedded relationship IDs are exposed in metadata (TIKA-4814).
518-
519524
* Dependency upgrades since 4.0.0-beta-1, including Jetty 12.1.12, CXF
520525
4.2.3 and SolrJ 10.0.0 (TIKA-4327).
521526

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/GUID.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ public static GUID fromCurlyBraceUTF16Bytes(byte[] guid) throws TikaException {
5656
throw new TikaException("Invalid GUID string");
5757
}
5858
for (int i = 0; i < hex.length(); i += 2) {
59-
int high = Character.digit(hex.charAt(i), 16);
60-
int low = Character.digit(hex.charAt(i + 1), 16);
59+
int high = asciiHexDigit(hex.charAt(i));
60+
int low = asciiHexDigit(hex.charAt(i + 1));
6161
if (high < 0 || low < 0) {
6262
throw new TikaException("Invalid GUID string");
6363
}
@@ -66,6 +66,20 @@ public static GUID fromCurlyBraceUTF16Bytes(byte[] guid) throws TikaException {
6666
return new GUID(intGuid);
6767
}
6868

69+
// Character.digit accepts non-ASCII Unicode digits; GUIDs are ASCII hex only
70+
private static int asciiHexDigit(char c) {
71+
if (c >= '0' && c <= '9') {
72+
return c - '0';
73+
}
74+
if (c >= 'a' && c <= 'f') {
75+
return c - 'a' + 10;
76+
}
77+
if (c >= 'A' && c <= 'F') {
78+
return c - 'A' + 10;
79+
}
80+
return -1;
81+
}
82+
6983
public static int memcmp(int[] b1, int[] b2, int sz) {
7084
for (int i = 0; i < sz; i++) {
7185
if (b1[i] != b2[i]) {

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteDocument.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class OneNoteDocument {
3434
Map<ExtendedGUID, Pair<Long, ExtendedGUID>> revisionRoleMap = new HashMap<>();
3535
ExtendedGUID currentRevision = ExtendedGUID.nil();
3636
FileNodeList root = new FileNodeList();
37+
// set when the root file node list could not be fully parsed (e.g. a truncated file);
38+
// the header and any structure parsed so far remain usable
39+
Exception structureParseException;
3740

3841
public OneNoteDocument() {
3942

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java

Lines changed: 99 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -129,39 +129,56 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata
129129
metadata.set(OneNote.RGB_PLACEHOLDER,
130130
"0x" + Long.toHexString(oneNoteDocument.header.rgbPlaceholder));
131131

132-
Pair<Long, ExtendedGUID> roleAndContext = Pair.of(1L, ExtendedGUID.nil());
133-
OneNoteTreeWalker oneNoteTreeWalker =
134-
new OneNoteTreeWalker(options, oneNoteDocument, oneNoteDirectFileResource,
135-
xhtml, metadata, context, roleAndContext);
132+
Exception structureFailure = oneNoteDocument.structureParseException;
133+
boolean walked = false;
134+
if (structureFailure == null) {
135+
try {
136+
Pair<Long, ExtendedGUID> roleAndContext = Pair.of(1L, ExtendedGUID.nil());
137+
OneNoteTreeWalker oneNoteTreeWalker =
138+
new OneNoteTreeWalker(options, oneNoteDocument,
139+
oneNoteDirectFileResource, xhtml, metadata, context,
140+
roleAndContext);
136141

137-
oneNoteTreeWalker.walkTree();
142+
oneNoteTreeWalker.walkTree();
138143

139-
if (!oneNoteTreeWalker.getAuthors().isEmpty()) {
140-
metadata.set(TikaCoreProperties.CREATOR,
141-
sortedValues(oneNoteTreeWalker.getAuthors()));
142-
}
143-
if (!oneNoteTreeWalker.getMostRecentAuthors().isEmpty()) {
144-
metadata.set(OneNote.MOST_RECENT_AUTHORS,
145-
sortedValues(oneNoteTreeWalker.getMostRecentAuthors()));
146-
}
147-
if (!oneNoteTreeWalker.getOriginalAuthors().isEmpty()) {
148-
metadata.set(OneNote.ORIGINAL_AUTHORS,
149-
sortedValues(oneNoteTreeWalker.getOriginalAuthors()));
150-
}
151-
if (!Instant.MAX.equals(
152-
Instant.ofEpochMilli(oneNoteTreeWalker.getCreationTimestamp()))) {
153-
metadata.set(OneNote.CREATION_TIMESTAMP,
154-
String.valueOf(oneNoteTreeWalker.getCreationTimestamp()));
155-
}
156-
if (!Instant.MIN.equals(oneNoteTreeWalker.getLastModifiedTimestamp())) {
157-
metadata.set(OneNote.LAST_MODIFIED_TIMESTAMP, String.valueOf(
158-
oneNoteTreeWalker.getLastModifiedTimestamp().toEpochMilli()));
144+
if (!oneNoteTreeWalker.getAuthors().isEmpty()) {
145+
metadata.set(TikaCoreProperties.CREATOR,
146+
sortedValues(oneNoteTreeWalker.getAuthors()));
147+
}
148+
if (!oneNoteTreeWalker.getMostRecentAuthors().isEmpty()) {
149+
metadata.set(OneNote.MOST_RECENT_AUTHORS,
150+
sortedValues(oneNoteTreeWalker.getMostRecentAuthors()));
151+
}
152+
if (!oneNoteTreeWalker.getOriginalAuthors().isEmpty()) {
153+
metadata.set(OneNote.ORIGINAL_AUTHORS,
154+
sortedValues(oneNoteTreeWalker.getOriginalAuthors()));
155+
}
156+
if (!Instant.MAX.equals(
157+
Instant.ofEpochMilli(oneNoteTreeWalker.getCreationTimestamp()))) {
158+
metadata.set(OneNote.CREATION_TIMESTAMP,
159+
String.valueOf(oneNoteTreeWalker.getCreationTimestamp()));
160+
}
161+
if (!Instant.MIN.equals(oneNoteTreeWalker.getLastModifiedTimestamp())) {
162+
metadata.set(OneNote.LAST_MODIFIED_TIMESTAMP, String.valueOf(
163+
oneNoteTreeWalker.getLastModifiedTimestamp().toEpochMilli()));
164+
}
165+
if (oneNoteTreeWalker.getLastModified() > Long.MIN_VALUE) {
166+
metadata.set(TikaCoreProperties.MODIFIED,
167+
String.valueOf(oneNoteTreeWalker.getLastModified()));
168+
}
169+
walked = true;
170+
} catch (Exception e) {
171+
rethrowIfLimitReached(e);
172+
structureFailure = e;
173+
}
159174
}
160-
if (oneNoteTreeWalker.getLastModified() > Long.MIN_VALUE) {
161-
metadata.set(TikaCoreProperties.MODIFIED,
162-
String.valueOf(oneNoteTreeWalker.getLastModified()));
175+
if (!walked) {
176+
legacyFallbackDump("OneNote parse failed; falling back to legacy text dump: " +
177+
failureMessage(structureFailure), structureFailure, metadata,
178+
xhtml, oneNoteDirectFileResource);
163179
}
164180
} else if (header.isLegacyOrAlternativePackaging()) {
181+
MSOneStorePackage pkg = null;
165182
try {
166183
AlternativePackaging alternatePackageOneStoreFile = new AlternativePackaging();
167184
byte[] bytes;
@@ -172,27 +189,18 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata
172189
alternatePackageOneStoreFile.doDeserializeFromByteArray(bytes, 0);
173190

174191
MSOneStoreParser onenoteParser = new MSOneStoreParser();
175-
MSOneStorePackage pkg =
176-
onenoteParser.parse(alternatePackageOneStoreFile.dataElementPackage);
192+
pkg = onenoteParser.parse(alternatePackageOneStoreFile.dataElementPackage);
177193

178194
pkg.walkTree(options, metadata, xhtml, context);
179195
} catch (Exception e) {
180-
WriteLimitReachedException.throwIfWriteLimitReached(e);
181-
if (e instanceof EmbeddedLimitReachedException) {
182-
throw (EmbeddedLimitReachedException) e;
183-
}
184-
String failure = e.getMessage() == null ? e.getClass().getSimpleName() :
185-
e.getMessage();
186-
LOG.warn("OneNote FSSHTTPB parse failed; falling back to legacy text dump: {}",
187-
failure);
188-
LOG.debug("OneNote FSSHTTPB parse failure", e);
189-
metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
196+
rethrowIfLimitReached(e);
197+
legacyFallbackDump(
190198
"OneNote FSSHTTPB parse failed; falling back to legacy text dump: " +
191-
failure);
192-
OneNoteLegacyDumpStrings dumpStrings =
193-
new OneNoteLegacyDumpStrings(oneNoteDirectFileResource, xhtml);
194-
dumpStrings.dump();
199+
failureMessage(e), e, metadata, xhtml,
200+
oneNoteDirectFileResource);
201+
pkg = null;
195202
}
203+
legacyFallbackIfNoContent(pkg, metadata, xhtml, oneNoteDirectFileResource);
196204
} else {
197205
throw new TikaException("Invalid OneStore document - could not parse headers");
198206
}
@@ -208,6 +216,42 @@ private static String[] sortedValues(Set<String> values) {
208216
return sorted;
209217
}
210218

219+
private static void rethrowIfLimitReached(Exception e) throws TikaException, SAXException {
220+
WriteLimitReachedException.throwIfWriteLimitReached(e);
221+
if (e instanceof EmbeddedLimitReachedException) {
222+
throw (EmbeddedLimitReachedException) e;
223+
}
224+
}
225+
226+
private static String failureMessage(Exception e) {
227+
return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
228+
}
229+
230+
// the walk completed but every page dangled - without this a degraded
231+
// file would yield empty output where the dump still finds its text
232+
static void legacyFallbackIfNoContent(MSOneStorePackage pkg, Metadata metadata,
233+
XHTMLContentHandler xhtml,
234+
OneNoteDirectFileResource oneNoteDirectFileResource)
235+
throws TikaException, SAXException {
236+
if (pkg != null && !pkg.hasEmittedContent()) {
237+
legacyFallbackDump("OneNote FSSHTTPB parse produced no content; " +
238+
"falling back to legacy text dump", null, metadata, xhtml,
239+
oneNoteDirectFileResource);
240+
}
241+
}
242+
243+
private static void legacyFallbackDump(String warning, Exception cause, Metadata metadata,
244+
XHTMLContentHandler xhtml,
245+
OneNoteDirectFileResource oneNoteDirectFileResource)
246+
throws TikaException, SAXException {
247+
LOG.warn(warning);
248+
if (cause != null) {
249+
LOG.debug("OneNote parse failure", cause);
250+
}
251+
metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, warning);
252+
new OneNoteLegacyDumpStrings(oneNoteDirectFileResource, xhtml).dump();
253+
}
254+
211255
/**
212256
* Create a OneNoteDocument object.
213257
* <p>
@@ -241,7 +285,8 @@ private static String[] sortedValues(Set<String> values) {
241285
* content.
242286
* @return A parsed one note document. This document does not contain any of the binary data,
243287
* rather it just contains
244-
* the data pointers and metadata.
288+
* the data pointers and metadata. A failure while parsing the root file node list is not
289+
* thrown; it is recorded in the returned document's {@code structureParseException}.
245290
* @throws IOException Will throw IOException in typical IO issue situations.
246291
*/
247292
public OneNoteDocument createOneNoteDocumentFromDirectFileResource(
@@ -253,9 +298,15 @@ public OneNoteDocument createOneNoteDocumentFromDirectFileResource(
253298

254299
if (oneNoteDocument.header.isMsOneStoreFormat()) {
255300
// Now that we parsed the header, the "root file node list"
256-
oneNotePtr.reposition(oneNoteDocument.header.fcrFileNodeListRoot);
257-
FileNodePtr curPath = new FileNodePtr();
258-
oneNotePtr.deserializeFileNodeList(oneNoteDocument.root, curPath);
301+
try {
302+
oneNotePtr.reposition(oneNoteDocument.header.fcrFileNodeListRoot);
303+
FileNodePtr curPath = new FileNodePtr();
304+
oneNotePtr.deserializeFileNodeList(oneNoteDocument.root, curPath);
305+
} catch (TikaException | IOException | RuntimeException e) {
306+
// a truncated or malformed root list is recorded, not thrown, so the
307+
// caller can fall back to the legacy string dump
308+
oneNoteDocument.structureParseException = e;
309+
}
259310
}
260311
return oneNoteDocument;
261312
}

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNotePtr.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
import java.nio.charset.StandardCharsets;
2222
import java.util.ArrayList;
2323
import java.util.Arrays;
24+
import java.util.HashSet;
2425
import java.util.List;
2526
import java.util.Map;
27+
import java.util.Set;
2628

2729
import org.apache.commons.codec.binary.Hex;
2830
import org.apache.commons.io.EndianUtils;
@@ -55,13 +57,26 @@ class OneNotePtr {
5557
"{638DE92F-A6D4-4BC1-9A36-B3FC2511A5B7}";
5658
private static final int MAX_PROPERTY_VALUES = 100_000;
5759
private static final int MAX_PROPERTY_SET_DEPTH = 1000;
60+
// the format nests file node lists only a handful of levels deep; 100 leaves wide
61+
// margin while keeping the recursion far from any stack limit
62+
private static final int MAX_FILE_NODE_LIST_DEPTH = 100;
5863

5964
private static final class PropertyValueBudget {
6065
private long remaining = MAX_PROPERTY_VALUES;
6166
}
6267

68+
/**
69+
* Recursion state for the baseType-2 file-node-list nesting, shared by every pointer
70+
* copied while parsing one document.
71+
*/
72+
private static final class FileNodeListRecursion {
73+
private int depth;
74+
private final Set<Long> activeListOffsets = new HashSet<>();
75+
}
76+
6377
int indentLevel = 0;
6478
private PropertyValueBudget propertyValueBudget = new PropertyValueBudget();
79+
private FileNodeListRecursion fileNodeListRecursion = new FileNodeListRecursion();
6580

6681
long offset;
6782
long end;
@@ -84,6 +99,7 @@ public OneNotePtr(OneNotePtr oneNotePtr) {
8499
this.end = oneNotePtr.end;
85100
this.indentLevel = oneNotePtr.indentLevel;
86101
this.propertyValueBudget = oneNotePtr.propertyValueBudget;
102+
this.fileNodeListRecursion = oneNotePtr.fileNodeListRecursion;
87103
}
88104

89105
public OneNoteHeader deserializeHeader() throws IOException, TikaException {
@@ -269,13 +285,21 @@ public OneNotePtr internalDeserializeFileNodeList(OneNotePtr ptr, FileNodeList f
269285
throws IOException, TikaException {
270286
OneNotePtr localPtr = new OneNotePtr(ptr);
271287
FileNodePtrBackPush bp = new FileNodePtrBackPush(curPath);
288+
// a next-fragment reference pointing at an already-seen fragment would loop forever
289+
Set<Long> seenFragmentOffsets = new HashSet<>();
290+
seenFragmentOffsets.add(ptr.offset);
272291
try {
273292
while (true) {
274293
FileChunkReference next = FileChunkReference.nil();
275294
ptr.deserializeFileNodeListFragment(fileNodeList, next, curPath);
276295
if (FileChunkReference.nil().equals(next)) {
277296
break;
278297
}
298+
if (!seenFragmentOffsets.add(next.stp)) {
299+
throw new TikaException(
300+
"OneNote file node list fragment cycle detected at offset " +
301+
next.stp);
302+
}
279303
localPtr.reposition(next);
280304
ptr = localPtr;
281305
}
@@ -292,8 +316,24 @@ public OneNotePtr internalDeserializeFileNodeList(OneNotePtr ptr, FileNodeList f
292316
*/
293317
public OneNotePtr deserializeFileNodeList(FileNodeList fileNodeList, FileNodePtr curPath)
294318
throws IOException, TikaException {
295-
propertyValueBudget = new PropertyValueBudget();
296-
return internalDeserializeFileNodeList(this, fileNodeList, curPath);
319+
if (fileNodeListRecursion.depth >= MAX_FILE_NODE_LIST_DEPTH) {
320+
throw new TikaMemoryLimitException(
321+
"OneNote file node list nesting exceeds depth limit " +
322+
MAX_FILE_NODE_LIST_DEPTH);
323+
}
324+
long listOffset = offset;
325+
if (!fileNodeListRecursion.activeListOffsets.add(listOffset)) {
326+
throw new TikaException(
327+
"OneNote file node list cycle detected at offset " + listOffset);
328+
}
329+
fileNodeListRecursion.depth++;
330+
try {
331+
propertyValueBudget = new PropertyValueBudget();
332+
return internalDeserializeFileNodeList(this, fileNodeList, curPath);
333+
} finally {
334+
fileNodeListRecursion.depth--;
335+
fileNodeListRecursion.activeListOffsets.remove(listOffset);
336+
}
297337
}
298338

299339
/**

0 commit comments

Comments
 (0)