WireRestrictedParseContextTest.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.serialization;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.HashSet;
import java.util.Set;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.junit.jupiter.api.Test;

import org.apache.tika.config.loader.TikaObjectMapperFactory;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.serialization.serdes.ParseContextDeserializer;

/**
 * A request-supplied (wire) ParseContext may <em>configure</em> a parse but never
 * <em>introduce</em> an executable/IO component (parser, detector, renderer, ...). These tests pin
 * that boundary at the deserializer.
 */
public class WireRestrictedParseContextTest {

    /** Fresh mapper with the restricted ParseContext deserializer (untrusted/wire mode). */
    private ObjectMapper restrictedMapper() {
        ObjectMapper mapper = TikaObjectMapperFactory.createMapper();
        SimpleModule module = new SimpleModule();
        module.addDeserializer(ParseContext.class, new ParseContextDeserializer(true));
        mapper.registerModule(module);
        return mapper;
    }

    @Test
    public void everyContextKeyInterfaceIsClassifiedExactlyOnce() {
        Set<Class<?>> all = ComponentNameResolver.getContextKeyInterfaces();
        Set<Class<?>> allowed = ComponentNameResolver.getWireInstantiableContextKeys();
        Set<Class<?>> blocked = ComponentNameResolver.getWireBlockedContextKeys();
        for (Class<?> c : all) {
            assertTrue(allowed.contains(c) ^ blocked.contains(c),
                    c.getName() + " must be classified as exactly one of wire-allowed or wire-blocked");
        }
        Set<Class<?>> union = new HashSet<>(allowed);
        union.addAll(blocked);
        assertEquals(all, union, "wire-allowed union wire-blocked must equal CONTEXT_KEY_INTERFACES");
    }

    @Test
    public void restrictedRejectsTypedParserInjection() {
        // A blocked component nested under a wrapper key must be refused regardless of the wrapper.
        String json = "{\"typed\":{\"external-parser\":{\"config\":{" +
                "\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]," +
                "\"supportedTypes\":[\"text/plain\"]}}}}";
        Exception e = assertThrows(Exception.class,
                () -> restrictedMapper().readValue(json, ParseContext.class));
        assertTrue(rootMessage(e).contains("may not be supplied via a request parseContext"),
                "expected wire-blocked rejection, got: " + rootMessage(e));
    }

    @Test
    public void restrictedAllowsFlatSelfConfiguringParserConfig() throws Exception {
        // Per-request tuning of an already-loaded self-configuring parser is config, not
        // instantiation: stored as an inert jsonConfig, never bound, so it must be allowed
        // (mirrors the real pdf-parser sortByPosition / OCR-strategy use case).
        String json = "{\"configurable-test-parser\":{\"maxItems\":5}}";
        ParseContext ctx = restrictedMapper().readValue(json, ParseContext.class);
        assertTrue(ctx.hasJsonConfig("configurable-test-parser"));
    }

    @Test
    public void restrictedRejectsNestedParserObject() {
        // A blocked parser nested inside an allowed component's config (compact object form).
        String json = "{\"basic-content-handler-factory\":{\"nested\":{\"external-parser\":{" +
                "\"config\":{\"commandLine\":[\"/bin/sh\",\"-c\",\"echo x\"]}}}}}";
        Exception e = assertThrows(Exception.class,
                () -> restrictedMapper().readValue(json, ParseContext.class));
        assertTrue(rootMessage(e).contains("may not be supplied via a request parseContext"),
                "expected nested wire-blocked rejection, got: " + rootMessage(e));
    }

    @Test
    public void keysOnlyScanLetsInertBareStringReferencesThrough() {
        // A bare-string reference (compact "defaults" form, no config) is deliberately NOT scanned:
        // it cannot carry a commandLine and is never bound-and-invoked. Pins that decision so we
        // don't regress to scanning arbitrary string values (false positives).
        String json = "{\"metadata-filters\":[\"external-parser\"]}";
        assertDoesNotThrow(() -> restrictedMapper().readValue(json, ParseContext.class));
    }

    @Test
    public void restrictedRejectsParserSmuggledInArrayConfig() {
        // Nesting a parser inside an array config (e.g. metadata-filters) must also be refused.
        String json = "{\"metadata-filters\":[{\"external-parser\":{}}]}";
        Exception e = assertThrows(Exception.class,
                () -> restrictedMapper().readValue(json, ParseContext.class));
        assertTrue(rootMessage(e).contains("may not be supplied via a request parseContext"),
                "expected array-nested wire-blocked rejection, got: " + rootMessage(e));
    }

    @Test
    public void arrayElementOfWrongTypeRejectedBeforeConstruction() {
        // A bare-string element whose type doesn't match the array's component interface must be
        // refused at resolution, before it is constructed.
        String json = "{\"metadata-filters\":[\"external-parser\"]}";
        Exception e = assertThrows(Exception.class, () -> {
            ParseContext ctx = restrictedMapper().readValue(json, ParseContext.class);
            ParseContextUtils.resolveAll(ctx, ParseContextUtils.class.getClassLoader());
        });
        assertTrue(rootMessage(e).contains("not assignable to"),
                "expected assignability rejection at resolution, got: " + rootMessage(e));
    }

    @Test
    public void restrictedAllowsSafeConfig() throws Exception {
        // Allowed component (metadata filter) + bounded config DTOs (handler, timeout) must pass.
        String json = "{\"metadata-filters\":[\"mock-upper-case-filter\"]," +
                "\"basic-content-handler-factory\":{\"type\":\"XML\",\"writeLimit\":1000}," +
                "\"timeout-limits\":{\"progressTimeoutMillis\":5000,\"totalTaskTimeoutMillis\":60000}}";
        ParseContext ctx = restrictedMapper().readValue(json, ParseContext.class);
        assertNotNull(ctx);
        assertTrue(ctx.hasJsonConfig("metadata-filters"));
        assertTrue(ctx.hasJsonConfig("timeout-limits"));
    }

    private static String rootMessage(Throwable t) {
        Throwable r = t;
        while (r.getCause() != null && r.getCause() != r) {
            r = r.getCause();
        }
        return String.valueOf(r.getMessage());
    }
}