
OpenRewrite recipe that detects and fixes Spring Security header suppression (CVE-2026-22732) by identifying Content-Length header misuse and generating eager header-writing configuration.
OpenRewrite recipe that detects code susceptible to CVE-2026-22732, a Spring Security defect where setting Content-Length through one of three response methods bypasses Spring Security's OnCommittedResponseWrapper. Because the wrapper never sees the header, onResponseCommitted() never fires, and the lazy-added security headers (X-Frame-Options, X-Content-Type-Options, Cache-Control, etc.) are silently dropped.
The actual triggers, confirmed against vulnerable Spring Security 6.4.12 with Spring Boot 3.4.3 / embedded Tomcat:
Servlet Content-Length via the wrapper-bypassing overloads
response.setHeader("Content-Length", "42");
response.setIntHeader("Content-Length", 42);
response.addIntHeader("Content-Length", 42);
These three overloads are not overridden in OnCommittedResponseWrapper. Subsequent body writes complete the declared length and the container commits without firing the lazy header writer.
WebFlux Content-Length via HttpHeaders
serverHttpResponse.getHeaders().set("Content-Length", "42");
serverHttpResponse.getHeaders().add("Content-Length", "42");
serverHttpResponse.getHeaders().setContentLength(42L);
WebFlux unconditional response commits
serverHttpResponse.writeWith(Mono.just(dataBuffer));
serverHttpResponse.writeAndFlushWith(publisher);
serverHttpResponse.setComplete();
The recipe is gated on Spring Security presence — it emits nothing in files that don't reference any org.springframework.security.* type — and on affected Spring Security version ranges. Per the Spring advisory published 2026-03-19, the affected ranges and fix versions are:
| Series | Affected | Fixed |
|---|---|---|
| 5.7.x | 5.7.0 – 5.7.21 | 5.7.22 (Enterprise) |
| 5.8.x | 5.8.0 – 5.8.23 | 5.8.24 (Enterprise) |
| 6.3.x | 6.3.0 – 6.3.14 | 6.3.15 (Enterprise) |
| 6.4.x | 6.4.0 – 6.4.14 | 6.4.15 (Enterprise) |
| 6.5.x | 6.5.0 – 6.5.8 | 6.5.9 (OSS) |
| 7.0.x | 7.0.0 – 7.0.3 | 7.0.4 (OSS) |
Projects resolving a Spring Security version at or above the fix in its series (or on any future series past 7.0 / 6.5) are treated as unaffected and receive no per-sink or per-file markers. Projects where the version can't be resolved fall through to the usual pattern-based detection so a scanner errs toward reporting a finding it can't disprove. The SpringSecurityVersionByProject data table still records the resolved version and flags each project as affected or not, so you can audit what was filtered.
These look dangerous but are wrapper-tracked, so security headers are written before the response commits:
| Code | Why it's safe |
|---|---|
response.setContentLength(int) / setContentLengthLong(long) | Overridden — wrapper records the declared length and fires onResponseCommitted() when the body completes. |
response.flushBuffer() | Overridden — calls doOnResponseCommitted() before super.flushBuffer(). |
response.getOutputStream().write(..) / flush() / close() | Returns SaveContextServletOutputStream; every write/flush/close fires doOnResponseCommitted() before delegating. |
response.getWriter().write(..) / print(..) / println(..) / flush() / close() | Returns SaveContextPrintWriter; same pattern. |
response.addHeader("Content-Length", v) | Special-cased in the wrapper — routed through setContentLength(long). |
The Semgrep demo's /vuln/flush endpoint claims flushBuffer() is the trigger, but on a vulnerable Spring Security 6.4.12 the response actually returns all six security headers. The real triggers in the demo are the setIntHeader("Content-Length", ...) calls in /vuln/stream and /vuln/content-length.
Run this one:
| Recipe | Purpose |
|---|---|
io.moderne.recipe.cve202622732.FindSpringSecurityHeaderSuppression | Runs every detection and emits the version report table |
The aggregator above is composed of two smaller recipes. You can invoke them individually if you want only one detection.
| Recipe | Purpose |
|---|---|
io.moderne.recipe.cve202622732.FindHttpResponseContentLengthHeader | Taint-flow for "Content-Length" literal reaching setHeader / setIntHeader / addIntHeader (servlet) or HttpHeaders.set / add (WebFlux) |
io.moderne.recipe.cve202622732.FindHttpResponseContentLengthOrFlushBuffer | WebFlux unconditional commits: writeWith, writeAndFlushWith, setComplete, HttpHeaders.setContentLength |
Run this one:
| Recipe | Purpose |
|---|---|
io.moderne.recipe.cve202622732.FixSpringSecurityHeaderSuppression | Picks the cheapest remediation each project can actually take |
It runs two steps in order.
1. Bump to the fix on the project's own series. Spring Security published the fix as 6.5.9
and 7.0.4 on Maven Central. Each bump is gated on a FindAffectedSpringSecuritySeries
precondition, because UpgradeDependencyVersion only checks that its target is newer — told to
go to 7.0.4 it would happily drag a 5.8 project across two major versions.
2. Add an eager header-writing configuration to whatever step 1 could not fix. This generates
one @Configuration class per project:
@Bean
public static BeanPostProcessor eagerHeaderWriterFilterBeanPostProcessor() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof HeaderWriterFilter) {
((HeaderWriterFilter) bean).setShouldWriteHeadersEagerly(true);
}
return bean;
}
};
}
Writing the headers up front makes it irrelevant whether the wrapper ever observes the commit, so
this closes every sink in the project at once — including the ones taint analysis can't reach,
like the Map flow under "Known limitations". The BeanPostProcessor sees filters built by the
HttpSecurity DSL because AutowireBeanFactoryObjectPostProcessor initialises them through the
bean factory. Two independently published fixes for this CVE use exactly this shape
(hmcts/idam-web-public, and
armory-io's Spinnaker forks via the equivalent ObjectPostProcessor).
Step 2 is what covers the projects step 1 cannot help:
| Situation | Why the bump doesn't work |
|---|---|
| 5.7, 5.8, 6.3, 6.4 | Fix ships to Spring Enterprise subscribers only — not on Maven Central |
| 6.0 - 6.2 | No fix was ever released on those series |
| Version managed by an imported BOM | Nothing is declared locally for the bump to edit |
That last row is not a corner case. nla/bamboo resolves 7.0.3 — a series with an open-source
fix — entirely from the Spring Boot BOM, so a version check alone would skip it under both steps
and leave it vulnerable. AddEagerHeaderWriterConfiguration therefore only defers to the upgrade
when the project both has an open-source fix available and declares a version of its own.
The generated class is placed next to an @EnableWebSecurity class where one exists, falling back
to @SpringBootApplication and then any @Configuration, so it always lands somewhere component
scanning reaches. Projects that already write headers eagerly, or that vendor a patched
OnCommittedResponseWrapper (as jogetworkflow/jw-community
does), are left alone.
| Recipe | Purpose |
|---|---|
io.moderne.recipe.cve202622732.UpgradeSpringSecurityToPatchedVersion | Series-gated bump to 6.5.9 / 7.0.4 |
io.moderne.recipe.cve202622732.AddEagerHeaderWriterConfiguration | The generated configuration, on its own |
io.moderne.recipe.cve202622732.FindAffectedSpringSecuritySeries | Marks projects on one affected series; the upgrade precondition |
Applied to semgrep/cve-2026-22732-demo on
vulnerable Spring Security 6.4.12, against embedded Tomcat. Its HeaderVerificationTest asserts
that the security headers are missing, so a working fix makes it fail:
| Endpoint | Before | After |
|---|---|---|
/vuln/stream | X-Content-Type-Options: null | nosniff |
/vuln/content-length | X-Content-Type-Options: null | nosniff |
/safe | nosniff | nosniff |
X-Frame-Options and Cache-Control follow the same pattern.
Across the 16 repositories of the corpus that build (of 29 identified), the fix generated a configuration for three and correctly left the rest alone:
| Repository | Outcome |
|---|---|
semgrep/cve-2026-22732-demo | Generated into com/example/vuln, beside @EnableWebSecurity; compiles, headers restored |
nla/bamboo | Generated into ui/src/bamboo, beside @SpringBootApplication; compiles. The BOM-managed 7.0.3 case the upgrade cannot reach |
star-whale/starwhale | Generated into ai/starwhale/mlops/configuration/security, beside @EnableWebSecurity; compiles (JDK 11, its declared target) |
hmcts/idam-web-public | Left alone — already calls setShouldWriteHeadersEagerly |
okta/okta-idx-java (6.5.9), psi-probe (6.5.11), Ant-Media-Server (6.5.11) | Left alone — past the fix on their series |
apache/shenyu (6.3.1) | Left alone — reactive only; HeaderWriterFilter has no servlet API behind it, and the CVE is servlet-only |
brutusin/Brutusin-RPC (4.0.4) | Left alone — predates setShouldWriteHeadersEagerly (5.2) |
bootplus, template-app, front50, igor, rosco, spring-security, reportserver | Left alone — no affected Spring Security version resolved |
Re-running the fix over the three patched repositories generates nothing further, so the remediation is idempotent against its own output on real projects.
A second, wider corpus targets the population the fix actually addresses — any affected servlet Spring Security application, since no sink is required. Of 64 such projects found by code search, 52 built, 48 resolved an affected version, and 38 were patched; 35 of those compile (the other three fail identically without the generated file). All 8 projects on Spring Security below 5.2 were correctly skipped. See SUSCEPTIBLE-REPOSITORIES.md section 8.
OnCommittedResponseWrapper extends jakarta.servlet.http.HttpServletResponseWrapper, so
CVE-2026-22732 is servlet-only and a reactive application is not exposed to it. Findings from
FindHttpResponseContentLengthOrFlushBuffer flag the analogous reactive pattern and still need
manual review, but the fix deliberately does not act on them. AddEagerHeaderWriterConfiguration
skips any module that can see HeaderWriterFilter without the servlet API behind it — the
filter extends OncePerRequestFilter, and spring-security-web carries the servlet API as a
non-transitive provided dependency, so generating there fails with
cannot access jakarta.servlet.Filter (observed on apache/shenyu).HeaderWriterFilter.setShouldWriteHeadersEagerly arrives in 5.2; on 4.0.4 the filter has only a
constructor and doFilterInternal. Detection still reports EOL versions, but the remediation is
withheld rather than emitting a call that cannot compile.| Table | Rows |
|---|---|
TaintFlowTable (from rewrite-program-analysis) | One row per Content-Length-header taint hit |
HttpResponseDirectCommitTable | One row per WebFlux structural hit |
SpringSecurityVersionByProject | One row per project with detected Spring Security version |
Via the Moderne CLI:
mod run . --recipe io.moderne.recipe.cve202622732.FindSpringSecurityHeaderSuppression
Via rewrite.yml:
---
type: specs.openrewrite.org/v1beta/recipe
name: com.example.DetectSpringSecurityHeaderSuppression
displayName: Detect CVE-2026-22732
recipeList:
- io.moderne.recipe.cve202622732.FindSpringSecurityHeaderSuppression
repos.csv lists the 75 public repositories these recipes were developed and measured
against, pinned to the exact commit each was evaluated at. Several are actively maintained
and will be patched upstream, so the changeset column is what makes the numbers below
reproducible rather than merely plausible.
mod git sync csv ./corpus repos.csv --with-sources
mod build ./corpus
mod run ./corpus --recipe=io.moderne.recipe.cve202622732.CveDevCenter
mod devcenter ./corpus --last-recipe-run
The sync takes about 20 seconds and 1.4 GB; the build takes roughly 15 minutes and is the
only slow step. mod devcenter writes devcenter.html into
corpus/.moderne/run/<runId>/, plus one per organisation subdirectory.
The org1 column splits the corpus into groups that reflect why each repository is present
— Servlet Sinks and WebFlux Sinks for the two vulnerable call shapes, Patched for
repositories already remediated upstream, Reference for Spring Security itself and other
non-consumers, Verified for the case checked against a running server, Gradle for
build-tool coverage, and Wide for the bulk sample.
Expect, on the pinned commits:
| Result | |
|---|---|
| Upgrade card | 39 Major, 21 Minor, 6 Patch, 4 Completed (70 repositories) |
| Security card | 65 exposed repositories |
| Not applicable | 5 repositories resolve no Spring Security dependency |
Four of those five genuinely do not use Spring Security — spring-projects/spring-security
is the library itself, JoeyBling/bootplus uses Apache Shiro, jenkinsci/stapler targets
the servlet API directly, and infofabrik/reportserver has no Maven or Gradle build to
resolve. The fifth, xtuer/template-app, declares spring-security-web:5.0.0.RELEASE but
its Gradle build resolves no dependencies at all during mod build, so no recipe can see
the version. Treat it as unmeasured rather than unaffected.
To apply the fix and check the result:
mod run ./corpus --recipe=io.moderne.recipe.cve202622732.FixSpringSecurityHeaderSuppression
mod git apply ./corpus --last-recipe-run
mod exec ./corpus --last-recipe-run MODERNE_BUILD_TOOL_CHECK
mod git apply writes to the checkouts in place. A full check across the corpus is slow
and will surface failures unrelated to this change — missing JDK toolchains, unreachable
dependency repositories, tests that were already red — so the meaningful signal is the
delta against the same command run before applying.
The taint analysis from rewrite-program-analysis handles local dataflow and per-method summaries, so these patterns are detected automatically:
String h = "Content-Length"; response.setIntHeader(h, 42); is flagged — the framework tracks the literal taint through the local assignment.Map, List, custom collections). stash.put("k", "Content-Length") followed by response.setIntHeader(stash.get("k"), 42) is not detected — the put/get identity is opaque to the analysis.Moderne Proprietary. Only for use by Moderne customers under the terms of a commercial contract.