Search code examples
javaspringspring-securityjunitspring-security-test

Unit testing Spring controllers with CSRF


I have the version 5.6.10 in the following dependencies

  • spring-security-test
  • spring-security-core
  • spring-security-web

I have a controller with CSRF

@GetMapping(value = "/data")
public ResponseEntity<DataResponse> data(@RequestParam(required = false) Double param, CsrfToken token){
    ...
}

I have a JUnit test that was working before adding the , CsrfToken token to Repository.

@WebMvcTest(controllers = Controller.class, excludeAutoConfiguration = {SecurityAutoConfiguration.class})
@ContextConfiguration(classes = {Controller.class, TestConfiguration.class})
class ControllerTest {

    @Autowired private MockMvc mockMvc;


    @Test
    void test() throws Exception {
    
        mockMvc.perform(get("/.../data?param=2.0")
                    .contextPath("/CONTEXT").servletPath("/.../data")
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andExpectAll(
                status().isOk(),
                ...
            )
            .andReturn();
    }
}

@WebAppConfiguration
@EnableWebMvc
public class TestConfiguration {

    @Bean
    ReactjsControllerExceptionHandler reactjsControllerExceptionHandler() {
        return new ControllerExceptionHandler(); // is a @ControllerAdvice that extends ResponseEntityExceptionHandler. I think it does not matter for this case.
    }
}

I am getting

No primary or single unique constructor found for interface org.springframework.security.web.csrf.CsrfToken

org.springframework.web.util.NestedServletException: Request processing failed; nested exception is java.lang.IllegalStateException: No primary or single unique constructor found for interface org.springframework.security.web.csrf.CsrfToken

    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:502)
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
    at org.springframework.test.web.servlet.TestDispatcherServlet.service(TestDispatcherServlet.java:72)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:596)
    at org.springframework.mock.web.MockFilterChain$ServletFilterProxy.doFilter(MockFilterChain.java:167)
    at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:134)
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    at org.springframework.mock.web.MockFilterChain.doFilter(MockFilterChain.java:134)
    at org.springframework.test.web.servlet.MockMvc.perform(MockMvc.java:201)
Caused by: java.lang.IllegalStateException: No primary or single unique constructor found for interface org.springframework.security.web.csrf.CsrfToken
    at org.springframework.beans.BeanUtils.getResolvableConstructor(BeanUtils.java:268)
    at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.createAttribute(ModelAttributeMethodProcessor.java:219)
    at org.springframework.web.servlet.mvc.method.annotation.ServletModelAttributeMethodProcessor.createAttribute(ServletModelAttributeMethodProcessor.java:85)
    at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:147)
    at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122)
    at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:179)
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:146)
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808)
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1072)
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
    ... 87 more

I already tried these options:

  • mockMvc.perform(get(...).with(csrf()))
  • Setting csfrToken as attribute:
.with(request -> {
    request.setAttribute(CsrfToken.class.getName(), csrfToken);
    return request;
})
  • Mocking with Mockito
CsrfToken csrfToken = Mockito.mock(CsrfToken.class);
Mockito.when(csrfToken.getToken()).thenReturn("myToken");

I am getting always the same error, what can I do?


Solution

  • Quite sure, that spring-security does not "csrf protect" GET endpoints "by default" (extend the later mentioned test with csrf "expectations" they won't hold/invalid csrf won't have effect)

    Nevertheless, some "magic" makes it possible to "populate" CsrfToken param/attribute (also in spring-web(-security!) GET requests) ...

    Sorry, can only partially reproduce!

    • starter used: boot:2+web+security(+devtools)

      (For "best version match" use:

      • parent:2.6.14
      • and <spring-security.version>5.6.10</spring-security.version> (property)

      )

    • Sa/imple controller (in child package of main class/root package):

      @RestController
      public class MyController {
         @GetMapping(value = "/data")
         public ResponseEntity<String> data(
           @RequestParam(required = false) Double param
         ) {
           return ResponseEntity.ok("Hello");
         }
      }
      
    • protected by spring-boot-starter-security (defaults)

    • (a "comparable", realistic, security-integrating, suceeding) Test:

      import org.junit.jupiter.api.Test;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
      import org.springframework.http.MediaType;
      import org.springframework.security.test.context.support.WithMockUser;
      import org.springframework.test.web.servlet.MockMvc;
      import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
      import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
      import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
      
      @WebMvcTest(controllers = MyController.class)
      class MyControllerTest {
      
         @Autowired
         private MockMvc mockMvc;
      
         @Test
         void testUnatuhorized() throws Exception {
            mockMvc.perform(
                    get("/data?param=2.0")
                            .contentType(MediaType.APPLICATION_JSON))
                    .andExpect(
                            status().isUnauthorized()); // 1.
         }
      
         @Test
         @WithMockUser // 2.
         void testAuthorized1() throws Exception {
            mockMvc.perform(
                    get("/data?param=2.0")
                            .contentType(MediaType.APPLICATION_JSON))
                    .andExpectAll(
                            status().isOk(),
                            content().string("Hello"));
         }
      
         @Test
         void testAuthorized2() throws Exception {
            mockMvc.perform(
                    get("/data?param=2.0")
                            .with(user("user")) // 3.
                            .contentType(MediaType.APPLICATION_JSON))
                    .andExpectAll(
                            status().isOk(),
                            content().string("Hello"));
         }
      }
      
    • Modifying controller to:

      import org.springframework.security.web.csrf.CsrfToken;
      //...
          @GetMapping(value = "/data")
          public ResponseEntity<String> data(
            @RequestParam(required = false) Double param,
            CsrfToken token /*!!*/) {
                System.err.println(token); // !
                return ResponseEntity.ok("Hello");
          }
      

      Doesn't break the test!

    • But excludeAutoConfiguration = {SecurityAutoConfiguration.class} Does! (with exact same exception cause/message!;) :

      org.springframework.web.util.NestedServletException:
         Request processing failed; nested exception is java.lang.IllegalStateException: 
           No primary or single unique constructor found for interface org.springframework.security.web.csrf.CsrfToken ...
      

    So the issue/solution must be in the missing SecurityAutoConfiguration:your-version/ your custom TestConfiguration.

    ... (digging source code) (CsrfToken, LazyCsrfTokenRepository, SecurityAutoConfiguration, EnableWebSecurity, ...)

    You miss a "csrf configuration" in your (test) context, which could be eligible to populate/resolve these arguments!!


    Possible fixes:

    • don't excludeAutoConfiguration = {SecurityAutoConfiguration.class} (on your @WebMvc-/SpringBoot-Test)
    • alternatively: Add @EnableWebSecurity annotation to your TestConfiguration.

    Both configure a CsrfTokenRepository in your (test) context, which is capable of populating/resolving these "request mapping arguments"...

    • ideally you should use a "real(istic)" security configuration for "integration tests".
    • ...