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}