| <!-- | |
| Licensed to the Apache Software Foundation (ASF) under one or more | |
| contributor license agreements. See the NOTICE file distributed with | |
| this work for additional information regarding copyright ownership. | |
| The ASF licenses this file to You under the Apache License, Version 2.0 | |
| (the "License"); you may not use this file except in compliance with | |
| the License. You may obtain a copy of the License at | |
| http://www.apache.org/licenses/LICENSE-2.0 | |
| Unless required by applicable law or agreed to in writing, software | |
| distributed under the License is distributed on an "AS IS" BASIS, | |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| See the License for the specific language governing permissions and | |
| limitations under the License. | |
| --> | |
| # Apache Struts — Threat Model (v0 draft) | |
| ## §1 Header | |
| - **Project:** Apache Struts (`apache/struts`), `main` @ HEAD (2026-06). Scope: the | |
| Struts framework in `apache/struts` only (the core MVC framework, its | |
| interceptors, tags, and the plugins shipped in this repo). | |
| - **Date:** 2026-06-24. **Drafted for PMC review** via the threat-model-producer | |
| rubric (Scovetta). This is an unratified proposal, not an ASF Security team or | |
| PMC position; authorship and sponsorship are settled only once the PMC adopts it | |
| (see Status below and §14). | |
| - **Status:** DRAFT — not yet reviewed by the Struts PMC. Built as a strict | |
| superset of the existing [`SECURITY.md`](SECURITY.md) and the published | |
| [Struts security guidance](https://struts.apache.org/security/); every | |
| load-bearing claim is tagged for provenance (see §14 for open questions). | |
| - **Last revised:** 2026-10-31 — added §8.8, output encoding by the framework's own | |
| tags, which §8 had never stated although the project has CVE'd a failure of it | |
| (CVE-2011-2087), and which §9's "no output encoding for the application's own data" | |
| read as if it disclaimed; with the matching §9 cross-reference, §11a non-finding, | |
| §14 question and §15 row. The previous revision, 2026-10-05, re-baselined against | |
| Security Bulletins **S2-075 … S2-078**, published 2026-10-05. The §12 trigger fired again on both counts: | |
| 7.4.0 / 6.12.0 added default controls (§5a), and S2-076 named a consumption class §8.7 | |
| did not carry — amplification, where a small input drives unbounded output. The pass | |
| also corrected the FetchMetadata / COOP / COEP interceptors from "opt-in" to on by | |
| default in `defaultStack` (§5a, §8.5). The revision before that, 2026-08-15, | |
| re-baselined against S2-070 … S2-074 and added §8.6 and §8.7. Bulletin-to-section map | |
| in §16. | |
| - **Version binding:** versioned with the project; a report against version *N* | |
| is triaged against the model as it stood at *N*. The security envelope changed | |
| materially at **7.0** (several hardening knobs flipped to secure-by-default) and | |
| again at **7.3.0 / 6.11.0** (the first resource-bound controls — §5a) and at | |
| **7.4.0 / 6.12.0** (a bounded REST body read, and isolation interceptors in the plugin | |
| stacks), so the version is itself load-bearing. | |
| - **Reporting cross-reference:** §8-property violations → report privately per | |
| [`SECURITY.md`](SECURITY.md) (`security@struts.apache.org`); §3/§9/§11a findings | |
| are closed citing this document and the existing `SECURITY.md` "Before | |
| Reporting" checks. | |
| - **Provenance legend:** *(documented)* = Struts' own docs/`SECURITY.md`/security | |
| site; *(maintainer)* = confirmed by a Struts PMC member through this process; | |
| *(inferred)* = reasoned from architecture/docs, not yet PMC-ratified — each has | |
| a matching §14 open question. | |
| - **Draft confidence:** the bulk is *(documented)* — Struts has an unusually rich | |
| published security policy — with a handful of *(inferred)* scoping calls for the | |
| PMC to ratify. | |
| **What Struts is.** Apache Struts 2 is a **Java MVC web framework** for building | |
| server-side web applications. A request flows: servlet filter → action mapping → | |
| **interceptor stack** (parameter population, validation, etc.) → **Action** → | |
| **result** (typically a JSP/FreeMarker view). Request parameters are bound onto | |
| action properties via setters, and view/configuration expressions are evaluated | |
| through **OGNL (Object-Graph Navigation Language)** against the **ValueStack**. | |
| *(documented — struts.apache.org)* | |
| **The framework's own security philosophy (load-bearing).** Struts | |
| **"doesn't provide any security mechanism — it is just a pure web framework."** | |
| *(documented — [security guidance](https://struts.apache.org/security/))* It is | |
| not an authentication, authorization, session-security, or input-sanitisation | |
| layer; those are the embedding application's responsibility (§3/§10). What Struts | |
| *does* take an active stance on is **not letting its own machinery — chiefly OGNL | |
| expression evaluation and request-parameter binding — become an injection vector**. | |
| That single sentence shapes the whole model: most "Struts is insecure" reports are | |
| either OGNL-injection-class (in model, §8) or application-responsibility (out of | |
| model, §3/§11a). | |
| Two corrections to that framing, drawn from the project's own published record. | |
| First, injection containment is not the only thing the framework owes its own | |
| machinery: it also owes **isolation between concurrently-served requests** and | |
| **bounds on what a single client can make it allocate or retain** (§8.6, §8.7). | |
| Second, OGNL remains the highest-*severity* class but is no longer the high-*volume* | |
| one — of the nine bulletins S2-070 through S2-078, one is OGNL, and it needs an opt-in | |
| legacy component (§16). A triager who expects every real finding to be OGNL-shaped will misroute the | |
| ones that are not. | |
| ## §2 Scope and intended use | |
| Intended deployment: the Struts JARs are a **dependency embedded inside a web | |
| application** (a WAR) that the application developer writes, configures, and | |
| deploys into a servlet container (Tomcat, Jetty, …) behind the operator's | |
| perimeter. Struts is **in-process** with the application; it has no daemon, no | |
| listening socket of its own, and no trust boundary against the application code | |
| it runs inside. *(documented — it is a framework, not a server.)* | |
| **Second deployment shape — Struts Boot (`struts2-boot-starter`, since 7.5.0).** The | |
| application is a plain `main()` that calls `StrutsBoot.run(...)`; the starter starts an | |
| embedded Tomcat, registers the Struts filter itself, and opens one HTTP listening socket. | |
| Actions come from a Java `ActionRegistry` instead of (or alongside) `struts.xml`. The | |
| request pipeline behind the filter is unchanged, so everything in §4–§9 applies as-is. | |
| What moves is the **container's defaults** (§5, §5a): with Boot they are chosen by the | |
| framework, not by the operator. *(documented — `boot/`, `apps/boot-hello/README.md`.)* | |
| **Caller roles.** | |
| - **Untrusted HTTP client** — sends requests (parameters, headers, cookies, | |
| multipart uploads) to a Struts-backed endpoint. **The primary untrusted boundary.** | |
| Struts must treat all request-derived values as hostile. *(documented — the | |
| parameter/OGNL hardening exists precisely for this actor.)* | |
| - **Application developer** — writes the actions, JSPs, struts.xml/annotations, | |
| and chooses the hardening settings (§5a). **Trusted by the framework** — their | |
| code and configuration run with the application's privileges. A finding that | |
| requires the developer to write unsafe code or disable a default protection is | |
| the application's bug, not Struts' (§3). *(documented — the developer-responsibility | |
| section of the security guidance.)* | |
| - **Operator** — deploys the WAR, sets `devMode` off, restricts dev-only plugins, | |
| configures the container and JVM. **Trusted.** *(documented.)* | |
| With Boot, the operator still owns the JVM, the host, the network perimeter and the | |
| `struts.boot.*` settings (port, bind address, context path); the container defaults | |
| the starter applies are Struts' (§5). | |
| **Component families.** | |
| | Family | Entry point | Touches | In model? | | |
| | --- | --- | --- | --- | | |
| | OGNL evaluation + ValueStack | expression eval for params, tags, results | in-JVM code paths | **In — the central attack surface** *(documented)* | | |
| | Parameter binding (`ParametersInterceptor`, `@StrutsParameter`) | request params → action setters | reflection into app objects | **In — primary boundary** *(documented)* | | |
| | Interceptor stack (cookie, fileupload, fetch-metadata, COOP/COEP, …) | per-request processing | request data | **In** *(documented)* | | |
| | Tag library / JSP & FreeMarker integration | view rendering, expression output | template eval | **In — output-side OGNL/EL** *(documented)* | | |
| | File upload (Jakarta multipart) | multipart request parsing | temp files | **In — historical CVE surface** *(documented — S2 bulletins)* | | |
| | Request-body readers (JSON body population, CSP violation-report collection) | reads the raw request body | heap, parse state | **In** *(documented — S2-070, S2-072, S2-073)* | | |
| | Bundled plugins (REST, JSON, Convention, …) in this repo | extra mappers/result types | request data | **In — same request-trust surface** *(documented — S2-070/071/072 are JSON-plugin bulletins; §14 Q-plugins answered)* | | |
| | Config Browser Plugin | exposes internal config | dev-only diagnostic | **In as dev-only** — exposure in prod is operator misconfig (§3/§11a) *(documented)* | | |
| | Embedding application's own actions/JSPs/config | the developer's code | as the app | **Out — application responsibility (§3)** *(documented)* | | |
| | Examples / showcase / test apps | demo code | n/a | **Out** *(see §3)* | | |
| **An optional feature is not a "non-default configuration."** Four of the five most | |
| recent bulletins concern surfaces that stay inert until an application turns them on: | |
| the JSON plugin's population of actions from a request body, its SMD / JSON-RPC branch | |
| (`enableSMD`), and a mapped endpoint collecting CSP violation reports. **None of that | |
| is `OUT-OF-MODEL: non-default-config`.** That disposition is for settings the project | |
| documents as *unsafe* (`devMode`, a disabled §5a hardening knob); a feature the project | |
| documents as *available* is in model at full request-trust level from the moment an | |
| application enables it, and the framework owes it the same §8 properties as the default | |
| path. Read literally, the v0 draft let a triager close all four — S2-070, S2-071, | |
| S2-072, S2-073 — as non-default configuration; this paragraph is what prevents that. | |
| *(documented — S2-070/071/072/073; §13, §14 Q-optional.)* | |
| ## §3 Out of scope (explicit non-goals) | |
| The detailed lists of developer anti-patterns and insecure configurations are | |
| maintained in the project's own docs and are **not duplicated here** — this model | |
| links to them and assigns each a triage disposition (§13): | |
| - **Anything the application developer is responsible for.** Struts provides no | |
| security mechanism of its own *(documented)*. The full enumeration — | |
| developer-exposed unsafe setters, request parameters used in localization or | |
| forced OGNL evaluation, raw `${...}` JSP-EL over untrusted values, direct JSP | |
| access, mixing security levels in one namespace — is in the | |
| [security guidance](https://struts.apache.org/security/) and | |
| [`SECURITY.md`](SECURITY.md). All are `OUT-OF-MODEL: application-responsibility`. | |
| - **Findings that only manifest with a documented-insecure / non-default setting** | |
| (`devMode=true`, Config Browser Plugin exposed in production, DMI enabled, or a | |
| §5a hardening knob turned off) → `OUT-OF-MODEL: non-default-config`. *(documented.)* | |
| - **The servlet container, JVM, JDK, and OS** — except the embedded-container defaults | |
| the Boot starter itself applies (§5), which are in model — and the application's own | |
| authentication, authorization, session management, CSRF token storage, and | |
| transport (TLS). Struts is "a pure web framework," not a security framework. | |
| *(documented / inferred — §14 Q-env.)* | |
| - **Generic denial of service** — flooding, bandwidth exhaustion, or repeating a URL: | |
| cases where the cost to the server stays proportionate and no framework limit is | |
| missing or ineffective. The defence is the operator's, ahead of the application | |
| (container, reverse proxy, network). *(documented — [`SECURITY.md`](SECURITY.md).)* | |
| **This exclusion is narrower than the v0 draft claimed.** That draft read | |
| `SECURITY.md`'s "avoid resource consumption non-linear in the size of inputs" as a | |
| triage threshold and put everything linear out of model. The project's own bulletins | |
| say otherwise: S2-072, S2-073, and S2-074 are all linear and all carry CVEs. The test | |
| is **whether a bound exists, not what shape the curve is** (§8.7) — `SECURITY.md` | |
| now states this directly. | |
| - **Already-disclosed S2-series vulnerabilities** — a duplicate of an existing | |
| Security Bulletin/CVE is closed by reference (the | |
| [`SECURITY.md` "Before Reporting"](SECURITY.md) checks), not re-triaged. | |
| - **Examples, showcase, and test applications** shipped in the repo. *(inferred — §14 Q-scope.)* | |
| ## §4 Trust boundaries and data flow | |
| ``` | |
| Untrusted HTTP request | |
| │ params, headers, cookies, multipart | |
| ▼ | |
| Servlet filter ─► action mapping ─► Interceptor stack ─► Action ─► Result (JSP/FreeMarker) | |
| │ │ | |
| ParametersInterceptor tag/result OGNL eval | |
| binds params to setters against ValueStack | |
| │ │ | |
| ▼ ▼ | |
| OGNL evaluation against the ValueStack ◄── the trust boundary | |
| (allowlist / excluded classes+packages / | |
| expression length / @StrutsParameter) | |
| ``` | |
| - **HTTP client → framework** is the one boundary Struts owns. Every request-derived | |
| string (parameter *names* as well as *values*, cookie names/values, header values, | |
| multipart filenames) is untrusted and may carry an OGNL payload. The framework's | |
| job at this boundary is to bind parameters and evaluate expressions **without | |
| letting attacker input reach an OGNL evaluation that creates or changes executable | |
| code**. *(documented.)* | |
| - **Framework → application code** is *not* a trust boundary — Struts runs the | |
| developer's actions and templates in-process, fully trusted. *(documented.)* | |
| **Reachability precondition (triager's test).** A finding is in-model only if it is | |
| reachable by an **untrusted HTTP client against a Struts application that follows the | |
| documented secure configuration** (current-version defaults, `devMode` off, dev-only | |
| plugins restricted, no developer anti-patterns from §3). A finding that needs | |
| `devMode`, a disabled default protection, a developer-introduced unsafe setter, or a | |
| documented anti-pattern is `OUT-OF-MODEL`. *(documented/inferred — §14 Q-default.)* | |
| ## §5 Assumptions about the environment | |
| - A servlet container and a JVM the operator maintains; Struts does not patch or | |
| harden them. *(inferred — §14 Q-env.)* | |
| - The application is deployed with the **current supported version** (7.x or 6.x per | |
| `SECURITY.md`); 2.x is end-of-life and out of support. *(documented — Supported | |
| Versions table.)* | |
| - The operator runs production with `devMode=false` and dev-only diagnostics (Config | |
| Browser Plugin) disabled or access-controlled. *(documented.)* | |
| - Struts makes no outbound connections of its own; any network egress is the | |
| application's. In the WAR shape it opens no sockets. The Boot starter opens exactly | |
| one inbound HTTP listener (§2); it makes no outbound connections either. | |
| *(inferred — §14 Q-egress, Q-boot.)* | |
| - **Boot container defaults are in model.** When `struts2-boot-starter` starts Tomcat, | |
| the framework owns these defaults: | |
| - listens on all interfaces, port 8080 (override `struts.boot.address` / | |
| `struts.boot.port`) | |
| - error responses render without exception report or server version | |
| - `DefaultServlet` serves only an empty per-run docBase | |
| - Tomcat's base directory is a fresh owner-only temp directory, deleted on shutdown | |
| - session tracking is cookie-only | |
| - the session cookie carries `SameSite=Lax` | |
| - `devMode` is never switched on | |
| A deviation from these defaults is a Struts finding. Anything the developer adds on | |
| top (TLS, proxies, a different container) is theirs. *(documented — §14 Q-boot.)* | |
| ## §5a Build-time and configuration variants — **the central knob set** | |
| Struts' security envelope is set almost entirely by **runtime configuration**. The | |
| **authoritative, current list of every hardening setting (purpose + secure default) | |
| lives in the [security guidance](https://struts.apache.org/security/) and is not | |
| reproduced here.** Only the triage-load-bearing facts: | |
| - The security posture **changed materially at 7.0**, where a cluster of | |
| OGNL-injection and parameter-binding defences became **secure-by-default** — | |
| notably the OGNL allowlist (`struts.allowlist.enable`), the `@StrutsParameter` | |
| annotation requirement (`struts.parameters.requireAnnotations`), excluded | |
| classes/packages, the expression-length cap (`struts.ognl.expressionMaxLength`, | |
| default 256), and the static-field/proxy/default-package/custom-map disallows. | |
| - The envelope moved again at **7.3.0 / 6.11.0**, which added the first *resource-bound* | |
| controls — the substance of §8.7, and on by default. The localized-text caches became | |
| bounded (`struts.i18n.cacheMaxSize`) and the CSP violation-report read became bounded | |
| (`struts.csp.report.maxSize`, default 8192; a configured value outside 1..1048576 is | |
| ignored and the default kept). `struts.locale.validateRequestLocale` (default `false`) | |
| is the one opt-in of the set: it restricts request-derived locales to the runtime's | |
| available-locale set. *(documented — S2-073, S2-074.)* | |
| - **7.4.0 / 6.12.0** extended both: the REST plugin bounds the request body it reads | |
| (`struts.rest.content.maxLength`, default 2097152 characters, on by default), the REST | |
| and Bean Validation plugins' default stacks gained the COOP / COEP / FetchMetadata | |
| interceptors core's `defaultStack` already carries, and the legacy `restful` action | |
| mapper applies `struts.allowed.action.names` like the default mapper. *(documented — | |
| S2-075, S2-077.)* | |
| - `struts.devMode` (must be `false` in production) and Dynamic Method Invocation | |
| (gated by Strict Method Invocation since 2.5) are the two settings whose *insecure* | |
| value most often turns a non-finding into an apparent finding. | |
| - The **FetchMetadata / COOP / COEP** interceptors are in core's `defaultStack` and | |
| enabled since 6.0, COEP in report-only mode; an application opts *out*, per action or | |
| stack (§8.5). | |
| - **Registry-defined actions (Boot) are never broader than the `struts.xml` | |
| equivalent.** They extend `struts-default`, keep Strict Method Invocation on, are | |
| added to the OGNL allowlist exactly as XML-declared action classes are, and allow | |
| only their registered method — narrower than XML, which also inherits the package's | |
| global allowed methods. A registry action reachable where the XML equivalent would | |
| not be is a finding. *(documented — WW-5791 spec.)* | |
| **Insecure-default question (wave 1).** Because the secure posture is the **7.0 | |
| default set**, the triage rule needs ratifying: is "a finding that only works with a | |
| pre-7.0 default, or with a 7.0 hardening knob turned off" `OUT-OF-MODEL: | |
| non-default-config`, with §10 carrying "deploy current version with defaults"? — §14 | |
| Q-default. The OGNL **Java Security Manager sandbox** (`-Dognl.security.manager`) is a | |
| separate, opt-in defence built on the JDK `SecurityManager`, which has been | |
| **deprecated for removal since JDK 17 (JEP 411), disabled by default since JDK 18, | |
| and permanently disabled in JDK 24 (JEP 486)** *(documented — JDK release notes)* — | |
| so on modern JDKs the model cannot treat it as a relied-upon control (§14 Q-jsm). | |
| ## §6 Assumptions about inputs | |
| | Surface | Input | Attacker-controllable? | Concern | | |
| | --- | --- | --- | --- | | |
| | Parameter binding | request parameter **names and values** | **yes** | OGNL injection via crafted names; binding to unsafe setters | | |
| | Cookies | cookie names/values (Cookie Interceptor) | **yes** | same OGNL/parameter concerns; checked by accepted/excluded patterns | | |
| | Headers | request headers | **yes** | header-driven expression/log paths | | |
| | Multipart upload | file content, filename, content-type | **yes** | parser robustness, temp-file handling (S2 history) | | |
| | Request body | raw body bytes (JSON body, CSP violation report) | **yes** | must be read under a bound; a limit the framework advertises must bound the read it names (§8.7) | | |
| | Expression context | values that reach an OGNL eval (tags, results, forced eval) | **yes if developer feeds untrusted input in** | the core RCE channel | | |
| | struts.xml / annotations / action code | framework + app configuration | **no — developer-trusted** | not an attacker surface (§3) | | |
| The accepted/excluded pattern checkers (`AcceptedPatternsChecker` / | |
| `ExcludedPatternsChecker`, since 2.3.20) validate parameter names/values for the | |
| Parameters and Cookie interceptors; a custom override that drops below the framework | |
| defaults is a developer error, not a framework flaw. *(documented.)* | |
| A request-derived value that becomes a **key in framework-managed state** is an input | |
| class in its own right, not merely a value in flight — a request-derived locale keys the | |
| framework's localized-text caches, for instance. Such state can be reached through | |
| ordinary request handling, not only through an application feature deliberately built on | |
| it. When triaging, ask not only "where does this value get evaluated?" but "what does the | |
| framework *retain*, keyed on it, and is that bounded?" *(documented — S2-074.)* | |
| ## §7 Adversary model | |
| - **In scope:** an **untrusted remote HTTP client** with no credentials, able to send | |
| arbitrary parameters, headers, cookies, and multipart uploads to any | |
| Struts-handled endpoint. Capabilities: craft parameter names/values carrying OGNL, | |
| attempt to reach executable-code creation through the ValueStack, pollute | |
| parameter binding, exploit a file-upload or multipart parsing bug, or drive framework | |
| code into an unbounded allocation or an unbounded retention (§8.7). Goal: **remote | |
| code execution via OGNL** (the dominant Struts threat), and secondarily data | |
| disclosure — including disclosure *across* concurrent requests — SSRF through | |
| framework features, or denial of service. *(documented — the OGNL lineage is the | |
| framework's stated central concern.)* | |
| - **Two capabilities the v0 draft left implicit.** The client can issue requests | |
| **concurrently**, racing any state the framework shares between them (§8.6); and it | |
| can **repeat** requests to accumulate framework-side state, where no single request | |
| is remarkable and the harm is in the aggregate (§8.7). Single-request reasoning is | |
| not sufficient to clear a surface. *(documented — S2-070/071, S2-074.)* | |
| - **On-path network attacker** — only where the application/operator has not deployed | |
| TLS; transport security is the app's, so this is largely out of model (§3). *(inferred — §14 Q-env.)* | |
| - **Out of scope:** the application developer (writes trusted code/config); the | |
| operator (deploys, sets devMode/plugins); anyone with container/host/JVM control; | |
| and a developer who disables a default protection or follows a documented | |
| anti-pattern (§3). *(documented.)* | |
| ## §8 Security properties the framework provides | |
| *(In the current-version, default-hardening posture; each lists violation symptom + | |
| severity. Most are documented controls — the OGNL-injection defences are the core of | |
| Struts' security work.)* | |
| 1. **OGNL injection containment.** Attacker-supplied request data (parameter names/ | |
| values, cookies, headers) must not reach an OGNL evaluation that creates or alters | |
| executable code. Enforced in depth by the default controls listed in §5a / the | |
| [security guidance](https://struts.apache.org/security/) (allowlist, excluded | |
| classes/packages, expression-length cap, static-field/proxy/default-package/ | |
| custom-map disallows, excluded node types). *Violation:* a crafted request | |
| achieving OGNL-driven code execution (or class-loader/member access beyond the | |
| allowlist) on a default-configured current-version app. *Severity:* | |
| security-critical (the S2-RCE class). *(documented.)* | |
| 2. **Parameter-binding safety (7.0).** Request parameters bind only to setters the | |
| developer marked `@StrutsParameter` (to the declared depth); arbitrary deep/nested | |
| property traversal is not reachable by default. *Violation:* parameters reaching | |
| an unannotated setter, or nesting beyond the declared depth, on a default 7.0 app. | |
| *Severity:* critical. *(documented.)* | |
| 3. **Method-invocation control.** Dynamic Method Invocation is gated by Strict Method | |
| Invocation; a client cannot invoke arbitrary action methods by name when DMI is at | |
| its recommended (off/strict) setting. *Violation:* arbitrary method invocation on a | |
| default app. *Severity:* high–critical. *(documented.)* | |
| 4. **Expression-length and node-type bounds.** OGNL expressions over the configured | |
| length (default 256) and forbidden node types are rejected before evaluation. | |
| *Violation:* bypass of these bounds. *Severity:* high. *(documented.)* | |
| 5. **Cross-origin / fetch-metadata defences.** Where the FetchMetadata, COOP, and COEP | |
| interceptors are in the stack — core's `defaultStack` since 6.0, the REST and Bean | |
| Validation plugin stacks since 7.4.0 / 6.12.0 — the framework emits/enforces the | |
| corresponding `Sec-Fetch-*` and cross-origin isolation behaviour. *Violation:* the | |
| interceptor failing to enforce its documented behaviour while in the stack. | |
| *Severity:* medium–high. *(documented — on by default since 6.0.)* | |
| 6. **Per-request state isolation.** A framework component holding per-request state — | |
| parse state, serialization state, buffers, and the mutable helper objects (such as | |
| formatters) a framework cache hands out for reuse — is not shared between requests | |
| being served concurrently. *Violation:* data associated with one request becoming | |
| observable in another, or a limit that holds for a single request being defeated by | |
| racing two. *Severity:* high — it is a disclosure and integrity failure at once, and | |
| it can void another §8 control rather than merely leaking. *Note:* this property is | |
| violated by an ordinary concurrency bug, with no attacker sophistication required; | |
| the same defect harms honest concurrent users. *(documented — S2-070, S2-071, | |
| S2-078.)* | |
| 7. **Bounded consumption of request-derived input.** Framework code that reads a request | |
| body reads it under a limit, framework-managed state keyed on request-derived values | |
| (caches, maps) is bounded, and the work the framework does with a request-derived | |
| value — the output it renders from it, the computation it spends on it — is bounded | |
| too. A limit the framework advertises bounds the operation it appears to govern. | |
| *Violation:* a single request making the framework allocate in proportion to its size | |
| with no ceiling; unbounded retention accumulated across requests; a small input | |
| driving output or computation with no ceiling (amplification); or a configured limit | |
| that does not constrain the read it names. | |
| *Severity:* moderate–high (denial of service). *Note:* **linear growth is a | |
| violation.** The test is whether a bound exists, not whether the curve bends — see | |
| §3, and `SECURITY.md`'s paragraph on the linear cases. *(documented — S2-072, | |
| S2-073, S2-074, S2-076, S2-077.)* | |
| 8. **Output encoding by the framework's own tags.** A value a UI tag renders into the | |
| markup it emits — an attribute value, a label, a message — is HTML-escaped by the | |
| tag, under every bundled theme and rendering engine, unless the tag's own documented | |
| attribute turns escaping off (`escape`, `escapeHtmlBody`). URL-valued attributes are | |
| built by the URL renderer, which URL-encodes and ampersand-escapes them instead. This | |
| is the framework encoding *its own output*: §9's "no output encoding for the | |
| application's own data" covers markup the application writes, not a value it hands | |
| to a tag. *Violation:* a tag writing a value it was given into HTML unescaped, so | |
| request-derived data bound to a tag attribute or body lands in the page as markup. | |
| *Severity:* moderate (XSS through framework-emitted markup), subject to the bulletin | |
| rating scale's mitigation clauses. *(documented — CVE-2011-2087 / WW-3597.)* | |
| ## §9 Security properties the framework does *not* provide | |
| - **No security mechanism in the general sense.** Struts provides no authentication, | |
| authorization, session security, CSRF token store, input sanitisation, or output | |
| encoding *for the application's own data* — "it is just a pure web framework." | |
| *(documented.)* A value the application hands to a framework tag is the framework's | |
| output, and §8.8 covers it. | |
| - *False friend:* "Struts has no built-in login/access control" is **by design**, | |
| not a vulnerability. | |
| - **No protection against developer anti-patterns or non-default config** — unsafe | |
| setters, raw `${}` on user input, request params in localization/forced eval, | |
| direct JSP access, `devMode` on, disabled hardening (§3/§5a). | |
| - **No defence once OGNL evaluation is fed untrusted input by the application | |
| itself** (forced expression evaluation on a request value) — that is the developer | |
| handing OGNL the attacker's string. *(documented.)* | |
| - **No hard anti-DoS guarantee.** The framework bounds what its own code reads and | |
| retains (§8.7) and designs against super-linear consumption, but it cannot protect an | |
| application from flooding, bandwidth exhaustion, or repeated requests whose cost to | |
| the server stays proportionate — that is the operator's to absorb, ahead of the | |
| application. The disclaimed property is *availability under generic load*, not | |
| *boundedness of framework code*, which §8.7 does provide. *(documented.)* | |
| - **The OGNL Java Security Manager sandbox is not a relied-upon control on modern | |
| JDKs** (the underlying `SecurityManager` is deprecated for removal since JDK 17 and | |
| permanently disabled in JDK 24; see §5a). *(documented.)* | |
| - **Auto-generated error pages do not escape action names** (historical S2-006) — the | |
| app must define custom error pages; XSS in the default error page is a documented | |
| hardening item, not a defended property. *(documented.)* Boot apps have no | |
| `web.xml`, so the starter suppresses Tomcat's exception report and server info itself | |
| (§5); custom error *content* is still the application's. | |
| - **Well-known classes (framework):** OGNL/expression injection, multipart/file-upload | |
| parsing bugs, and parameter-pollution are the highest-severity recurring classes — but | |
| the recent record adds two more that are currently the *more frequent* ones: | |
| **unbounded reads and unbounded framework-managed state** (S2-072/073/074) and **state | |
| shared across concurrent requests** (S2-070/071). Reflected XSS, CSRF token | |
| management, and transport security remain the application's. *(documented — §16.)* | |
| ## §10 Downstream (developer + operator) responsibilities | |
| The full, authoritative how-to is the [security guidance](https://struts.apache.org/security/) | |
| and [`SECURITY.md`](SECURITY.md); in one line: **deploy a current supported version | |
| with the default hardening left on, `devMode` off, dev-only plugins restricted, | |
| parameter setters annotated, JSPs hidden behind actions, and the application's own | |
| authn/authz/CSRF/TLS supplied** (Struts provides none of those). The threat-model | |
| value is only that a finding requiring the developer to *violate* one of these is | |
| `OUT-OF-MODEL` (§3/§13), not that this list is novel. | |
| Two more that the recent bulletins name explicitly, both as workarounds and as standing | |
| practice: the **operator** enforces a maximum request-body size ahead of the | |
| application, in the reverse proxy or the servlet container, for any endpoint that | |
| accepts a body; and the **developer** sets `struts.locale` to a fixed value wherever | |
| request-derived locales are not actually needed. Both are defence in depth — they are | |
| *not* substitutes for the §8.7 bounds, and a missing framework bound stays a framework | |
| finding whether or not the operator happened to configure around it. | |
| *(documented — S2-072, S2-073, S2-074.)* | |
| ## §11 Known misuse patterns | |
| These are the §3 application-responsibility / non-default-config items viewed as | |
| "things integrators get wrong" — running `devMode=true` in production or exposing the | |
| Config Browser Plugin; disabling a default OGNL/binding protection "to make something | |
| work"; exposing unsafe setters to binding; feeding request parameters into forced | |
| OGNL evaluation or localization; allowing direct `*.jsp` access or raw `${}` EL on | |
| untrusted values; relying on the OGNL Java Security Manager sandbox on modern JDKs. Each | |
| is documented in the [security guidance](https://struts.apache.org/security/); the | |
| disposition mapping is §11a/§13. | |
| ## §11a Known non-findings (recurring false positives) | |
| *(Seeded directly from `SECURITY.md` "Before Reporting" — the PMC owns the | |
| authoritative list; §14 Q12.)* | |
| - **"OGNL/RCE that only works with `devMode=true`."** `OUT-OF-MODEL: non-default-config` | |
| — devMode is a development-only setting documented as unsafe for production. | |
| - **"An action setter lets me inject a value / reach a dangerous method."** When the | |
| setter is developer-exposed without `@StrutsParameter` (7.0), or performs an unsafe | |
| side effect, this is `OUT-OF-MODEL: application-responsibility`. In-model only if it | |
| bypasses the framework's *default* binding/OGNL protections. | |
| - **"Direct JSP access discloses X / executes Y."** App-deployment misconfiguration — | |
| JSPs must be hidden behind actions. `OUT-OF-MODEL: application-responsibility`. | |
| - **"Raw `${}` EL / forced OGNL eval on my request parameter is exploitable."** The | |
| application fed untrusted input to expression evaluation — documented anti-pattern, | |
| not a framework flaw. | |
| - **"A tag with `escape="false"` / `escapeHtmlBody="false"` writes my HTML unescaped."** | |
| That is the documented opt-out of §8.8, chosen in the application's own template — | |
| `BY-DESIGN`. In-model only when a tag writes a value unescaped with escaping left on. | |
| - **"Config Browser Plugin exposes internal configuration."** Dev-only diagnostic; | |
| exposing it in production is operator misconfiguration. `OUT-OF-MODEL: non-default-config`. | |
| - **"I can enumerate / pass arbitrary parameters."** Parameter binding is the point of | |
| the framework; in-model only when it crosses the default annotation/allowlist | |
| protections. | |
| - **"Generic DoS: I hammered a URL / saturated the pipe."** Not accepted — proportionate | |
| cost with no missing framework bound is the operator's to absorb (§3). **But "I sent a | |
| large body" does not belong to this bullet by default:** if framework code reads that | |
| body into memory with no ceiling, it is a §8.7 violation and `VALID`, as S2-072 and | |
| S2-073 both were. Establish that a bound exists before closing on this ground. *(This | |
| bullet previously read "I streamed a huge body," which would have closed S2-072.)* | |
| - **"A framework cache grew when I sent many distinct values."** In model only where the | |
| cache is genuinely *unbounded*, or keyed on an unvalidated and unbounded input set. A | |
| bounded cache reaching its ceiling and evicting is working as designed, and a cache | |
| keyed on a validated finite set — a locale restricted to the runtime's available | |
| locales, for instance — is not unbounded. *(documented — S2-074.)* | |
| - **"This was reported before and closed as hardening."** Not a ground to close. Check | |
| whether a control was added since; if it was and the new report shows it does not | |
| enforce, the report is `VALID: control-not-enforced`, not `DUPLICATE`. *(documented — | |
| WW-5618 → S2-072.)* | |
| - **Duplicate of a disclosed S2-series bulletin/CVE** — closed by reference. | |
| - **Dependency-tail CVEs** (a transitive jar, e.g. a logging or XML library) from an | |
| SCA scan — triage upstream unless Struts' own code reaches the vulnerable path with | |
| untrusted input. | |
| - **"Boot listens on 0.0.0.0."** That's the documented default, matching standalone | |
| Tomcat and Spring Boot. `struts.boot.address` restricts it. → | |
| `OUT-OF-MODEL: non-default-config` only if the operator set a wider exposure; the | |
| default itself is by design. | |
| ## §12 Conditions that would change this model | |
| - A change to the default-hardening set (e.g. a new secure-by-default knob, or a | |
| default flipped) — re-baseline §5a/§8/§11a. | |
| - A new request-facing surface, a new bundled plugin, or a new expression/templating | |
| integration with its own trust surface. | |
| - A change to how OGNL evaluation, the allowlist, or parameter binding works. | |
| - A report that cannot be routed to a §13 disposition → revise §8/§9. | |
| - **A published bulletin whose vulnerability class is not already a §8 property** — the | |
| clearest signal that the model under-describes what the framework actually guarantees, | |
| and the strongest one, because the PMC has already decided the question by issuing the | |
| CVE. S2-070 … S2-074 triggered exactly this re-baseline, and S2-076 triggered it again | |
| (§16); the check belongs in the release routine, not in the next report's triage. | |
| ## §13 Triage dispositions | |
| | Disposition | Meaning | Licensed by | | |
| | --- | --- | --- | | |
| | `VALID` | A §8 property breaks via an untrusted HTTP client on a current-version, default-hardened app. | §8, §6, §7 | | |
| | `VALID: control-not-enforced` | A documented framework limit, filter, or policy does not hold for the inputs it targets. Independent of default-config arguments — the control's own documentation is the claim being broken. | §8 (esp. §8.4, §8.5, §8.7) | | |
| | `VALID-HARDENING` | A §11 misuse is too easy, or a default could be tightened. | §11/§5a | | |
| | `OUT-OF-MODEL: application-responsibility` | Requires a developer anti-pattern (unsafe setter, raw EL, forced eval, direct JSP) or the app's own authn/authz. | §3/§10 | | |
| | `OUT-OF-MODEL: non-default-config` | Only manifests with `devMode`, a dev-only plugin, DMI, or a disabled default protection — i.e. a setting the project documents as *unsafe*. **Not** a feature the project documents as optional and an application has enabled (§2). | §5a, §2 | | |
| | `OUT-OF-MODEL: adversary-not-in-scope` | Requires container/host/JVM/developer control. | §7 | | |
| | `OUT-OF-MODEL: unsupported-version` | Only affects an end-of-life (2.x) version. | §5 | | |
| | `BY-DESIGN: property-disclaimed` | Concerns a property §9 disclaims (no built-in authn/authz/encoding; generic DoS; JSM on JDK21+). | §9 | | |
| | `KNOWN-NON-FINDING` | Matches §11a. | §11a | | |
| | `DUPLICATE` | Matches a disclosed S2-series bulletin/CVE. | §3 | | |
| | `MODEL-GAP` | Unroutable. | triggers §12 | | |
| ## §14 Open questions for the maintainers | |
| **Answered since v0 — by the project's own published record (S2-070 … S2-074)** | |
| These two are carried here rather than deleted: the PMC settled them by issuing CVEs, | |
| so what remains is ratifying the *wording*, not the substance. | |
| - **Q-dos — answered.** The line is **boundedness, not curve shape**. Framework code | |
| that reads or retains request-derived input with no ceiling is in model even where the | |
| growth is linear — S2-072, S2-073, and S2-074 all carry CVEs on linear growth — while | |
| generic load whose cost stays proportionate is not. Folded into §3, §8.7, §9, and | |
| §11a; the matching clarification is now in [`SECURITY.md`](SECURITY.md) so the model | |
| is no longer paraphrasing that document into a rule it does not state. | |
| - **Q-plugins — answered.** Bundled plugins are in scope at the same request-trust | |
| level: S2-070, S2-071, and S2-072 are all JSON-plugin bulletins with CVEs. The | |
| follow-on distinction this exposed — optional *feature* versus documented-unsafe | |
| *configuration* — is now in §2 and §13, and is put to the PMC as Q-optional below. | |
| **Wave 1 — scope, defaults, intended use** | |
| - **Q-default.** Confirm the triage baseline is "current supported version (7.x/6.x) | |
| with the documented default hardening on, `devMode` off, dev-only plugins | |
| restricted" — and that a finding requiring a pre-7.0 default or a disabled hardening | |
| knob is `OUT-OF-MODEL: non-default-config`. (§5a/§13.) *Evidence against a blanket | |
| rule:* S2-075 was issued with a CVE for an opt-in legacy component that affects 7.x | |
| only with the allowlist disabled, and the 6.x line at its own defaults (§16). | |
| - **Q-scope.** Confirm the in-scope surface is the framework in `apache/struts` | |
| (core + interceptors + tags + bundled plugins), with the embedding application's own | |
| actions/JSPs/config, and examples/showcase, out of scope. (§2/§3.) | |
| - **Q-philosophy.** Confirm the framing that Struts provides **no security mechanism | |
| of its own** beyond OGNL/parameter-binding injection containment — i.e. authn, | |
| authz, session security, CSRF token storage, output encoding, and transport are the | |
| application's. (§9.) | |
| - **Q-env.** Confirm the servlet container, JVM, JDK, and OS are out of scope — Struts | |
| does not patch or harden them, and the operator maintains them. (§3/§5.) | |
| - **Q-egress.** Confirm Struts opens no sockets and makes no outbound connections of | |
| its own, so any network egress (and the SSRF surface it implies) is the | |
| application's. (§5/§7.) | |
| - **Q-boot.** Confirm the Boot starter's embedded-container defaults (§5) are in model | |
| — a framework-shipped container default that leaks or exposes is a Struts finding, | |
| not `OUT-OF-MODEL: environment` — and that the example app `apps/boot-hello` stays | |
| out of scope like the other showcase apps. (§2/§3/§5.) | |
| **Wave 2 — mechanism confirmations** | |
| - **Q-ognl.** Confirm the §8.1 list is the authoritative set of default OGNL-injection | |
| defences (allowlist, excluded classes/packages/patterns, expression length, | |
| static-field/proxy/default-package/custom-map disallows, excluded node types) and | |
| that a bypass of any on a default app is `VALID`. (§8.) | |
| - **Q-jsm.** Confirm the OGNL Java Security Manager sandbox is **not** a relied-upon | |
| control (opt-in, and non-functional on modern JDKs — see §5a), so a report premised | |
| on its absence is not a finding. (§5a/§9.) | |
| - **Q-concurrency.** Confirm §8.6 as a framework property: per-request state must not be | |
| shared between concurrently-served requests, and a violation is `VALID` on its own — | |
| including where the only demonstrated consequence is that another framework limit can | |
| be raced, with no data shown to cross. (§8.6, from S2-070/071.) | |
| - **Q-bounds.** Confirm the §8.7 scope: does it cover *all* framework-managed state | |
| keyed on request-derived values, or only the caches and body reads bounded in | |
| 7.3.0/6.11.0? A triager needs to know whether an unbounded structure found elsewhere | |
| in the framework is `VALID` or `VALID-HARDENING`. (§8.7.) | |
| - **Q-encoding.** Confirm §8.8 as a framework property: a value handed to a bundled UI | |
| tag is HTML-escaped in the markup the tag emits, under every bundled theme and | |
| rendering engine, and a tag that writes it raw with escaping left on is `VALID` | |
| rather than application responsibility under §9. (§8.8, from CVE-2011-2087.) | |
| **Wave 3 — surfaces & false-friends** | |
| - **Q-optional.** Confirm the §2 rule that a documented-optional feature (JSON body | |
| population, `enableSMD`, a mapped CSP-report endpoint, any bundled plugin) is in model | |
| once an application enables it, and that `OUT-OF-MODEL: non-default-config` is | |
| reserved for settings the project documents as *unsafe*. (§2/§13.) | |
| - **Q-plugin-risk.** With Q-plugins answered, the residual: are any bundled plugins | |
| (e.g. REST/XML) historically higher-risk enough to warrant their own §8 note? (§2.) | |
| - **Q-upload.** Confirm the multipart/file-upload surface (Jakarta) and what the | |
| framework guarantees vs. leaves to the container/app. (§2/§6.) | |
| - **Q12.** Beyond the `SECURITY.md` "Before Reporting" list already folded into §11a, | |
| what do scanners/researchers most often report against Struts that you consider a | |
| non-finding? (Feeds §11a.) | |
| ## §15 Appendix — existing-policy back-map | |
| This `THREAT_MODEL.md` is **additive** — it does not replace | |
| [`SECURITY.md`](SECURITY.md) (reporting process, supported versions, "Before | |
| Reporting" checks) or the published [security guidance](https://struts.apache.org/security/); | |
| both are preserved and remain canonical for the reporting workflow. The discoverability | |
| chain is `AGENTS.md` → `SECURITY.md` → this model. Mapping of existing-policy claims to | |
| sections: | |
| | Existing-policy statement | Threat-model § | | |
| | --- | --- | | |
| | "Struts doesn't provide any security mechanism — pure web framework" | §1, §9, §13 (`BY-DESIGN`) | | |
| | OGNL is the central historical vuln class | §1, §7, §8.1 | | |
| | devMode / Config Browser Plugin are dev-only | §3, §5a, §11a | | |
| | `@StrutsParameter` / unsafe setters | §6, §8.2, §10, §11a | | |
| | Direct JSP access / raw `${}` EL / forced eval / localization | §3, §10, §11a | | |
| | Allowlist / excluded classes/packages / expression length (7.0 defaults) | §5a, §8.1 | | |
| | DMI / Strict Method Invocation | §5a, §8.3 | | |
| | FetchMetadata / COOP / COEP | §5a, §8.5 | | |
| | Tags escape what they render; `escape` / `escapeHtmlBody` are the documented opt-outs | §8.8, §9, §11a | | |
| | OGNL JSM sandbox (modern-JDK limitation) | §5a, §9 | | |
| | Generic DoS not accepted; non-linear-in-input philosophy | §3, §9 | | |
| | Unbounded reads/state are in scope even when linear | §3, §8.7, §11a | | |
| | Resource bounds (i18n cache size, CSP report size, locale validation) | §5a, §8.7 | | |
| | "Before Reporting" duplicate/known-config checks | §3, §11a, §13 (`DUPLICATE`) | | |
| | Supported versions (2.x EOL) | §5, §13 (`OUT-OF-MODEL: unsupported-version`) | | |
| ## §16 Appendix — recent-bulletin back-map (S2-070 … S2-078) | |
| ### S2-070 … S2-074 (published 2026-08-14) | |
| The five bulletins published on **2026-08-14** are the evidence base for the 2026-08-15 | |
| revision, and the reason §8 grew two properties. Each is a published | |
| [Security Bulletin](https://cwiki.apache.org/confluence/display/WW/Security+Bulletins) | |
| on the Struts cwiki, all five published the same day. Ratings are the bulletins' own. | |
| | Bulletin | Rating | What it establishes for the model | § | | |
| | --- | --- | --- | --- | | |
| | **S2-070** (CVE-2026-73631) — shared parsing state, JSON plugin | Moderate | Per-request parse state shared across concurrent requests: cross-request disclosure and integrity loss, **and bypass of a configured limit** — one defect voiding another control | §8.6, §7, §2 | | |
| | **S2-071** (CVE-2026-73632) — shared serialization state, JSON plugin | Low | The same on the response side; the `json` *result type* is unaffected because a writer is built per request — the negative control for §8.6 | §8.6, §2 | | |
| | **S2-072** (CVE-2026-73633) — unbounded read of a JSON request body | Moderate | Linear DoS with a CVE, and an advertised limit (JSON input length) that did **not** bound the read it appeared to govern | §8.7, §3, §11a | | |
| | **S2-073** (CVE-2026-73634) — unbounded read of a CSP violation report | Moderate | Core rather than a plugin; inert until an application maps the endpoint; ordinarily unauthenticated *by design*, since browsers post to it directly | §8.7, §2, §10 | | |
| | **S2-074** (CVE-2026-73635) — unbounded localized-text cache growth | Moderate | Affects the **default configuration** rather than an opt-in feature; a request-derived value used as a **cache key**, making unbounded *retention* a class of its own alongside unbounded reads | §8.7, §6, §5a, §11a | | |
| Three of the five would have been closed as non-findings by the v0 draft — S2-072 and | |
| S2-073 under §11a's "I streamed a huge body," S2-074 under §3's super-linear-only rule — | |
| and the remaining two had no §8 property to violate. That is the honest summary of what | |
| this revision fixes, and the reason §12 now carries a bulletin-driven re-baseline | |
| trigger. | |
| ### S2-075 … S2-078 (published 2026-10-05) | |
| The four bulletins published on **2026-10-05**, all fixed in 7.4.0 and 6.12.0, are the | |
| evidence base for the 2026-10-05 revision. Three fall inside properties §8 already | |
| carried; one widened §8.7. | |
| | Bulletin | Rating | What it establishes for the model | § | | |
| | --- | --- | --- | --- | | |
| | **S2-075** (CVE-2026-104711) — OGNL injection in the legacy RESTful action mapper | Moderate | The one OGNL bulletin of the nine. It needs the opt-in legacy `restful` mapper, and on 7.x the allowlist disabled; the 6.x line, which has no allowlist on by default, is affected at its own defaults. Evidence on the open Q-default question, not an answer to it | §8.1, §5a, §14 | | |
| | **S2-076** (CVE-2026-104712) — disproportionate response size when rendering BigDecimal request parameters | Moderate | A small parameter driving a response orders of magnitude larger, with no large read and no retention: **amplification**, a third consumption shape alongside unbounded reads and unbounded retention. The §12 trigger | §8.7 | | |
| | **S2-077** (CVE-2026-104713) — unbounded request body read in the REST plugin | Important | The S2-072 class in a plugin that fix did not reach, exposed in the plugin's default configuration; rated a level above S2-072 because no setting gates it | §8.7, §2 | | |
| | **S2-078** (CVE-2026-104714) — shared message formatter across concurrent requests | Moderate | The §8.6 class held not as per-request state but as a mutable object a framework cache reuses; it needs no attacker — ordinary concurrent traffic is enough — and harms the application's own users | §8.6 | | |
| The 2026-08-15 revision would have routed all four correctly except S2-076, which | |
| §8.7's read-or-retain wording did not describe. The pass also found §5a and §8.5 | |
| describing the FetchMetadata / COOP / COEP interceptors as opt-in; they have been in | |
| core's `defaultStack`, enabled, since 6.0. |