X-Forwarded-Prefix: Magic that almost worked
Something unexpected happened the other day when working on a small frontend project. There was an internal backend service developed by one of the teams. The kind of service in a corporate organisation behind a big API gateway, implemented in Kotlin and Spring. It’s main job is accepting HTTP requests and responding using it’s persistence layer and such. The team apparently built an internal basic admin UI by exposing some GET endpoints that respond with good old HTML and inline js and css.
This UI got a bit more traction than they’ve initially anticipted and was enabled internally company-wide for early testing of the feature. My job was integrating it with a modern frontend component built in React. Even though Kotlin & Spring is not my forte, I thought “how hard can it be?”. Spring is a very powerful web framework and original implementation conveniently uses Thymeleaf to render the pages.
After setting up a basic frontend bundling setup with Parcel, and learning Gradle via an accelerated LLM training session, I’ve discovered a node plugin that I could use without messing with the build pipeline Docker image. It worked perfectly by setting everything up automatically on the same base Java image. After some fiddling, my basic setup worked with proper file dependencies and cleanup. The build copies frontend artifacts from parcel into resources as static assets and the main html is copied as a template to replace temporary dummy ones. It builds fine even if you skip this frontend build step, to minimally affect original service owners’ backend work. Spring is capable enough to easily serve those static files by it’s default web server, which in my case was Webflux, after some tinkering and finding the correct incantation:
class WebFluxConfig(
private val environment: Environment
) : WebFluxConfigurer {
override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.maxAge(3600, TimeUnit.DAYS)) // Aggressive caching for content-hash appended resources
.resourceChain(true)
.addResolver(EncodedResourceResolver()) // Add encoding support (gzip etc.)
.addResolver(PathResourceResolver());
}
}
How I could possibly tell Thymeleaf about the http path of those files without hard coding them? Didn’t take long to realize context-relative paths was a feature. It looked like magic. I was thinking, how possibly could the web server know about the path prefix it exists in? The public gateway is serving this service behind /api/gateway and then the API gateway does additional path replacements, changing a few more path segments. Finally the webserver does its final routing inside the app. I’ve quickly scanned the codebase, expecting there must be something to account for these prefixes, but none! The last part of the path is where Spring would serve the files and somehow it needs to know about the prefix up to that point to be able to resolve things properly. Basically, {@/assets/entry.js} has to become /api/gateway/more-unrelated-segments/assets/entry.js on the final document, with that context-relative path magic.
I didn’t understand how this could work but gave it a go to see what happens on the staging environment. To my amazement, it partially worked. Indeed the API gateways’ path replacement was in the html document as a prefix to the static asset! At this point Spring is too much magic. I simply asked an LLM what is going on. Apparently, there is this class of “proxy” headers;
X-Forwarded-For
X-Forwarded-Host
X-Forwarded-Proto
And their well-documented “standard” versions in RFC-7239. Interestingly non-standard X-Forwarded-* seems more widespread though. Each proxy server in the request path appends information from it’s own point of view. For is for comma separated IP addresses, Host is for original host and so on.
X-Forwarded-Prefix, on the other hand is the under documented one, but after verifying Spring is indeed using this header, I was able to find the hidden logic. Similar to X-Forwarded-For, it supports multiple path segments appended at different proxy layers.
At the end of the day, it didn’t work for my case. Even though our API gateway supports it, public proxy doesn’t support it and I was stuck only with a partial prefix. I had to hardcode the value and call it a day and TIL something. Later I learned that SERVER_SERVLET_CONTEXT_PATH can be set to have a deployment specific override, but haven’t tried it yet.
You can reach me out for any comments or questions on Bluesky.