javaspringspring-booturlencodeurldecode

Spring boot @PathVariable cannot contains slash/semicolon/percent characters


I have a REST API with spring-boot 2.5.5 with spring-security 5.5.2.

On many API endpoints I use @PathVariable for resources identifiers.
But when some special (but valid in my domain context) characters are passed as PathVariables, the request fails.
The three failing special characters are : slash (/), semicolon (;) and percent (%).

Let's take a very minimal example with the following :

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1")
public class Controller {

    @GetMapping("echo/{value}")
    public ResponseEntity<String> echo(@PathVariable("value") String value) {
        return ResponseEntity
                .status(HttpStatus.OK)
                .contentType(MediaType.TEXT_XML)
                .body(value);
    }

}

For value "hello/there", I send GET /api/v1/echo/hello%2Fthere and I receive :

<!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 – Bad Request</h1><hr class="line" /><p><b>Type</b> Status Report</p><p><b>Message</b> Invalid URI: noSlash</p><p><b>Description</b> The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).</p><hr class="line" /><h3>Apache Tomcat/9.0.53</h3></body></html>

For value "hello;there", I send GET /api/v1/echo/hello%3Bthere and I receive :

{
  "stackTrace": [
    {
      "classLoaderName": "app",
      "methodName": "handleAccessDeniedException",
      "fileName": "ExceptionTranslationFilter.java",
      "lineNumber": 194,
      "nativeMethod": false,
      "className": "org.springframework.security.web.access.ExceptionTranslationFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "handleSpringSecurityException",
      "fileName": "ExceptionTranslationFilter.java",
      "lineNumber": 173,
      "nativeMethod": false,
      "className": "org.springframework.security.web.access.ExceptionTranslationFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "ExceptionTranslationFilter.java",
      "lineNumber": 142,
      "nativeMethod": false,
      "className": "org.springframework.security.web.access.ExceptionTranslationFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "ExceptionTranslationFilter.java",
      "lineNumber": 115,
      "nativeMethod": false,
      "className": "org.springframework.security.web.access.ExceptionTranslationFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "SessionManagementFilter.java",
      "lineNumber": 126,
      "nativeMethod": false,
      "className": "org.springframework.security.web.session.SessionManagementFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "SessionManagementFilter.java",
      "lineNumber": 81,
      "nativeMethod": false,
      "className": "org.springframework.security.web.session.SessionManagementFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "AnonymousAuthenticationFilter.java",
      "lineNumber": 105,
      "nativeMethod": false,
      "className": "org.springframework.security.web.authentication.AnonymousAuthenticationFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "SecurityContextHolderAwareRequestFilter.java",
      "lineNumber": 149,
      "nativeMethod": false,
      "className": "org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "RequestCacheAwareFilter.java",
      "lineNumber": 63,
      "nativeMethod": false,
      "className": "org.springframework.security.web.savedrequest.RequestCacheAwareFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "OncePerRequestFilter.java",
      "lineNumber": 103,
      "nativeMethod": false,
      "className": "org.springframework.web.filter.OncePerRequestFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "LogoutFilter.java",
      "lineNumber": 103,
      "nativeMethod": false,
      "className": "org.springframework.security.web.authentication.logout.LogoutFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "LogoutFilter.java",
      "lineNumber": 89,
      "nativeMethod": false,
      "className": "org.springframework.security.web.authentication.logout.LogoutFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "OncePerRequestFilter.java",
      "lineNumber": 103,
      "nativeMethod": false,
      "className": "org.springframework.web.filter.OncePerRequestFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "SecurityContextPersistenceFilter.java",
      "lineNumber": 110,
      "nativeMethod": false,
      "className": "org.springframework.security.web.context.SecurityContextPersistenceFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "SecurityContextPersistenceFilter.java",
      "lineNumber": 80,
      "nativeMethod": false,
      "className": "org.springframework.security.web.context.SecurityContextPersistenceFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "OncePerRequestFilter.java",
      "lineNumber": 103,
      "nativeMethod": false,
      "className": "org.springframework.web.filter.OncePerRequestFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "ChannelProcessingFilter.java",
      "lineNumber": 133,
      "nativeMethod": false,
      "className": "org.springframework.security.web.access.channel.ChannelProcessingFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 336,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy$VirtualFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilterInternal",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 211,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "FilterChainProxy.java",
      "lineNumber": 183,
      "nativeMethod": false,
      "className": "org.springframework.security.web.FilterChainProxy"
    },
    {
      "classLoaderName": "app",
      "methodName": "invokeDelegate",
      "fileName": "DelegatingFilterProxy.java",
      "lineNumber": 358,
      "nativeMethod": false,
      "className": "org.springframework.web.filter.DelegatingFilterProxy"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "DelegatingFilterProxy.java",
      "lineNumber": 271,
      "nativeMethod": false,
      "className": "org.springframework.web.filter.DelegatingFilterProxy"
    },
    {
      "classLoaderName": "app",
      "methodName": "internalDoFilter",
      "fileName": "ApplicationFilterChain.java",
      "lineNumber": 189,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "ApplicationFilterChain.java",
      "lineNumber": 162,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilterInternal",
      "fileName": "RequestContextFilter.java",
      "lineNumber": 100,
      "nativeMethod": false,
      "className": "org.springframework.web.filter.RequestContextFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "OncePerRequestFilter.java",
      "lineNumber": 119,
      "nativeMethod": false,
      "className": "org.springframework.web.filter.OncePerRequestFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "internalDoFilter",
      "fileName": "ApplicationFilterChain.java",
      "lineNumber": 189,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "ApplicationFilterChain.java",
      "lineNumber": 162,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "OncePerRequestFilter.java",
      "lineNumber": 103,
      "nativeMethod": false,
      "className": "org.springframework.web.filter.OncePerRequestFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "internalDoFilter",
      "fileName": "ApplicationFilterChain.java",
      "lineNumber": 189,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "ApplicationFilterChain.java",
      "lineNumber": 162,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "OncePerRequestFilter.java",
      "lineNumber": 103,
      "nativeMethod": false,
      "className": "org.springframework.web.filter.OncePerRequestFilter"
    },
    {
      "classLoaderName": "app",
      "methodName": "internalDoFilter",
      "fileName": "ApplicationFilterChain.java",
      "lineNumber": 189,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "doFilter",
      "fileName": "ApplicationFilterChain.java",
      "lineNumber": 162,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationFilterChain"
    },
    {
      "classLoaderName": "app",
      "methodName": "invoke",
      "fileName": "ApplicationDispatcher.java",
      "lineNumber": 711,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationDispatcher"
    },
    {
      "classLoaderName": "app",
      "methodName": "processRequest",
      "fileName": "ApplicationDispatcher.java",
      "lineNumber": 461,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationDispatcher"
    },
    {
      "classLoaderName": "app",
      "methodName": "doForward",
      "fileName": "ApplicationDispatcher.java",
      "lineNumber": 385,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationDispatcher"
    },
    {
      "classLoaderName": "app",
      "methodName": "forward",
      "fileName": "ApplicationDispatcher.java",
      "lineNumber": 313,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.ApplicationDispatcher"
    },
    {
      "classLoaderName": "app",
      "methodName": "custom",
      "fileName": "StandardHostValve.java",
      "lineNumber": 403,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.StandardHostValve"
    },
    {
      "classLoaderName": "app",
      "methodName": "status",
      "fileName": "StandardHostValve.java",
      "lineNumber": 249,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.StandardHostValve"
    },
    {
      "classLoaderName": "app",
      "methodName": "throwable",
      "fileName": "StandardHostValve.java",
      "lineNumber": 344,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.StandardHostValve"
    },
    {
      "classLoaderName": "app",
      "methodName": "invoke",
      "fileName": "StandardHostValve.java",
      "lineNumber": 169,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.StandardHostValve"
    },
    {
      "classLoaderName": "app",
      "methodName": "invoke",
      "fileName": "ErrorReportValve.java",
      "lineNumber": 92,
      "nativeMethod": false,
      "className": "org.apache.catalina.valves.ErrorReportValve"
    },
    {
      "classLoaderName": "app",
      "methodName": "invoke",
      "fileName": "StandardEngineValve.java",
      "lineNumber": 78,
      "nativeMethod": false,
      "className": "org.apache.catalina.core.StandardEngineValve"
    },
    {
      "classLoaderName": "app",
      "methodName": "service",
      "fileName": "CoyoteAdapter.java",
      "lineNumber": 357,
      "nativeMethod": false,
      "className": "org.apache.catalina.connector.CoyoteAdapter"
    },
    {
      "classLoaderName": "app",
      "methodName": "service",
      "fileName": "Http11Processor.java",
      "lineNumber": 382,
      "nativeMethod": false,
      "className": "org.apache.coyote.http11.Http11Processor"
    },
    {
      "classLoaderName": "app",
      "methodName": "process",
      "fileName": "AbstractProcessorLight.java",
      "lineNumber": 65,
      "nativeMethod": false,
      "className": "org.apache.coyote.AbstractProcessorLight"
    },
    {
      "classLoaderName": "app",
      "methodName": "process",
      "fileName": "AbstractProtocol.java",
      "lineNumber": 893,
      "nativeMethod": false,
      "className": "org.apache.coyote.AbstractProtocol$ConnectionHandler"
    },
    {
      "classLoaderName": "app",
      "methodName": "doRun",
      "fileName": "NioEndpoint.java",
      "lineNumber": 1726,
      "nativeMethod": false,
      "className": "org.apache.tomcat.util.net.NioEndpoint$SocketProcessor"
    },
    {
      "classLoaderName": "app",
      "methodName": "run",
      "fileName": "SocketProcessorBase.java",
      "lineNumber": 49,
      "nativeMethod": false,
      "className": "org.apache.tomcat.util.net.SocketProcessorBase"
    },
    {
      "classLoaderName": "app",
      "methodName": "runWorker",
      "fileName": "ThreadPoolExecutor.java",
      "lineNumber": 1191,
      "nativeMethod": false,
      "className": "org.apache.tomcat.util.threads.ThreadPoolExecutor"
    },
    {
      "classLoaderName": "app",
      "methodName": "run",
      "fileName": "ThreadPoolExecutor.java",
      "lineNumber": 659,
      "nativeMethod": false,
      "className": "org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker"
    },
    {
      "classLoaderName": "app",
      "methodName": "run",
      "fileName": "TaskThread.java",
      "lineNumber": 61,
      "nativeMethod": false,
      "className": "org.apache.tomcat.util.threads.TaskThread$WrappingRunnable"
    },
    {
      "moduleName": "java.base",
      "moduleVersion": "17.0.2",
      "methodName": "run",
      "fileName": "Thread.java",
      "lineNumber": 833,
      "nativeMethod": false,
      "className": "java.lang.Thread"
    }
  ],
  "type": "about:blank",
  "title": "Unauthorized",
  "status": "UNAUTHORIZED",
  "detail": "Full authentication is required to access this resource",
  "message": "Unauthorized: Full authentication is required to access this resource",
  "localizedMessage": "Unauthorized: Full authentication is required to access this resource"
}

For value "hello%there", I send GET /api/v1/echo/hello%25there and I receive the same as for semicolon.

Any other special character seems to be correctly decoded by Spring, but not these 3 ones.

Am I missing something ?
Is there any good way to achieve this, without having to tell spring "hey don't decode the path variables, I will do it by myself" and without having to mess with security configuration (as mentioned in https://www.baeldung.com/spring-slash-character-in-url) ?


Solution

  • Trying to achieve what you ask, you will have to do some deep configurations in tomcat and/or probably some custom rewrite rules. All these could easily backfire by creating malfunctions on uri matcher of your application or even worse create security malfunctions.

    The easiest way here to move forward is to transform your controllers from expecting this string with special characters as a @PathVariable into a query parameter with @RequestParam.

    So instead of using the

        @GetMapping("echo/{value}")
        public ResponseEntity<String> echo(@PathVariable("value") String value) {
            return ResponseEntity
                    .status(HttpStatus.OK)
                    .contentType(MediaType.TEXT_XML)
                    .body(value);
        }
    

    you might change into

       @GetMapping("echo/")
        public ResponseEntity<String> echo(@RequestParam("value") String value) {
            return ResponseEntity
                    .status(HttpStatus.OK)
                    .contentType(MediaType.TEXT_XML)
                    .body(value);
        }
    

    For value "hello/there", I send GET /api/v1/echo/hello%2Fthere

    Then try with GET /api/v1/echo?value=hello%2Fthere