OperatorOnlyComponentTest.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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import org.apache.tika.config.JsonConfig;
import org.apache.tika.metadata.filter.CompositeMetadataFilter;
import org.apache.tika.metadata.filter.MetadataFilter;
import org.apache.tika.metadata.filter.NestingTestFilter;
import org.apache.tika.metadata.filter.OperatorOnlyClassTestFilter;
import org.apache.tika.metadata.filter.OperatorOnlyJsonConfigTestFilter;
import org.apache.tika.metadata.filter.OperatorOnlySetterTestFilter;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.serialization.serdes.ParseContextDeserializer;

/**
 * Per-request JSON may not set an {@code @OperatorOnly} property or name an
 * {@code @OperatorOnly} class, however the component is reached: a {@code metadata-filters}
 * entry, a top-level entry, a nested property, or a bare-string reference.
 */
public class OperatorOnlyComponentTest {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    private static ParseContext resolve(String json, boolean trusted) throws Exception {
        ParseContext context = ParseContextDeserializer.readParseContext(MAPPER.readTree(json), !trusted);
        ParseContextUtils.resolveAll(context, OperatorOnlyComponentTest.class.getClassLoader());
        return context;
    }

    /** The reason must reach the top-level message: that is all a server client sees. */
    private static void assertRefused(String json) {
        Exception e = assertThrows(Exception.class, () -> resolve(json, false), json);
        String message = e.getMessage();
        assertTrue(message.contains("Cannot modify") || message.contains("may not be supplied via a request"),
                json + ": expected an operator-only refusal, got: " + messages(e));
    }

    private static String messages(Throwable t) {
        StringBuilder sb = new StringBuilder();
        for (; t != null; t = t.getCause()) {
            sb.append(t.getMessage()).append(" | ");
        }
        return sb.toString();
    }

    private static MetadataFilter onlyFilter(ParseContext context) {
        MetadataFilter filter = context.get(MetadataFilter.class);
        if (filter instanceof CompositeMetadataFilter composite) {
            assertEquals(1, composite.getFilters().size());
            return composite.getFilters().get(0);
        }
        return filter;
    }

    @ParameterizedTest
    @ValueSource(strings = {
            "{\"metadata-filters\":[{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}]}",
            "{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}",
            "{\"metadata-filters\":[{\"operator-only-json-config-test-filter\":{\"endpoint\":\"http://x\"}}]}",
            "{\"metadata-filters\":[\"operator-only-class-test-filter\"]}",
            "{\"metadata-filters\":[{\"operator-only-class-test-filter\":{\"label\":\"a\"}}]}",
            "{\"operator-only-class-test-filter\":{\"label\":\"a\"}}",
            "{\"metadata-filters\":[{\"nesting-test-filter\":{\"delegate\":"
                    + "{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}}}]}",
            "{\"metadata-filters\":[{\"nesting-test-filter\":{\"delegate\":\"operator-only-class-test-filter\"}}]}",
            "{\"metadata-filters\":[{\"nesting-test-filter\":{\"parser\":\"external-parser\"}}]}",
            "{\"metadata-filters\":[{\"operator-only-json-config-test-filter\":"
                    + "{\"delegate\":\"operator-only-class-test-filter\"}}]}"
    })
    public void untrustedIsRefused(String json) {
        assertRefused(json);
    }

    /** A self-configuring component's runtime config with a component-typed property. */
    public static class ComponentHoldingConfig {
        private MetadataFilter delegate;

        public MetadataFilter getDelegate() {
            return delegate;
        }

        public void setDelegate(MetadataFilter delegate) {
            this.delegate = delegate;
        }
    }

    private static ComponentHoldingConfig runtimeConfig(String json, boolean trusted,
                                                        ComponentHoldingConfig defaults) throws IOException {
        ParseContext context = new ParseContext();
        context.setJsonConfig("holding-parser", json, trusted);
        return ConfigDeserializer.getConfig(context, "holding-parser", ComponentHoldingConfig.class, defaults);
    }

    @ParameterizedTest
    @ValueSource(strings = {
            "{\"delegate\":\"operator-only-class-test-filter\"}",
            "{\"delegate\":{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}}"
    })
    public void runtimeConfigCarriesTrustIntoNestedComponents(String json) throws Exception {
        for (ComponentHoldingConfig defaults : new ComponentHoldingConfig[]{null, new ComponentHoldingConfig()}) {
            IOException e = assertThrows(IOException.class, () -> runtimeConfig(json, false, defaults), json);
            assertTrue(messages(e).contains("Cannot modify") || messages(e).contains("may not be supplied"),
                    messages(e));
            assertDoesNotThrow(() -> runtimeConfig(json, true, defaults), json);
        }
        assertTrue(runtimeConfig("{\"delegate\":\"operator-only-class-test-filter\"}", true, null)
                .getDelegate() instanceof OperatorOnlyClassTestFilter);
    }

    @Test
    public void untrustedMaySetOrdinaryProperties() throws Exception {
        ParseContext context = resolve(
                "{\"metadata-filters\":[{\"operator-only-setter-test-filter\":{\"label\":\"a\"}}]}", false);
        assertEquals("a", ((OperatorOnlySetterTestFilter) onlyFilter(context)).getLabel());
        assertDoesNotThrow(() -> resolve("{\"metadata-filters\":[{\"nesting-test-filter\":{\"delegate\":"
                + "{\"operator-only-setter-test-filter\":{\"label\":\"a\"}}}}]}", false));
    }

    @Test
    public void javaCodeDeclaresTrust() throws Exception {
        String json = "{\"endpoint\":\"http://x\"}";
        RuntimeException e = assertThrows(RuntimeException.class,
                () -> new OperatorOnlyJsonConfigTestFilter(() -> json));
        assertTrue(e.getMessage().contains("OperatorOnlyJsonConfigTestFilter$Config"), e.getMessage());
        assertTrue(e.getMessage().contains("JsonConfig.trusted(json)"), e.getMessage());
        assertEquals("http://x", new OperatorOnlyJsonConfigTestFilter(JsonConfig.trusted(json)).getEndpoint());
    }

    @Test
    public void trustedMaySetEverything() throws Exception {
        ParseContext context = resolve(
                "{\"metadata-filters\":[{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}]}", true);
        assertEquals("http://x", ((OperatorOnlySetterTestFilter) onlyFilter(context)).getEndpoint());

        context = resolve("{\"metadata-filters\":"
                + "[{\"operator-only-json-config-test-filter\":{\"endpoint\":\"http://x\"}}]}", true);
        assertEquals("http://x", ((OperatorOnlyJsonConfigTestFilter) onlyFilter(context)).getEndpoint());

        assertDoesNotThrow(() -> resolve("{\"metadata-filters\":[\"operator-only-class-test-filter\"]}", true));

        context = resolve("{\"metadata-filters\":[{\"nesting-test-filter\":{\"delegate\":"
                + "{\"operator-only-setter-test-filter\":{\"endpoint\":\"http://x\"}}}}]}", true);
        NestingTestFilter nesting = (NestingTestFilter) onlyFilter(context);
        assertEquals("http://x", ((OperatorOnlySetterTestFilter) nesting.getDelegate()).getEndpoint());
    }
}