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}