diff --git a/README.md b/README.md index 70b1b56..6a4af0c 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Requests to a RestApi Backend Server.** ### The Nexus Backend application can be configured by the following keys SpringBoot and Settings properties - **SpringBoot keys application.properties** + **SpringBoot keys application.properties:** | **Keys** | **Default value** | **Descriptions** | |-----------------------------------------------|:------------------|:---------------------------------------------------| @@ -84,7 +84,7 @@ Requests to a RestApi Backend Server.** ### The Nexus-Backend Url Server and miscellaneous options can be configured by the following keys Settings - **Settings keys settings.properties** + **Settings keys settings.properties:** | **Keys** | **Default value** | **Example value** | **Descriptions** | |--------------------------------------------------|:-----------------------------|:--------------------------------|:------------------------------------------------| @@ -129,28 +129,29 @@ Requests to a RestApi Backend Server.** **ApiBackend ResponseType** can be now a **ByteArray Resource.** -**Download** any content in a **ByteArray** included **JSON, PDF, Gif, PNG, TEXT, HTML!** +**Download** any content in a **ByteArray** included commons extensions files (see **MediaTypes** section) The **ResourceMatchers** Config can be configured on specific ByteArray Resources path and on specific Methods **GET, POST, PUT, PATCH** and Ant Path pattern: -**Settings keys settings.properties** +**Settings keys settings.properties:** -| **Keys Methods** and **Keys Path pattern** | **Default value** | -|---------------------------------------------------------------|:------------------| -| nexus.backend.api-backend-resource.matchers.matchers1.method | GET | -| nexus.backend.api-backend-resource.matchers.matchers1.pattern | /api/encoding/** | -| nexus.backend.api-backend-resource.matchers.matchers2.method | GET | -| nexus.backend.api-backend-resource.matchers.matchers2.pattern | /api/streaming/** | -| nexus.backend.api-backend-resource.matchers.matchers3.method | POST | -| nexus.backend.api-backend-resource.matchers.matchers3.pattern | /api/streaming/** | -| nexus.backend.api-backend-resource.matchers.matchers3.method | Others Methods | -| nexus.backend.api-backend-resource.matchers.matchers3.pattern | Others Pattern | +| **Keys Methods** and **Keys Path pattern** | **Default value** | **Content-Type** | +|---------------------------------------------------------------|:-----------------------|:-------------------------| +| nexus.backend.api-backend-resource.matchers.1.method | GET | | +| nexus.backend.api-backend-resource.matchers.1.pattern | /api/encoding/** | text/html;charset=utf-8 | +| nexus.backend.api-backend-resource.matchers.2.method | GET | | +| nexus.backend.api-backend-resource.matchers.2.pattern | /api/streaming/** | application/octet-stream | +| nexus.backend.api-backend-resource.matchers.3.method | GET | | +| nexus.backend.api-backend-resource.matchers.3.pattern | /api/time/now | text/html;charset=utf-8 | +| nexus.backend.api-backend-resource.matchers.{name}[X].method | Methods | | +| nexus.backend.api-backend-resource.matchers.{name}[X].pattern | Patterns | | **Http Responses** are considerate as **Resources**, the Http header **"Accept-Ranges: bytes"** is injected and allow you to use -the Http header **'Range:bytes=1-100'** in the request and grabbed only range of Bytes desired.
+the Http header **'Range: bytes=1-100'** in the request and grabbed only range of Bytes desired.
And the Http Responses didn't come back with a HttpHeader **"Transfer-Encoding: chunked"** cause the header **Content-Length**. + **Noted:** For configure **all the Responses** in **Resource** put an empty Method and use the path pattern=/api/** | **Keys Methods** and **Keys Path pattern** | **Default value** | @@ -162,11 +163,34 @@ And the Http Responses didn't come back with a HttpHeader **"Transfer-Encoding: enable the **ShallowEtagHeader Filter** in the configuration for force to calculate the header **Content-Length** for all the **Response Json Entity Object**, no more HttpHeader **"Transfer-Encoding: chunked"**. +**MediaTypes safe extensions** + +The Spring ContentNegotiation load the safe extensions files that can be extended. +A commons MediaTypes properties file is loaded [resources/mime/MediaTypes_commons.properties](https://github.com/javaguru/nexus-backend/blob/master/src/main/resources/mime/MediaTypes_commons.properties) +and can be disabled: + +**Settings keys settings.properties:** + +Default Header ContentNegotiation Strategy: + +| **ContentNegotiation Strategy** | **Default value** | **Descriptions Strategy** | +|---------------------------------------------------------------|:------------------|:----------------------------| +| **Header Strategy** | | | +| nexus.backend.content.negotiation.ignoreAcceptHeader | false | Header Strategy Enabled | +| **Parameter Strategy** | | | +| nexus.backend.content.negotiation.favorParameter | false | Parameter Strategy Disabled | +| nexus.backend.content.negotiation.parameterName | mediaType | | +| **Registered Extensions** | | | +| nexus.backend.content.negotiation.useRegisteredExtensionsOnly | true | Registered Only Enabled | +| **Load commons MediaTypes** | | | +| nexus.backend.content.negotiation.commonMediaTypes | true | Enabled | + + ### The Nexus-Backend provides a full support MultipartRequest and Map parameters inside a form-data HttpRequest #### MultipartConfig -**SpringBoot keys application.properties** +**SpringBoot keys application.properties:** | **Keys** | **Default value** | **Example value** | **Descriptions** | |----------------------------------------------|:------------------|:------------------|:--------------------| @@ -181,7 +205,7 @@ This BackendResource can convert a **MultipartFile** to a temporary **Resource** ### The BackendService HttpFactory Client Configuration - **Settings keys settings.properties** + **Settings keys settings.properties:** | **Keys** | **Default value** | **Example value** | **Descriptions** | |-----------------------------------------------------|:------------------|:------------------|:-------------------------------| @@ -216,7 +240,7 @@ by the **Apache Coyote http11 processor** (see coyote Error parsing HTTP request All the Http request with **Cookies, Headers, Parameters and RequestBody** will be filtered and the suspicious **IP address** in fault will be logged. - **Settings keys settings.properties** + **Settings keys settings.properties:** | **Keys** | **Default value** | **Descriptions** | |------------------------------------------------------------|:---------------------------------------|:--------------------------------------| @@ -233,26 +257,26 @@ All the Http request with **Cookies, Headers, Parameters and RequestBody** will | nexus.backend.security.allowUrlEncodedParagraphSeparator | false | Allow url encoded Paragraph Separator | | nexus.backend.security.allowUrlEncodedLineSeparator | false | Allow url encoded Line Separator | +**The WAF Utilities Predicates checked for potential evasion:** + +* XSS script injection +* SQL injection +* Google injection +* Command injection +* File injection +* Link injection + **Implements a WAF Predicate for potential evasion by Headers:** - * HeaderNames / HeaderValues - * ParameterNames / ParameterValues + * Header Names / Header Values + * Parameter Names / Parameter Values * Hostnames **And check for Buffer Overflow evasion by the Length:** - * Parameter Names/Values - * Header Names/Values - * Hostnames - -**The WAF Utilities Predicates checked for potential evasion:** - - * XSS script injection - * SQL injection - * Google injection - * Command injection - * File injection - * Link injection + * Parameter Names 255 characters max. / Values 10.000 characters max. + * Header Names 255 characters max. / Values 7.000 characters max. + * Hostnames 255 characters max. **The WAF Reactive mode configuration:** @@ -263,7 +287,7 @@ All the Http request with **Cookies, Headers, Parameters and RequestBody** will ### Activated the Mutual Authentication or mTLS connection on the HttpFactory Client - **Settings keys settings.properties** *nexus.backend.client.ssl.mtls.enable* at **true** for activated the mTLS connection + **Settings keys settings.properties:** *nexus.backend.client.ssl.mtls.enable* at **true** for activated the mTLS connection | **Keys** | **Default value** | **Descriptions** | |---------------------------------------------|:-----------------------|:--------------------------| @@ -277,7 +301,7 @@ All the Http request with **Cookies, Headers, Parameters and RequestBody** will ### Activated Tomcat Catalina Connector TLS/SSL on a wildcard domain Certificate - **Settings keys settings.properties** + **Settings keys settings.properties:** **SpringBoot key** *nexus.backend.tomcat.connector.https.enable* at **true** for activated the TLS/SSL protocol @@ -295,7 +319,7 @@ All the Http request with **Cookies, Headers, Parameters and RequestBody** will ### Activated Tomcat Catalina Extended AccessLog Valve - **Settings keys settings.properties** + **Settings keys settings.properties:** **StringBoot key** *nexus.backend.tomcat.accesslog.valve.enable* at **true** for activated the Accesslogs diff --git a/pom.xml b/pom.xml index 253ad27..672c259 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.jservlet.nexus.backend nexus-backend ${packaging} - 1.0.13 + 1.0.14 nexus-backend The Java Nexus BackendService, an advanced and secure Rest Backend Gateway @@ -272,6 +272,7 @@ logo-marianne.svg persistence.xml api-ui/api-docs.yaml + mime/*.properties META-INF/services/javax.servlet.ServletContainerInitializer false diff --git a/src/main/java/com/jservlet/nexus/config/ApplicationConfig.java b/src/main/java/com/jservlet/nexus/config/ApplicationConfig.java index 98a50a6..8cd5ef5 100644 --- a/src/main/java/com/jservlet/nexus/config/ApplicationConfig.java +++ b/src/main/java/com/jservlet/nexus/config/ApplicationConfig.java @@ -89,7 +89,7 @@ public class ApplicationConfig { public BackendService backendService(@Value("${nexus.backend.url}") String backendUrl, RestOperations restOperations, ObjectMapper objectMapper) { - final BackendServiceImpl backendService = new BackendServiceImpl(); + final BackendServiceImpl backendService = new BackendServiceImpl(true); // return a Generics Object! backendService.setBackendURL(backendUrl); backendService.setRestOperations(restOperations); backendService.setObjectMapper(objectMapper); @@ -182,9 +182,10 @@ public void serialize(Double value, JsonGenerator jgen, SerializerProvider unuse } @Bean - public RestOperations backendRestOperations(MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter) throws Exception { + public RestOperations backendRestOperations(MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter, + ClientHttpRequestFactory httpRequestFactory) throws Exception { - RestTemplate restTemplate = new RestTemplate(httpRequestFactory()); + RestTemplate restTemplate = new RestTemplate(httpRequestFactory); // Does not encode the URI template, prevent to re-encode again the Uri with percent encoded in %25 DefaultUriBuilderFactory uriFactory = new DefaultUriBuilderFactory(); @@ -337,7 +338,7 @@ public String chooseAlias(Map aliases, Socket socket) .setKeepAliveStrategy(myStrategy) .setRedirectStrategy(new LaxRedirectStrategy()) .setRetryHandler(new DefaultHttpRequestRetryHandler(retryCount, requestSentRetryEnabled)) - .disableCookieManagement() + //.disableCookieManagement() .disableAuthCaching() .disableConnectionState() .build()); diff --git a/src/main/java/com/jservlet/nexus/config/web/WebConfig.java b/src/main/java/com/jservlet/nexus/config/web/WebConfig.java index b1f3e70..5dfbd6e 100644 --- a/src/main/java/com/jservlet/nexus/config/web/WebConfig.java +++ b/src/main/java/com/jservlet/nexus/config/web/WebConfig.java @@ -31,17 +31,18 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.ResourceLoaderAware; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.*; import org.springframework.core.annotation.Order; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.EnumerablePropertySource; import org.springframework.core.env.Environment; import org.springframework.core.env.PropertySource; +import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.lang.NonNull; +import org.springframework.util.StringUtils; import org.springframework.web.context.ServletContextAware; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.filter.*; @@ -52,8 +53,9 @@ import javax.servlet.*; import javax.servlet.http.HttpServletRequest; -import java.util.LinkedHashMap; -import java.util.Map; +import java.io.IOException; +import java.util.*; + /* * Web Mvc Configuration */ @@ -68,18 +70,19 @@ }) @EnableWebMvc //@MultipartConfig(location="/tmp", fileSizeThreshold=1024*1024, maxFileSize=1024*1024*15, maxRequestSize=1024*1024*15) -@SuppressWarnings({ "unchecked", "rawtypes" }) public class WebConfig implements WebMvcConfigurer, ResourceLoaderAware, ServletContextAware, ApplicationContextAware { private final static Logger logger = LoggerFactory.getLogger(WebConfig.class); - private static final String ENV_VAR = "environment"; - private Environment env; private ResourceLoader resourceLoader; + private ServletContext servletContext; - private static ApplicationContext context; + private static ApplicationContext appContext; + + private Environment env; + private static final String ENV_VAR = "environment"; @Autowired public void setEnv(Environment env) { @@ -90,7 +93,7 @@ public void setEnv(Environment env) { @Override public synchronized void setApplicationContext(@NonNull ApplicationContext ac) { logger.info("SpringBoot set ApplicationContext -> ac: ['{}']", ac.getId()); - WebConfig.context = ac; + WebConfig.appContext = ac; if (logger.isInfoEnabled()) { Map map = getApplicationProperties(env); @@ -106,8 +109,8 @@ public synchronized void setApplicationContext(@NonNull ApplicationContext ac) { * Get the current ApplicationContext * @return ApplicationContext */ - public synchronized ApplicationContext getApplicationContext() { - return context; + public static synchronized ApplicationContext getApplicationContext() { + return appContext; } /* Stuff, get loaded Application Properties */ @@ -117,7 +120,7 @@ private static Map getApplicationProperties(Environment env) { for (PropertySource propertySource : ((ConfigurableEnvironment) env).getPropertySources()) { // WARN all tracked springboot config file *.properties! if (propertySource instanceof OriginTrackedMapPropertySource) { - for (String key : ((EnumerablePropertySource) propertySource).getPropertyNames()) { + for (String key : ((EnumerablePropertySource) propertySource).getPropertyNames()) { map.put(key, propertySource.getProperty(key)); } } @@ -181,8 +184,8 @@ public void addViewControllers(ViewControllerRegistry registry) { @Bean @Order(1) @ConditionalOnProperty(value="nexus.backend.filter.forwardedHeader.enabled", havingValue = "true") - public FilterRegistrationBean forwardedInfoFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean(); + public FilterRegistrationBean forwardedInfoFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); ForwardedHeaderFilter filter = new ForwardedHeaderFilter(); filter.setRemoveOnly(forwardedHeaderRemoveOnly); registrationBean.setFilter(filter); @@ -198,8 +201,8 @@ public FilterRegistrationBean forwardedInfoFilter() { @Bean @Order(2) @ConditionalOnProperty(value="nexus.backend.filter.gzip.enabled", havingValue = "true") - public FilterRegistrationBean gzipFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean(); + public FilterRegistrationBean gzipFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new CompressingFilter()); registrationBean.setOrder(2); return registrationBean; @@ -213,8 +216,8 @@ public FilterRegistrationBean gzipFilter() { @Bean @Order(3) @ConditionalOnProperty(value="nexus.backend.filter.cors.enabled", havingValue = "true") - public FilterRegistrationBean corsFilterRegistrationBean() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean(); + public FilterRegistrationBean corsFilterRegistrationBean() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR, DispatcherType.ASYNC); registrationBean.setFilter(new CorsFilter(request -> { final CorsConfiguration configuration = new CorsConfiguration(); @@ -235,8 +238,8 @@ public FilterRegistrationBean corsFilterRegistrationBean() { @Bean @Order(4) @ConditionalOnProperty(value="nexus.backend.filter.shallowEtag.enabled", havingValue = "true") - public FilterRegistrationBean shallowEtagHeaderFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean(); + public FilterRegistrationBean shallowEtagHeaderFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new ShallowEtagHeaderFilter()); registrationBean.setOrder(4); return registrationBean; @@ -272,21 +275,18 @@ public WebServerFactoryCustomizer enableDef } /** - * Use Servlet 4 style MultipartResolver: StandardServletMultipartResolver - * @return the MultipartResolver + * Use Servlet 4 style MultipartResolver: StandardServletMultipartResolver
+ * Strict Servlet compliance only multipart/form-data + * @return the MultipartResolver, */ @Bean public MultipartResolver multipartResolver() { return new StandardServletMultipartResolver() { + private final Set SUPPORTED_METHODS = EnumSet.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH); @Override public boolean isMultipart(@NonNull HttpServletRequest request) { - if (!"POST".equalsIgnoreCase(request.getMethod()) && - !"PUT".equalsIgnoreCase(request.getMethod()) && - !"PATCH".equalsIgnoreCase(request.getMethod())) { - return false; - } - String contentType = request.getContentType(); - return contentType != null && contentType.toLowerCase().startsWith("multipart/"); + if (!SUPPORTED_METHODS.contains(HttpMethod.resolve(request.getMethod()))) return false; + return StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.MULTIPART_FORM_DATA_VALUE); } }; } @@ -302,4 +302,58 @@ public void addResourceHandlers(@NonNull ResourceHandlerRegistry registry) { registry.addResourceHandler("/static/**").addResourceLocations("/static/"); } + + @Value("${nexus.backend.content.negotiation.favorParameter:false}") + private boolean favorParameter; + @Value("${nexus.backend.content.negotiation.parameterName:mediaType}") + private String parameterName; + + @Value("${nexus.backend.content.negotiation.ignoreAcceptHeader:false}") + private boolean ignoreAcceptHeader; + + @Value("${nexus.backend.content.negotiation.useRegisteredExtensionsOnly:true}") + private boolean useRegisteredExtensionsOnly; + + @Value("${nexus.backend.content.negotiation.commonMediaTypes:true}") + private boolean commonMediaTypes; + + + /** + * Configure a default ContentNegotiation with a default HeaderContentNegotiationStrategy (ignoreAcceptHeader at false) + * Force the defaultContentType by application/octet-stream + * @see org.springframework.web.accept.ContentNegotiationManagerFactoryBean + * + * @param configurer The current ContentNegotiationConfigurer + */ + @Override + public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { + configurer // JSON Mandatory forced for Resource 404 not found from the Backend + .defaultContentType(MediaType.APPLICATION_JSON, MediaType.APPLICATION_OCTET_STREAM) + //.defaultContentTypeStrategy(new HeaderContentNegotiationStrategy()) + .favorParameter(favorParameter) + .parameterName(parameterName) + .ignoreAcceptHeader(ignoreAcceptHeader) + .useRegisteredExtensionsOnly(useRegisteredExtensionsOnly) + .mediaType("pdf", MediaType.APPLICATION_PDF); // Add pdf as safe extensions by default! + + if (commonMediaTypes) { + extractedMediaTypes(configurer, "classpath:mime/MediaTypes_commons.properties"); + } + } + + @SuppressWarnings("SameParameterValue") + private void extractedMediaTypes(ContentNegotiationConfigurer configurer, String classpath) { + Resource resource = resourceLoader.getResource(classpath); + if (resource.exists()) { + try { + Properties properties = new Properties(); + properties.load(resource.getInputStream()); + properties.forEach((key, value) -> + configurer.mediaType(key.toString(), MediaType.parseMediaType(value.toString()))); + } catch (IOException e) { + logger.error("Failed to load '{}' media types {}", classpath, e.getMessage()); + } + } + } + } diff --git a/src/main/java/com/jservlet/nexus/controller/GlobalDefaultExceptionHandler.java b/src/main/java/com/jservlet/nexus/controller/GlobalDefaultExceptionHandler.java index c408203..3961832 100644 --- a/src/main/java/com/jservlet/nexus/controller/GlobalDefaultExceptionHandler.java +++ b/src/main/java/com/jservlet/nexus/controller/GlobalDefaultExceptionHandler.java @@ -21,9 +21,12 @@ import com.jservlet.nexus.shared.web.controller.ApiBase; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.web.firewall.RequestRejectedException; +import org.springframework.web.HttpMediaTypeNotAcceptableException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.client.RestClientException; @@ -54,10 +57,10 @@ public ResponseEntity handleResourceAccessException(HttpServletRequest reques e.getMessage(), request.getRemoteHost(), request.getMethod(), request.getServletPath(), request.getHeader("User-Agent")); // ByeArray Resource request could not be completed due to a conflict with the current state of the target resource! if (Objects.requireNonNull(e.getMessage()).startsWith("Error while extracting response for type [class java.lang.Object] and content type")) { - String msg = "No ResourceMatchers configured. Open the Method and Uri : " + request.getMethod() + " '" + request.getServletPath() + "' and as ByteArray Resource!"; + String msg = "No ResourceMatchers configured. Open the Method '" + request.getMethod() + "' and the Uri '" + request.getServletPath() + "' as ByteArray Resource!"; return super.getResponseEntity("409", "ERROR", msg, CONFLICT); } - return super.getResponseEntity("503", "ERROR", "Remote Service Unavailable" + e.getMessage(), SERVICE_UNAVAILABLE); + return super.getResponseEntity("503", "ERROR", "Remote Service Unavailable: " + e.getMessage(), SERVICE_UNAVAILABLE); } @ExceptionHandler(value = { HttpMessageNotReadableException.class }) @@ -75,6 +78,18 @@ public ResponseEntity handleRejectedException(HttpServletRequest request, Req return super.getResponseEntity("400", "ERROR", "Request rejected!", BAD_REQUEST); } + /* + * For no acceptable representation for a Resource, but will never more happens with the ContentNegotiation + */ + @ExceptionHandler(value = { HttpMediaTypeNotAcceptableException.class }) + public ResponseEntity handleMediaTypeNotAcceptableException(HttpServletRequest request, HttpMediaTypeNotAcceptableException e) { + logger.error("Intercepted HttpMediaTypeNotAcceptableException: {} RemoteHost: {} RequestURL: {} {} UserAgent: {}", + e.getMessage(), request.getRemoteHost(), request.getMethod(), request.getServletPath(), request.getHeader("User-Agent")); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); // Mandatory forced ContentType can be null + return super.getResponseEntity("406", "ERROR", e, headers, NOT_ACCEPTABLE); + } + /* * Any other's error */ diff --git a/src/main/java/com/jservlet/nexus/controller/MockController.java b/src/main/java/com/jservlet/nexus/controller/MockController.java index 7da9a50..1dc4d59 100644 --- a/src/main/java/com/jservlet/nexus/controller/MockController.java +++ b/src/main/java/com/jservlet/nexus/controller/MockController.java @@ -369,7 +369,7 @@ private static HttpHeaders extractHeaders(HttpServletRequest request) { private HttpHeaders filterHeaders(HttpHeaders originalHeaders) { HttpHeaders responseHeaders = new HttpHeaders(); responseHeaders.putAll(originalHeaders); - //chuncked and length headers must not be forwarded + // chunked and length headers must not be forwarded responseHeaders.remove(HttpHeaders.TRANSFER_ENCODING); responseHeaders.remove(HttpHeaders.CONTENT_LENGTH); //responseHeaders.remove("Accept-Encoding"); // Gzip !? diff --git a/src/main/java/com/jservlet/nexus/shared/service/backend/BackendServiceImpl.java b/src/main/java/com/jservlet/nexus/shared/service/backend/BackendServiceImpl.java index 41c49dd..18d531b 100644 --- a/src/main/java/com/jservlet/nexus/shared/service/backend/BackendServiceImpl.java +++ b/src/main/java/com/jservlet/nexus/shared/service/backend/BackendServiceImpl.java @@ -62,6 +62,18 @@ public class BackendServiceImpl implements BackendService { private ObjectMapper objectMapper; + /** + * Return by the default a Json Entity Object or Resource, else if true a Generics Object. + */ + private boolean isHandleBackendEntity = false; + + public BackendServiceImpl() { + } + + public BackendServiceImpl(boolean isHandleBackendEntity) { + this.isHandleBackendEntity = isHandleBackendEntity; + } + public void setBackendURL(String backendURL) { this.backendURL = backendURL; } @@ -347,7 +359,8 @@ private T handleResponse(ResponseEntity exchange) { } } if (responseBody == null) return (T) httpStatus; - if (isHandleHttpState(httpStatus)) return (T) new EntityError<>(responseBody, httpStatus); + if (isHandleHttpState(httpStatus)) return (T) new EntityError<>(responseBody, httpHeaders, httpStatus); + if (isHandleBackendEntity) return (T) new EntityBackend<>(responseBody, httpHeaders, httpStatus); return responseBody; } @@ -444,10 +457,12 @@ private ResponseTypeImpl(Class responseClass, ParameterizedTypeReference p public static class EntityError { private final T body; + private final HttpHeaders headers; private final HttpStatus status; - public EntityError(T body, HttpStatus status) { + public EntityError(T body, HttpHeaders headers, HttpStatus status) { this.body = body; + this.headers = headers; this.status = status; } @@ -455,6 +470,34 @@ public T getBody() { return this.body; } + public HttpHeaders getHttpHeaders() { + return this.headers; + } + + public HttpStatus getStatus() { + return this.status; + } + } + + public static class EntityBackend { + private final T body; + private final HttpHeaders headers; + private final HttpStatus status; + + public EntityBackend(T body, HttpHeaders headers, HttpStatus status) { + this.body = body; + this.headers = headers; + this.status = status; + } + + public T getBody() { + return this.body; + } + + public HttpHeaders getHttpHeaders() { + return this.headers; + } + public HttpStatus getStatus() { return this.status; } diff --git a/src/main/java/com/jservlet/nexus/shared/web/controller/ApiBase.java b/src/main/java/com/jservlet/nexus/shared/web/controller/ApiBase.java index f179679..875e9f0 100644 --- a/src/main/java/com/jservlet/nexus/shared/web/controller/ApiBase.java +++ b/src/main/java/com/jservlet/nexus/shared/web/controller/ApiBase.java @@ -19,6 +19,7 @@ package com.jservlet.nexus.shared.web.controller; import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; @@ -57,6 +58,12 @@ protected final ResponseEntity getResponseEntity(String code, String level, E return new ResponseEntity<>(message, httpStatus); } + protected final ResponseEntity getResponseEntity(String code, String level, Exception e, HttpHeaders headers, HttpStatus httpStatus) { + Message message = getMessageObject(code, level); + message.setMessage(e.getMessage()); + return new ResponseEntity<>(message, headers, httpStatus); + } + protected final ResponseEntity getResponseEntity(String code, String level, HttpStatus httpStatus) { return new ResponseEntity<>(getMessageObject(code, level), httpStatus); } diff --git a/src/main/java/com/jservlet/nexus/shared/web/controller/api/ApiBackend.java b/src/main/java/com/jservlet/nexus/shared/web/controller/api/ApiBackend.java index 0984599..4895ac4 100644 --- a/src/main/java/com/jservlet/nexus/shared/web/controller/api/ApiBackend.java +++ b/src/main/java/com/jservlet/nexus/shared/web/controller/api/ApiBackend.java @@ -24,6 +24,7 @@ import com.jservlet.nexus.shared.service.backend.BackendService; import com.jservlet.nexus.shared.service.backend.BackendService.ResponseType; import com.jservlet.nexus.shared.service.backend.BackendServiceImpl.EntityError; +import com.jservlet.nexus.shared.service.backend.BackendServiceImpl.EntityBackend; import com.jservlet.nexus.shared.web.controller.ApiBase; import io.swagger.v3.oas.annotations.Hidden; import org.slf4j.Logger; @@ -41,13 +42,14 @@ import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.*; import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartRequest; import org.springframework.web.servlet.HandlerMapping; -import org.springframework.web.util.WebUtils; import javax.annotation.PostConstruct; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import java.io.File; import java.io.FileInputStream; import java.io.IOException; @@ -82,9 +84,9 @@ * etc... *

*

- * The Http Responses are considerate as Resources, the Http header "Accept-Ranges: bytes" is injected and allow you to use - * the Http header 'Range:bytes=1-100' in the request and grabbed only range of Bytes desired.
- * And the Http Responses didn't come back with a HttpHeader "Transfer-Encoding: chunked" cause the header Content-Length. + * The Http Responses can be considerate as Resources, the Http header "Accept-Ranges: bytes" is injected and allow you to use + * the Http header 'Range:bytes=-1000' in the request and by example grabbed the last 1000 bytes (or a range of Bytes).
+ * And the Http Responses will come back without a "Transfer-Encoding: chunked" HttpHeader cause now the header Content-Length. *

* For configure all the Responses in Resource put eh Method empty and use the path pattern=/api/**
* nexus.backend.api-backend-resource.matchers.matchers1.method=
@@ -121,23 +123,27 @@ public final void setBackendService(BackendService backendService, ResourceMatch } /** - * Prepare matchers Methods and Ant paths pattern dedicated only for the Resources (see settings.properties)
- */ + * Prepare matchers Methods and Ant paths pattern dedicated only for the Resources + */ @PostConstruct private void postConstruct() { List requestMatchers = new ArrayList<>(); Map map = matchersConfig.getMatchers(); for (Map.Entry entry : map.entrySet()) { requestMatchers.add(new AntPathRequestMatcher(entry.getValue().getPattern(), entry.getValue().getMethod())); + logger.info("Config ResourceMatchers: {} '{}'", entry.getValue().getMethod(), entry.getValue().getPattern()); } // Mandatory, not an empty RequestMatcher! - if (requestMatchers.isEmpty()) requestMatchers.add(new AntPathRequestMatcher("*/**", null)); + if (requestMatchers.isEmpty()) { + requestMatchers.add(new AntPathRequestMatcher("*/**", null)); + logger.warn("Config ResourceMatchers: No ByteArray Resource specified!"); + } orRequestMatcher = new OrRequestMatcher(requestMatchers); } /** * Inner ConfigurationProperties keys prefixed with 'nexus.backend.api-backend-resource' and - * Lopping on incremental keys 'matchers.matchers[X].method' and 'matchers.matchers[X].pattern' + * Lopping on incremental keys 'matchers.{name}[X].method' and 'matchers.{name}[X].pattern' */ @ConfigurationProperties("nexus.backend.api-backend-resource") public static class ResourceMatchersConfig { @@ -171,28 +177,32 @@ public void setPattern(String pattern) { /** * Manage a Request Json Entity Object and a Request Map parameters.
- * Or a MultipartRequest encapsulated a List of BackendResource Json Entity Object and a Request Map parameters
- * And return a Response Entity Object or a ByteArray Resource file or any others content in ByteArray... + * Or a MultipartRequest encapsulated a List of BackendResource and a Request Map parameters, and form Json Entity Object
+ * And return a ResponseEntity Json Entity Object or a ByteArray Resource file or any others content in ByteArray...
+ *
+ * For a @RequestMapping allow headers is set to GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS * * @param body String representing the RequestBody Object, just transfer the RequestBody * @param method HttpMethod GET, POST, PUT, PATCH or DELETE * @param request The current HttpServletRequest + * @param nativeWebRequest The current NativeWebRequest for get the MultipartRequest * @return Object Return a ResponseEntity Object or a ByteArray Resource * @throws NexusHttpException Exception when a http request to the backend fails * @throws NexusIllegalUrlException Exception when an illegal url will be requested */ - @RequestMapping(value = "/**", produces = MediaType.APPLICATION_JSON_VALUE) - public final Object requestEntity(@RequestBody(required = false) String body, HttpMethod method, HttpServletRequest request) + @RequestMapping(value = "/**") + public final Object requestEntity(@RequestBody(required = false) String body, HttpMethod method, + HttpServletRequest request, HttpServletResponse response, NativeWebRequest nativeWebRequest) throws NexusHttpException, NexusIllegalUrlException { // MultiValueMap store the MultiPartFiles and the Parameters Map MultiValueMap map = null; try { - // The path within the handler mapping and its query + // Any path within handler mapping without "api/" and with its query String url = ((String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE)).replaceAll("api/", ""); if (request.getQueryString() != null) url = url + "?" + request.getQueryString(); - // Get the MultipartRequest from the miscellaneous Web utilities NativeRequest! - MultipartRequest multipartRequest = WebUtils.getNativeRequest(request, MultipartRequest.class); + // Get the MultipartRequest from the NativeRequest! + MultipartRequest multipartRequest = nativeWebRequest.getNativeRequest(MultipartRequest.class); map = processMapResources(multipartRequest, request.getParameterMap()); // Optimize logs writing, log methods can take time! @@ -201,7 +211,7 @@ public final Object requestEntity(@RequestBody(required = false) String body, Ht method, url, printQueryString(request.getQueryString()), printParameterMap(request.getParameterMap()), body, map.entrySet()); } - // Create a ResponseType Object or Resource + // Create a ResponseType Object or Resource by RequestMatcher ResponseType responseType; if (!orRequestMatcher.matches(request)) { responseType = backendService.createResponseType(Object.class); @@ -209,14 +219,25 @@ public final Object requestEntity(@RequestBody(required = false) String body, Ht responseType = backendService.createResponseType(Resource.class); } - // Return a Json Entity Object or any Resource + // Return a EntityError or a EntityBackend Object obj = backendService.doRequest(url, method, responseType, !map.isEmpty() ? map : body, getAllHeaders(request)); - // Manage an EntityError! - if (obj instanceof EntityError) - return new ResponseEntity<>(((EntityError) obj).getBody(), ((EntityError) obj).getStatus()); + // Manage a Generics EntityError embedded a Json Entity Object! + if (obj instanceof EntityError) { + EntityError entityError = (EntityError) obj; + final HttpHeaders newHeaders = getBackendHeaders(entityError.getHttpHeaders()); + return new ResponseEntity<>(entityError, newHeaders, entityError.getStatus()); + } - return obj; + // Manage a Generics EntityBackend embedded a Json Entity Object or a Resource! + EntityBackend entityBackend = (EntityBackend) obj; + final HttpHeaders newHeaders = getBackendHeaders(entityBackend.getHttpHeaders()); + if (entityBackend.getBody() instanceof Resource) { + Resource resource = (Resource) entityBackend.getBody(); + return new ResponseEntity<>(resource, newHeaders, entityBackend.getStatus()); + } else { + return new ResponseEntity<>(entityBackend.getBody(), newHeaders, entityBackend.getStatus()); + } } catch (NexusResourceNotFoundException e) { // Return an error Message NOT_FOUND return super.getResponseEntity("404", "ERROR", e, HttpStatus.NOT_FOUND); @@ -240,6 +261,44 @@ private static HttpHeaders getAllHeaders(HttpServletRequest request) { return headers; } + /** + * Default List of Headers transfer + */ + private final static List TRANSFER_HEADERS = + List.of(HttpHeaders.SERVER, + HttpHeaders.SET_COOKIE, + HttpHeaders.ETAG, + HttpHeaders.DATE, // transfer as Date-Backend + HttpHeaders.USER_AGENT, + "test" // Test postman-echo + ); + + /** + * Transfer some headers from the Backend RestOperations. + * Not CONTENT_LENGTH, CONTENT_RANGE or TRANSFER_ENCODING. Cause already sent in their own Context. + * Case SET_COOKIE need a Store! + */ + private static HttpHeaders getBackendHeaders(HttpHeaders readHeaders) { + HttpHeaders newHeaders = new HttpHeaders(); + if (readHeaders == null || readHeaders.isEmpty()) return newHeaders; + // Original CONTENT_TYPE for a Resource and its charset if it exists + if (readHeaders.getFirst(HttpHeaders.CONTENT_TYPE) != null) { + newHeaders.set(HttpHeaders.CONTENT_TYPE, readHeaders.getFirst(HttpHeaders.CONTENT_TYPE)); + } else {// ByteArray by default! + newHeaders.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE); + } + for (String headerName : TRANSFER_HEADERS) { + if (readHeaders.getFirst(headerName) != null) { + if (HttpHeaders.DATE.equals(headerName)) { + newHeaders.add(HttpHeaders.DATE + "-Backend", readHeaders.getFirst(HttpHeaders.DATE)); + } else { + newHeaders.add(headerName, readHeaders.getFirst(headerName)); + } + } + } + return newHeaders; + } + /** * Print the parameterMap in a "Json" object style */ diff --git a/src/main/java/com/jservlet/nexus/shared/web/filter/WAFFilter.java b/src/main/java/com/jservlet/nexus/shared/web/filter/WAFFilter.java index 2705b61..1ad3057 100644 --- a/src/main/java/com/jservlet/nexus/shared/web/filter/WAFFilter.java +++ b/src/main/java/com/jservlet/nexus/shared/web/filter/WAFFilter.java @@ -38,8 +38,10 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.regex.Pattern; import static com.jservlet.nexus.shared.web.filter.WAFFilter.Reactive.*; @@ -144,7 +146,7 @@ public void doFilter(final ServletRequest request, final ServletResponse respons // Just clean the Json body! String body = IOUtils.toString(wrappedRequest.getReader()); if (!StringUtils.isBlank(body)) { - wrappedRequest.setInputStream(WAFUtils.stripWAFPattern(body, wafPredicate.getWafPatterns()).getBytes()); + wrappedRequest.setInputStream(stripWAFPattern(body, wafPredicate.getWafPatterns()).getBytes()); } } @@ -202,12 +204,23 @@ private Map cleanerParameterMap(Map modifiab int len = values.length; String[] encodedValues = new String[len]; for (int i = 0; i < len; i++) - encodedValues[i] = WAFUtils.stripWAFPattern(values[i], wafPredicate.getWafPatterns()); + encodedValues[i] = stripWAFPattern(values[i], wafPredicate.getWafPatterns()); parameters.put(key, encodedValues); } return parameters; } + private static String stripWAFPattern(String value, List patterns) { + if (value == null) return null; + if (value.length() > 10000) { // Prevent RegExp Denial of Service - ReDoS! + throw new RequestRejectedException("Input value is too long!"); + } + // matcher xssPattern replaceAll ? + for (Pattern pattern : patterns) + value = pattern.matcher(value).replaceAll(""); // Cut! + return value; + } + private void rejectBody(String body) { if (!wafPredicate.getWAFParameterValues().test(body)) { throw new RequestRejectedException( diff --git a/src/main/java/com/jservlet/nexus/shared/web/filter/WAFPredicate.java b/src/main/java/com/jservlet/nexus/shared/web/filter/WAFPredicate.java index e0e06ed..6c87d86 100644 --- a/src/main/java/com/jservlet/nexus/shared/web/filter/WAFPredicate.java +++ b/src/main/java/com/jservlet/nexus/shared/web/filter/WAFPredicate.java @@ -42,7 +42,7 @@ public class WAFPredicate { return x.test(param) && s.test(param) && c.test(param) && f.test(param) && l.test(param); }; final private Predicate WAFParameterValues = (param) -> { - if (param.length() > 1000000) return true; + if (param.length() > 10000) return true; return x.test(param) && s.test(param) && c.test(param) && f.test(param) && l.test(param); }; final private Predicate WAFHeaderNames = (header) -> { @@ -50,7 +50,7 @@ public class WAFPredicate { return x.test(header) && s.test(header) && c.test(header); }; final private Predicate WAFHeaderValues = (header) -> { - if (header.length() > 25000) return true; + if (header.length() > 7000) return true; return x.test(header) && s.test(header) && c.test(header); }; final private Predicate WAFHostnames = (names) -> { diff --git a/src/main/java/com/jservlet/nexus/shared/web/filter/WAFRequestWrapper.java b/src/main/java/com/jservlet/nexus/shared/web/filter/WAFRequestWrapper.java index fae8708..d090595 100644 --- a/src/main/java/com/jservlet/nexus/shared/web/filter/WAFRequestWrapper.java +++ b/src/main/java/com/jservlet/nexus/shared/web/filter/WAFRequestWrapper.java @@ -64,8 +64,7 @@ public void setParameterMap(Map map) { */ public void setInputStream(byte[] newRawData) { cachedBytes = new ByteArrayOutputStream(newRawData.length); - cachedBytes.write(newRawData, 0, newRawData.length); - //cachedBytes.writeBytes(newRawData); + cachedBytes.writeBytes(newRawData); } @Override diff --git a/src/main/java/com/jservlet/nexus/shared/web/filter/WAFUtils.java b/src/main/java/com/jservlet/nexus/shared/web/filter/WAFUtils.java index 75c8c2c..e9e461f 100644 --- a/src/main/java/com/jservlet/nexus/shared/web/filter/WAFUtils.java +++ b/src/main/java/com/jservlet/nexus/shared/web/filter/WAFUtils.java @@ -86,14 +86,6 @@ public static boolean isWAFPattern(String value, List patterns) { return true; } - public static String stripWAFPattern(String value, List patterns) { - if (value == null) return null; - // matcher xssPattern replaceAll ? - for (Pattern pattern : patterns) - value = pattern.matcher(value).replaceAll(""); - return value; - } - public static void main(String[] args) { String test = "image/avif,image/webp,image/apng,image/svg+xml;q=0.8"; System.out.println(isWAFPattern(test, xssPattern)); diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 1240900..7ba5732 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -42,9 +42,7 @@ - - diff --git a/src/main/resources/mime/MediaTypes_commons.properties b/src/main/resources/mime/MediaTypes_commons.properties new file mode 100644 index 0000000..c44daf1 --- /dev/null +++ b/src/main/resources/mime/MediaTypes_commons.properties @@ -0,0 +1,64 @@ +aac=audio/aac +abw=application/x-abiword +apng=image/apng +arc=application/x-freearc +avif=image/avif +avi=video/x-msvideo +azw=application/vnd.amazon.ebook +bmp=image/bmp +bz=application/x-bzip +bz2=application/x-bzip2 +doc=application/msword +docx=application/vnd.openxmlformats-officedocument.wordprocessingml.document +eot=application/vnd.ms-fontobject +epub=application/epub+zip +gz=application/gzip;application/x-gzip +gif=image/gif +ico=image/vnd.microsoft.icon +ics=text/calendar +jar=application/java-archive +jpeg=image/jpeg +jpg=image/jpeg +js=text/javascript +mid=audio/midi +midi=audio/x-midi +mjs=text/javascript +mp3=audio/mpeg +mp4=video/mp4 +mpeg=video/mpeg +odp=application/vnd.oasis.opendocument.presentation +ods=application/vnd.oasis.opendocument.spreadsheet +odt=application/vnd.oasis.opendocument.text +oga=audio/ogg +ogv=video/ogg +ogx=application/ogg +opus=audio/ogg +otf=font/otf +png=image/png +pdf=application/pdf +ppt=application/vnd.ms-powerpoint +pptx=application/vnd.openxmlformats-officedocument.presentationml.presentation +rar=application/vnd.rar +rtf=application/rtf +svg=image/svg+xml +tar=application/x-tar +tif=image/tiff +tiff=image/tiff +ts=video/mp2t +ttf=font/ttf +vsd=application/vnd.visio +wav=audio/wav +weba=audio/webm +webm=video/webm +webp=image/webp +woff=font/woff +woff2=font/woff2 +xhtml=application/xhtml+xml +xls=application/vnd.ms-excel +xlsx=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet +xml=application/xml +xul=application/vnd.mozilla.xul+xml +zip=application/zip;application/x-zip-compressed +3gp=video/3gpp;audio/3gpp +3g2=video/3gpp2;audio/3gpp2 +7z=application/x-7z-compressed diff --git a/src/main/resources/settings.properties b/src/main/resources/settings.properties index 3812a8c..a6d0cd6 100644 --- a/src/main/resources/settings.properties +++ b/src/main/resources/settings.properties @@ -9,21 +9,37 @@ nexus.backend.uri.alive=/get ################################################################## -### ApiBackend can switch Http Response in Json Entity Object or ByteArray Resource and download any content in ByteArray. +### The ApiBackend can switch Http Response in Json Entity Object or ByteArray Resource and download any content in ByteArray. # To manage this behavior an AntMatcher Resources on Http Methods and Ant Paths pattern can be configured: # Examples dedicated to postman-echo.com, here: ## # https://postman-echo.com/encoding/utf8 -#nexus.backend.api-backend-resource.matchers.matchers1.method=GET -#nexus.backend.api-backend-resource.matchers.matchers1.pattern=/api/encoding/** +nexus.backend.api-backend-resource.matchers.1.method=GET +nexus.backend.api-backend-resource.matchers.1.pattern=/api/encoding/** ## # https://postman-echo.com/stream/10 -#nexus.backend.api-backend-resource.matchers.matchers2.method=GET -#nexus.backend.api-backend-resource.matchers.matchers2.pattern=/api/stream/** +nexus.backend.api-backend-resource.matchers.2.method=GET +nexus.backend.api-backend-resource.matchers.2.pattern=/api/stream/** ## -# or Download any content in ByteArray Resource: Json, PDF, Gif, Png, Text, Html etc... -#nexus.backend.api-backend-resource.matchers.matchers1.method= -#nexus.backend.api-backend-resource.matchers.matchers1.pattern=/api/** +# https://postman-echo.com/time/now +nexus.backend.api-backend-resource.matchers.3.method=GET +nexus.backend.api-backend-resource.matchers.3.pattern=/api/time/now +## +# OR Download any content in ByteArray Resource (All method and all the endpoints under /api/**) +#nexus.backend.api-backend-resource.matchers.1.method= +#nexus.backend.api-backend-resource.matchers.1.pattern=/api/** + +#### Spring ContentNegotiation Factory for the Resources from the Backend Server + +## Strategy By default: Header Content Negotiation +#nexus.backend.content.negotiation.ignoreAcceptHeader=false +## favor Parameter disable +#nexus.backend.content.negotiation.favorParameter=false +#nexus.backend.content.negotiation.parameterName=mediaType +## Registered extensions only +#nexus.backend.content.negotiation.useRegisteredExtensionsOnly=true +## Loaded commons MediaTypes PDF, DOC, XLS, Audio, video +#nexus.backend.content.negotiation.commonMediaTypes=true ################################################################## diff --git a/src/test/java/com/jservlet/nexus/test/config/ApplicationTestConfig.java b/src/test/java/com/jservlet/nexus/test/config/ApplicationTestConfig.java index 038ab21..70d78a0 100644 --- a/src/test/java/com/jservlet/nexus/test/config/ApplicationTestConfig.java +++ b/src/test/java/com/jservlet/nexus/test/config/ApplicationTestConfig.java @@ -131,7 +131,7 @@ public boolean canWrite(@NonNull Class clazz, MediaType mediaType) { @Override public @NonNull List getSupportedMediaTypes() { - return Collections.singletonList(MediaType.APPLICATION_OCTET_STREAM); + return List.of(MediaType.APPLICATION_OCTET_STREAM); } @Override @@ -156,18 +156,9 @@ public boolean canWrite(@NonNull Class clazz, MediaType mediaType) { @Override public String getFilename() { - HttpHeaders httpHeaders = inputMessage.getHeaders(); - String disposition = httpHeaders.getFirst(HttpHeaders.CONTENT_DISPOSITION); - if (disposition != null) { - int filenameIdx = disposition.indexOf("filename"); - if (filenameIdx != -1) { - return disposition.substring(filenameIdx + 9); - } - } - return null; + return inputMessage.getHeaders().getContentDisposition().getFilename(); } }; - } @Override