1616 */
1717package org .apache .tika .parser .microsoft .ooxml ;
1818
19+ import java .io .InputStream ;
1920import java .math .BigDecimal ;
2021import java .util .Date ;
2122import java .util .Optional ;
2223
2324import org .apache .poi .ooxml .POIXMLProperties ;
2425import org .apache .poi .ooxml .extractor .POIXMLTextExtractor ;
26+ import org .apache .poi .openxml4j .opc .OPCPackage ;
27+ import org .apache .poi .openxml4j .opc .PackagePart ;
28+ import org .apache .poi .openxml4j .opc .PackageRelationship ;
29+ import org .apache .poi .openxml4j .opc .PackageRelationshipCollection ;
2530import org .apache .poi .openxml4j .opc .internal .PackagePropertiesPart ;
2631import org .apache .poi .xssf .extractor .XSSFEventBasedExcelExtractor ;
2732import org .apache .xmlbeans .impl .values .XmlValueOutOfRangeException ;
28- import org .openxmlformats .schemas .officeDocument .x2006 .customProperties .CTProperty ;
2933import org .openxmlformats .schemas .officeDocument .x2006 .extendedProperties .CTProperties ;
34+ import org .xml .sax .Attributes ;
35+ import org .xml .sax .helpers .DefaultHandler ;
3036
3137import org .apache .tika .exception .TikaException ;
3238import org .apache .tika .metadata .DublinCore ;
3743import org .apache .tika .metadata .PagedText ;
3844import org .apache .tika .metadata .Property ;
3945import org .apache .tika .metadata .TikaCoreProperties ;
46+ import org .apache .tika .parser .ParseContext ;
4047import org .apache .tika .parser .microsoft .SummaryExtractor ;
4148import org .apache .tika .parser .microsoft .ooxml .xps .XPSTextExtractor ;
4249import org .apache .tika .parser .microsoft .ooxml .xslf .XSLFEventBasedPowerPointExtractor ;
4350import org .apache .tika .parser .microsoft .ooxml .xwpf .XWPFEventBasedWordExtractor ;
51+ import org .apache .tika .utils .XMLReaderUtils ;
4452
4553/**
4654 * OOXML metadata extractor.
5159 */
5260public class MetadataExtractor {
5361
62+ private static final String CUSTOM_PROPERTIES_REL =
63+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" ;
64+
65+ /**
66+ * Hard cap on the accumulated text-content of a single property element
67+ * inside docProps/custom.xml. Real OOXML property values are at most a few
68+ * hundred bytes; anything beyond this is either corruption or an attacker
69+ * trying to drive memory or CPU pressure (cf. the {@code <vt:decimal>}
70+ * BigDecimal DoS where a 1M-digit literal compresses ~1000:1 in deflate).
71+ * 64 KB leaves headroom for any legitimate value while bounding the
72+ * slow-path inputs decisively.
73+ */
74+ static final int MAX_TEXT_BUFFER_LENGTH = 64 * 1024 ;
75+
76+ /**
77+ * Hard cap on the {@code <vt:decimal>} text length passed to
78+ * {@link BigDecimal#BigDecimal(String)}. JDK 17's parser is O(n²) in the
79+ * digit count, so even a 64 KB string costs noticeable CPU. Real-world
80+ * decimal values fit in well under 50 digits; 256 is generous.
81+ */
82+ static final int MAX_DECIMAL_LENGTH = 256 ;
83+
5484 private final POIXMLTextExtractor extractor ;
5585
5686 public MetadataExtractor (POIXMLTextExtractor extractor ) {
@@ -65,7 +95,13 @@ public void extract(Metadata metadata) throws TikaException {
6595 extractor instanceof XPSTextExtractor ) && extractor .getPackage () != null )) {
6696 extractMetadata (extractor .getCoreProperties (), metadata );
6797 extractMetadata (extractor .getExtendedProperties (), metadata );
68- extractMetadata (extractor .getCustomProperties (), metadata );
98+ // Custom properties are read via SAX directly from the OPC part
99+ // rather than through POI/XMLBeans. The XMLBeans path materializes
100+ // an attacker-controlled <vt:decimal> through BigDecimal(String),
101+ // which is O(n²) on JDK 17 -- a 3 KB crafted carrier with a
102+ // 1,000,000-digit literal burns ~25 s of CPU before this method
103+ // even returns. See ooxml-bigdecimal-dos.
104+ extractCustomPropertiesViaSAX (extractor .getPackage (), metadata );
69105 }
70106 }
71107
@@ -157,85 +193,181 @@ private String getDocSecurityString(int docSecurityFlag) {
157193 }
158194 }
159195
160- private void extractMetadata (POIXMLProperties .CustomProperties properties , Metadata metadata ) {
161- org .openxmlformats .schemas .officeDocument .x2006 .customProperties .CTProperties props =
162- properties .getUnderlyingProperties ();
163- for (int i = 0 ; i < props .sizeOfPropertyArray (); i ++) {
164- CTProperty property = props .getPropertyArray (i );
165- String val = null ;
166- Date date = null ;
167-
168- if (property .isSetLpwstr ()) {
169- val = property .getLpwstr ();
170- } else if (property .isSetLpstr ()) {
171- val = property .getLpstr ();
172- } else if (property .isSetDate ()) {
173- date = property .getDate ().getTime ();
174- } else if (property .isSetFiletime ()) {
175- date = property .getFiletime ().getTime ();
176- } else if (property .isSetBool ()) {
177- val = Boolean .toString (property .getBool ());
196+ /**
197+ * Parse {@code docProps/custom.xml} directly via SAX, bypassing
198+ * POI/XMLBeans. The XMLBeans path materializes an attacker-controlled
199+ * {@code <vt:decimal>} through {@link BigDecimal#BigDecimal(String)}
200+ * during XML deserialization, which is O(n²) in the digit count on
201+ * JDK 17. By reading the part ourselves we can cap both the buffered
202+ * text content ({@link #MAX_TEXT_BUFFER_LENGTH}) and the decimal
203+ * literal length ({@link #MAX_DECIMAL_LENGTH}) before any slow parse
204+ * runs.
205+ */
206+ private void extractCustomPropertiesViaSAX (OPCPackage opcPackage , Metadata metadata ) {
207+ if (opcPackage == null ) {
208+ return ;
209+ }
210+ try {
211+ PackagePart custPart = getRelatedPart (opcPackage , CUSTOM_PROPERTIES_REL );
212+ if (custPart == null ) {
213+ return ;
214+ }
215+ CustomPropertiesHandler handler = new CustomPropertiesHandler ();
216+ try (InputStream is = custPart .getInputStream ()) {
217+ XMLReaderUtils .parseSAX (is , handler , new ParseContext ());
178218 }
219+ handler .applyTo (metadata );
220+ } catch (Exception e ) {
221+ //swallow
222+ }
223+ }
179224
180- // Integers
181- else if (property .isSetI1 ()) {
182- val = Integer .toString (property .getI1 ());
183- } else if (property .isSetI2 ()) {
184- val = Integer .toString (property .getI2 ());
185- } else if (property .isSetI4 ()) {
186- val = Integer .toString (property .getI4 ());
187- } else if (property .isSetI8 ()) {
188- val = Long .toString (property .getI8 ());
189- } else if (property .isSetInt ()) {
190- val = Integer .toString (property .getInt ());
225+ private static PackagePart getRelatedPart (OPCPackage opcPackage , String relationshipType ) {
226+ try {
227+ PackageRelationshipCollection rels =
228+ opcPackage .getRelationshipsByType (relationshipType );
229+ if (rels == null || rels .size () == 0 ) {
230+ return null ;
191231 }
232+ PackageRelationship rel = rels .getRelationship (0 );
233+ if (rel == null ) {
234+ return null ;
235+ }
236+ return opcPackage .getPart (rel );
237+ } catch (Exception e ) {
238+ return null ;
239+ }
240+ }
192241
193- // Unsigned Integers
194- else if (property .isSetUi1 ()) {
195- val = Integer .toString (property .getUi1 ());
196- } else if (property .isSetUi2 ()) {
197- val = Integer .toString (property .getUi2 ());
198- } else if (property .isSetUi4 ()) {
199- val = Long .toString (property .getUi4 ());
200- } else if (property .isSetUi8 ()) {
201- val = property .getUi8 ().toString ();
202- } else if (property .isSetUint ()) {
203- val = Long .toString (property .getUint ());
242+ /**
243+ * Append SAX {@code characters()} content to {@code buf}, but stop accepting
244+ * once {@link #MAX_TEXT_BUFFER_LENGTH} is reached. Excess characters are
245+ * silently dropped; truncated values still flow through downstream parsing.
246+ */
247+ static void appendCapped (StringBuilder buf , char [] ch , int start , int length ) {
248+ if (buf .length () >= MAX_TEXT_BUFFER_LENGTH ) {
249+ return ;
250+ }
251+ int remaining = MAX_TEXT_BUFFER_LENGTH - buf .length ();
252+ buf .append (ch , start , Math .min (length , remaining ));
253+ }
254+
255+ /**
256+ * SAX handler for {@code docProps/custom.xml} (custom properties).
257+ * Matches the schema defined by Microsoft's
258+ * {@code http://schemas.openxmlformats.org/officeDocument/2006/custom-properties}
259+ * namespace, with value types coming from the {@code vt:} namespace.
260+ */
261+ static class CustomPropertiesHandler extends DefaultHandler {
262+
263+ private static final String VT_NS =
264+ "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes" ;
265+
266+ private final Metadata customMetadata = new Metadata ();
267+ private String currentPropertyName ;
268+ private String currentValueType ;
269+ private final StringBuilder textBuffer = new StringBuilder ();
270+
271+ @ Override
272+ public void startElement (String uri , String localName , String qName , Attributes atts ) {
273+ if ("property" .equals (localName )) {
274+ currentPropertyName = atts .getValue ("name" );
275+ currentValueType = null ;
276+ } else if (VT_NS .equals (uri ) && currentPropertyName != null
277+ && currentValueType == null ) {
278+ // Only the direct vt: child of <property> is captured.
279+ // Containers like <vt:vector>/<vt:array> latch currentValueType
280+ // here and their scalar children are then ignored, matching the
281+ // prior POI/XMLBeans behavior which skipped vectors/arrays.
282+ currentValueType = localName ;
283+ textBuffer .setLength (0 );
204284 }
285+ }
286+
287+ @ Override
288+ public void characters (char [] ch , int start , int length ) {
289+ appendCapped (textBuffer , ch , start , length );
290+ }
205291
206- // Reals
207- else if (property .isSetR4 ()) {
208- val = Float .toString (property .getR4 ());
209- } else if (property .isSetR8 ()) {
210- val = Double .toString (property .getR8 ());
211- } else if (property .isSetDecimal ()) {
212- BigDecimal d = property .getDecimal ();
213- if (d == null ) {
214- val = null ;
215- } else {
216- val = d .toPlainString ();
292+ @ Override
293+ public void endElement (String uri , String localName , String qName ) {
294+ if (VT_NS .equals (uri ) && currentValueType != null &&
295+ localName .equals (currentValueType ) && currentPropertyName != null ) {
296+ String raw = textBuffer .toString ();
297+ String trimmed = raw .trim ();
298+ String propName = "custom:" + currentPropertyName ;
299+ switch (currentValueType ) {
300+ case "lpwstr" :
301+ case "lpstr" :
302+ case "bstr" :
303+ // String values are user-controlled metadata content;
304+ // preserve leading/trailing whitespace as the prior
305+ // POI getLpwstr()/getLpstr() path did.
306+ customMetadata .set (propName , raw );
307+ break ;
308+ case "filetime" :
309+ case "date" :
310+ Property tikaProp = Property .externalDate (propName );
311+ customMetadata .set (tikaProp , trimmed );
312+ break ;
313+ case "bool" :
314+ // xs:boolean lexical space allows "1"/"0" alongside
315+ // "true"/"false"; the prior POI path emitted
316+ // Boolean.toString(...). Preserve that normalization.
317+ if ("1" .equals (trimmed ) || "true" .equalsIgnoreCase (trimmed )) {
318+ customMetadata .set (propName , "true" );
319+ } else if ("0" .equals (trimmed ) || "false" .equalsIgnoreCase (trimmed )) {
320+ customMetadata .set (propName , "false" );
321+ }
322+ break ;
323+ case "i1" :
324+ case "i2" :
325+ case "i4" :
326+ case "int" :
327+ case "ui1" :
328+ case "ui2" :
329+ customMetadata .set (propName , trimmed );
330+ break ;
331+ case "i8" :
332+ case "ui4" :
333+ case "ui8" :
334+ case "uint" :
335+ customMetadata .set (propName , trimmed );
336+ break ;
337+ case "r4" :
338+ case "r8" :
339+ customMetadata .set (propName , trimmed );
340+ break ;
341+ case "decimal" :
342+ // BigDecimal(String) is O(n²) on JDK 17; cap the input
343+ // length to keep an attacker-controlled <vt:decimal>
344+ // from burning CPU. Real values are < 50 chars; 256 is
345+ // generous. See ooxml-bigdecimal-dos.
346+ if (trimmed .length () > MAX_DECIMAL_LENGTH ) {
347+ break ;
348+ }
349+ try {
350+ BigDecimal d = new BigDecimal (trimmed );
351+ customMetadata .set (propName , d .toPlainString ());
352+ } catch (NumberFormatException e ) {
353+ //swallow
354+ }
355+ break ;
356+ default :
357+ break ;
217358 }
218- } else if (property .isSetArray ()) {
219- // TODO Fetch the array values and output
220- } else if (property .isSetVector ()) {
221- // TODO Fetch the vector values and output
222- } else if (property .isSetBlob () || property .isSetOblob ()) {
223- // TODO Decode, if possible
224- } else if (property .isSetStream () || property .isSetOstream () ||
225- property .isSetVstream ()) {
226- // TODO Decode, if possible
227- } else if (property .isSetStorage () || property .isSetOstorage ()) {
228- // TODO Decode, if possible
229- } else {
230- // This type isn't currently supported yet, skip the property
359+ currentValueType = null ;
360+ } else if ("property" .equals (localName )) {
361+ currentPropertyName = null ;
362+ currentValueType = null ;
231363 }
364+ }
232365
233- String propName = "custom:" + property .getName ();
234- if (date != null ) {
235- Property tikaProp = Property .externalDate (propName );
236- metadata .set (tikaProp , date );
237- } else if (val != null ) {
238- metadata .set (propName , val );
366+ void applyTo (Metadata metadata ) {
367+ for (String name : customMetadata .names ()) {
368+ for (String value : customMetadata .getValues (name )) {
369+ metadata .add (name , value );
370+ }
239371 }
240372 }
241373 }
0 commit comments