001/*
002 * Copyright 2002-2020 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.web.servlet.mvc.method.annotation;
018
019import java.io.IOException;
020import java.lang.reflect.Type;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Collections;
024import java.util.HashSet;
025import java.util.List;
026import java.util.Locale;
027import java.util.Set;
028
029import javax.servlet.ServletRequest;
030import javax.servlet.http.HttpServletRequest;
031import javax.servlet.http.HttpServletResponse;
032
033import org.springframework.core.GenericTypeResolver;
034import org.springframework.core.MethodParameter;
035import org.springframework.core.ParameterizedTypeReference;
036import org.springframework.core.ResolvableType;
037import org.springframework.core.io.InputStreamResource;
038import org.springframework.core.io.Resource;
039import org.springframework.core.io.support.ResourceRegion;
040import org.springframework.core.log.LogFormatUtils;
041import org.springframework.http.HttpEntity;
042import org.springframework.http.HttpHeaders;
043import org.springframework.http.HttpOutputMessage;
044import org.springframework.http.HttpRange;
045import org.springframework.http.HttpStatus;
046import org.springframework.http.MediaType;
047import org.springframework.http.MediaTypeFactory;
048import org.springframework.http.converter.GenericHttpMessageConverter;
049import org.springframework.http.converter.HttpMessageConverter;
050import org.springframework.http.converter.HttpMessageNotWritableException;
051import org.springframework.http.server.ServletServerHttpRequest;
052import org.springframework.http.server.ServletServerHttpResponse;
053import org.springframework.lang.Nullable;
054import org.springframework.util.Assert;
055import org.springframework.util.CollectionUtils;
056import org.springframework.util.StringUtils;
057import org.springframework.web.HttpMediaTypeNotAcceptableException;
058import org.springframework.web.accept.ContentNegotiationManager;
059import org.springframework.web.context.request.NativeWebRequest;
060import org.springframework.web.context.request.ServletWebRequest;
061import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
062import org.springframework.web.servlet.HandlerMapping;
063import org.springframework.web.util.UrlPathHelper;
064
065/**
066 * Extends {@link AbstractMessageConverterMethodArgumentResolver} with the ability to handle method
067 * return values by writing to the response with {@link HttpMessageConverter HttpMessageConverters}.
068 *
069 * @author Arjen Poutsma
070 * @author Rossen Stoyanchev
071 * @author Brian Clozel
072 * @author Juergen Hoeller
073 * @since 3.1
074 */
075public abstract class AbstractMessageConverterMethodProcessor extends AbstractMessageConverterMethodArgumentResolver
076                implements HandlerMethodReturnValueHandler {
077
078        /* Extensions associated with the built-in message converters */
079        private static final Set<String> SAFE_EXTENSIONS = new HashSet<>(Arrays.asList(
080                        "txt", "text", "yml", "properties", "csv",
081                        "json", "xml", "atom", "rss",
082                        "png", "jpe", "jpeg", "jpg", "gif", "wbmp", "bmp"));
083
084        private static final Set<String> SAFE_MEDIA_BASE_TYPES = new HashSet<>(
085                        Arrays.asList("audio", "image", "video"));
086
087        private static final List<MediaType> ALL_APPLICATION_MEDIA_TYPES =
088                        Arrays.asList(MediaType.ALL, new MediaType("application"));
089
090        private static final Type RESOURCE_REGION_LIST_TYPE =
091                        new ParameterizedTypeReference<List<ResourceRegion>>() { }.getType();
092
093
094        private final ContentNegotiationManager contentNegotiationManager;
095
096        private final Set<String> safeExtensions = new HashSet<>();
097
098
099        /**
100         * Constructor with list of converters only.
101         */
102        protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> converters) {
103                this(converters, null, null);
104        }
105
106        /**
107         * Constructor with list of converters and ContentNegotiationManager.
108         */
109        protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> converters,
110                        @Nullable ContentNegotiationManager contentNegotiationManager) {
111
112                this(converters, contentNegotiationManager, null);
113        }
114
115        /**
116         * Constructor with list of converters and ContentNegotiationManager as well
117         * as request/response body advice instances.
118         */
119        protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> converters,
120                        @Nullable ContentNegotiationManager manager, @Nullable List<Object> requestResponseBodyAdvice) {
121
122                super(converters, requestResponseBodyAdvice);
123
124                this.contentNegotiationManager = (manager != null ? manager : new ContentNegotiationManager());
125                this.safeExtensions.addAll(this.contentNegotiationManager.getAllFileExtensions());
126                this.safeExtensions.addAll(SAFE_EXTENSIONS);
127        }
128
129
130        /**
131         * Creates a new {@link HttpOutputMessage} from the given {@link NativeWebRequest}.
132         * @param webRequest the web request to create an output message from
133         * @return the output message
134         */
135        protected ServletServerHttpResponse createOutputMessage(NativeWebRequest webRequest) {
136                HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
137                Assert.state(response != null, "No HttpServletResponse");
138                return new ServletServerHttpResponse(response);
139        }
140
141        /**
142         * Writes the given return value to the given web request. Delegates to
143         * {@link #writeWithMessageConverters(Object, MethodParameter, ServletServerHttpRequest, ServletServerHttpResponse)}
144         */
145        protected <T> void writeWithMessageConverters(T value, MethodParameter returnType, NativeWebRequest webRequest)
146                        throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
147
148                ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
149                ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
150                writeWithMessageConverters(value, returnType, inputMessage, outputMessage);
151        }
152
153        /**
154         * Writes the given return type to the given output message.
155         * @param value the value to write to the output message
156         * @param returnType the type of the value
157         * @param inputMessage the input messages. Used to inspect the {@code Accept} header.
158         * @param outputMessage the output message to write to
159         * @throws IOException thrown in case of I/O errors
160         * @throws HttpMediaTypeNotAcceptableException thrown when the conditions indicated
161         * by the {@code Accept} header on the request cannot be met by the message converters
162         * @throws HttpMessageNotWritableException thrown if a given message cannot
163         * be written by a converter, or if the content-type chosen by the server
164         * has no compatible converter.
165         */
166        @SuppressWarnings({"rawtypes", "unchecked"})
167        protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType,
168                        ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)
169                        throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
170
171                Object body;
172                Class<?> valueType;
173                Type targetType;
174
175                if (value instanceof CharSequence) {
176                        body = value.toString();
177                        valueType = String.class;
178                        targetType = String.class;
179                }
180                else {
181                        body = value;
182                        valueType = getReturnValueType(body, returnType);
183                        targetType = GenericTypeResolver.resolveType(getGenericType(returnType), returnType.getContainingClass());
184                }
185
186                if (isResourceType(value, returnType)) {
187                        outputMessage.getHeaders().set(HttpHeaders.ACCEPT_RANGES, "bytes");
188                        if (value != null && inputMessage.getHeaders().getFirst(HttpHeaders.RANGE) != null &&
189                                        outputMessage.getServletResponse().getStatus() == 200) {
190                                Resource resource = (Resource) value;
191                                try {
192                                        List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
193                                        outputMessage.getServletResponse().setStatus(HttpStatus.PARTIAL_CONTENT.value());
194                                        body = HttpRange.toResourceRegions(httpRanges, resource);
195                                        valueType = body.getClass();
196                                        targetType = RESOURCE_REGION_LIST_TYPE;
197                                }
198                                catch (IllegalArgumentException ex) {
199                                        outputMessage.getHeaders().set(HttpHeaders.CONTENT_RANGE, "bytes */" + resource.contentLength());
200                                        outputMessage.getServletResponse().setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value());
201                                }
202                        }
203                }
204
205                MediaType selectedMediaType = null;
206                MediaType contentType = outputMessage.getHeaders().getContentType();
207                boolean isContentTypePreset = contentType != null && contentType.isConcrete();
208                if (isContentTypePreset) {
209                        if (logger.isDebugEnabled()) {
210                                logger.debug("Found 'Content-Type:" + contentType + "' in response");
211                        }
212                        selectedMediaType = contentType;
213                }
214                else {
215                        HttpServletRequest request = inputMessage.getServletRequest();
216                        List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
217                        List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
218
219                        if (body != null && producibleTypes.isEmpty()) {
220                                throw new HttpMessageNotWritableException(
221                                                "No converter found for return value of type: " + valueType);
222                        }
223                        List<MediaType> mediaTypesToUse = new ArrayList<>();
224                        for (MediaType requestedType : acceptableTypes) {
225                                for (MediaType producibleType : producibleTypes) {
226                                        if (requestedType.isCompatibleWith(producibleType)) {
227                                                mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
228                                        }
229                                }
230                        }
231                        if (mediaTypesToUse.isEmpty()) {
232                                if (body != null) {
233                                        throw new HttpMediaTypeNotAcceptableException(producibleTypes);
234                                }
235                                if (logger.isDebugEnabled()) {
236                                        logger.debug("No match for " + acceptableTypes + ", supported: " + producibleTypes);
237                                }
238                                return;
239                        }
240
241                        MediaType.sortBySpecificityAndQuality(mediaTypesToUse);
242
243                        for (MediaType mediaType : mediaTypesToUse) {
244                                if (mediaType.isConcrete()) {
245                                        selectedMediaType = mediaType;
246                                        break;
247                                }
248                                else if (mediaType.isPresentIn(ALL_APPLICATION_MEDIA_TYPES)) {
249                                        selectedMediaType = MediaType.APPLICATION_OCTET_STREAM;
250                                        break;
251                                }
252                        }
253
254                        if (logger.isDebugEnabled()) {
255                                logger.debug("Using '" + selectedMediaType + "', given " +
256                                                acceptableTypes + " and supported " + producibleTypes);
257                        }
258                }
259
260                if (selectedMediaType != null) {
261                        selectedMediaType = selectedMediaType.removeQualityValue();
262                        for (HttpMessageConverter<?> converter : this.messageConverters) {
263                                GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ?
264                                                (GenericHttpMessageConverter<?>) converter : null);
265                                if (genericConverter != null ?
266                                                ((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) :
267                                                converter.canWrite(valueType, selectedMediaType)) {
268                                        body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType,
269                                                        (Class<? extends HttpMessageConverter<?>>) converter.getClass(),
270                                                        inputMessage, outputMessage);
271                                        if (body != null) {
272                                                Object theBody = body;
273                                                LogFormatUtils.traceDebug(logger, traceOn ->
274                                                                "Writing [" + LogFormatUtils.formatValue(theBody, !traceOn) + "]");
275                                                addContentDispositionHeader(inputMessage, outputMessage);
276                                                if (genericConverter != null) {
277                                                        genericConverter.write(body, targetType, selectedMediaType, outputMessage);
278                                                }
279                                                else {
280                                                        ((HttpMessageConverter) converter).write(body, selectedMediaType, outputMessage);
281                                                }
282                                        }
283                                        else {
284                                                if (logger.isDebugEnabled()) {
285                                                        logger.debug("Nothing to write: null body");
286                                                }
287                                        }
288                                        return;
289                                }
290                        }
291                }
292
293                if (body != null) {
294                        Set<MediaType> producibleMediaTypes =
295                                        (Set<MediaType>) inputMessage.getServletRequest()
296                                                        .getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
297
298                        if (isContentTypePreset || !CollectionUtils.isEmpty(producibleMediaTypes)) {
299                                throw new HttpMessageNotWritableException(
300                                                "No converter for [" + valueType + "] with preset Content-Type '" + contentType + "'");
301                        }
302                        throw new HttpMediaTypeNotAcceptableException(this.allSupportedMediaTypes);
303                }
304        }
305
306        /**
307         * Return the type of the value to be written to the response. Typically this is
308         * a simple check via getClass on the value but if the value is null, then the
309         * return type needs to be examined possibly including generic type determination
310         * (e.g. {@code ResponseEntity<T>}).
311         */
312        protected Class<?> getReturnValueType(@Nullable Object value, MethodParameter returnType) {
313                return (value != null ? value.getClass() : returnType.getParameterType());
314        }
315
316        /**
317         * Return whether the returned value or the declared return type extends {@link Resource}.
318         */
319        protected boolean isResourceType(@Nullable Object value, MethodParameter returnType) {
320                Class<?> clazz = getReturnValueType(value, returnType);
321                return clazz != InputStreamResource.class && Resource.class.isAssignableFrom(clazz);
322        }
323
324        /**
325         * Return the generic type of the {@code returnType} (or of the nested type
326         * if it is an {@link HttpEntity}).
327         */
328        private Type getGenericType(MethodParameter returnType) {
329                if (HttpEntity.class.isAssignableFrom(returnType.getParameterType())) {
330                        return ResolvableType.forType(returnType.getGenericParameterType()).getGeneric().getType();
331                }
332                else {
333                        return returnType.getGenericParameterType();
334                }
335        }
336
337        /**
338         * Returns the media types that can be produced.
339         * @see #getProducibleMediaTypes(HttpServletRequest, Class, Type)
340         */
341        @SuppressWarnings("unused")
342        protected List<MediaType> getProducibleMediaTypes(HttpServletRequest request, Class<?> valueClass) {
343                return getProducibleMediaTypes(request, valueClass, null);
344        }
345
346        /**
347         * Returns the media types that can be produced. The resulting media types are:
348         * <ul>
349         * <li>The producible media types specified in the request mappings, or
350         * <li>Media types of configured converters that can write the specific return value, or
351         * <li>{@link MediaType#ALL}
352         * </ul>
353         * @since 4.2
354         */
355        @SuppressWarnings("unchecked")
356        protected List<MediaType> getProducibleMediaTypes(
357                        HttpServletRequest request, Class<?> valueClass, @Nullable Type targetType) {
358
359                Set<MediaType> mediaTypes =
360                                (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
361                if (!CollectionUtils.isEmpty(mediaTypes)) {
362                        return new ArrayList<>(mediaTypes);
363                }
364                else if (!this.allSupportedMediaTypes.isEmpty()) {
365                        List<MediaType> result = new ArrayList<>();
366                        for (HttpMessageConverter<?> converter : this.messageConverters) {
367                                if (converter instanceof GenericHttpMessageConverter && targetType != null) {
368                                        if (((GenericHttpMessageConverter<?>) converter).canWrite(targetType, valueClass, null)) {
369                                                result.addAll(converter.getSupportedMediaTypes());
370                                        }
371                                }
372                                else if (converter.canWrite(valueClass, null)) {
373                                        result.addAll(converter.getSupportedMediaTypes());
374                                }
375                        }
376                        return result;
377                }
378                else {
379                        return Collections.singletonList(MediaType.ALL);
380                }
381        }
382
383        private List<MediaType> getAcceptableMediaTypes(HttpServletRequest request)
384                        throws HttpMediaTypeNotAcceptableException {
385
386                return this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request));
387        }
388
389        /**
390         * Return the more specific of the acceptable and the producible media types
391         * with the q-value of the former.
392         */
393        private MediaType getMostSpecificMediaType(MediaType acceptType, MediaType produceType) {
394                MediaType produceTypeToUse = produceType.copyQualityValue(acceptType);
395                return (MediaType.SPECIFICITY_COMPARATOR.compare(acceptType, produceTypeToUse) <= 0 ? acceptType : produceTypeToUse);
396        }
397
398        /**
399         * Check if the path has a file extension and whether the extension is either
400         * on the list of {@link #SAFE_EXTENSIONS safe extensions} or explicitly
401         * {@link ContentNegotiationManager#getAllFileExtensions() registered}.
402         * If not, and the status is in the 2xx range, a 'Content-Disposition'
403         * header with a safe attachment file name ("f.txt") is added to prevent
404         * RFD exploits.
405         */
406        private void addContentDispositionHeader(ServletServerHttpRequest request, ServletServerHttpResponse response) {
407                HttpHeaders headers = response.getHeaders();
408                if (headers.containsKey(HttpHeaders.CONTENT_DISPOSITION)) {
409                        return;
410                }
411
412                try {
413                        int status = response.getServletResponse().getStatus();
414                        if (status < 200 || (status > 299 && status < 400)) {
415                                return;
416                        }
417                }
418                catch (Throwable ex) {
419                        // ignore
420                }
421
422                HttpServletRequest servletRequest = request.getServletRequest();
423                String requestUri = UrlPathHelper.rawPathInstance.getOriginatingRequestUri(servletRequest);
424
425                int index = requestUri.lastIndexOf('/') + 1;
426                String filename = requestUri.substring(index);
427                String pathParams = "";
428
429                index = filename.indexOf(';');
430                if (index != -1) {
431                        pathParams = filename.substring(index);
432                        filename = filename.substring(0, index);
433                }
434
435                filename = UrlPathHelper.defaultInstance.decodeRequestString(servletRequest, filename);
436                String ext = StringUtils.getFilenameExtension(filename);
437
438                pathParams = UrlPathHelper.defaultInstance.decodeRequestString(servletRequest, pathParams);
439                String extInPathParams = StringUtils.getFilenameExtension(pathParams);
440
441                if (!safeExtension(servletRequest, ext) || !safeExtension(servletRequest, extInPathParams)) {
442                        headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline;filename=f.txt");
443                }
444        }
445
446        @SuppressWarnings("unchecked")
447        private boolean safeExtension(HttpServletRequest request, @Nullable String extension) {
448                if (!StringUtils.hasText(extension)) {
449                        return true;
450                }
451                extension = extension.toLowerCase(Locale.ENGLISH);
452                if (this.safeExtensions.contains(extension)) {
453                        return true;
454                }
455                String pattern = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
456                if (pattern != null && pattern.endsWith("." + extension)) {
457                        return true;
458                }
459                if (extension.equals("html")) {
460                        String name = HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE;
461                        Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(name);
462                        if (!CollectionUtils.isEmpty(mediaTypes) && mediaTypes.contains(MediaType.TEXT_HTML)) {
463                                return true;
464                        }
465                }
466                MediaType mediaType = resolveMediaType(request, extension);
467                return (mediaType != null && (safeMediaType(mediaType)));
468        }
469
470        @Nullable
471        private MediaType resolveMediaType(ServletRequest request, String extension) {
472                MediaType result = null;
473                String rawMimeType = request.getServletContext().getMimeType("file." + extension);
474                if (StringUtils.hasText(rawMimeType)) {
475                        result = MediaType.parseMediaType(rawMimeType);
476                }
477                if (result == null || MediaType.APPLICATION_OCTET_STREAM.equals(result)) {
478                        result = MediaTypeFactory.getMediaType("file." + extension).orElse(null);
479                }
480                return result;
481        }
482
483        private boolean safeMediaType(MediaType mediaType) {
484                return (SAFE_MEDIA_BASE_TYPES.contains(mediaType.getType()) ||
485                                mediaType.getSubtype().endsWith("+xml"));
486        }
487
488}