An OpenRewrite recipe, an honest contrast with pattern matching, and what an afternoon’s composition looks like
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.
response.getOutputStream() .write(data); response.getOutputStream() .flush();
response.getWriter() .write(html); response.flushBuffer();
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.
response.getOutputStream().write(…)response.flushBuffer()response.setIntHeader("Content-Length", …)getWriter()matchOverrideswriteWith/setComplete — coveredclass 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] } }
getOutputStream() tags the return with taint type OUTPUT_STREAM_COMMIT.stream(r) inherits the return taint so every caller sees it.OutputStream s = stream(r) carries the taint onto s.this.out = s records the taint on the instance field.out.write(…) reads it back in a different method — the receiver is still tainted.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.
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.
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.
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.
response.getOutputStream().write(data); response.flushBuffer(); response.setIntHeader("Content-Length", n);
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", …)).
| Source | Sink line | Sink | Taint 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" | 39 | setIntHeader(…, data.length) | CONTENT_LENGTH_HEADER_NAME |
| "Content-Length" | 67 | setIntHeader(…, 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.
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)); } }
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.
UsesType("org.springframework.security..*") — files that don’t reference Spring Security are skipped entirelygetOutputStream() → write / flush / close / print receivergetWriter() → write / print / println / format / close receiver"Content-Length" literal → arg 0 of setIntHeader / setHeader / HttpHeaders.setsetContentLength(…), flushBuffer(), reactive setComplete(), writeWith — no taint neededMavenResolutionResult and GradleProject markers — emits one row per projectUpgradeDependencyVersion downstreamThe 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.
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.