Security Hardening

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Struts 7.4.0 (released 2026-10-02) running on Apache Tomcat 11.0.26 and Java 21, the combination on which every example was built, deployed and requested. The facts were checked on 2026-10-08 and 2026-10-10 against the struts2-core 7.4.0 jar (default.properties, struts-default.xml, struts-beans.xml, struts-excluded-classes.xml, class listings and javap), the official security guide at https://struts.apache.org/security/ (read section by section; this page follows its order), the Core Developer Guide, the Version Notes of 6.4.0, 7.0.0 and 7.4.0, the 6.x to 7.x migration guide and the security bulletins S2-006, S2-045 and S2-075. The guide opens by saying that Struts provides no security mechanism of its own, that it is a web framework and that it offers tips; what the framework does offer is a set of defences against the attacks that its design invites, because it turns request data into method calls and expressions. Struts 7 made most of those defences the default, so a new application starts hardened and an upgraded one inherits settings that its authors may have switched off years ago. The running scenario is the Bookshelf, a catalogue of books: a search action with a filter, a profile form with a nested DTO, a greeting that uses getText, an admin namespace, and one "weak" deployment that reverts every default so that the same requests can be compared. The pages of this section are linked where they come up: Security Headers, Security Bulletins, Testing, Debugging, Dev Mode and Logging and Deployment and Production.

The Bookshelf Used on This Page

All examples belong to one scratch WAR built from the bookshelf-struts base: struts2-core 7.4.0 from org.apache.struts:struts2-bom:7.4.0, log4j-slf4j2-impl, the Servlet and Pages APIs with scope provided, the filter StrutsPrepareAndExecuteFilter mapped to /*, and a log4j2.xml that writes the framework’s WARN output (and the org.apache.struts2.interceptor.parameter DEBUG output) to a file, because almost every defence on this page reports its decisions in the log and answers the browser with something empty. Imports and the package line are left out of the listings; the package is org.example.bookshelf. The views are JSPs under WEB-INF/views that print the properties they are asked about as plain text. The base WAR was deployed on Tomcat 11.0.26 under several context names; the variants differ only in one file, WEB-INF/classes/struts.properties (and, where stated, in web.xml).

Table 1. The actions of the scratch WAR
Action URL Class What it demonstrates

/search

SearchAction

Three setters: setFilter annotated, setLimit and setAdmin not

/profile

ProfileAction

A getter getForm() annotated with depth = 1 (a DTO), and an unannotated getter getAccount() (a domain object with a role)

/greet-unsafe, /greet

GreetAction

getText with an incoming value, and the safe form with a fixed key

/forced

ProbeAction

Tags that evaluate an attribute as an expression

/echo

EchoAction

The same value printed with raw EL and with a Struts tag

/probe, /longexpr

ProbeAction

Ten OGNL expressions in a JSP (own bean, new, static method, static field, #session…​) and one that is 323 characters long

/save, /saveAction

RealAction

The action hierarchy of the guide’s ambiguous-method warning

/crud, /crud2, /crud-*, /legacy/crud

CrudAction

Methods list, delete and purge under four method-invocation configurations

/secure/admin

SecureAction

A namespace under a security-constraint

/boom, /raw/boom

BoomAction

An action that throws, with and without a global exception mapping

/direct.jsp

(none)

A JSP outside WEB-INF that uses a Struts tag

Table 2. The deployments (all variants of the base WAR, Struts 7.4.0 defaults unless the line says otherwise)
Context Difference

/g12sechard

Struts 7.4.0 defaults, web.xml with the three constraints of this page

/g12sechardnocon

The same WAR without any security-constraint

/g12sechardallow

struts.allowlist.packageNames=org.example.bookshelf

/g12sechardallowcls

The package above plus struts.allowlist.classes=org.example.vendor.Imprint

/g12sechardnoallow

struts.allowlist.enable=false

/g12sechardexclpkg

The allowlist off and org.example.vendor appended to struts.excludedPackageNames

/g12sechardguard

The allowlist package plus struts.ognl.excludedNodeTypes and struts.ognl.valueStackFallbackToContext=false

/g12sechardmaxlen

struts.ognl.expressionMaxLength=400

/g12sechardcg

A custom OgnlGuard registered with struts.ognlGuard

/g12secharddev

struts.devMode=true only

/g12sechardweak

Everything reverted: devMode, no annotations, no allowlist, DMI on, static field access on, namespace fallbacks on

/g12sechardcb, /g12sechardcbopen

The Config Browser plugin, with and without a constraint on /config-browser/*

The request examples use curl against http://localhost:18080. The outputs are trimmed to the relevant lines and a session cookie that the server sets in a response is never shown. Where a variant context is meant, the example says so.

The Defence Layers

Struts security is not one switch; it is a stack of independent checks, each of which assumes that the one before it can fail. The diagram orders them as a request meets them. The first three layers decide whether a request may reach an action at all, the fourth decides which request data may be written into the application, the fifth limits what an expression may do once data has been written, and the last two are about what the application does with the data itself and what it tells the world when something goes wrong.

flowchart TB REQ["HTTP request"] --> L1 L1["1 Container: security-constraint, JSPs under WEB-INF, TLS"] --> L2 L2["2 Mapper: exact namespace, DMI off, strict method invocation, action names"] --> L3 L3["3 Interceptors: Fetch Metadata, COOP, COEP, CSP"] --> L4 L4["4 Parameters: name length, accepted and excluded patterns, @StrutsParameter, value patterns"] --> L5 L5["5 OGNL: expression length, OGNL Guard, exclusion lists, static, proxy and default-package rules, allowlist"] --> L6 L6["6 Application code and views: no input in getText or forced evaluation, tags escape, DTOs for input"] --> L7 L7["7 Errors and logs: custom error pages, devMode off, WARN logging, startup warnings"] --> RESP["HTTP response"] L1 -. "401, 403 or 404" .-> X["Request refused or parameter dropped or expression blocked, and logged"] L2 -. "404 no action or method" .-> X L3 -. "403 result" .-> X L4 -. "parameter dropped, DEBUG or WARN log" .-> X L5 -. "expression blocked, WARN log" .-> X L7 -. "generic page, details in the server log only" .-> X

Two facts about the picture matter for everything below. First, a refusal is almost never visible to the user: a parameter that fails layer 4 is dropped and the page renders as if it had not been sent, and an expression that fails layer 5 evaluates to nothing, so the page shows an empty value. The evidence is in the log, and this page quotes the log lines that each layer writes. Second, the layers are cumulative, not alternatives: @StrutsParameter (layer 4) decides which properties may be written from a request, the allowlist (layer 5) decides which classes an expression may touch even when the request was legitimate, and neither one saves an application that passes a request value to getText (layer 6).

Table 3. The layers, what each stops and its 7.4.0 default
Layer Stops Default in 7.4.0

1 Container

Direct requests to JSPs, to an admin namespace and to development tools without credentials

Nothing configured: an application must declare its constraints

2 Mapper and method invocation

Access to an action through another namespace, calling a method that was not configured, the action: and method: prefixes

Exact namespace, DMI off, strict method invocation, action: prefix off

3 Interceptors

Cross-site requests, cross-origin embedding and opener attacks

fetchMetadata, coop and coep (report-only) in defaultStack; a rejected request needs a mapped 403 result

4 Parameters

Writing to members that were never meant to receive a request value

struts.parameters.requireAnnotations=true, excluded and accepted name patterns, name length limit

5 OGNL

Expressions that reach classes or syntax that no page needs

Allowlist on, exclusion lists, expression limit 256, static fields, proxies, default package and custom maps refused

6 Application and views

Evaluating or printing request data as an expression or as markup

Not enforced by the framework; this is code review

7 Errors and logs

Disclosure of stack traces, configuration and internals

devMode off, Struts classes log at the level the application sets

The Struts 7 Secure Defaults

The migration guide from 6.x to 7.x calls its security section "Stronger security" and lists the defaults that changed. The table below combines that list with the values read from the 7.4.0 default.properties (and struts-excluded-classes.xml) inside the jar, which is the authority for what an application gets.

Table 4. The security defaults of Struts 7.4.0 (constant, 7.4.0 default, what it does, and what changed in 7.0)
Constant 7.4.0 default Effect Before 7.0

struts.devMode

false

No debugging interceptor output, no Struts Problem Report page

Same default; applications often set it to true

struts.enable.DynamicMethodInvocation

false

The !method URL suffix and the method: prefix are off

Not among the changes of the 7.0 migration guide

struts.mapper.action.prefix.enabled

false

The action: prefix is off

Not among the changes of the 7.0 migration guide

struts.mapper.alwaysSelectFullNamespace

true

A request is matched against the exact namespace only

false

struts.actionConfig.fallbackToEmptyNamespace

false

No fallback to the empty namespace

true

struts.parameters.requireAnnotations

true

A parameter reaches only members annotated with @StrutsParameter

Introduced in 6.4, off by default

struts.parameters.requireAnnotations.transitionMode

false

Migration aid; see the section on @StrutsParameter

Documented by the security guide next to the annotation requirement

struts.allowlist.enable

true

OGNL may touch only allowlisted classes in addition to the exclusion lists

Introduced in 6.4, off by default

struts.ognl.expressionMaxLength

256

Longer expressions are not evaluated

200 before 7.0

struts.ognl.allowStaticFieldAccess

false

No static field reads from OGNL

true

struts.disallowProxyObjectAccess

true

Proxied objects (Hibernate, Spring) are not reachable from OGNL

Introduced in 7.0

struts.disallowDefaultPackageAccess

true

Classes in the default package are not reachable

Blocking added in 6.4 (WW-5342); the guide lists the constant among those enabled by default in 7.0

struts.ognl.disallowCustomOgnlMap

true

Only HashMap, TreeMap and LinkedHashMap may be built by an expression

Introduced in 7.0

struts.ognl.valueStackFallbackToContext

commented out (fallback on)

An expression that finds no value on the stack falls back to the ActionContext

Introduced in 6.4; see the ActionContext section

struts.ognl.excludedNodeTypes

commented out (Guard off)

No OGNL syntax is forbidden unless listed

Introduced in 6.4

struts.excludedClasses, struts.excludedPackageNames

the lists of struts-excluded-classes.xml

OGNL never touches these classes and packages

Extended in 6.4 and 7.0

struts.i18n.encoding

UTF-8

Request and response encoding

Not among the changes of the 7.0 migration guide

The 256 in the table is the value in the 7.4.0 jar and in the security guide ("defaults to 256"). The migration guide for 7.0 says the limit "has been reduced from 200 to 150" and still shows struts.ognl.expressionMaxLength=150 in its constants table; that text describes the 7.0 milestone and was not corrected afterwards. This page uses 256 and shows below that a 323-character expression is refused while the same expression passes with the constant set to 400.

Struts 7 also tells the operator when a default has been reverted. The weak deployment, which sets struts.parameters.requireAnnotations=false, struts.allowlist.enable=false and struts.devMode=true, logs these three lines at startup, and the hardened deployment logs none of them:

WARN  SecurityMemberAccessConfig - OGNL allowlist is disabled! We strongly recommend keeping it enabled to protect against critical vulnerabilities. Set the configuration `struts.allowlist.enable=true` to enable it. Please refer to the Struts 7.0 migration guide and security documentation for further information.
WARN  SecurityMemberAccessConfig - DevMode enabled, using DevMode excluded classes and packages for OGNL security enforcement!
WARN  ParametersInterceptor - @StrutsParameter annotation requirement is disabled! We strongly recommend keeping it enabled to protect against critical vulnerabilities. Set the configuration `struts.parameters.requireAnnotations=true` to enable it. Please refer to the Struts 7.0 migration guide and security documentation for further information.

The warnings come from WW-5454 ("Log warnings on startup for disabled critical security features"), in the 7.0.0 Version Notes. A deployment pipeline can fail on them: they are the cheapest hardening check there is.

The Security Guide as a Checklist

The guide has two parts, "Security tips" (seventeen headings) and "Proactively protecting against OGNL Expression Injections attacks" (five headings plus subsections). The next two chapters go through them in order. Each item gives the guide’s rule, an example from the scratch WAR that shows what happens when the rule is followed and when it is not, and the source.

Restrict Access to the Config Browser Plugin

The Config Browser plugin (struts2-config-browser-plugin) publishes the action configuration of the running application: namespaces, classes, interceptors, results and properties. The guide says it must be used only in development, and that a production site that must keep it should restrict it with Basic Authentication or another security mechanism. Adding the plugin to the scratch WAR also added struts2-velocity-plugin 7.4.0 (a transitive dependency, seen in the WAR listing). Without a constraint, anyone can read the configuration:

$ curl -si http://localhost:18080/g12sechardcbopen/config-browser/index.action | head -2
HTTP/1.1 302
Location: /g12sechardcbopen/config-browser/actionNames.action

$ curl -s "http://localhost:18080/g12sechardcbopen/config-browser/showConfig.action?namespace=/&actionName=search"
...
        Action name:   search
        Namespace:     /
        Action class:  org.example.bookshelf.SearchAction
...
        location = /WEB-INF/views/search.jsp
...

With a security-constraint on /config-browser/* and a Basic login configuration in web.xml, the same request without credentials is refused by the container before the Struts filter runs:

<security-constraint>
    <display-name>Config browser</display-name>
    <web-resource-collection>
        <web-resource-name>config-browser</web-resource-name>
        <url-pattern>/config-browser/*</url-pattern>
    </web-resource-collection>
    <auth-constraint>
        <role-name>admin</role-name>
    </auth-constraint>
</security-constraint>

<login-config>
    <auth-method>BASIC</auth-method>
    <realm-name>Bookshelf</realm-name>
</login-config>

<security-role>
    <description>Administrators</description>
    <role-name>admin</role-name>
</security-role>
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12sechardcb/config-browser/index.action
401

The better rule is not to ship the plugin in a production artifact at all: a Maven profile for development, or a separate dependency scope, removes the question. Basic authentication also needs TLS to be acceptable; the constraint is a last resort, not a design. The plugin and the other development-only plugins are described in the Other Plugins page of this section.

See also other plugins.

Do Not Mix Access Levels in One Namespace

A container constraint is a URL pattern, and a Struts action is a URL. The guide therefore asks for one namespace per security level, so that one pattern describes one level. In the scratch WAR the admin action lives in the namespace /secure, the constraint covers /secure/*, and a request without credentials is refused with 401 (a challenge, because the WAR has a Basic login configuration; a deployment with the same constraints and no login-config answered 403 for both /secure/admin and /direct.jsp):

<package name="secure" extends="struts-default" namespace="/secure">
    <action name="admin" class="org.example.bookshelf.SecureAction">
        <result>/WEB-INF/views/secure.jsp</result>
    </action>
</package>

<security-constraint>
    <web-resource-collection>
        <web-resource-name>admin</web-resource-name>
        <url-pattern>/secure/*</url-pattern>
    </web-resource-collection>
    <auth-constraint>
        <role-name>admin</role-name>
    </auth-constraint>
</security-constraint>
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12sechard/secure/admin
401
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12sechardnocon/secure/admin
200

The second request is the same action in the deployment without the constraint: the framework itself does not know what "admin" means. The related risk is a second path to the same action. With the Struts 6 defaults for the two namespace constants (struts.mapper.alwaysSelectFullNamespace=false and struts.actionConfig.fallbackToEmptyNamespace=true), an action reaches through URLs that name a namespace that does not exist; the 7.x defaults require the exact namespace:

$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12sechardweak/nope/books
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12sechard/nope/books
404

A URL-pattern constraint that protects /secure/* is useless when the same action can also be requested under another prefix; this is the reason the migration guide lists the two constants first. The namespaces and the packages that carry them are the subject of the Packages and Namespaces page of this section.

Never Expose JSP Files Directly

A JSP is a servlet that can run without an action, and a JSP that uses Struts tags without an action runs without the framework’s protections (no value stack of the action, no parameter filtering). The guide’s rule is to hide every JSP behind an action by placing the files under WEB-INF, and to add a *.jsp constraint with a role that nobody has, "the best approach is to use both". In the scratch WAR the views are under WEB-INF/views, and one JSP, direct.jsp, was placed in the root on purpose. Without any constraint:

$ curl -s http://localhost:18080/g12sechardnocon/direct.jsp
<p>direct: 2</p>
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12sechardnocon/WEB-INF/views/books.jsp
404
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12sechardnocon/WEB-INF/web.xml
404

WEB-INF is never served, but the JSP in the root ran, evaluated %{1+1} and printed 2. The Struts log shows what 7.2.0 added: since that release the framework warns when a tag is rendered outside an action.

WARN  TagUtils - Rendering tag out of Action scope, accessing directly JSPs is not recommended! Please read https://struts.apache.org/security/#never-expose-jsp-files-directly

With the second protection from the guide in web.xml, the container refuses the direct request, while the action that forwards to a JSP under WEB-INF still works, because a forward is not a client request and constraints do not apply to it:

<security-constraint>
    <display-name>No direct JSP access</display-name>
    <web-resource-collection>
        <web-resource-name>No-JSP</web-resource-name>
        <url-pattern>*.jsp</url-pattern>
    </web-resource-collection>
    <auth-constraint>
        <role-name>no-users</role-name>
    </auth-constraint>
</security-constraint>

<security-role>
    <description>Do not assign users to this role</description>
    <role-name>no-users</role-name>
</security-role>
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12sechard/direct.jsp
401
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12sechard/books
200

Even a request for a file under WEB-INF answers 401 in this deployment, because the *.jsp pattern matches first (observed); that is harmless, and the constraint is what protects a JSP that someone later moves out of WEB-INF. The JSP-side view of the same topic (the Default Servlet, uploads, Tomcat hardening) is in the JSP security page.

Disable devMode

struts.devMode turns on friendly development behaviour. The default is false and the guide says to make sure that no struts.xml of the application sets it to true for production. The price of a forgotten true is shown by the g12secharddev deployment, which has the secure defaults for everything else. The debugging interceptor is in the default stack and reacts to a debug parameter when devMode is on:

$ curl -s "http://localhost:18080/g12secharddev/books?debug=xml"
<debug>
  <parameters/>
  <context>
    <struts.actionMapping>
      <class>class org.apache.struts2.dispatcher.mapper.ActionMapping</class>
      <name>books</name>
      <namespace>/</namespace>
...
    <action>
      <class>class org.example.bookshelf.BookAction</class>
      <locale>
        <country>US</country>
...
$ curl -s "http://localhost:18080/g12secharddev/books?debug=command&expression=1%2B1"
2
$ curl -s "http://localhost:18080/g12sechard/books?debug=command&expression=1%2B1" | head -3

<!DOCTYPE html>
<html>

With devMode on, anyone can dump the value stack and the action context as XML, open the OGNL console page, and have the server evaluate an expression: debug=command answered 2. The expression still passes through the member-access rules and the allowlist (the dev deployment has both on), which is why the evaluation of harmless arithmetic is the strongest thing shown here: the point is that the endpoint exists, and what an expression can then reach is limited only by the member-access rules. With the default false the same request is an ordinary request for /books and the parameter is ignored. devMode has a second face: an unmapped exception or an unknown action answers HTTP 200 with the "Struts Problem Report", a page with the message and the whole stack trace (observed in the weak deployment on /crud-purge). The debugging page of this section covers devMode for development; for production the rule is a single line in struts.xml or struts.properties:

<constant name="struts.devMode" value="false" />

Reduce the Logging Level

The guide asks to reduce framework logging from DEBUG to INFO or WARN, because the framework writes many entries and because the DEBUG output of the parameter and OGNL machinery repeats request values. Its example logs the two prefixes at WARN. The scratch log4j2.xml follows the same idea, with one deliberate exception for the parameter interceptor so that the decisions on this page can be read:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Appenders>
        <File name="F" fileName="${sys:catalina.base}/logs/bookshelf-struts.log" append="false">
            <PatternLayout pattern="%-5p %c{1} - %m%n"/>
        </File>
    </Appenders>
    <Loggers>
        <Logger name="org.apache.struts2" level="warn"/>
        <Logger name="org.apache.struts2.interceptor.parameter" level="debug"/>
        <Root level="warn"><AppenderRef ref="F"/></Root>
    </Loggers>
</Configuration>

Two details deserve attention. The guide’s example also lists com.opensymphony.xwork2 at WARN; since 7.0 that package no longer exists (the 7.4.0 jar has no com/opensymphony entry, and the migration guide renames it to org.apache.struts2), so that line configures a logger that nothing writes to and org.apache.struts2 is the only name that matters. And WARN is the level at which every defence on this page reports: the SecurityMemberAccess blocks, the StrutsOgnlGuard blocks, the rejected Fetch Metadata requests and the startup warnings. Dropping the framework to ERROR, or to OFF to quiet a noisy log, switches off the only evidence that an attack was attempted. The pattern shown here keeps the framework at WARN and raises a single logger to DEBUG when a parameter disappears; the debugging and logging page of this section covers the Log4j 2 configuration for development and production.

Use UTF-8

The guide asks for UTF-8 everywhere and adds contentType="text/html; charset=UTF-8" to each JSP. The framework default is struts.i18n.encoding=UTF-8 (7.4.0 default.properties), and the scratch JSPs also carry the page directive. The effect that matters for security is that the request decoder, the response encoder and the page agree, because a mismatch between what a filter inspects and what the browser decodes is a classic way around an escaping rule:

$ curl -si http://localhost:18080/g12sechard/search | grep -i content-type
Content-Type: text/html;charset=UTF-8
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>

The character-encoding page of the JSP part of this guide covers the directive, request.setCharacterEncoding and the filter.

See also character encoding.

Define and Annotate Your Action Parameters

Since 6.4 as an option and by default since 7.0, a request parameter reaches an action only through a member annotated with @StrutsParameter. The guide’s rules, which this section reproduces as checks rather than text: annotate the setter that is meant to receive a request value; to fill a DTO, define a getter returning it and annotate the getter with depth = 1 (more for nested objects, "do not set this depth field to a value greater than the minimum required"); annotated getters should return only DTOs or collections of DTOs; never annotate a method that returns live Hibernate objects, container or Spring beans, or an object with setters used for anything other than form values; keep database DTOs apart from form DTOs. The scratch action has one member of each kind:

public class SearchAction extends ActionSupport {
    private String filter = "";
    private int limit = 10;
    private boolean admin;

    public String getFilter() { return filter; }

    @StrutsParameter
    public void setFilter(String filter) { this.filter = filter; }

    public int getLimit() { return limit; }

    // no annotation: not meant to be bound from a request
    public void setLimit(int limit) { this.limit = limit; }

    public boolean isAdmin() { return admin; }

    // a setter added "for tests" that nobody remembers
    public void setAdmin(boolean admin) { this.admin = admin; }
}

public class ProfileAction extends ActionSupport {
    private final FormDto form = new FormDto();
    private final Account account = new Account();

    @StrutsParameter(depth = 1)
    public FormDto getForm() { return form; }

    // public getter without annotation: the account is not reachable from parameters
    public Account getAccount() { return account; }

    public String getAccountRole() { return account.getRole(); }
}
$ curl -s "http://localhost:18080/g12sechard/search?filter=dune&limit=99&admin=true"
filter=dune
limit=10
admin=false
$ curl -s -d 'form.title=Dune&form.note=hi&account.role=admin' http://localhost:18080/g12sechard/profile
form.title=Dune
form.note=hi
account.role=reader

The two unannotated setters were ignored and so was the nested account.role, while filter and the DTO properties arrived. The log of StrutsParameterAuthorizer explains each decision at DEBUG:

DEBUG StrutsParameterAuthorizer - Parameter injection for method [setAdmin] on target [org.example.bookshelf.SearchAction] rejected. Ensure it is annotated with @StrutsParameter with an appropriate 'depth'.
DEBUG StrutsParameterAuthorizer - Success: Matching annotated method [setFilter] of depth [0] found on target [SearchAction]
DEBUG StrutsParameterAuthorizer - Parameter injection for method [getAccount] on target [org.example.bookshelf.ProfileAction] rejected. Ensure it is annotated with @StrutsParameter with an appropriate 'depth'.
DEBUG StrutsParameterAuthorizer - Success: Matching annotated method [getForm] of depth [1] found on target [ProfileAction]

The annotation has a second job that is easy to overlook: a parameter that matches an annotated member also puts the member’s type on the allowlist for that request (the section on the allowlist shows it). For an upgrade from 6.x, the guide offers struts.parameters.requireAnnotations.transitionMode=true: only nested parameters (DTOs and collections behind public getters) need annotations while public setters stay exposed, and the allowlist keeps working, so an application can switch on the allowlist before it has annotated every setter. It is a migration aid, not a destination. The complete binding decision (name length, patterns, ParameterNameAware, value patterns), ModelDriven and JSON/REST bodies are in parameter injection and @StrutsParameter; this page only records the rule as a checklist line.

Do Not Define Setters When They Are Not Needed

The guide’s rule for applications that do not require annotations is to design actions without public setters and getters that nothing needs, because an action’s setter can receive an untrusted value, and because some results populate their own parameters from values on the value stack, so an incoming value can be evaluated as an expression during that process. The admin setter above is the example: it exists for a test and nobody remembers it. In the g12sechardweak deployment (annotations off, the Struts 6 behaviour) the same requests as above do this:

$ curl -s "http://localhost:18080/g12sechardweak/search?filter=dune&limit=99&admin=true"
filter=dune
limit=99
admin=true
$ curl -s -d 'form.title=Dune&account.role=admin' http://localhost:18080/g12sechardweak/profile
form.title=Dune
form.note=
account.role=admin

The request set a privilege flag (admin=true) and changed the role of a domain object that the form never mentioned (account.role=admin), without any exploit, only by naming members. This is parameter injection at its simplest and the reason the annotation requirement exists. The rule outlives the annotation: with annotations on, a setter that exists without being needed is still an attack surface for anything that is not a request parameter (a static parameter, a wildcard value, a JSON body in the REST plugin). Delete setters that nothing calls, and keep domain objects out of the action’s public API.

Do Not Use Incoming Values as Input for Localisation Logic

Every getText(...) method of a TextProvider (for instance in ActionSupport) evaluates the parameters that a message contains. The guide’s example is an action with a message property that does message = getText(getMessage()): the key is a request value, and when no bundle defines the key, the default text, which is the key itself, is evaluated as an expression. The scratch GreetAction has the unsafe form and the safe form, with a harmless expression as the payload:

public class GreetAction extends ActionSupport {
    private String message = "";

    public String getMessage() { return message; }

    @StrutsParameter
    public void setMessage(String message) { this.message = message; }

    /** Unsafe: the incoming value is used as a message key / default text. */
    public String unsafe() {
        message = getText(getMessage());
        return SUCCESS;
    }

    /** Safe: fixed key from the bundle, incoming value only as a format argument. */
    public String safe() {
        message = getText("greeting", new String[] {getMessage()});
        return SUCCESS;
    }
}
# org/example/bookshelf/GreetAction.properties
greeting=Hello, {0}
$ curl -s "http://localhost:18080/g12sechard/greet-unsafe?message=%25%7B1%2B1%7D"
message=2
$ curl -s "http://localhost:18080/g12sechard/greet?message=%25%7B1%2B1%7D"
message=Hello, %{1+1}

The request carried the text %{1+1}. The unsafe action answered 2: a request value was evaluated as OGNL, and this happens on a hardened deployment too, because @StrutsParameter correctly allowed message (it is annotated) and the allowlist and exclusion lists only restrict what the expression may touch. The safe action answered the literal text. The unsafe request also leaves a trace in the log, which is the way to find this class of bug:

WARN  AbstractLocalizedTextProvider - Missing key [%{1+1}] in bundles [[org/apache/struts2/struts-messages, org/apache/struts2/xwork-messages]]!

An attacker’s expression would be a longer one, and the defences of the next chapter (member access, allowlist, expression limit) are what keep it from reaching anything dangerous; they are the second line, not an excuse for the first. The %{...} evaluation of message texts is useful (it is how a bundle message can read action properties) and is exactly why the key must never come from a request.

Do Not Use Untrusted Input in Forced Expression Evaluation

Most tag attributes of the Struts tag library accept the %{...} syntax, which forces the attribute to be evaluated as an expression. The guide: "Never use forced expression evaluation if you didn’t verify the input or it can be passed in by a user", and "never use the value of an incoming request parameter as input for forced expression evaluation". Two questions decide whether a use is safe: where does the text inside the braces come from, and does a tag then use the evaluated result as another expression? The scratch page forced.jsp binds the request value userInput and uses it in four ways:

<%@ taglib prefix="s" uri="/struts-tags" %>
A property value:  [<s:property value="userInput"/>]
B forced in value: [<s:property value="%{userInput}"/>]
C textfield label: [<s:textfield name="x" label="%{userInput}" theme="simple" value="v"/>]
D textfield name:  [<s:textfield name="%{userInput}" theme="simple"/>]
$ curl -s "http://localhost:18080/g12sechard/forced?userInput=%25%7B1%2B1%7D"
A property value:  [%{1+1}]
B forced in value: [%{1+1}]
D textfield name:  [<input type="text" name="%{1+1}" value="%{1+1}" id="__1_1_"/>]
$ curl -s "http://localhost:18080/g12sechard/forced?userInput=book.title"
D textfield name:  [<input type="text" name="book.title" value="" id="book_title"/>]

Lines A and B are safe: the attribute is an expression, userInput is a property, and its value is printed (the text %{1+1} is printed as text, and s:property escapes HTML by default). Line D is the dangerous shape: the name attribute is evaluated to the request value, and then a textfield uses its name as an expression to find the value to display, so a request value became an expression. The second request shows that the lookup really happens (the field name book.title is read from the stack; it printed an empty value because Book is not allowlisted in this deployment). The framework refuses the dangerous variants on its own in two places, both visible in the log: the arithmetic text of the first request is refused by the accepted-pattern check that components apply to such expressions, and the property read of the second is refused by the allowlist:

WARN  Component - Expression [%{1+1}] isn't allowed by pattern [[\w+((\.\w+)|(\[\d+])|(\(\d+\))|(\['(\w-?|[一-龥]-?)+'])|(\('(\w-?|[一-龥]-?)+'\)))*]]! See Accepted / Excluded patterns at
https://struts.apache.org/security/
WARN  SecurityMemberAccess - Declaring class [class org.example.bookshelf.Book] of member type [public java.lang.String org.example.bookshelf.Book.getTitle()] is not allowlisted! Add to 'struts.allowlist.classes' or 'struts.allowlist.packageNames' configuration.

That the framework blocks these particular probes is not a licence: pattern checks and exclusion lists are defences in depth, and every one of the historical Struts OGNL injections (the bulletins in the upgrade section) was a double evaluation that the defences of its day did not anticipate. The rule for code review is simple: a value that comes from a request, a cookie, a header or a database column written by a user never appears inside %{...}, in name, value, label, key, action, namespace or a link target, and never in getText. The 7.4.0 Version Notes record, among the rejected requests (WW-3338), that String-valued attributes are parsed for the %{} notation by design, so the burden is on the author of the JSP.

Use Struts Tags Instead of Raw EL Expressions

JSP EL does not escape: the guide says never to use a raw ${} expression on incoming values, and that the safest option is a Struts tag. The Struts request wrapper makes value-stack properties visible to EL, which makes the mistake easy. echo.jsp prints the same value four ways, with the payload <b>hi</b> (harmless markup):

<%@ taglib prefix="s" uri="/struts-tags" %>
el-param: ${param.q}
el-action: ${q}
tag: <s:property value="q"/>
tag-unescaped: <s:property value="q" escapeHtml="false"/>
$ curl -s "http://localhost:18080/g12sechard/echo?q=%3Cb%3Ehi%3C/b%3E"
el-param: <b>hi</b>
el-action: <b>hi</b>
tag: &lt;b&gt;hi&lt;/b&gt;
tag-unescaped: <b>hi</b>

The two EL lines are printed as markup, and with a script element in place of b they would be stored or reflected cross-site scripting. The tag escaped the value, and escapeHtml="false" turned the escaping off, so a code search for that attribute is a review item. Struts tags escape for HTML text; inside a JavaScript block or an attribute that holds a URL or a CSS value another encoder is needed (the OWASP Java Encoder and the escaping rules of each context are in the JSP security page, and the same idea is explained for another view technology in Thymeleaf template security and XSS).

Define Custom Error Pages

The guide’s reference is S2-006: by default XWork did not escape action names in generated error pages, and with Dynamic Method Invocation the action name is built from request parameters, so an attacker could put script into an error page (that bulletin is CVE-2011-1772, fixed in 2.2.3). The guide’s two remedies are to disable DMI and to define your own error pages. The second is also the answer to a less dramatic problem, information disclosure. The scratch BoomAction throws an exception whose message names an internal database host. In the default package a global exception mapping catches it, and in the package /raw no mapping exists:

<global-results>
    <result name="error">/WEB-INF/views/error.jsp</result>
    <result name="403" type="httpheader">
        <param name="status">403</param>
    </result>
</global-results>

<global-exception-mappings>
    <exception-mapping exception="java.lang.Exception" result="error"/>
</global-exception-mappings>
$ curl -s http://localhost:18080/g12sechard/boom
error page
$ curl -s http://localhost:18080/g12sechard/raw/boom | grep -o 'Internal Server Error\|jdbc:[^<]*\|BoomAction.execute[^<]*\|Note.*'
Internal Server Error
jdbc:postgresql:&#47;&#47;db.internal&#47;bookshelf failed
BoomAction.execute(BoomAction.java:8)
Note</b> The full stack trace of the root cause is available in the server logs.

The mapped path shows a generic page and nothing else. The unmapped path ended in Tomcat’s default error page, with HTTP 500, the exception message and the stack of the request (dozens of frames that name every interceptor of the stack and the Struts and Tomcat classes, even with devMode off). The fix has two parts: map exceptions in Struts so that a user sees a short page, and set error-page elements in web.xml (or the showReport option of Tomcat’s ErrorReportValve) so that the container’s default page never leaks a trace for what Struts did not catch. The guide’s other remedy, disabling DMI, is the default of 7.4.0 (see strict method invocation below). The exception mappings and global results are described in exception handling and global results.

Source: CVE-2011-1772

Ambiguous Action Methods

The guide warns of actions with a deep class hierarchy that reuse one method name (WW-4405): its example is a RealAction whose save() calls the save() of BaseAction, which calls a protected save(Double) in AbstractAction, and it says OGNL cannot decide which method to call; the remedy is to rename the action method (saveAction()) and keep the URL by annotation. The scratch WAR has exactly that hierarchy, mapped twice (a method attribute on the action, with no annotation):

public abstract class AbstractAction extends ActionSupport {
    protected String log = "";
    protected void save(Double val) { log += "[abstract save(" + val + ")]"; }
    public String getLog() { return log; }
}

public class BaseAction extends AbstractAction {
    public String save() throws Exception {
        save(Double.MAX_VALUE);
        log += "[base save()]";
        return SUCCESS;
    }
}

public class RealAction extends BaseAction {
    public String save() throws Exception {
        super.save();
        log += "[real save()]";
        return SUCCESS;
    }

    public String saveAction() throws Exception {
        super.save();
        log += "[real saveAction()]";
        return SUCCESS;
    }
}
$ curl -s http://localhost:18080/g12sechard/save
log=[abstract save(1.7976931348623157E308)][base save()][real save()]
$ curl -s http://localhost:18080/g12sechard/saveAction
log=[abstract save(1.7976931348623157E308)][base save()][real saveAction()]

On 7.4.0 both mappings work: the ticket quoted by the guide is closed as fixed in 2.5, the invocation goes through OgnlUtil.callMethod and the overloaded save resolved correctly. The guide’s advice is still reasonable for an application that must run on old versions, and a method name that is unique in the hierarchy costs nothing, but it should not be a reason to rewrite a working 7.x action (recorded as an inconsistency of the guide at the end of this page). The configuration of action methods is in actions.

Accepted and Excluded Patterns

Since 2.3.20 the framework has two interfaces, AcceptedPatternsChecker and ExcludedPatternsChecker, with default implementations (DefaultAcceptedPatternsChecker, DefaultExcludedPatternsChecker), used by the Parameters Interceptor and the Cookie Interceptor to accept or refuse parameter and cookie names; applications that used excludeParams should compare their patterns with the defaults. The decisions are logged at DEBUG, and a request with five hostile names shows the defaults in action:

curl -s -o /dev/null "http://localhost:18080/g12sechard/search?filter=ok&class.classLoader.x=1"
curl -s -o /dev/null --data-urlencode '#session.x=1' --data-urlencode 'filter=ok' http://localhost:18080/g12sechard/search
curl -s -o /dev/null --data-urlencode 'filter[0]=1' --data-urlencode '(filter)(x)=1' \
     --data-urlencode 'top.filter=1' --data-urlencode 'actionErrors=1' http://localhost:18080/g12sechard/search
DEBUG StrutsParameterAuthorizer - Matching field for property [class] not found on target [SearchAction]
DEBUG ParametersInterceptor - Parameter [#session.x] matches excluded pattern [(^|\%\{)((#?)(\[\d\]\.)?)(dojo|struts|session|request|response|application|servlet(Request|Response|Context)|parameters|context|_memberAccess)(\.|\[).*]!
DEBUG ParametersInterceptor - Parameter [(filter)(x)] didn't match accepted pattern [[\w+((\.\w+)|(\[\d+])|(\(\d+\))|(\['(\w-?|[一-龥]-?)+'])|(\('(\w-?|[一-龥]-?)+'\)))*]]!
DEBUG ParametersInterceptor - Parameter [actionErrors] matches excluded pattern [actionErrors|actionMessages|fieldErrors]!
DEBUG ParametersInterceptor - Parameter [top.filter] matches excluded pattern [(^|\%\{)(#?top\.)[^\s]*]!

All five requests answered HTTP 200 and nothing was bound. The excluded pattern covers the context roots (session, request, parameters, _memberAccess…​), top, the class property and the three error collections; the accepted pattern allows property paths, numeric indexes and quoted keys but no operators or parentheses with names. A rule worth remembering from the parameter-injection page is that a parameter name that is rejected is not an error, it is dropped, so a monitoring rule needs the DEBUG log or a WARN-level custom ParameterNameAware to notice a probe. And an excludeParams setting on the params interceptor replaces the default exclusion list entirely (verified on the parameter-injection page), so do not use it as a way to add one pattern.

Strict Method Invocation

Strict Method Invocation (SMI) controls which methods of an action a request may reach, particularly through the bang (!) of Dynamic Method Invocation. The guide dates it to 2.5 ("introduced in version 2.5"), while the Core Developer Guide’s action configuration page dates it to 2.3; either way, the mechanism is on by default today: an action mapped with method="list" can run only list unless its allowed-methods says otherwise. The scratch CrudAction has three public methods and four mappings; purge is the destructive one:

<action name="crud" class="org.example.bookshelf.CrudAction" method="list">
    <result>/WEB-INF/views/crud.jsp</result>
</action>

<action name="crud2" class="org.example.bookshelf.CrudAction" method="list">
    <result>/WEB-INF/views/crud.jsp</result>
    <allowed-methods>list,delete</allowed-methods>
</action>

<action name="crud-*" class="org.example.bookshelf.CrudAction" method="{1}">
    <result>/WEB-INF/views/crud.jsp</result>
    <allowed-methods>list,delete</allowed-methods>
</action>

<package name="legacy" extends="struts-default" namespace="/legacy" strict-method-invocation="false">
    <action name="crud" class="org.example.bookshelf.CrudAction" method="list">
        <result>/WEB-INF/views/crud.jsp</result>
    </action>
</package>

The results on the hardened deployment, where DMI is off, and on the weak one, where struts.enable.DynamicMethodInvocation=true (the allowed-methods element must come after the result elements, or the filter fails to start, as the wildcard page records):

request                  hardened (DMI off)        weak (DMI on, devMode on)
GET /crud-purge          404 no action mapped      Struts Problem Report: no action mapped
GET /crud!purge          404                       Problem Report: Method purge for action crud is not allowed!
GET /crud2!delete        404                       done=delete
GET /crud2!purge         404                       Problem Report: Method purge for action crud2 is not allowed!
GET /legacy/crud!purge   404                       done=purge (destructive)

Three conclusions follow. With the 7.x defaults a wildcard method whose name is not in allowed-methods is not reachable (crud-purge is refused on both deployments, a 404 on the hardened one; the request crud-delete, whose method is listed, printed done=delete on the hardened deployment). With DMI on, the bang works only for methods that allowed-methods lists, so strict invocation protects even a deployment that turned DMI on. And the package attribute strict-method-invocation="false" brings back the Struts 2.0 behaviour in that package (the last row: a request for /legacy/crud!purge ran the destructive method), which is the reason the attribute is a code review item. The same mechanism restricts a wildcard that puts the method in {1}: the default regular expression of 7.4.0 for such methods is ([A-Za-z0-9_$]*), configurable with struts.strictMethodInvocation.methodRegex (the wildcard page has the details). Also keep struts.mapper.action.prefix.enabled=false (the default): when on, the action: prefix lets a request choose another action, and struts.mapper.action.prefix.crossNamespaces=false (also the default) keeps that choice inside the current namespace. All of this is described and tested in wildcard mappings and method invocation.

Resource Isolation with Fetch Metadata

Fetch Metadata is a browser feature: every request carries Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest and Sec-Fetch-User, which tell the server whether the request was started by the same origin, a same-site page or another site, and why. The fetchMetadata interceptor implements a Resource Isolation Policy against them, as a mitigation against cross-site request forgery and cross-site leaks; the guide dates it to Struts 6.0.0. In 7.4.0 it is part of defaultStack (struts-default.xml) with disabled=false, and it adds a Vary header to every response. The scratch requests send the headers by hand:

$ curl -si http://localhost:18080/g12sechard/books | head -4
HTTP/1.1 200
Vary: Sec-Fetch-Dest,Sec-Fetch-Mode,Sec-Fetch-Site,Sec-Fetch-User
Cross-Origin-Embedder-Policy-Report-Only: require-corp
Cross-Origin-Opener-Policy: same-origin

$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Sec-Fetch-Site: cross-site' -H 'Sec-Fetch-Mode: cors' \
       -H 'Sec-Fetch-Dest: empty' http://localhost:18080/g12sechard/search
403
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Sec-Fetch-Site: same-origin' -H 'Sec-Fetch-Mode: cors' \
       -H 'Sec-Fetch-Dest: empty' http://localhost:18080/g12sechard/search
200
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:18080/g12sechard/search
200

The cross-site request is rejected, the same-origin one passes, and a request without the headers (an old browser, curl) passes. The last line is a property of the policy, not a bug: Fetch Metadata is a defence for browsers, and server-to-server calls need other protections. The log says Fetch metadata rejected cross-origin request to: /g12sechard. The trap is how the rejection is reported. The interceptor returns the result code 403; the action needs a result with that name. In the first attempt, without a 403 result, the same cross-site request answered HTTP 200 with the application’s error page in the package with a global exception mapping, and HTTP 404 ("No result defined for action") in a package without one; the httpheader global result shown in the error-page section above turned both into a real 403. The interceptor parameters (disabled, exemptedPaths for endpoints that serve cross-site clients, such as a public API) and the response headers Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy-Report-Only seen above are the subject of the security headers page of this section, which also covers Content Security Policy.

Cross-Origin Isolation with COOP and COEP

The Cross-Origin Opener Policy lets a document ask for a separate browsing context group, and the Cross-Origin Embedder Policy prevents loading cross-origin resources that do not grant permission. They are independent; set together they allow powerful features (SharedArrayBuffer, performance.measureMemory()) to be used safely. The guide’s recommended values are Cross-Origin-Embedder-Policy: require-corp; and Cross-Origin-Opener-Policy: same-origin;, implemented by the CoopInterceptor and the CoepInterceptor (since 6.0.0). The defaultStack of 7.4.0 contains both: coop with mode=same-origin and coep with enforcingMode=false, which is why the response above has the COOP header and the report-only form of the COEP header. The csp interceptor is in the same stack with enforcingMode=false; the response of the scratch application carried no Content-Security-Policy header, so a policy has to be configured before there is anything to enforce or to report. Enforcing COEP breaks any page that embeds images or scripts from another origin without Cross-Origin-Resource-Policy, so the guide’s advice to "test and deploy separately" is literal. The details, the Content Security Policy interceptor and CORS interplay (CORS) are in the security headers page of this section.

Proactively Protecting Against OGNL Expression Injection

The second part of the guide opens with a candid sentence: the framework "has a history of critical security bugs, many tied to its use of OGNL", which can create or change executable code, and the framework has therefore been equipped with a number of additional protections, "some of which need to be manually enabled". Its warning that they "might break your current app functionality" is the reason to test them on a deployment that exercises every page. Each protection below is shown on the /probe page, a JSP with ten expressions, run on several deployments:

1 own bean:        [<s:property value="book.title"/>]
2 new instance:    [<s:property value="new org.example.bookshelf.Book().title"/>]
3 static method:   [<s:property value="@java.lang.Math@max(1,2)"/>]
4 static field:    [<s:property value="@org.example.bookshelf.Book@SHELF"/>]
5 getClass:        [<s:property value="book.getClass().getSimpleName()"/>]
6 JDK constructor: [<s:property value="new java.util.TreeMap().size()"/>]
7 vendor (no ann): [<s:property value="imprint.name"/>]
8 vendor (ann):    [<s:property value="exposed.name"/>]
9 context #session:[<s:property value="#session"/>]
10 ctx fallback:   [<s:property value="parameters"/>]

The action behind it owns a Book (package org.example.bookshelf, a static SHELF constant) and two Imprint objects of a class in another package, org.example.vendor, one behind an unannotated getter and one behind a getter annotated with @StrutsParameter(depth = 1). Only trusted page authors write such expressions; the point is to see what the framework lets an expression do, and the same rules apply to an expression that an attacker managed to inject.

Table 5. The /probe lines on five deployments (empty means blocked; a value means evaluated)
Expression Defaults allowlist.packageNames set Allowlist off Weak (static fields, no allowlist) Guard on

1 book.title

empty

Dune

Dune

Dune

Dune

2 new Book().title

empty

empty

empty

empty

empty

3 static method

empty

empty

empty

empty

empty

4 static field

empty

empty

empty

main

empty

5 getClass()

empty

empty

empty

empty

empty

6 new TreeMap().size()

empty

empty

0

0

empty

7 vendor, no annotation

empty

empty

Acme Press

Acme Press

empty

8 vendor, annotated

empty

empty

Acme Press

Acme Press

empty

9 #session

{}

{}

{}

{}

empty (Guard)

10 context fallback

{}

{}

{}

{}

empty (fallback off)

The next sections explain each blocked cell, with the log line that says why.

Run OGNL Expressions Inside a Sandbox

The guide: add -Dognl.security.manager to the JVM arguments and OGNL runs expressions (and therefore actions) inside a Java Security Manager sandbox with no permissions, affecting only OGNL execution, and "this feature does not work with JDK 21 and above". Tested on this machine, the second half is a simplification, and the first half is a dead end:

$ java -Djava.security.manager=allow Sm.java        # JDK 21.0.6
installed          (after four deprecation warnings)
$ java Sm.java                                       # JDK 21.0.6
java.lang.UnsupportedOperationException: The Security Manager is deprecated and will be removed in a future release
$ java -Djava.security.manager=allow Sm.java        # JDK 25.0.2
Error occurred during initialization of VM
java.lang.Error: A command line option has attempted to allow or enable the Security Manager. Enabling a Security Manager is not supported.

(Sm.java is a one-line program that calls System.setSecurityManager(new SecurityManager()).) On JDK 21 a Security Manager can still be installed when the command line allows it, with warnings; on JDK 24 and later (JEP 486, tested on 25) it cannot be enabled at all. The result of this check is limited to the JDK facts: this page did not run OGNL under a Security Manager. The practical conclusion is that the sandbox is a mechanism of old JVMs: Struts 7 requires Java 17, a 17 or 21 runtime is the only place where it could still apply, and no new design should depend on it. The other layers of this chapter do not depend on the JVM version.

Restrict Access to the Struts Context (ActionContext)

The ActionContext is shared throughout the framework and reaches parameters, the value stack, the request, the response, the session, the servlet context and, through them, more. An expression can use it with the # operator (#session, #request) or indirectly: when an expression finds no value on the value stack, OgnlValueStack falls back to a lookup in the context. The exclusion lists block the known harmful members, but "the myriad of changing objects" on the context makes that unreliable; the allowlist is stronger; and the strongest protection is to forbid the access, with two constants from the guide. Note the guide’s warning that as of 6.4.0 the Set, Iterator and Action components require ActionContext access from OGNL, so test the pages that use them:

<constant name="struts.ognl.valueStackFallbackToContext" value="false"/>
<constant name="struts.ognl.excludedNodeTypes" value="
            ognl.ASTThisVarRef,
            ognl.ASTVarRef
"/>

The guard setting forbids the syntax node of #name; the fallback constant turns off the indirect lookup. On the /g12sechardguard deployment (the Bookshelf allowlist package plus the two constants above, set in struts.properties) the probe lines 9 and 10 became empty, and the log has the Guard’s decision:

WARN  StrutsOgnlGuard - Expression contains blocked node type [ognl.ASTVarRef]

Without the two constants lines 9 and 10 printed {} (an empty map: the expressions were allowed to read the context). Before switching them on, search the views for # and for #attr, #session, #parameters, and run every page; the migration of the iterator and s:set tags is where an application usually finds a dependency it forgot.

Apply a Maximum Length to OGNL Expressions

struts.ognl.expressionMaxLength (default 256 in 7.4.0) makes OGNL refuse to evaluate any expression longer than the value; the guide says to choose a value large enough for all valid expressions and that values above the 200 to 400 range have diminishing value ("really only a style guard"). longexpr.jsp has a short expression ("a"+"b") and one of 323 characters made of the same pieces (80 additions), evaluated on the default deployment and on a deployment with 400:

$ curl -s http://localhost:18080/g12sechard/longexpr
short: [ab]
long: []
$ curl -s http://localhost:18080/g12sechardmaxlen/longexpr | tr -cd 'a' | wc -c
81
ERROR OgnlValueStack - Could not evaluate this expression due to security constraints: ["a"+"a"+"a"+...
ognl.OgnlException: Parsing blocked due to security reasons!
Caused by: java.lang.SecurityException: This expression exceeded maximum allowed length: "a"+"a"+"a"+...

The log is at ERROR level here (the other layers use WARN), with the expression in the message; so expression text that reached the log is itself user-influenced input and should not be fed to a log viewer that renders markup. A legitimate page that hits the limit, usually a long validation rule in an XML validator file, should move the logic into Java; raising the constant is the last resort, and each increase hands attackers room for a longer payload.

OGNL Member Access

The SecurityMemberAccess bean (declared in struts-beans.xml with the name struts and prototype scope) is consulted for every member of every object that an expression touches, by target class and by member class: parameters, JSP expressions, validators, results. Its protected checks, as listed by javap on the 7.4.0 class, are checkAllowlist, checkExclusionList, checkDefaultPackageAccess, checkProxyObjectAccess, checkProxyMemberAccess, checkStaticMethodAccess, checkStaticFieldAccess and checkPublicMemberAccess; static method access is refused in every deployment of this page, including the weak one. The guide lists four options for the exclusion lists, and 7.4.0 ships values for two of them in struts-excluded-classes.xml:

Table 6. The exclusion options and the 7.4.0 defaults
Constant Matching 7.4.0 default

struts.excludedClasses

Exact class names

java.lang.Class, ClassLoader, InheritableThreadLocal, Object, Package, Process, ProcessBuilder, Runtime, RuntimePermission, SecurityManager, Shutdown, System, Thread, ThreadGroup, ThreadLocal and org.apache.struts2.ActionContext

struts.excludedPackageNames

String prefix, sub-packages included

29 entries: antlr.build, freemarker.core, freemarker.ext.jsp, freemarker.ext.rhino, freemarker.template, jakarta, java.io, java.net, java.nio, javassist, javax, ognl, org.apache.catalina.core, org.apache.commons.beanutils, org.apache.commons.collections, org.apache.jasper, org.apache.struts2.config, org.apache.struts2.inject, org.apache.struts2.ognl, org.apache.struts2.security, org.apache.struts2.util, org.apache.tomcat, org.apache.velocity, org.objectweb.asm, org.springframework.context, org.wildfly.extension.undertow.deployment, org.yaml.snakeyaml, sun.misc, sun.reflect

struts.excludedPackageNamePatterns

Regular expressions, slower

Commented out; more flexible and with "a greater impact on performance and page latency"

struts.excludedPackageExemptClasses

Exact class names exempt from an excluded package or pattern

Empty

With devMode on, Struts reads the parallel constants struts.devMode.excludedClasses and struts.devMode.excludedPackageNames instead (the startup WARN "DevMode enabled, using DevMode excluded classes" says so); in the 7.4.0 jar the dev-mode class list is the same as the normal one except that it lacks org.apache.struts2.ActionContext, which is one more reason not to run production with devMode. The guide shows the log line for a blocked expression; the observed lines on the 7.4.0 scratch deployments are:

WARN  SecurityMemberAccess - Access to static method [public static int java.lang.Math.max(int,int)] is blocked!
WARN  SecurityMemberAccess - Access to static field [public static final java.lang.String org.example.bookshelf.Book.SHELF] is blocked!
WARN  SecurityMemberAccess - Declaring class of member type [class java.lang.Object] is excluded!
WARN  SecurityMemberAccess - Access to non-public [private java.lang.String org.example.bookshelf.Book.title] is blocked!

Lines 3 and 5 of the table are blocked in every deployment, as the guide states for static methods and as the exclusion of java.lang.Object (the declaring class of getClass()) implies. new Book() (line 2) produced an empty value everywhere, and with the allowlist off it logged nothing at WARN, whereas the guide shows a WARN for the example new MyBean(); the expression is blocked either way and the guide’s text on the reason (the target of a constructor call is java.lang.Class, which is excluded) is consistent with the empty result. The guide’s advice for the lists: redefine them in struts.xml if you must, "but avoid reducing the list, instead extending the list with other known dangerous classes or packages in your application". A constant set in struts.properties replaces the list as a whole, so an extension restates the defaults. /g12sechardexclpkg does it: the allowlist off, the 29 default package names and org.example.vendor; line 7 of the probe, which printed Acme Press with the allowlist off, became empty, with the log line

WARN  SecurityMemberAccess - Package [package org.example.vendor] of member class [class org.example.vendor.Imprint] of member [org.example.vendor.Imprint@7abec177] is excluded!

(the identity hash is not significant). The 7.4.0 Version Notes also record a breaking change for code that customises the mechanism: five dev-mode configuration setters were removed from SecurityMemberAccess and the eleven remaining use* setters are deprecated in favour of SecurityMemberAccessConfig (WW-5675, removal planned in WW-5682).

The Additional Options

The guide recommends five more options, "enabled by default in 7.0". Their 7.4.0 values come from default.properties, and the first one was exercised with the weak deployment:

Table 7. The additional member-access options
Constant 7.4.0 default What it blocks, and where it was tested

struts.ognl.allowStaticFieldAccess

false

Static field values that are not primitives could reach otherwise inaccessible classes. Tested: line 4 of the probe printed main only when the constant was true

struts.disallowProxyObjectAccess

true

Proxied objects (Spring, Hibernate) that often stand for beans and entities. Not run: the scratch app has no proxies

struts.disallowDefaultPackageAccess

true

Classes in the default package, "which should not be used in production". Not run

struts.ognl.disallowCustomOgnlMap

true

A custom map class built inside an expression can bypass the SecurityMemberAccess policy; only HashMap, TreeMap and LinkedHashMap can be built. Not run

struts.actionConfig.fallbackToEmptyNamespace

false

Actions in the empty namespace reached through other endpoints. Tested: the /nope/books request above

struts.disallowProxyMemberAccess also exists in 7.4.0 (a class listing of StrutsConstants shows it) and is not mentioned in the guide. When the proxy rule is disabled, the migration guide says the allowlist keeps working with an exemption for Hibernate proxies (and for Spring proxies since 7.1), so an application with proxied beans can still get the allowlist.

The Allowlist Capability

The strongest OGNL protection is to invert the question: not "is this class dangerous?" but "is this class known to the application?". With struts.allowlist.enable=true (the default since 7.0, optional since 6.4), a class involved in an expression must be allowlisted in addition to passing the exclusion lists. The allowlist contains the Struts classes that the framework needs, the action classes (and other classes) defined in the packages of struts.xml, the types of annotated parameters (when a matching parameter arrives), and what the application adds with struts.allowlist.classes (exact names) and struts.allowlist.packageNames (startsWith, sub-packages included). The guide’s advice is to monitor the logs for blocked classes and add them, and that "depending on the functionality of your application, you may not need to manually allowlist any classes". The probe shows the four cases that decide whether a class needs an entry:

defaults                         1 own bean: []    7 vendor (no ann): []    8 vendor (ann): []
allowlist.packageNames=bookshelf 1 own bean: Dune  7 vendor (no ann): []    8 vendor (ann): []
+ allowlist.classes=...Imprint   1 own bean: Dune  7 vendor (no ann): Acme Press
$ curl -s "http://localhost:18080/g12sechard/probe?exposed.name=Zed" | grep '^[78] '
7 vendor (no ann): [Acme Press]
8 vendor (ann):    [Zed]
WARN  SecurityMemberAccess - Declaring class [class org.example.bookshelf.Book] of member type [public java.lang.String org.example.bookshelf.Book.getTitle()] is not allowlisted! Add to 'struts.allowlist.classes' or 'struts.allowlist.packageNames' configuration.
WARN  SecurityMemberAccess - Declaring class [class org.example.vendor.Imprint] of member type [public java.lang.String org.example.vendor.Imprint.getName()] is not allowlisted! Add to 'struts.allowlist.classes' or 'struts.allowlist.packageNames' configuration.

Four observations follow from it. First, a class in the application’s own package is not automatically allowlisted: Book is a plain bean in the package of the actions, and book.title was blocked until the package or the class was added, which is what an upgrade from 6.x trips over first (the getting-started page of this section records the same effect on the official Hello World). Second, the allowlist is the outermost OGNL gate: it stood in front of the Imprint class in a package that no deployment had configured. Third, an annotated getter allowlists its return type only for a request in which a parameter matched it: with exposed.name=Zed in the query, line 8 printed Zed, and line 7, an unrelated read of an Imprint through an unannotated getter, also worked in the same request because the class was by then allowlisted for the duration of that request; without the parameter, both lines were empty. A reviewer should read that as: the allowlist is per class and per request, not per property. Fourth, the guide’s recommendation to turn on the parameter annotation capability "to ensure any necessary parameter injection types are allowlisted" is how most applications get by without listing anything. The declaration in struts.xml is as short as

<constant name="struts.allowlist.packageNames" value="org.example.bookshelf"/>
<constant name="struts.allowlist.classes" value="org.example.vendor.Imprint"/>

and the 6.x to 7.x guide’s procedure for choosing entries is practical: start the application with the allowlist on, view pages and submit forms, and read the WARN lines. A wildcard action whose class is chosen only by a pattern variable (class="actions.{1}") is not allowlisted for the same reason, and its package must be listed (see the wildcard page).

Extending the Member Access Mechanism

Since 6.4 the member access mechanism is extensible: the guide names the extension point struts.securityMemberAccess for defining your own rules for blocking OGNL evaluations, with the warning to be vigilant "as not to reduce protections offered by the default implementation". In 7.4.0 the default is the bean org.apache.struts2.ognl.SecurityMemberAccess (declared in struts-beans.xml as <bean name="struts" class="...SecurityMemberAccess" scope="prototype"/>), and the configuration holder is SecurityMemberAccessConfig. A class that extends SecurityMemberAccess and overrides one of its protected checks (checkAllowlist, checkExclusionList, isClassExcluded, checkStaticFieldAccess and so on, all present in the 7.4.0 class) and calls super first keeps the defaults. This page did not build a custom member access class: the Version Notes mention that the setter methods that configuration code used are deprecated in 7.4.0, so any custom subclass should be written against SecurityMemberAccessConfig, and the extension of the sibling mechanism below is the one that was verified.

The Struts OGNL Guard

The Guard (since 6.4) forbids OGNL features outright. It is off by default and configured with a list of node types in struts.ognl.excludedNodeTypes. The guide’s advice is to disable every feature that the application does not use: for example, an application that never adds inside an expression can list ognl.ASTAdd, "which will mitigate against a host of String concatenation attacks". Its list of 30 node types is a starting point for applications with minimal use of Struts features, and "this list WILL break certain Struts features": it contains ASTAdd, ASTAssign, ASTCtor, ASTEval, ASTList, ASTMap, ASTProject, ASTSelect, ASTStaticField, ASTStaticMethod, ASTThisVarRef, ASTVarRef and the arithmetic and bit operators. The /g12sechardguard deployment blocked the + of the earlier longexpr page with the shortest possible list:

struts.ognl.excludedNodeTypes=ognl.ASTAdd,ognl.ASTThisVarRef,ognl.ASTVarRef
$ curl -s http://localhost:18080/g12sechardguard/longexpr
short: []
long: []
WARN  StrutsOgnlGuard - Expression contains blocked node type [ognl.ASTAdd]

Both expressions were blocked, including the short one, because they use addition, and every view that concatenates with + inside an OGNL attribute would stop working; this is the cost model of the Guard, and its reward is that a whole class of payloads (those built from concatenation) cannot be parsed. Roll out a list by adding the node types the audit of the views proves unused, one at a time, with the WARN lines in front of you.

The Guard is extensible: the extension point struts.ognlGuard accepts a bean of type org.apache.struts2.ognl.OgnlGuard (an interface with two abstract methods, isRawExpressionBlocked(String) and isParsedTreeBlocked(Object)), and the guide says one may override the default implementation (StrutsOgnlGuard, the bean named struts) or implement the interface directly. The scratch WAR registers a subclass that keeps the default node rules and refuses any expression that reads a property called title:

public class BookshelfOgnlGuard extends StrutsOgnlGuard {
    @Override
    public boolean isRawExpressionBlocked(String expr) {
        return expr.contains(".title") || super.isRawExpressionBlocked(expr);
    }
}
<bean type="org.apache.struts2.ognl.OgnlGuard" name="bookshelf" class="org.example.bookshelf.BookshelfOgnlGuard"/>
<constant name="struts.ognlGuard" value="bookshelf"/>
$ curl -s http://localhost:18080/g12sechardcg/probe | sed -n 3,4p
1 own bean:        []
$ curl -s http://localhost:18080/g12sechardallow/probe | sed -n 3,4p
1 own bean:        [Dune]

The same page with the same allowlist package printed Dune without the custom guard and an empty value with it, so the extension point works as documented. It is a blunt instrument: a guard that rejects by substring also rejects legitimate expressions, and a custom guard must be at least as strict as the default, hence the super call.

A Worked Bookshelf Hardening

The earlier sections each tested one rule; this one runs one battery of requests against two deployments of the same classes, the weak one and the hardened one, and reads the differences as a hardening report. The weak deployment reverts the 7.x defaults one by one (the migration guide says how: "reverting the following configuration option"):

struts.devMode=true
struts.parameters.requireAnnotations=false
struts.allowlist.enable=false
struts.enable.DynamicMethodInvocation=true
struts.ognl.allowStaticFieldAccess=true
struts.disallowProxyObjectAccess=false
struts.disallowDefaultPackageAccess=false
struts.ognl.disallowCustomOgnlMap=false
struts.actionConfig.fallbackToEmptyNamespace=true
struts.mapper.alwaysSelectFullNamespace=false
Table 8. The same requests on the weak and the hardened Bookshelf
Request Weak (Struts 6 settings) Hardened (7.4.0 defaults)

GET /search?filter=dune&limit=99&admin=true

limit=99, admin=true

limit=10, admin=false

POST /profile with account.role=admin

role admin

role reader

GET /greet-unsafe?message=%{1+1} (URL-encoded)

2

2 (not stopped by these layers)

GET /nope/books

200, the books page

404

GET /crud2!purge

Problem Report: not allowed (strict invocation holds)

404

GET /legacy/crud!purge

done=purge (destructive)

404

GET /books?debug=command&expression=1+1

2

the books page, parameter ignored

GET /probe, line 4 (static field)

main

empty

GET /probe, lines 7 and 8 (vendor class)

Acme Press

empty

Startup log

three WARN lines about disabled protections

no WARN lines

The table has one row where the hardened deployment does not help: the getText with a request value. That row is the reason the guide’s code-level rules (layer 6) exist, and the one to put at the top of a review list. Everything else is the result of keeping the defaults, which is the whole hardening recipe for a new application, and for an old one the order of work is: (1) upgrade to 6.12.0 or 7.4.0, the supported lines; (2) turn on the allowlist and annotations in a test environment and read the WARN lines; (3) disable DMI and action: prefixes, make namespaces exact; (4) remove devMode and the config browser from the production artifact; (5) add the container constraints; (6) review getText, %{...} and raw EL against the rules above; (7) add the Guard node types and the context restrictions that the audit allows; (8) keep the WARN logs in the monitoring.

The Upgrade Policy

The guide’s tips cannot compensate for running a version with a known bulletin. The project’s end-of-life page lists the facts (checked 2026-10-08): Struts 2.5.x reached end of life on 30 October 2023, Struts 2.3.x on 12 September 2019 and Struts 1.x on 5 April 2013; the supported lines are 6.12.0 and 7.4.0 (both released 2026-10-02; 7.3.0 and 6.11.0 preceded them). A bulletin lists affected and fixed versions, a maximum security rating and a recommendation, and the pattern is that fixes appear only in supported lines. Three bulletins show what hardening settings are worth:

  • S2-045 (CVE-2017-5638), a remote code execution through a malicious Content-Type value in a request handled by the Jakarta Multipart parser: when the value was not valid an exception was thrown, and the text of the error message shown to the user was evaluated. It was fixed in 2.3.32 and 2.5.10.1, and it is why a version that old cannot be hardened by configuration: the defences of this page did not exist.

  • S2-006 (CVE-2011-1772), cited by the guide, is the cross-site scripting in generated error pages with DMI (the error pages section above).

  • S2-075 (CVE-2026-104711; the CVE record was published on 2026-10-05), fixed in 7.4.0 and 6.12.0, is an OGNL injection in the legacy RESTful action mapper. Its affected list is the instructive part: 2.0.0 to 2.3.37 and 2.5.0 to 2.5.33 (both end of life), 6.0.0 to 6.11.0, "and Struts 7.0.0 through 7.3.0 only when the OGNL allowlist is disabled"; the bulletin states that Struts 7 "is not affected in its default configuration, where the OGNL allowlist is enabled". That sentence is the case for the allowlist in a vendor’s own words: the defence turned a remote code execution on 7.x into a non-issue for everyone who kept the default. The recommended fix is to upgrade to 7.4.0 (or 6.12.0) or to migrate off the legacy mapper, which the 7.4.0 Version Notes also deprecate in favour of the REST plugin.

The CVE identifiers above were verified in the CVE Program records on 2026-10-10 (CVE-2011-1772 published 2011-05-13, CVE-2017-5638 published 2017-03-11, CVE-2026-104711 published 2026-10-05); the S2 numbers are those of the bulletin pages. The timeline of the significant bulletins, their affected and fixed versions and the lesson of each are in the security bulletins page of this section.

A workable policy has four parts. Follow the announcements (the Struts security page https://struts.apache.org/security.html and the user mailing list) and subscribe a team address; keep the dependency on a supported minor line and update patch and minor releases of that line promptly (the 7.4.0 Version Notes alone list four security bulletins, S2-075 to S2-078); upgrade the major line while the previous one is still supported, because the lines that have reached end of life no longer receive fixes; and test the upgrade with the allowlist and the annotation requirement already on, which are exactly the settings that break first. For an application that is stuck on 2.5 or earlier, the migration is a security task and not a refactoring: see the Struts 1 and migration page of this section and the cross-cutting migration path from a legacy stack to a modern one.

Source: CVE-2026-104711

The Same Ideas in Other Stacks

Every rule on this page has a counterpart in another framework, which helps when a team maintains both. Allowlisting what a request may bind is the mass-assignment protection of Spring (setAllowedFields, DTOs), of Rails (strong parameters) and of the ASP.NET Bind attribute and view models; the container constraints correspond to the authorisation filters of Spring Security; the escaping rules to those of Thymeleaf and the auto-escaping of Django templates in Django security; the production checklist to ASP.NET Core security hardening and ASP.NET MVC security hardening. Browser-side controls (Fetch Metadata, CORS) are discussed in CORS.

Pitfalls

Table 9. Struts hardening pitfalls (rows marked "observed" were reproduced on Struts 7.4.0 and Tomcat 11.0.26)
Symptom Cause Fix

A page that worked on 6.x shows an empty value after the upgrade, and the log has is not allowlisted! (observed)

The allowlist is on by default and a plain bean in the application’s own package is not on it

Add the package to struts.allowlist.packageNames or the class to struts.allowlist.classes, after reading the WARN lines

A form field is silently dropped (observed)

No @StrutsParameter on the setter; the decision is logged at DEBUG only

Annotate the setter, with depth only as large as needed

requireAnnotations=false is set "temporarily" and stays

It is the quickest way to make an upgrade green

Use transitionMode=true while annotating; never ship false (a startup WARN reports it)

A request value is evaluated as an expression on a hardened deployment (observed: %{1+1} printed 2 with getText)

A request value is used as a message key or default text; the allowlist limits what the expression may touch, not whether it runs

Use a fixed key and pass the value as an argument; never getText(request value)

A tag attribute with %{...} around a request value is rendered with surprising output (observed with name)

The tag evaluates the attribute and then uses the result as an expression or a name

Keep request values out of %{...}; print them with s:property

Raw ${param.q} prints markup (observed)

JSP EL does not escape

Use s:property (escapes), or c:out/fn:escapeXml, and review each escapeHtml="false"

devMode left on in production (observed: debug=xml dumps the context, debug=command evaluates an expression)

A struts.devMode constant in struts.xml that nobody removed

Set it to false, fail the build when true appears in a production profile, watch the startup WARN

The config browser is reachable in production (observed: 302 then the action list)

The plugin is in the WAR and unprotected

Remove the dependency in production profiles; otherwise constrain /config-browser/* over TLS

A stack trace with an internal host name appears in the browser (observed on /raw/boom)

No exception mapping in the package and no error-page in web.xml; Tomcat’s default report prints the trace

Map java.lang.Exception globally and add error-page elements; remove stack traces from the container’s report

A cross-site request is rejected with HTTP 200 or 404 instead of 403 (observed)

The fetchMetadata interceptor returns the result code 403 and the action has no result with that name

Add a global result 403 (type httpheader), and use exemptedPaths for real cross-site clients

A custom stack silently loses the Fetch Metadata, COOP and COEP interceptors

The stack was copied from an older defaultStack that did not contain them

Reference defaultStack and override parameters; check the interceptor list in the config browser during development

strict-method-invocation="false" on a package re-enables !method calls (observed: purge ran)

A package attribute left from an old configuration

Remove it; list the allowed methods with allowed-methods

A allowed-methods element before result stops the application from starting (observed)

The DTD requires the order param, result, interceptor-ref, exception-mapping, then allowed-methods

Put it last

Logging is set to ERROR "because the Struts log is noisy"

The defences log at WARN

Keep org.apache.struts2 at WARN and send it to the monitoring

The guide’s logger com.opensymphony.xwork2 is configured and has no effect

The package was renamed in 7.0

Use org.apache.struts2

The Guard list from the guide is pasted and pages break

The guide’s own warning: the list WILL break certain features

Add node types one at a time and test every page

The sandbox flag is copied from the guide and does nothing useful

-Dognl.security.manager depends on a Security Manager, which JDK 24 and later cannot enable

Do not rely on it; use the allowlist and the Guard

The expression limit is set to the largest value that works

A long limit gives attackers room

Choose the smallest value that all pages need, and move long logic to Java

The security checks pass and the application is still vulnerable

Struts has no authentication or authorisation of its own

Add container or Spring Security constraints, and test them

What Changed Recently

Table 10. Security-relevant changes between Struts 6.4 and 7.4 (from the Version Notes and the migration guide; all notes were read on 2026-10-10)
Version Change

6.4.0

The allowlist (WW-5350, with automatic allowlisting from annotated parameters, WW-5364), the @StrutsParameter annotation (WW-5352), the OGNL Guard with struts.ognl.excludedNodeTypes (WW-5340), the extensible SecurityMemberAccess (WW-5343), blocking of the default package (WW-5342), optional no fallback to the context (WW-5378), mitigation of custom map nodes (WW-5339), an expanded exclusion list (WW-5358), actionErrors/actionMessages/fieldErrors in the excluded patterns (WW-5354)

7.0.0

Stronger security defaults (WW-5353): annotations, allowlist, namespace and static field settings; startup warnings for disabled protections (WW-5454); devMode logs annotation problems at ERROR (WW-5429); the dev-mode exclusion lists cleaned up (WW-5439); the old file upload interceptor removed (use actionFileUpload); com.opensymphony.xwork2 merged into org.apache.struts2; FreeMarker parameters renamed attributes (WW-5465)

7.2.0

A warning when a tag is rendered outside an action (the guide’s note under "Never expose JSP files directly"; verified above)

7.4.0

@StrutsParameter is enforced on the action’s own members of a ModelDriven action (WW-5698) and on fluent setters (WW-5709); one-argument get* and two-argument set* methods that are not indexed accessors are no longer invoked during binding (WW-5697); the legacy RESTful mapper deprecated and fixed (WW-5706, WW-5707, S2-075); the REST and Bean Validation default stacks gain coep, coop and fetchMetadata; request body limit in the REST plugin (WW-5723); Tiles legacy OGNL: evaluator disabled by default (WW-5713); security bulletins S2-075 to S2-078

Where the Books Are Outdated

The printed books on Struts (the Struts 1 and Struts 2.0-era titles that this guide’s outline draws on) predate every mechanism of this page, so a reader who learned from them carries habits that the project has since moved away from. The table lists the habits in general terms and does not quote any book.

Table 11. Book-era security habits against Struts 7.4
Habit common in Struts 2.0-era and Struts 1 material Struts 7.4

Every public setter and every getter of an action is a parameter, and the framework’s params interceptor is the only filter

Parameters need @StrutsParameter; name patterns and the allowlist stand in front of the binding

The OGNL context can be read freely with #session, #request, #application; static method calls in expressions are a feature

Static methods are always blocked; static fields are off by default; the Guard and valueStackFallbackToContext can forbid the # operator

struts.devMode=true in struts.xml as a general development convenience, and !method links as the way to reach several methods from one action

devMode is for development only and is a vulnerability in production; DMI is off by default and strict method invocation applies

The ActionForm of Struts 1 is the "firewall" between the request and the model

Struts 7 has no form bean; the DTO that the annotation exposes plays that role, and the same discipline applies (separate form DTOs from entities)

Struts 1 applications keep running under their old configuration

Struts 1 reached end of life on 5 April 2013 and had its own critical issue, the class property reachable through Commons BeanUtils as shipped with it (CVE-2014-0114); the migration is not optional

com.opensymphony.xwork2 class and logger names, FilterDispatcher, ActionContextCleanUp

Replaced by org.apache.struts2 names and StrutsPrepareAndExecuteFilter

Security means a login page and role checks

Roles are the container’s or Spring Security’s; Struts hardening is the layered list of this page

Source: CVE-2014-0114

Notes on the Official Documentation

Inconsistencies found while verifying the examples against Struts 7.4.0:

  • The 6.x to 7.x migration guide says the maximum expression length was "reduced from 200 to 150"; default.properties of 7.4.0 and the security guide say 256.

  • The security guide says the OGNL sandbox "does not work with JDK 21 and above"; on JDK 21 a Security Manager can still be installed with -Djava.security.manager=allow, and only JDK 24 and later refuse it entirely. This page did not test OGNL inside the sandbox.

  • The security guide’s logging example configures com.opensymphony.xwork2, a package that no longer exists in 7.x.

  • The guide’s note on ambiguous action methods (WW-4405) describes a limitation that 7.4.0 does not have (both mappings worked); the guide dates strict method invocation to 2.5 while the Core Developer Guide’s action configuration says 2.3.

  • The Fetch Metadata interceptor’s documentation does not say that the rejection is a result named 403 that the action must map; without it the user sees the application’s error page (HTTP 200 in the scratch app) or a 404.

  • The guide shows a WARN line for new MyBean() as an excluded target; with the allowlist off, 7.4.0 evaluated the expression to nothing and wrote no WARN line.

References