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.
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.