spring-securitycsrfswagger-uispringdocspringdoc-openapi-ui

CSRF acting strange with springdoc in stateless service


Let's consider that I have a stateless service with an authentication mechanism that makes the browser automatically send the credentials which then makes even a stateless service vulnerable. I want to apply the CSRF filter conditionally after authentication already happened. Does the authentication contain a "service" or an actual human being? If it's a service, I don't want CSRF at all, if it's a human being I absolutely want one.

Human users will call my service through springdoc's Swagger UI, other services integrate with various things (RestTemplate/RestClient from Java, Boost from C++, requests library from Python, etc).

Let's say I have my security filter chain:

@Bean
public SecurityFilterChain securityFilterchain(HttpSecurity httpSecurity) {
    CsrfTokenRepository repository = new CsrfTokenRepository();
    repository.setCookieCustomizer(c -> c
            .httpOnly(false)
            .secure(true)
            .sameSite("Strict"));
    XorCsrfTokenRequestAttributeHandler delegate = new XorCsrfTokenRequestAttributeHandler();
    httpSecurity
            .csrf(customizer -> customizer
                    .requireCsrfProtectionMatcher(request -> {
                            Set<String> require_csrf = Set.of("PUT", "PATCH", "POST", "DELETE");
                            if (!require_csrf.contains(request.getMethod()) {
                                return false;
                            }
                            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
                            MyUser user = (MyUser) auth.getPrincipal();
                            return !user.isSystem;
                    })
                    .csrfTokenRepository(repository)
                    .csrfTokenRequestHandler(delegate::handle))
            .sessionManagement(smc -> smc.sessionCreationPolicy(STATELESS))
            .addFilterBefore(authFilter(), CsrfFilter.class)
            .authorizeHttpRequests(r -> r
                    // irrelevant
            );
    return httpSecurity.build();
}

You basically call an endpoint and get a 403 with a cookie and then you send your request again with the cookie as well as the header. This is actually working but Swagger does not handle this case.

I did configure:

springdoc:
    show-actuator: true
    swagger-ui:
        csrf:
            enabled: true

I however did not explicitly set cookie-name and header-name because the defaults match what Spring Security expects already.

My solution is working for the most part, but there is one thing that renders Swagger use somewhat annoying:

Is there a way to configure Swagger to "retry" after a 403 if in the response it had a CSRF cookie (PUT/POST/DELETE requests, GET are obviously skipped)? I guess it is possible, maybe via request interceptors but for I don't know how they should work.

Is there a way around this double-execute-button-press to properly handle CSRF part?

@Update:

I thought I'd inject a custom script to the swagger index page through using SwaggerIndexPageTransformer, boy was I wrong. The ui doesn't render anymore.

@Bean
SwaggerIndexPageTransformer foobar(ServerProperties serverProperties, SwaggerUiConfigProperties swaggerUiConfigProperties, SwaggerUiOauthProperties uiOauthProperties, SwaggerWelcomeCommon swaggerWelcomeCommon, ObjectMapperProvider objectMapperProvider) {
    return new SwaggerIndexPageTransformer(swaggerUiConfigProperties, uiOauthProperties, swaggerWelcomeCommon, objectMapperProvider) {
        @Override
        public Resource transform(HttpServletRequest request, Resource resource, ResrouceTransformerChain transformerChain) throws IOException {
            String html = readResource(resource);
            Document doc = Jsoup.parse(html);
            Element body = doc.body();
            Element script = doc.createElement("script");
            script.attr("src", "/interceptors/csrf.js");
            script.attr("charset", "UTF-8");
            body.appendChild(script);
            return new TransformedResource(resource, doc.html().getBytes(UTF_8));
        }

        private static String readresource(Resource resource) throws IOException {
            // omitted
        }
    }
}

Using this, upon inspecting in any browser I can see my js added. For now I added a dummy js:

console.log("Hello World");

I dev-tools console I can see Hello World printed but the swagger-ui won't render and I have effectively a blank page.


Solution

  • For anyone that might stumble upon this. SwaggerIndexPageTransformer is not just called only for the index page, but for quite a few other files too.

    For completeness sake, I put a javascript file under: src/main/resources/static/interceptors/foo.js
    with the contents being: console.log("Hello World");

    What I ended up doing in the end:

    @Bean
    SwaggerIndexPageTransformer foobar(SwaggerUiConfigProperties swaggerUiConfigProperties, SwaggerUiOauthProperties uiOauthProperties, SwaggerWelcomeCommon swaggerWelcomeCommon, ObjectMapperProvider objectMapperProvider) {
        return new SwaggerIndexPageTransformer(swaggerUiConfigProperties, uiOauthProperties, swaggerWelcomeCommon, objectMapperProvider) {
            @Override
            public Resource transform(HttpServletRequest request, Resource resource, ResrouceTransformerChain transformerChain) throws IOException {
                Resource transformedResource = super.transform(request, resource, transformerChain);
                AntPathMatcher matcher = new AntPathMatcher();
                boolean isIndexHtml = matcher.match("**/swagger-ui/" + new WebJarVersionLocator().version("swagger-ui") + "/index.html", resource.getURL().toString());
                if (isIndexHtml) {
                    String source = new String(FileCopyUtils.copyToByteArray(transformedResource.getInputStream)), UTF_8);
                    Document html = Jsoup.parse(source);
                    Element script = html.createElement("script");
                    script.attr("src", "/interceptors/foo.js");
                    script.attr("charset", UTF_8.name());
                    html.body().appendChild(script);
                    return new TransformedResource(transformedResource, html.outerHtml().getBytes(UTF_8));
                }
                return transformedResource;
            }
        }
    }
    

    swagger-page properly loads, everything is in it's place and the custom foo.js is loaded. From this point on forward the only thing I needed was to implement a csrf interceptor that backs up the original window.fetch function and create a new one that handles the CSRF "handshake" for PUT/POST/DELETE/PATCH operations only and retries the request when the backend sends an XSRF cookie.
    I could remove the springdoc.swagger-ui.csrf.enabled=true property from application.properties and now I have a spring-security6 (deferred-csrf-token) compatible swagger-ui for a stateless service.