MODERNE

Detecting CVE-2026-22732
at scale

An OpenRewrite recipe, an honest contrast with pattern matching, and what an afternoon’s composition looks like

The vulnerability

Spring Security writes its headers lazily.
If the controller commits the response first, those headers vanish.

When a controller calls response.getOutputStream().write(…), response.flushBuffer(), or sets Content-Length by hand, the response buffer commits before Spring Security’s filter chain has a chance to install X-Frame-Options, X-Content-Type-Options, Cache-Control, and friends. The page renders. The browser sees no protective headers. The bug is silent and the browser is the only place you’d notice.

Affected: Spring Security 5.7–5.8, 6.3–6.5, 7.0 below the patch line.

9.1
CVSS 3.1 base score (Critical)
0
Authentication required
3
Distinct trigger patterns
What we look for

Three patterns. All committing the response too early.

Pattern 1 — servlet output stream
response.getOutputStream()
  .write(data);
response.getOutputStream()
  .flush();
Pattern 2 — writer + flushBuffer
response.getWriter()
  .write(html);
response.flushBuffer();
Pattern 3 — Content-Length header
response.setIntHeader(
  "Content-Length", n);
response.setContentLength(n);

These shapes are obvious in the canonical demo. They are not always obvious in real customer code, where the dangerous call sits behind a helper, the Content-Length string lives in a constant, and the response is a custom HttpServletResponseWrapper subclass.

Two ways to find these

Pattern match catches the demo. Real estates have indirection.

Pure pattern match (semgrep-style)
  • Direct response.getOutputStream().write(…)
  • Direct response.flushBuffer()
  • Direct response.setIntHeader("Content-Length", …)
  • Helper that returns the writer, caller writes
  • Local variable holding the literal "Content-Length"
  • Custom wrapper subclass overriding getWriter()
  • Field stash: capture in one method, write in another
Taint-flow analysis (this recipe)
  • Same direct cases — covered
  • Helper returning the source — covered via method summaries
  • Constant-propagated header name — covered via dataflow
  • Wrapper subclasses — covered via matchOverrides
  • Field stash across methods — covered via summaries
  • Reactive WebFlux writeWith/setComplete — covered
Inside one finding

A single taint trace crosses six analysis stages.

class JsonExporter {
  private OutputStream out;

  OutputStream stream(HttpServletResponse r) {
    return r.getOutputStream();  // [1]
  }

  void init(HttpServletResponse r) {
    OutputStream s = stream(r);      // [2][3]
    this.out = s;                // [4]
  }

  void emit(byte[] data) {
    out.write(data);             // [5][6]
  }
}
  1. Source match. getOutputStream() tags the return with taint type OUTPUT_STREAM_COMMIT.
  2. Method summary. stream(r) inherits the return taint so every caller sees it.
  3. Local binding. OutputStream s = stream(r) carries the taint onto s.
  4. Field store. this.out = s records the taint on the instance field.
  5. Field load. out.write(…) reads it back in a different method — the receiver is still tainted.
  6. Sink match. Receiver check + write(..) matcher fire; the TaintFlowTable row is emitted.

Every stage is a transfer function already in the framework. The recipe declares the source and sink — stages 2 through 5 are the same field-sensitive interprocedural summary machinery that every rewrite-program-analysis taint recipe reuses.

Where pattern matching breaks

A helper that returns the writer is the most common miss.

Real-world shape
void handle(HttpServletResponse response) {
  PrintWriter w = obtain(response);
  w.write("hi");  // flagged
}

PrintWriter obtain(HttpServletResponse r) {
  return r.getWriter();
}

A pure response.getWriter() matcher inspects each call site in isolation and finds nothing in handle(). The actual source — the call into obtain() — is wrapped in a helper, and the dangerous write happens at the call site, not where the writer was constructed.

rewrite-program-analysis’s TaintAnalysis builds method summaries automatically. When obtain() returns a value derived from a tainted source, the framework records that fact. At the caller, the return of obtain(response) carries the taint forward into the w.write(…) sink. No extra spec configuration. The recipe author writes one source matcher and one sink matcher; interprocedural flow falls out for free.

Two more shapes a literal-only matcher misses

Constant-propagated header names. Wrapper subclasses.

Constant in a local variable
String h = "Content-Length";
response.setIntHeader(h, 42);
// Source taints the literal,
// dataflow carries it through h
// to the sink’s arg 0.

The HttpResponseContentLengthHeaderSpec declares a literal source ("Content-Length") and a parameter-index sink. The framework handles the propagation. No bespoke constant folding.

User-authored response wrapper
class LoggingResponse
    extends HttpServletResponseWrapper {
  @Override
  public PrintWriter getWriter() {...}
}
LoggingResponse w = new LoggingResponse(r);
w.getWriter().write("hi");

The recipe’s MethodMatchers are constructed with matchOverrides=true. The receiver type can be any subclass of HttpServletResponse — including framework wrappers and customer wrappers — and the method walk finds the override.

Reactive coverage

Same patterns. Different API surface.

Servlet
response.getOutputStream().write(data);
response.flushBuffer();
response.setIntHeader("Content-Length", n);
WebFlux
serverHttpResponse.writeWith(buf);
serverHttpResponse.setComplete();
serverHttpResponse.getHeaders()
  .setContentLength(n);
serverHttpResponse.getHeaders()
  .set("Content-Length", "42");

The structural recipe also matches org.springframework.http.ReactiveHttpOutputMessage.writeWith(…), writeAndFlushWith(…), and setComplete() — methods declared on the parent of ServerHttpResponse, so all reactive subtypes are caught. The Content-Length taint-flow spec catches both the typed setter (HttpHeaders.setContentLength(long)) and the header-name form (HttpHeaders.set("Content-Length", …)).

What it produces

Three data tables. Joinable across the estate.

TaintFlowTable — six rows from the canonical demo
SourceSink lineSinkTaint type
response.getOutputStream()40.write(data)OUTPUT_STREAM_COMMIT
response.getOutputStream()41.flush()OUTPUT_STREAM_COMMIT
response.getWriter()53.write("<html>…")WRITER_COMMIT
response.getWriter()68.write(body)WRITER_COMMIT
"Content-Length"39setIntHeader(…, data.length)CONTENT_LENGTH_HEADER_NAME
"Content-Length"67setIntHeader(…, body.getBytes().length)CONTENT_LENGTH_HEADER_NAME

A second table records FLUSH_BUFFER hits structurally. A third lists the resolved Spring Security version per project (com.example:cve-2026-22732-demo → spring-security-web:6.4.12). Customers join on source file or project to get a single triage view.

Beyond detection

The remediation is a version bump.
Do it where it matters.

CVE-2026-22732 is fixed in Spring Security 6.5.9, 7.0.4, and the corresponding 5.x and 6.x patch lines. The remediation isn’t a code rewrite; it’s a dependency upgrade. A pattern-match tool can find the call sites but the developer still has to chase the dependency.

OpenRewrite already has UpgradeDependencyVersion. Compose it with this detection recipe and the result is a single artifact that finds the vulnerable shapes and bumps Spring Security — only in repositories where the pattern actually exists, so clean repos don’t get gratuitously upgraded.

class FixCve_2026_22732 extends Recipe {
  @Override
  public void buildRecipeList(RecipeList r) {
    r.recipe(new FindSpringSecurity
        HeaderSuppression());
    r.recipe(new UpgradeDependencyVersion(
        "org.springframework.security",
        "spring-security-*",
        "6.5.9", null));
  }
}
How long this took

An afternoon’s composition on top of existing recipes.

The recipe is six small Java classes plus tests. The taint-flow framework, the data table machinery, the SimpleTaintTrackingRecipe base, the MethodMatcher with matchOverrides, the UsesType precondition primitive, and the receiver-as-sink pattern all came from rewrite-program-analysis. Two existing specs — FindXxeVulnerability and DirectSSLConfigurationEditingSpec — served as direct shape templates.

The project starts from a copy of rewrite-cryptography’s build scripts and CI workflows. Net new code is the four matchers and the Spring Security version lookup. The hard work was already done by the framework.

~6
Java source files in the recipe
39
Unit tests, all green
0
New framework code written
The pipeline

Five independent stages. One composable output.

Gate: UsesType("org.springframework.security..*") — files that don’t reference Spring Security are skipped entirely
four taint / structural detections · one project-level scan
STAGE 1
OutputStream commit
Taint: getOutputStream() → write / flush / close / print receiver
→ TaintFlowTable
STAGE 2
Writer commit
Taint: getWriter() → write / print / println / format / close receiver
→ TaintFlowTable
STAGE 3
Content-Length literal
Taint: "Content-Length" literal → arg 0 of setIntHeader / setHeader / HttpHeaders.set
→ TaintFlowTable
STAGE 4
Direct commit (structural)
setContentLength(…), flushBuffer(), reactive setComplete(), writeWith — no taint needed
→ HttpResponseDirectCommitTable
STAGE 5
Spring Security version
Per-project scan of MavenResolutionResult and GradleProject markers — emits one row per project
→ SpringSecurityVersionByProject
stitched by project identity
findings × project version → triage subset
only vulnerable + affected → actionable
feeds UpgradeDependencyVersion downstream

The aggregator FindSpringSecurityHeaderSuppression runs all five stages in one invocation and emits into shared data tables. A downstream recipe list joins the tables on project_id, filters to projects that both contain a suppression flow and are on an affected Spring Security version, and applies UpgradeDependencyVersion only there — no gratuitous upgrades on clean repos.

MODERNE

Run it on your estate.

mod run . --recipe io.moderne.recipe.cve202622732
.FindSpringSecurityHeaderSuppression

github.com/moderneinc/cve-2026-22732 — sources, tests, and the data-table contracts. Compose with UpgradeDependencyVersion for the gated bump. Reach out if you want help wiring it into your DevCenter pipeline.

1 / 13