SAXBasedMetadataExtractorTest.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.parser.microsoft.ooxml;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

import org.junit.jupiter.api.Test;

import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.utils.XMLReaderUtils;

/**
 * Tests for length-cap defenses in {@link SAXBasedMetadataExtractor}.
 * <p>
 * Both caps target attacker-controlled docProps/custom.xml. A 3 KB OOXML
 * carrier whose {@code <vt:decimal>} contains a 1,000,000-digit numeric
 * literal would otherwise burn ~25 s of CPU per file in JDK 17's
 * {@code BigDecimal(String)} (O(n��)).
 */
public class SAXBasedMetadataExtractorTest {

    private static final String CUSTOM_HEADER = "<?xml version=\"1.0\"?>"
            + "<Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument"
            + "/2006/custom-properties\""
            + " xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument"
            + "/2006/docPropsVTypes\">";
    private static final String CUSTOM_FOOTER = "</Properties>";

    @Test
    public void appendCappedTruncatesAtLimit() {
        StringBuilder buf = new StringBuilder();
        char[] giant = new char[SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH + 10_000];
        java.util.Arrays.fill(giant, '9');

        SAXBasedMetadataExtractor.appendCapped(buf, giant, 0, giant.length);
        assertEquals(SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH, buf.length(),
                "buffer must be capped at MAX_TEXT_BUFFER_LENGTH");

        // Further appends after the cap are silent no-ops.
        SAXBasedMetadataExtractor.appendCapped(buf, giant, 0, 100);
        assertEquals(SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH, buf.length(),
                "appends past the cap must be silently dropped");
    }

    @Test
    public void appendCappedRespectsRemainingRoom() {
        StringBuilder buf = new StringBuilder();
        // Pre-fill to one short of the cap; next 3 chars should partially land.
        char[] padding = new char[SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH - 1];
        java.util.Arrays.fill(padding, 'x');
        buf.append(padding);

        SAXBasedMetadataExtractor.appendCapped(buf, new char[]{'a', 'b', 'c'}, 0, 3);
        assertEquals(SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH, buf.length(),
                "remaining room (1 char) must be filled; overflow dropped");
        assertEquals('a', buf.charAt(buf.length() - 1));
    }

    @Test
    public void normalDecimalIsExtracted() throws Exception {
        Metadata m = parseCustomProperties(customProperty("price", "decimal", "1234.56"));
        assertEquals("1234.56", m.get("custom:price"));
    }

    @Test
    public void decimalAtMaxLengthIsAccepted() throws Exception {
        // Boundary: exactly MAX_DECIMAL_LENGTH digits must still parse. This is
        // the upper edge of the accept-and-parse path.
        String digits = "9".repeat(SAXBasedMetadataExtractor.MAX_DECIMAL_LENGTH);
        Metadata m = parseCustomProperties(customProperty("ok", "decimal", digits));
        assertEquals(digits, m.get("custom:ok"),
                "decimal of exactly MAX_DECIMAL_LENGTH digits must round-trip");
    }

    @Test
    public void decimalOneOverMaxLengthIsRejected() throws Exception {
        // Boundary: one character past the cap must short-circuit before
        // BigDecimal(String). Combined with appendCappedTruncatesAtLimit this
        // mechanically proves the O(n��) parser is never invoked above the cap.
        String digits = "9".repeat(SAXBasedMetadataExtractor.MAX_DECIMAL_LENGTH + 1);
        Metadata m = parseCustomProperties(customProperty("evil", "decimal", digits));
        assertNull(m.get("custom:evil"),
                "decimal one char over MAX_DECIMAL_LENGTH must be rejected, not parsed");
    }

    @Test
    public void oversizedDecimalAttackPayloadIsRejected() throws Exception {
        // Reporter's actual attack shape: 1,000,000 digits. The SAX read
        // truncates accumulation at MAX_TEXT_BUFFER_LENGTH (64 KB) via
        // appendCapped, then the decimal-length check rejects the truncated
        // value before BigDecimal(String) runs. No wall-clock assertion ���
        // the boundary tests above are the mechanical proof that
        // BigDecimal is never called on an oversized payload.
        String attackDigits = "9".repeat(1_000_000);
        Metadata m = parseCustomProperties(customProperty("evil", "decimal", attackDigits));
        assertNull(m.get("custom:evil"),
                "1M-digit attack payload must be rejected without parsing");
    }

    @Test
    public void oversizedStringIsTruncatedNotRejected() throws Exception {
        // A large lpwstr isn't a CPU-DoS like decimal, but unbounded text
        // accumulation would still be a memory pressure vector. The buffer
        // cap stops accumulation at 64 KB; the truncated value still flows.
        String giantString = "a".repeat(200_000);
        Metadata m = parseCustomProperties(customProperty("bigstr", "lpwstr", giantString));
        String got = m.get("custom:bigstr");
        assertNotNull(got, "string-typed property survives truncation");
        assertEquals(SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH, got.length(),
                "string value must be capped at MAX_TEXT_BUFFER_LENGTH");
    }

    @Test
    public void stringValuesPreserveLeadingAndTrailingWhitespace() throws Exception {
        // Legacy POI's getLpwstr/getLpstr/getBstr returned the raw element text;
        // anything that depends on whitespace inside the value would regress if
        // the SAX path silently trimmed.
        Metadata m = parseCustomProperties(
                customProperty("padded", "lpwstr", "  hello world  "));
        assertEquals("  hello world  ", m.get("custom:padded"),
                "lpwstr must preserve leading/trailing whitespace");

        m = parseCustomProperties(customProperty("padded", "lpstr", " ascii "));
        assertEquals(" ascii ", m.get("custom:padded"));

        m = parseCustomProperties(customProperty("padded", "bstr", "\tindented\n"));
        assertEquals("\tindented\n", m.get("custom:padded"));
    }

    @Test
    public void boolLexicalOneIsNormalizedToTrue() throws Exception {
        Metadata m = parseCustomProperties(customProperty("flag", "bool", "1"));
        assertEquals("true", m.get("custom:flag"),
                "<vt:bool>1</vt:bool> must normalize to \"true\" (matching legacy POI)");
    }

    @Test
    public void boolLexicalZeroIsNormalizedToFalse() throws Exception {
        Metadata m = parseCustomProperties(customProperty("flag", "bool", "0"));
        assertEquals("false", m.get("custom:flag"),
                "<vt:bool>0</vt:bool> must normalize to \"false\" (matching legacy POI)");
    }

    @Test
    public void boolLexicalTrueAndFalsePassThrough() throws Exception {
        Metadata m = parseCustomProperties(customProperty("flag", "bool", "true"));
        assertEquals("true", m.get("custom:flag"));

        m = parseCustomProperties(customProperty("flag", "bool", "false"));
        assertEquals("false", m.get("custom:flag"));
    }

    @Test
    public void vectorContainingScalarIsNotEmittedAsScalar() throws Exception {
        // <vt:vector> with inner <vt:lpstr> children. The container latches as
        // the value type; inner children must NOT overwrite it, and the
        // container itself must not be emitted as a scalar. Legacy POI skipped
        // vector/array entirely.
        String xml = CUSTOM_HEADER
                + "<property fmtid=\"{DEADBEEF-0000-0000-0000-000000000000}\" pid=\"2\""
                + " name=\"tags\">"
                + "<vt:vector size=\"2\" baseType=\"lpstr\">"
                + "<vt:lpstr>alpha</vt:lpstr>"
                + "<vt:lpstr>beta</vt:lpstr>"
                + "</vt:vector>"
                + "</property>"
                + CUSTOM_FOOTER;
        Metadata m = parseCustomProperties(xml);
        assertNull(m.get("custom:tags"),
                "vector container must not emit a scalar custom property");
    }

    // ===== helpers =====

    private static String customProperty(String name, String type, String value) {
        return CUSTOM_HEADER
                + "<property fmtid=\"{DEADBEEF-0000-0000-0000-000000000000}\" pid=\"2\""
                + " name=\"" + name + "\">"
                + "<vt:" + type + ">" + value + "</vt:" + type + ">"
                + "</property>"
                + CUSTOM_FOOTER;
    }

    private static Metadata parseCustomProperties(String xml) throws Exception {
        SAXBasedMetadataExtractor.CustomPropertiesHandler handler =
                new SAXBasedMetadataExtractor.CustomPropertiesHandler();
        try (InputStream is = new ByteArrayInputStream(
                xml.getBytes(StandardCharsets.UTF_8))) {
            XMLReaderUtils.parseSAX(is, handler, new ParseContext());
        }
        Metadata metadata = new Metadata();
        handler.applyTo(metadata);
        return metadata;
    }
}