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).
| Action URL | Class | What it demonstrates |
|---|---|---|
|
|
Three setters: |
|
|
A getter |
|
|
|
|
|
Tags that evaluate an attribute as an expression |
|
|
The same value printed with raw EL and with a Struts tag |
|
|
Ten OGNL expressions in a JSP (own bean, |
|
|
The action hierarchy of the guide’s ambiguous-method warning |
|
|
Methods |
|
|
A namespace under a |
|
|
An action that throws, with and without a global exception mapping |
|
(none) |
A JSP outside |
| Context | Difference |
|---|---|
|
Struts 7.4.0 defaults, |
|
The same WAR without any |
|
|
|
The package above plus |
|
|
|
The allowlist off and |
|
The allowlist package plus |
|
|
|
A custom |
|
|
|
Everything reverted: devMode, no annotations, no allowlist, DMI on, static field access on, namespace fallbacks on |
|
The Config Browser plugin, with and without a constraint on |
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.
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).
| 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 |
Exact namespace, DMI off, strict method invocation, |
3 Interceptors |
Cross-site requests, cross-origin embedding and opener attacks |
|
4 Parameters |
Writing to members that were never meant to receive a request value |
|
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 |
Source: Apache Struts — Security
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.
| Constant | 7.4.0 default | Effect | Before 7.0 |
|---|---|---|---|
|
|
No debugging interceptor output, no Struts Problem Report page |
Same default; applications often set it to |
|
|
The |
Not among the changes of the 7.0 migration guide |
|
|
The |
Not among the changes of the 7.0 migration guide |
|
|
A request is matched against the exact namespace only |
|
|
|
No fallback to the empty namespace |
|
|
|
A parameter reaches only members annotated with |
Introduced in 6.4, off by default |
|
|
Migration aid; see the section on |
Documented by the security guide next to the annotation requirement |
|
|
OGNL may touch only allowlisted classes in addition to the exclusion lists |
Introduced in 6.4, off by default |
|
|
Longer expressions are not evaluated |
200 before 7.0 |
|
|
No static field reads from OGNL |
|
|
|
Proxied objects (Hibernate, Spring) are not reachable from OGNL |
Introduced in 7.0 |
|
|
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 |
|
|
Only |
Introduced in 7.0 |
|
commented out (fallback on) |
An expression that finds no value on the stack falls back to the |
Introduced in 6.4; see the |
|
commented out (Guard off) |
No OGNL syntax is forbidden unless listed |
Introduced in 6.4 |
|
the lists of |
OGNL never touches these classes and packages |
Extended in 6.4 and 7.0 |
|
|
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.
See also packages and namespaces.
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" />
Source: Apache Struts — Development Mode
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.
See also localization and i18n.
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.
Source: Apache Struts — Security, Do not use incoming, untrusted user input in forced expression evaluation
Source: Apache Struts — Tag Syntax
See also tags overview and generic tags.
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: <b>hi</b>
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).
Source: Apache Struts — property tag
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://db.internal/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.
Source: Apache Struts — COOP Interceptor
Source: Apache Struts — COEP Interceptor
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.
| Expression | Defaults | allowlist.packageNames set |
Allowlist off | Weak (static fields, no allowlist) | Guard on |
|---|---|---|---|---|---|
1 |
empty |
|
|
|
|
2 |
empty |
empty |
empty |
empty |
empty |
3 static method |
empty |
empty |
empty |
empty |
empty |
4 static field |
empty |
empty |
empty |
|
empty |
5 |
empty |
empty |
empty |
empty |
empty |
6 |
empty |
empty |
|
|
empty |
7 vendor, no annotation |
empty |
empty |
|
|
empty |
8 vendor, annotated |
empty |
empty |
|
|
empty |
9 |
|
|
|
|
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:
| Constant | Matching | 7.4.0 default |
|---|---|---|
|
Exact class names |
|
|
String prefix, sub-packages included |
29 entries: |
|
Regular expressions, slower |
Commented out; more flexible and with "a greater impact on performance and page latency" |
|
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:
| Constant | 7.4.0 default | What it blocks, and where it was tested |
|---|---|---|
|
|
Static field values that are not primitives could reach otherwise inaccessible classes. Tested: line 4 of the probe printed |
|
|
Proxied objects (Spring, Hibernate) that often stand for beans and entities. Not run: the scratch app has no proxies |
|
|
Classes in the default package, "which should not be used in production". Not run |
|
|
A custom map class built inside an expression can bypass the |
|
|
Actions in the empty namespace reached through other endpoints. Tested: the |
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
| Request | Weak (Struts 6 settings) | Hardened (7.4.0 defaults) |
|---|---|---|
|
|
|
|
role |
role |
|
|
|
|
200, the books page |
404 |
|
Problem Report: not allowed (strict invocation holds) |
404 |
|
|
404 |
|
|
the books page, parameter ignored |
|
|
empty |
|
|
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.
Source: Apache Struts — Security
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-Typevalue 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
| Symptom | Cause | Fix |
|---|---|---|
A page that worked on 6.x shows an empty value after the upgrade, and the log has |
The allowlist is on by default and a plain bean in the application’s own package is not on it |
Add the package to |
A form field is silently dropped (observed) |
No |
Annotate the setter, with |
|
It is the quickest way to make an upgrade green |
Use |
A request value is evaluated as an expression on a hardened deployment (observed: |
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 |
A tag attribute with |
The tag evaluates the attribute and then uses the result as an expression or a name |
Keep request values out of |
Raw |
JSP EL does not escape |
Use |
devMode left on in production (observed: |
A |
Set it to |
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 |
A stack trace with an internal host name appears in the browser (observed on |
No exception mapping in the package and no |
Map |
A cross-site request is rejected with HTTP 200 or 404 instead of 403 (observed) |
The |
Add a global result |
A custom stack silently loses the Fetch Metadata, COOP and COEP interceptors |
The stack was copied from an older |
Reference |
|
A package attribute left from an old configuration |
Remove it; list the allowed methods with |
A |
The DTD requires the order |
Put it last |
Logging is set to ERROR "because the Struts log is noisy" |
The defences log at WARN |
Keep |
The guide’s logger |
The package was renamed in 7.0 |
Use |
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 |
|
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
| Version | Change |
|---|---|
6.4.0 |
The allowlist (WW-5350, with automatic allowlisting from annotated parameters, WW-5364), the |
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 |
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 |
|
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.
| 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 |
Parameters need |
The OGNL context can be read freely with |
Static methods are always blocked; static fields are off by default; the Guard and |
|
devMode is for development only and is a vulnerability in production; DMI is off by default and strict method invocation applies |
The |
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 |
|
Replaced by |
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.propertiesof 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
403that 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
-
Apache Struts — Security, Restrict access to the Config Browser Plugin
-
Apache Struts — Security, Don’t mix different access levels in the same namespace
-
Apache Struts — Security, Defining and annotating your Action parameters
-
Apache Struts — Security, Do not define setters when not needed
-
Apache Struts — Security, Do not use incoming values as an input for localisation logic
-
Apache Struts — Security, Do not use incoming, untrusted user input in forced expression evaluation
-
Apache Struts — Security, Use Struts tags instead of raw EL expressions
-
Apache Struts — Security, Resource Isolation Using Fetch Metadata
-
Apache Struts — Security, Cross Origin Isolation with COOP and COEP
-
Apache Struts — Security, Proactively protecting against OGNL Expression Injections attacks
-
Apache Struts — Security, Run OGNL expressions inside sandbox
-
Apache Struts — Security, Restricting access to the Struts Context (ActionContext)
-
Apache Struts — Security, Apply a maximum allowed length on OGNL expressions
-
Apache Struts — Action Configuration, Strict Method Invocation