EntityPartProviderTest.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.cxf.jaxrs.provider;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.stream.IntStream;

import jakarta.ws.rs.core.EntityPart;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.ext.ContextResolver;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.MessageBodyReader;
import jakarta.ws.rs.ext.MessageBodyWriter;
import jakarta.ws.rs.ext.Providers;
import org.apache.cxf.attachment.AttachmentDeserializer;
import org.apache.cxf.jaxrs.ext.MessageContextImpl;
import org.apache.cxf.jaxrs.impl.MetadataMap;
import org.apache.cxf.message.Exchange;
import org.apache.cxf.message.ExchangeImpl;
import org.apache.cxf.message.Message;
import org.apache.cxf.message.MessageImpl;

import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;

public class EntityPartProviderTest {
    private final Providers providers = new Providers() {
        @Override
        public <T> MessageBodyReader<T> getMessageBodyReader(Class<T> type,
                Type genericType, Annotation[] annotations, MediaType mediaType) {
            return new BinaryDataProvider<>();
        }

        @Override
        public <T> MessageBodyWriter<T> getMessageBodyWriter(Class<T> type,
                Type genericType, Annotation[] annotations, MediaType mediaType) {
            throw new UnsupportedOperationException();
        }

        @Override
        public <T extends Throwable> ExceptionMapper<T> getExceptionMapper(Class<T> type) {
            throw new UnsupportedOperationException();
        }

        @Override
        public <T> ContextResolver<T> getContextResolver(Class<T> contextType, MediaType mediaType) {
            throw new UnsupportedOperationException();
        }
        
    };

    @SuppressWarnings("unchecked")
    @Test
    public void testChangingMaxAttachmentCount() throws Exception {
        final Exchange exchange = new ExchangeImpl();
        final EntityPartProvider p = new EntityPartProvider();

        StringBuilder sb = new StringBuilder(1000);
        sb.append("SomeHeader: foo\n")
            .append("------=_Part_34950_1098328613.1263781527359\n")
            .append("Content-Type: text/xml; charset=UTF-8\n")
            .append("Content-Transfer-Encoding: binary\n")
            .append("Content-Id: <318731183421.1263781527359.IBM.WEBSERVICES@auhpap02>\n")
            .append('\n')
            .append("<envelope/>\n");

        // Add many attachments
        IntStream.range(0, 40).forEach(i -> {
            sb.append("------=_Part_34950_1098328613.1263781527359\n")
                .append("Content-Type: text/xml\n")
                .append("Content-Transfer-Encoding: binary\n")
                .append("Content-Id: <b86a5f2d-e7af-4e5e-b71a-9f6f2307cab0>\n")
                .append('\n')
                .append("<message>\n")
                .append("------=_Part_34950_1098328613.1263781527359--\n");
        });

        // Too many attachments we'll not allow it
        final Message msg = new MessageImpl();
        msg.setExchange(exchange);
        exchange.setInMessage(msg);
        p.setMessageContext(new MessageContextImpl(msg));
        p.setProviders(providers);

        msg.put(AttachmentDeserializer.ATTACHMENT_MAX_COUNT, "30");
        msg.setContent(InputStream.class, new ByteArrayInputStream(sb.toString().getBytes(StandardCharsets.UTF_8)));
        msg.put(Message.CONTENT_TYPE, "multipart/related");

        assertThrows("Failure expected on too many attachments", RuntimeException.class,
                () -> p.readFrom((Class<List<EntityPart>>) (Class<?>) List.class, EntityPart.class, new Annotation[]{},
                    MediaType.APPLICATION_OCTET_STREAM_TYPE,
                    new MetadataMap<String, String>(),
                    msg.getContent(InputStream.class)));

        // Now we'll allow it
        final Message msg2 = new MessageImpl();
        msg2.setExchange(exchange);
        exchange.setInMessage(msg2);
        p.setMessageContext(new MessageContextImpl(msg2));

        msg2.put(AttachmentDeserializer.ATTACHMENT_MAX_COUNT, "60");
        msg2.setContent(InputStream.class, new ByteArrayInputStream(sb.toString().getBytes(StandardCharsets.UTF_8)));
        msg2.put(Message.CONTENT_TYPE, "multipart/related");

        List<EntityPart> body = p.readFrom((Class<List<EntityPart>>) (Class<?>) List.class, 
            EntityPart.class, new Annotation[]{},
            MediaType.APPLICATION_OCTET_STREAM_TYPE,
            new MetadataMap<String, String>(),
            msg2.getContent(InputStream.class));

        // Force it to load the attachments
        assertEquals(41, body.size());
    }
    
    @SuppressWarnings("unchecked")
    @Test
    public void testManyAttachmentHeaders() throws Exception {
        final Exchange exchange = new ExchangeImpl();
        final EntityPartProvider p = new EntityPartProvider();

        StringBuilder sb = new StringBuilder(10000);
        // Add many attachment headers
        sb.append("------=_Part_34950_1098328613.1263781527359\n");
        IntStream.range(0, 1000).forEach(i -> sb.append("Header-").append(i).append(": foo").append(i).append('\n'));
        sb.append("Content-Type: text/xml; charset=UTF-8\n")
            .append("Content-Transfer-Encoding: binary\n")
            .append("Content-Id: <318731183421.1263781527359.IBM.WEBSERVICES@auhpap02>\n")
            .append('\n')
            .append("<envelope/>\n");

        final Message msg = new MessageImpl();
        msg.setExchange(exchange);
        exchange.setInMessage(msg);
        p.setMessageContext(new MessageContextImpl(msg));

        msg.setContent(InputStream.class, new ByteArrayInputStream(sb.toString().getBytes(StandardCharsets.UTF_8)));
        msg.put(Message.CONTENT_TYPE, "multipart/related");

        assertThrows("Failure expected on too many attachment headers", RuntimeException.class,
                () -> p.readFrom((Class<List<EntityPart>>) (Class<?>) List.class,
                    EntityPart.class, new Annotation[]{},
                    MediaType.APPLICATION_OCTET_STREAM_TYPE,
                    new MetadataMap<String, String>(),
                    msg.getContent(InputStream.class)));
    }

    @Test
    public void testFilenameIsCarriedThroughRaw() throws Exception {
        // the EntityPart keeps the filename verbatim so a read-and-forwarded part is not
        // corrupted; sanitising is left to callers via ContentDisposition.getFilenameSanitized()
        final List<EntityPart> parts = readParts("form-data; name=\"file\"; filename=\"../../../etc/passwd\"");
        assertEquals(1, parts.size());
        assertEquals(Optional.of("../../../etc/passwd"), parts.get(0).getFileName());
    }

    @Test
    public void testPlainFilenamePassesThrough() throws Exception {
        final List<EntityPart> parts = readParts("form-data; name=\"file\"; filename=\"report.pdf\"");
        assertEquals(1, parts.size());
        assertEquals(Optional.of("report.pdf"), parts.get(0).getFileName());
    }

    @Test
    public void testTrailingSeparatorStaysAFilePart() throws Exception {
        // a non-empty filename in the header makes this a file part (named by its
        // content id) rather than a plain form field, and the value is kept raw
        final List<EntityPart> parts = readParts("form-data; name=\"file\"; filename=\"reports/\"");
        assertEquals(1, parts.size());
        assertEquals(Optional.of("reports/"), parts.get(0).getFileName());
        assertEquals("file-part", parts.get(0).getName());
    }

    @Test
    public void testEmptyFilenameIsAFormField() throws Exception {
        // browsers send filename="" for an empty HTML file input, so an empty value
        // stays a plain form field named after its part name rather than a file part
        final List<EntityPart> parts = readParts("form-data; name=\"file\"; filename=\"\"");
        assertEquals(1, parts.size());
        assertEquals(Optional.empty(), parts.get(0).getFileName());
        assertEquals("file", parts.get(0).getName());
    }

    @SuppressWarnings("unchecked")
    private List<EntityPart> readParts(final String contentDisposition) throws Exception {
        final Exchange exchange = new ExchangeImpl();
        final EntityPartProvider p = new EntityPartProvider();

        final String boundary = "----=_Part_34950_1098328613.1263781527359";
        final String body = "--" + boundary + "\n"
            + "Content-Type: application/octet-stream\n"
            + "Content-Disposition: " + contentDisposition + "\n"
            + "Content-Id: <file-part>\n"
            + '\n'
            + "some bytes\n"
            + "--" + boundary + "--\n";

        final Message msg = new MessageImpl();
        msg.setExchange(exchange);
        exchange.setInMessage(msg);
        p.setMessageContext(new MessageContextImpl(msg));
        p.setProviders(providers);

        msg.setContent(InputStream.class, new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
        msg.put(Message.CONTENT_TYPE, "multipart/form-data; boundary=\"" + boundary + "\"");

        return p.readFrom((Class<List<EntityPart>>) (Class<?>) List.class, EntityPart.class, new Annotation[]{},
            MediaType.MULTIPART_FORM_DATA_TYPE, new MetadataMap<String, String>(), msg.getContent(InputStream.class));
    }
}