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.LinkedHashSet;
026import java.util.List;
027import java.util.Locale;
028import java.util.Set;
029import javax.servlet.http.HttpServletRequest;
030import javax.servlet.http.HttpServletResponse;
031
032import org.springframework.core.MethodParameter;
033import org.springframework.core.ResolvableType;
034import org.springframework.http.HttpEntity;
035import org.springframework.http.HttpHeaders;
036import org.springframework.http.HttpOutputMessage;
037import org.springframework.http.MediaType;
038import org.springframework.http.converter.GenericHttpMessageConverter;
039import org.springframework.http.converter.HttpMessageConverter;
040import org.springframework.http.converter.HttpMessageNotWritableException;
041import org.springframework.http.server.ServletServerHttpRequest;
042import org.springframework.http.server.ServletServerHttpResponse;
043import org.springframework.util.CollectionUtils;
044import org.springframework.util.StringUtils;
045import org.springframework.web.HttpMediaTypeNotAcceptableException;
046import org.springframework.web.accept.ContentNegotiationManager;
047import org.springframework.web.accept.PathExtensionContentNegotiationStrategy;
048import org.springframework.web.context.request.NativeWebRequest;
049import org.springframework.web.context.request.ServletWebRequest;
050import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
051import org.springframework.web.servlet.HandlerMapping;
052import org.springframework.web.util.UrlPathHelper;
053
054/**
055 * Extends {@link AbstractMessageConverterMethodArgumentResolver} with the ability to handle
056 * method return values by writing to the response with {@link HttpMessageConverter}s.
057 *
058 * @author Arjen Poutsma
059 * @author Rossen Stoyanchev
060 * @since 3.1
061 */
062public abstract class AbstractMessageConverterMethodProcessor extends AbstractMessageConverterMethodArgumentResolver
063                implements HandlerMethodReturnValueHandler {
064
065        /* Extensions associated with the built-in message converters */
066        private static final Set<String> WHITELISTED_EXTENSIONS = new HashSet<String>(Arrays.asList(
067                        "txt", "text", "yml", "properties", "csv",
068                        "json", "xml", "atom", "rss",
069                        "png", "jpe", "jpeg", "jpg", "gif", "wbmp", "bmp"));
070
071        private static final Set<String> WHITELISTED_MEDIA_BASE_TYPES = new HashSet<String>(
072                        Arrays.asList("audio", "image", "video"));
073
074        private static final MediaType MEDIA_TYPE_APPLICATION = new MediaType("application");
075
076        private static final UrlPathHelper DECODING_URL_PATH_HELPER = new UrlPathHelper();
077
078
079        private final ContentNegotiationManager contentNegotiationManager;
080
081        private final PathExtensionContentNegotiationStrategy pathStrategy;
082
083        private final Set<String> safeExtensions = new HashSet<String>();
084
085
086        /**
087         * Constructor with list of converters only.
088         */
089        protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> converters) {
090                this(converters, null);
091        }
092
093        /**
094         * Constructor with list of converters and ContentNegotiationManager.
095         */
096        protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> converters,
097                        ContentNegotiationManager contentNegotiationManager) {
098
099                this(converters, contentNegotiationManager, null);
100        }
101
102        /**
103         * Constructor with list of converters and ContentNegotiationManager as well
104         * as request/response body advice instances.
105         */
106        protected AbstractMessageConverterMethodProcessor(List<HttpMessageConverter<?>> converters,
107                        ContentNegotiationManager manager, List<Object> requestResponseBodyAdvice) {
108
109                super(converters, requestResponseBodyAdvice);
110                this.contentNegotiationManager = (manager != null ? manager : new ContentNegotiationManager());
111                this.pathStrategy = initPathStrategy(this.contentNegotiationManager);
112                this.safeExtensions.addAll(this.contentNegotiationManager.getAllFileExtensions());
113                this.safeExtensions.addAll(WHITELISTED_EXTENSIONS);
114        }
115
116        private static PathExtensionContentNegotiationStrategy initPathStrategy(ContentNegotiationManager manager) {
117                Class<PathExtensionContentNegotiationStrategy> clazz = PathExtensionContentNegotiationStrategy.class;
118                PathExtensionContentNegotiationStrategy strategy = manager.getStrategy(clazz);
119                return (strategy != null ? strategy : new PathExtensionContentNegotiationStrategy());
120        }
121
122
123        /**
124         * Creates a new {@link HttpOutputMessage} from the given {@link NativeWebRequest}.
125         * @param webRequest the web request to create an output message from
126         * @return the output message
127         */
128        protected ServletServerHttpResponse createOutputMessage(NativeWebRequest webRequest) {
129                HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
130                return new ServletServerHttpResponse(response);
131        }
132
133        /**
134         * Writes the given return value to the given web request. Delegates to
135         * {@link #writeWithMessageConverters(Object, MethodParameter, ServletServerHttpRequest, ServletServerHttpResponse)}
136         */
137        protected <T> void writeWithMessageConverters(T value, MethodParameter returnType, NativeWebRequest webRequest)
138                        throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
139
140                ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
141                ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
142                writeWithMessageConverters(value, returnType, inputMessage, outputMessage);
143        }
144
145        /**
146         * Writes the given return type to the given output message.
147         * @param value the value to write to the output message
148         * @param returnType the type of the value
149         * @param inputMessage the input messages. Used to inspect the {@code Accept} header.
150         * @param outputMessage the output message to write to
151         * @throws IOException thrown in case of I/O errors
152         * @throws HttpMediaTypeNotAcceptableException thrown when the conditions indicated
153         * by the {@code Accept} header on the request cannot be met by the message converters
154         */
155        @SuppressWarnings("unchecked")
156        protected <T> void writeWithMessageConverters(T value, MethodParameter returnType,
157                        ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)
158                        throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
159
160                Object outputValue;
161                Class<?> valueType;
162                Type declaredType;
163
164                if (value instanceof CharSequence) {
165                        outputValue = value.toString();
166                        valueType = String.class;
167                        declaredType = String.class;
168                }
169                else {
170                        outputValue = value;
171                        valueType = getReturnValueType(outputValue, returnType);
172                        declaredType = getGenericType(returnType);
173                }
174
175                HttpServletRequest request = inputMessage.getServletRequest();
176                List<MediaType> requestedMediaTypes = getAcceptableMediaTypes(request);
177                List<MediaType> producibleMediaTypes = getProducibleMediaTypes(request, valueType, declaredType);
178
179                if (outputValue != null && producibleMediaTypes.isEmpty()) {
180                        throw new IllegalArgumentException("No converter found for return value of type: " + valueType);
181                }
182
183                Set<MediaType> compatibleMediaTypes = new LinkedHashSet<MediaType>();
184                for (MediaType requestedType : requestedMediaTypes) {
185                        for (MediaType producibleType : producibleMediaTypes) {
186                                if (requestedType.isCompatibleWith(producibleType)) {
187                                        compatibleMediaTypes.add(getMostSpecificMediaType(requestedType, producibleType));
188                                }
189                        }
190                }
191                if (compatibleMediaTypes.isEmpty()) {
192                        if (outputValue != null) {
193                                throw new HttpMediaTypeNotAcceptableException(producibleMediaTypes);
194                        }
195                        return;
196                }
197
198                List<MediaType> mediaTypes = new ArrayList<MediaType>(compatibleMediaTypes);
199                MediaType.sortBySpecificityAndQuality(mediaTypes);
200
201                MediaType selectedMediaType = null;
202                for (MediaType mediaType : mediaTypes) {
203                        if (mediaType.isConcrete()) {
204                                selectedMediaType = mediaType;
205                                break;
206                        }
207                        else if (mediaType.equals(MediaType.ALL) || mediaType.equals(MEDIA_TYPE_APPLICATION)) {
208                                selectedMediaType = MediaType.APPLICATION_OCTET_STREAM;
209                                break;
210                        }
211                }
212
213                if (selectedMediaType != null) {
214                        selectedMediaType = selectedMediaType.removeQualityValue();
215                        for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
216                                if (messageConverter instanceof GenericHttpMessageConverter) {
217                                        if (((GenericHttpMessageConverter) messageConverter).canWrite(
218                                                        declaredType, valueType, selectedMediaType)) {
219                                                outputValue = (T) getAdvice().beforeBodyWrite(outputValue, returnType, selectedMediaType,
220                                                                (Class<? extends HttpMessageConverter<?>>) messageConverter.getClass(),
221                                                                inputMessage, outputMessage);
222                                                if (outputValue != null) {
223                                                        addContentDispositionHeader(inputMessage, outputMessage);
224                                                        ((GenericHttpMessageConverter) messageConverter).write(
225                                                                        outputValue, declaredType, selectedMediaType, outputMessage);
226                                                        if (logger.isDebugEnabled()) {
227                                                                logger.debug("Written [" + outputValue + "] as \"" + selectedMediaType +
228                                                                                "\" using [" + messageConverter + "]");
229                                                        }
230                                                }
231                                                return;
232                                        }
233                                }
234                                else if (messageConverter.canWrite(valueType, selectedMediaType)) {
235                                        outputValue = (T) getAdvice().beforeBodyWrite(outputValue, returnType, selectedMediaType,
236                                                        (Class<? extends HttpMessageConverter<?>>) messageConverter.getClass(),
237                                                        inputMessage, outputMessage);
238                                        if (outputValue != null) {
239                                                addContentDispositionHeader(inputMessage, outputMessage);
240                                                ((HttpMessageConverter) messageConverter).write(outputValue, selectedMediaType, outputMessage);
241                                                if (logger.isDebugEnabled()) {
242                                                        logger.debug("Written [" + outputValue + "] as \"" + selectedMediaType +
243                                                                        "\" using [" + messageConverter + "]");
244                                                }
245                                        }
246                                        return;
247                                }
248                        }
249                }
250
251                if (outputValue != null) {
252                        throw new HttpMediaTypeNotAcceptableException(this.allSupportedMediaTypes);
253                }
254        }
255
256        /**
257         * Return the type of the value to be written to the response. Typically this is
258         * a simple check via getClass on the value but if the value is null, then the
259         * return type needs to be examined possibly including generic type determination
260         * (e.g. {@code ResponseEntity<T>}).
261         */
262        protected Class<?> getReturnValueType(Object value, MethodParameter returnType) {
263                return (value != null ? value.getClass() : returnType.getParameterType());
264        }
265
266        /**
267         * Return the generic type of the {@code returnType} (or of the nested type
268         * if it is an {@link HttpEntity}).
269         */
270        private Type getGenericType(MethodParameter returnType) {
271                if (HttpEntity.class.isAssignableFrom(returnType.getParameterType())) {
272                        return ResolvableType.forType(returnType.getGenericParameterType()).getGeneric(0).getType();
273                }
274                else {
275                        return returnType.getGenericParameterType();
276                }
277        }
278
279        /**
280         * @see #getProducibleMediaTypes(HttpServletRequest, Class, Type)
281         */
282        @SuppressWarnings({"unchecked", "unused"})
283        protected List<MediaType> getProducibleMediaTypes(HttpServletRequest request, Class<?> valueClass) {
284                return getProducibleMediaTypes(request, valueClass, null);
285        }
286
287        /**
288         * Returns the media types that can be produced:
289         * <ul>
290         * <li>The producible media types specified in the request mappings, or
291         * <li>Media types of configured converters that can write the specific return value, or
292         * <li>{@link MediaType#ALL}
293         * </ul>
294         * @since 4.2
295         */
296        @SuppressWarnings("unchecked")
297        protected List<MediaType> getProducibleMediaTypes(HttpServletRequest request, Class<?> valueClass, Type declaredType) {
298                Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
299                if (!CollectionUtils.isEmpty(mediaTypes)) {
300                        return new ArrayList<MediaType>(mediaTypes);
301                }
302                else if (!this.allSupportedMediaTypes.isEmpty()) {
303                        List<MediaType> result = new ArrayList<MediaType>();
304                        for (HttpMessageConverter<?> converter : this.messageConverters) {
305                                if (converter instanceof GenericHttpMessageConverter && declaredType != null) {
306                                        if (((GenericHttpMessageConverter<?>) converter).canWrite(declaredType, valueClass, null)) {
307                                                result.addAll(converter.getSupportedMediaTypes());
308                                        }
309                                }
310                                else if (converter.canWrite(valueClass, null)) {
311                                        result.addAll(converter.getSupportedMediaTypes());
312                                }
313                        }
314                        return result;
315                }
316                else {
317                        return Collections.singletonList(MediaType.ALL);
318                }
319        }
320
321        private List<MediaType> getAcceptableMediaTypes(HttpServletRequest request) throws HttpMediaTypeNotAcceptableException {
322                List<MediaType> mediaTypes = this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request));
323                return (mediaTypes.isEmpty() ? Collections.singletonList(MediaType.ALL) : mediaTypes);
324        }
325
326        /**
327         * Return the more specific of the acceptable and the producible media types
328         * with the q-value of the former.
329         */
330        private MediaType getMostSpecificMediaType(MediaType acceptType, MediaType produceType) {
331                MediaType produceTypeToUse = produceType.copyQualityValue(acceptType);
332                return (MediaType.SPECIFICITY_COMPARATOR.compare(acceptType, produceTypeToUse) <= 0 ? acceptType : produceTypeToUse);
333        }
334
335        /**
336         * Check if the path has a file extension and whether the extension is
337         * either {@link #WHITELISTED_EXTENSIONS whitelisted} or explicitly
338         * {@link ContentNegotiationManager#getAllFileExtensions() registered}.
339         * If not, and the status is in the 2xx range, a 'Content-Disposition'
340         * header with a safe attachment file name ("f.txt") is added to prevent
341         * RFD exploits.
342         */
343        private void addContentDispositionHeader(ServletServerHttpRequest request, ServletServerHttpResponse response) {
344                HttpHeaders headers = response.getHeaders();
345                if (headers.containsKey(HttpHeaders.CONTENT_DISPOSITION)) {
346                        return;
347                }
348
349                try {
350                        int status = response.getServletResponse().getStatus();
351                        if (status < 200 || status > 299) {
352                                return;
353                        }
354                }
355                catch (Throwable ex) {
356                        // ignore
357                }
358
359                HttpServletRequest servletRequest = request.getServletRequest();
360                String requestUri = UrlPathHelper.rawPathInstance.getOriginatingRequestUri(servletRequest);
361
362                int index = requestUri.lastIndexOf('/') + 1;
363                String filename = requestUri.substring(index);
364                String pathParams = "";
365
366                index = filename.indexOf(';');
367                if (index != -1) {
368                        pathParams = filename.substring(index);
369                        filename = filename.substring(0, index);
370                }
371
372                filename = UrlPathHelper.defaultInstance.decodeRequestString(servletRequest, filename);
373                String ext = StringUtils.getFilenameExtension(filename);
374
375                pathParams = UrlPathHelper.defaultInstance.decodeRequestString(servletRequest, pathParams);
376                String extInPathParams = StringUtils.getFilenameExtension(pathParams);
377
378                if (!safeExtension(servletRequest, ext) || !safeExtension(servletRequest, extInPathParams)) {
379                        headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline;filename=f.txt");
380                }
381        }
382
383        @SuppressWarnings("unchecked")
384        private boolean safeExtension(HttpServletRequest request, String extension) {
385                if (!StringUtils.hasText(extension)) {
386                        return true;
387                }
388                extension = extension.toLowerCase(Locale.ENGLISH);
389                if (this.safeExtensions.contains(extension)) {
390                        return true;
391                }
392                String pattern = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
393                if (pattern != null && pattern.endsWith("." + extension)) {
394                        return true;
395                }
396                if (extension.equals("html")) {
397                        String name = HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE;
398                        Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(name);
399                        if (!CollectionUtils.isEmpty(mediaTypes) && mediaTypes.contains(MediaType.TEXT_HTML)) {
400                                return true;
401                        }
402                }
403                return safeMediaTypesForExtension(extension);
404        }
405
406        private boolean safeMediaTypesForExtension(String extension) {
407                List<MediaType> mediaTypes = null;
408                try {
409                        mediaTypes = this.pathStrategy.resolveMediaTypeKey(null, extension);
410                }
411                catch (HttpMediaTypeNotAcceptableException ex) {
412                        // Ignore
413                }
414                if (CollectionUtils.isEmpty(mediaTypes)) {
415                        return false;
416                }
417                for (MediaType mediaType : mediaTypes) {
418                        if (!safeMediaType(mediaType)) {
419                                return false;
420                        }
421                }
422                return true;
423        }
424
425        private boolean safeMediaType(MediaType mediaType) {
426                return (WHITELISTED_MEDIA_BASE_TYPES.contains(mediaType.getType()) ||
427                                mediaType.getSubtype().endsWith("+xml"));
428        }
429
430}