Parameter Injection and @StrutsParameter
|
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 against the
struts2-core 7.4.0 jar (its default.properties, struts-default.xml and the bytecode of
ParametersInterceptor), the Javadoc, and the Core Developer Guide pages StrutsParameter Annotation and Parameters
Interceptor on https://struts.apache.org/core-developers/. A form field, a link query string and a JSON body all reach
an action the same way: as names and values that the framework turns into method calls on the action. That is
convenient, because the application does not parse requests, and dangerous, because the client chooses the names. Struts
7 therefore makes the author state, per member, which properties may be filled from a request, with the annotation
@StrutsParameter, and it layers several other checks around it. The running scenario is the Bookshelf, a catalogue of
books: an action that receives a filter, a limit, a book being edited and a list of books, and a battery of requests
that show which parameters arrive and which are dropped. Related pages of this section are linked where they come up: Value Stack and OGNL, Type Conversion, Validation, ModelDriven, Preparable and CRUD, Action Chaining, Sessions and Scopes, File Upload and Download and Security Hardening.
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 the
org.apache.struts:struts2-bom:7.4.0, log4j-slf4j2-impl, the Servlet and Pages APIs with scope provided, the filter
StrutsPrepareAndExecuteFilter mapped to /* in web.xml, and a log4j2.xml that writes the logger
org.apache.struts2.interceptor.parameter at DEBUG to a file, so that the decision about every parameter can be read.
The WAR is deployed on Tomcat 11.0.26 under the context /g9params. Three more deployments of the same classes differ
only in one file, WEB-INF/classes/struts.properties, which changes the binding constants (the table below).
Imports and package lines are left out of the listings; the package is org.example.bookshelf. The view of every action
is a JSP that prints a dump property as plain text, so that one request shows everything that the binding did.
| Action URL | Class | What it demonstrates |
|---|---|---|
|
|
Setters, a public field, a fluent setter, lists, maps and nested objects at several depths |
|
|
A |
|
|
|
|
|
A subclass of |
|
|
|
|
|
A form built with Struts tags, posted back to |
|
|
A stack that sets |
|
|
A stack that sets |
|
|
A stack that sets |
| Context | Binding constants |
|---|---|
|
Struts 7.4.0 defaults: |
|
|
|
|
|
|
The request examples use curl against http://localhost:18080. The brackets of books[0].title are not legal in a
raw URL query (Tomcat answers HTTP 400, Invalid character found in the request target), so the examples that contain
brackets send them in a form body with POST, which is also how a browser sends a form.
What Parameter Injection Is
Struts has no form-bean layer between the request and the action. The params interceptor reads the request
parameters from the ActionContext and, for each name and value, calls ValueStack.setValue(name, value). The name is
an OGNL expression evaluated against the value stack, so filter calls setFilter, book.title calls
getBook().setTitle(...), tags with two values fills a List, and books[0].title reaches the first element of a
list. The conversion of the String values to int, Date, List<String> and so on happens during that call; the
type conversion page of this part covers it and conversionError (the next interceptor in the stack) reports failures.
While the call runs, the interceptor sets three flags on the OGNL context: no method execution (a name such as
person.doSomething() is refused), automatic creation of null objects on the path (so book.publisher.name can create
the Publisher), and reporting of conversion errors.
The same mechanism is what made old Struts applications vulnerable: any public setter, and any property reachable
through a getter, was a way to write into the server. Two verified examples from the project’s own history sit at the
two ends of the spectrum. CVE-2014-0114 is the class loader manipulation through Commons BeanUtils in Struts 1.x, where
a request parameter named class reached the class loader through the bean that BeanUtils populated. CVE-2024-53677
(bulletin S2-067, see Security Bulletins) is a flaw of the file upload logic in which an attacker mixes the setters of uploaded files with normal
fields to manipulate upload paths, which is one reason the old upload interceptor was removed in 7.0. Struts 7 answers with defence in depth, and
@StrutsParameter is the part that the action author writes.
Source: CVE-2014-0114
Where the params Interceptor Runs
params is interceptor 17 of the 24 in defaultStack (see Interceptors) and it appears twice in
paramsPrepareParamsStack, before and after prepare. Four neighbours matter for binding:
-
staticParams(15) andactionMappingParams(16) run just before it and set values that do not come from the client (the<param>elements of the action and the values matched by a wildcard). -
modelDriven(10) runs earlier and pushes the model on the value stack, so that a parameter name can reach the model. -
conversionError(18) runs after it and adds a field error for every value that could not be converted. -
validationandworkflow(19 and 20) run last and see the populated action.
In defaultStack the interceptor prepare (7) runs before params, so prepare() sees an action that has no request
data yet; the stack paramsPrepareParamsStack exists for the case where it must see it. Both stacks are described in the
interceptors page, and the action class that receives the values is described in
Actions.
The Binding Decision
A parameter is applied only if it passes every check below, in this order. The order comes from the 7.4.0 bytecode of
ParametersInterceptor (toAcceptableParameters calls the name checks, then the annotation check, then the value
checks) and was confirmed by the DEBUG log of the scratch WAR.
The diagram has two outcomes that look the same from the outside and differ in the log. A parameter dropped by the
interceptor is logged at DEBUG (a name pattern, an annotation, a value pattern) or, for an over-long name, at WARN. A
parameter that passes the interceptor but reaches a class that the OGNL allowlist does not know is blocked inside OGNL
and logged at WARN by SecurityMemberAccess. The two layers are independent: @StrutsParameter decides which
properties may be written, the allowlist decides which classes OGNL may touch at all, and a nested example below needs
both.
| Check | What it does | Configured with |
|---|---|---|
|
A marker interface ( |
Implement the interface |
Name length and accepted pattern |
A name longer than the limit, or not matching the accepted name pattern, is dropped; the default accepted pattern allows word characters with |
|
Excluded name patterns |
A name that starts with or contains a reference to |
|
|
The action itself vetoes a name; it cannot rescue a name that the checks above dropped |
Implement |
|
The property must be annotated, unless it belongs to the model of a |
|
Value patterns and |
A value that does not match any accepted pattern, or matches an excluded pattern, or is refused by the action, drops the whole parameter |
|
OGNL member access and allowlist |
The classes on the path must not be excluded and, with the allowlist on, must be allowlisted (the types of the annotated members are allowlisted automatically) |
|
The @StrutsParameter Annotation
org.apache.struts2.interceptor.parameter.StrutsParameter is a runtime annotation with two members:
| Member | Meaning |
|---|---|
|
How many periods or brackets may appear in the parameter name below the annotated property. The default is |
|
Documented for any-setters and dynamic keys of JSON and REST bodies (below); on ordinary request parameters it has no effect |
The annotation goes on one of three places, and the place says what is being authorised:
-
A public setter authorises assigning the whole value of the property: a
String, anint, a checkboxboolean, aList<String>filled from a checkbox list, or a whole object produced by a type converter. -
A public field is a shorthand for the setter on simple types.
-
A public getter authorises the properties of the object that the getter returns, down to
depthlevels; this is how a form fills a DTO, a collection of DTOs or a map.
Since 7.4.0 a setter that returns a value instead of void (a fluent setter) is honoured too; before, an annotation on
it was ignored (WW-5709). Annotated members must be safe for any user who can reach the action: the security guide says
never to annotate a getter that returns a live Hibernate entity, a Spring bean or any service, and to keep database
entities apart from request DTOs.
The ShelfAction
The action below receives the search filter and the limit of a list of books, a book that is being edited, a featured book with its publisher, a list of books and two maps. Each member has a different annotation so that the next section can show what each one allows.
public class ShelfAction extends ActionSupport {
private String filter = "-";
private int limit = 10; // setter without annotation
private String edition = "-";
private String sort = "-"; // setter without annotation
private List<String> tags = new ArrayList<>();
private List<String> genres = new ArrayList<>();
private Book book = new Book(); // Book: title, author, pages, publisher
private Book featured = new Book();
private Book hidden = new Book(); // getter without annotation
private List<Book> books = new ArrayList<>(List.of(new Book(), new Book()));
private Map<String, String> labels = new LinkedHashMap<>();
private Map<String, String> badges = new LinkedHashMap<>();
@StrutsParameter
public String note = "-"; // public field
@StrutsParameter
public void setFilter(String filter) { this.filter = filter; }
public void setLimit(int limit) { this.limit = limit; }
@StrutsParameter // fluent setter, honoured since 7.4.0
public ShelfAction setEdition(String edition) { this.edition = edition; return this; }
public void setSort(String sort) { this.sort = sort; }
@StrutsParameter
public void setTags(List<String> tags) { this.tags = tags; }
@StrutsParameter(depth = 1)
public List<String> getGenres() { return genres; }
@StrutsParameter(depth = 1)
public Book getBook() { return book; }
@StrutsParameter(depth = 2)
public Book getFeatured() { return featured; }
public Book getHidden() { return hidden; }
@StrutsParameter(depth = 2)
public List<Book> getBooks() { return books; }
@StrutsParameter(depth = 1)
public Map<String, String> getLabels() { return labels; }
@StrutsParameter(depth = 1, allowDynamicKeys = true)
public Map<String, String> getBadges() { return badges; }
// getDump() prints every field; execute() returns SUCCESS
}
Depth Rules, Verified
The official page states the rule in one sentence: depth is the number of periods or brackets that may appear in the parameter name, and indexing into a collection or a map is itself a level, so reaching a property of a list element costs one more than reaching the same property of a plain object. A first request on the default deployment sets only annotated members:
curl -s -X POST http://localhost:18080/g9params/shelf \
-d 'filter=dune&limit=5&sort=title&edition=2nd¬e=hi&tags=sf&tags=classic' \
-d 'genres[0]=space&book.title=Dune&hidden.title=X&books[0].title=Ubik&labels.en=Hello' \
-d "badges['x']=1"
filter=dune
limit=10
edition=2nd
sort=-
note=hi
tags=[sf, classic]
genres=[space]
book=Book[Dune,null,null,Publisher[null,null]]
featured=Book[null,null,null,Publisher[null,null]]
hidden=Book[null,null,null,Publisher[null,null]]
books=[Book[Ubik,null,null,Publisher[null,null]], Book[null,null,null,Publisher[null,null]]]
labels={en=Hello}
badges={x=1}
limit and sort kept their defaults (10 and -) and hidden.title was ignored: no annotation, no binding, and no
error in the response; the request is answered normally. The table lists the individual requests, each sent alone to
/g9params/shelf and compared with the unchanged page.
| Parameter | Result | Why |
|---|---|---|
|
Set |
|
|
Ignored |
|
|
Set |
Annotated fluent setter that returns |
|
Ignored |
No annotation |
|
Set |
Annotation on the public field |
|
|
Annotated setter takes the whole list, as a checkbox list submits it |
|
Ignored |
The getter is annotated but the whole list is assigned through a setter, which has none |
|
Set |
One bracket in the name, |
|
Set |
One period, |
|
Ignored |
Two periods need |
|
Set |
|
|
Ignored |
Depth is enough, but |
|
Ignored |
The getter has no annotation |
|
Set |
Index and property are two levels, |
|
Ignored |
Three levels |
|
Set |
A map key as a property, |
|
Ignored |
The unquoted bracket key does not match the accepted name pattern |
|
Set |
|
Three rules summarise the table. First, the setter annotation and the getter annotation do different jobs: tags=sf&tags=classic
needs the setter, genres[0]=space needs the getter with a depth, and an indexed list that is posted by a JSON body needs
the getter as well. Second, count the segments after the annotated property, and add one for every index, so books[0].title
is 2 and book.title is 1. Third, the depth is a ceiling that the security guide asks you to keep at the smallest value the use case needs (its DTO
example uses depth = 1 for formData.fullName).
In the DEBUG log the same decisions read as follows, with the logger StrutsParameterAuthorizer:
DEBUG StrutsParameterAuthorizer Success: Matching annotated method [getBook] of depth [1] found on target [ShelfAction]
DEBUG StrutsParameterAuthorizer Parameter injection for method [getHidden] on target [org.example.bookshelf.ShelfAction] rejected. Ensure it is annotated with @StrutsParameter with an appropriate 'depth'.
DEBUG StrutsParameterAuthorizer No matching annotated method found for property [hidden] of depth [1] on target [ShelfAction], now also checking for public field
Depth and the OGNL Allowlist
The annotation passes featured.publisher.name (depth 2 on getFeatured), and still the value does not arrive on the
default deployment. The log shows why, one layer further down:
DEBUG StrutsParameterAuthorizer Success: Matching annotated method [getFeatured] of depth [2] found on target [ShelfAction]
WARN SecurityMemberAccess Declaring class [class org.example.bookshelf.Publisher] of member type [public void org.example.bookshelf.Publisher.setName(java.lang.String)] is not allowlisted! Add to 'struts.allowlist.classes' or 'struts.allowlist.packageNames' configuration.
With struts.allowlist.enable=true (the 7.x default), OGNL may only touch allowlisted classes: the Struts classes, the
classes named in struts.xml packages, and the types that the annotated members return (here Book, which is why
book.title works). A class that sits deeper in the graph, Publisher at the second level, is not added by itself. The
deployment /g9paramsal adds it with struts.allowlist.classes=org.example.bookshelf.Publisher:
# WEB-INF/classes/struts.properties of /g9paramsal
struts.allowlist.classes=org.example.bookshelf.Publisher
curl -s -X POST http://localhost:18080/g9paramsal/shelf \
-d 'featured.publisher.name=Tor&book.publisher.name=Tor&books[0].publisher.name=Ace' | grep -E 'book|featured'
book=Book[null,null,null,Publisher[null,null]]
featured=Book[null,null,null,Publisher[Tor,null]]
books=[Book[null,null,null,Publisher[null,null]], Book[null,null,null,Publisher[null,null]]]
Only featured.publisher.name arrived: book.publisher.name and books[0].publisher.name still fail the depth check, which
the allowlist cannot change. The two constants are independent and a nested form needs both: the right depth and a class
that OGNL may touch. The apache/struts-examples project struts-parameter shows the same combination, a struts.xml
constant struts.allowlist.packageNames for the example package and @StrutsParameter(depth = 2) on a getter that returns
a list of users and on an adminUser property.
requireAnnotations and Transition Mode
Two constants in default.properties control the annotation requirement:
struts.allowlist.enable=true
struts.parameters.requireAnnotations=true
struts.parameters.requireAnnotations.transitionMode=false
struts.chaining.requireAnnotations=false
requireAnnotations was opt-in in Struts 6.4 and is the default since 7.0. transitionMode is a migration aid for an
application whose actions have many unannotated setters: with it on, only nested parameters (DTOs and collections that
are reached through getters) require annotations, and plain public setters stay exposed. The auto-allowlisting that the
annotations provide is not degraded, so an application can turn the allowlist on first and add the annotations later.
The scratch WAR was deployed three times and sent the same request. The request was
filter=dune&limit=5&sort=title&hidden.title=X&genres[0]=s&book.title=D&books[0].title=U.
| Deployment | filter |
limit |
sort |
hidden.title |
genres[0] |
book.title |
books[0].title |
|---|---|---|---|---|---|---|---|
Default (annotations required) |
set |
ignored |
ignored |
ignored |
set |
set |
set |
|
set |
set |
set |
ignored |
set |
ignored |
ignored |
|
set |
set |
set |
ignored |
set |
set |
set |
The requireAnnotations=false row holds a lesson: without annotations the allowlist is the only brake on nested
properties. Nothing allowlisted Book (no annotated getter returns it), so book.title, hidden.title and
books[0].title all failed with SecurityMemberAccess warnings, while the plain setters limit and sort were applied.
That configuration is not a safe mode: it is the pre-7 behaviour for every top-level setter, and a class that gets
allowlisted for another reason becomes writable from the request. In transition mode limit and sort are written
(non-nested) but hidden.title is still refused, and the log says so:
DEBUG StrutsParameterAuthorizer Annotation transition mode enabled, exempting non-nested parameter [edition] from @StrutsParameter annotation requirement
ModelDriven Actions
When an action implements ModelDriven<T> and the modelDriven interceptor has pushed the model on the value stack, the
model, not the action, is the authorisation target and the model’s members are exempt from the annotation requirement:
the whole model is bindable, nested properties included. The reasoning in the guide is that returning an object from
getModel() declares that object to be the request surface. The consequences are why the guide says a model must be a
request DTO and never a domain or persistence entity.
public class ShelfModelAction extends ActionSupport implements ModelDriven<Book> {
private final Book model = new Book();
private String filter = "-";
private int limit = 10;
@Override public Book getModel() { return model; }
@StrutsParameter
public void setFilter(String filter) { this.filter = filter; }
public void setLimit(int limit) { this.limit = limit; } // not annotated
public String getTitle() { return "action-title"; } // same name as a model property
}
curl -s -G http://localhost:18080/g9params/model \
--data-urlencode 'title=Dune' --data-urlencode 'author=Herbert' \
--data-urlencode 'filter=sf' --data-urlencode 'limit=5'
model=Book[Dune,Herbert,null,Publisher[null,null]]
filter=sf
limit=10
title and author reached the model without any annotation on Book. filter reached the action because it is
annotated. limit did not: since 7.4.0 the exemption covers the model and only the model, and the log names the
reason:
DEBUG StrutsParameterAuthorizer Property [limit] is declared on the ModelDriven action itself, applying the @StrutsParameter annotation requirement
DEBUG StrutsParameterAuthorizer Property [publisher] belongs to the ModelDriven model, exempting from @StrutsParameter annotation requirement
The decision order of the guide is: the model declares the property, so it is bound; the action declares it, so it is
bound only if annotated; neither declares it, so it is bound (to keep models that resolve properties through a custom OGNL
accessor, such as a map-backed model, working). Because the model is checked first, a model property that shadows an
action property, as getTitle does above, binds without an annotation, which matches how OGNL resolves the name against
the top of the stack. Before 7.4.0 every parameter sent to a ModelDriven action was exempt, so an application that
relied on that either annotates those setters now or turns on transition mode, which exempts non-nested parameters on
ModelDriven actions as well (the deployment /g9paramstm set limit=5 on this action, verified).
The exemption lifts the annotation requirement and nothing else: name patterns, ParameterNameAware and the OGNL allowlist
still apply. publisher.name=Tor on the default deployment changes nothing because Publisher is not allowlisted; on
/g9paramsal the same request yields model=Book[null,null,null,Publisher[Tor,null]]. A ModelDriven action outside
the model is not exempt, so a JSON interceptor root expression on an action that does not implement the interface still
needs annotations. The model-driven and Preparable page of this part covers the pattern; this page owns the binding rule.
Accepted and Excluded Parameter Names
Before the annotation is consulted, the interceptor checks the name. The 7.4.0 class DefaultAcceptedPatternsChecker holds
the default accepted name pattern, and DefaultExcludedPatternsChecker the excluded ones. The accepted pattern, copied from
the constant pool of the jar, is a word followed by any number of .word, [digits], (digits), ['key'] and ('key')
segments:
\w+((\.\w+)|(\[\d+])|(\(\d+\))|(\['(\w-?|[一-龥]-?)+'])|(\('(\w-?|[一-龥]-?)+'\)))*
That is why labels[en] is refused (a bracket needs digits or a quoted key) and labels['en'] is accepted. The log
names the pattern that rejected a name:
DEBUG ParametersInterceptor Parameter [labels[en]] didn't match accepted pattern [...]!
DEBUG ParametersInterceptor Parameter [request.value] matches excluded pattern [(^|\%\{)((#?)(\[\d\]\.)?)(dojo|struts|session|request|response|application|servlet(Request|Response|Context)|parameters|context|_memberAccess)(\.|\[).*]!
The excluded patterns cover the objects that an attacker would want to reach: the context maps (session, request,
application, parameters, context), the struts and dojo helpers, the member access object, top and any class
reference, and the names actionErrors, actionMessages and fieldErrors. When dynamic method invocation is disabled (the
default), the prefixes action: and method: are excluded as well. The official page lists a shorter, older regular
expression list under Default List of Parameters Excluded; trust the jar, as the next paragraph explains. On the
Bookshelf, request.value=1, struts.x=1, book.class.name=x and %23session.x=1 (the encoded form of #session.x) all
left the dump unchanged.
excludeParams replaces the Defaults
The official guide and the Exclude Parameters tutorial both show a way to exclude a button name such as submit: set
params.excludeParams and copy the default list in front of it. In 7.4.0 the setting replaces the built-in checker
patterns, so any list you write must be complete, and a short list silently re-opens everything that you did not repeat.
The stack weakExcludes of the scratch WAR sets one narrow pattern:
<interceptors>
<interceptor-stack name="weakExcludes">
<interceptor-ref name="defaultStack">
<param name="params.excludeParams">^shelf\..*</param>
</interceptor-ref>
</interceptor-stack>
</interceptors>
<action name="weak" class="org.example.bookshelf.GuardedAction">
<interceptor-ref name="weakExcludes"/>
<result>/WEB-INF/views/dump.jsp</result>
</action>
curl -s 'http://localhost:18080/g9params/tuned/weak?request.value=X&filter=a'
curl -s 'http://localhost:18080/g9params/guarded?request.value=X'
filter=a
secret=-
request.value=X
filter=-
secret=-
request.value=untouched
With the default list (/guarded) request.value is refused; with the one-pattern list (/tuned/weak) a parameter named
request.value writes into the action’s request property. To add one name to the exclusions without losing the rest, use
params.excludeParams only if you can paste the whole 7.4.0 list, or prefer ParameterNameAware, @StrutsParameter or the
additional-patterns method of the checker (DefaultExcludedPatternsChecker.setAdditionalExcludePatterns, listed in its
Javadoc but not exercised here). The accepted-name counterpart is params.acceptParamNames, which the guide warns not to
change without a clear understanding of the security implications.
The Maximum Name Length
params.paramNameMaxLength (default 100) drops parameters whose name is longer and logs a WARN, so a parameter-name flood
of long OGNL chains is cut. The tuned package sets it to 12:
<interceptor-stack name="bookshelfParams">
<interceptor-ref name="defaultStack">
<param name="params.paramNameMaxLength">12</param>
<param name="params.acceptedValuePatterns">[\w\s'-]*</param>
<param name="params.excludedValuePatterns">.*(\$|%)\{.*</param>
</interceptor-ref>
</interceptor-stack>
book.title (10 characters) still arrives on /tuned/shelf; featured.title (14) does not and the log says
WARN ParametersInterceptor Parameter [featured.title] is too long, allowed length is [12]. The setting is a blunt
defence; choose a value that fits the longest legitimate name (a form of the Bookshelf with books[12].publisher.city needs
more than 12).
Accepted and Excluded Parameter Values
Since Struts 6.1.0 the interceptor also checks values, with acceptedValuePatterns (an allowlist of regular expressions
that every value must match) and excludedValuePatterns (a denylist), both comma-separated. A value that fails drops the
whole parameter. The guide presents it as defence in depth against forced OGNL evaluation when user input is echoed back
into an expression, not as a replacement for good code. The bookshelfParams stack above accepts only word characters,
white space, apostrophes and hyphens, and refuses any value that contains ${ or %{:
B=http://localhost:18080/g9params/tuned/shelf
curl -s "$B?filter=Dune-2nd" | head -1
curl -s "$B?filter=it%27s" | head -1
curl -s "$B?filter=a%3Cb" | head -1
curl -s "$B?filter=%24%7B1%7D" | head -1
filter=Dune-2nd
filter=it's
filter=-
filter=-
The last two values, a<b and ${1}, matched neither the accepted pattern nor the excluded one’s negation and were
dropped, leaving the default -. The log line is DEBUG ParametersInterceptor Value [a{1}] of parameter [filter] was
not accepted and will be dropped!. Keep two cautions in mind. A pattern such as [a-zA-Z0-9]* (the guide’s example)
refuses names with accents and punctuation in a book title, so on the Bookshelf the accepted pattern would be too tight
for a title field; apply value patterns on the actions that take identifiers and codes, not on free text. And the
attribute is spelled excludedValuePatterns in the XML snippet of the guide and excludeValuePatterns in its prose; the
setter in the 7.4.0 jar is setExcludedValuePatterns.
ParameterNameAware and ParameterValueAware
An action can veto parameters itself by implementing org.apache.struts2.action.ParameterNameAware (the method
acceptableParameterName(String)) and org.apache.struts2.action.ParameterValueAware (acceptableParameterValue(String),
since 6.1.0). The GuardedAction accepts every name except secret and rejects every value that contains the text DROP,
and secret is annotated, so the veto is the only thing that stops it:
public class GuardedAction extends ActionSupport implements ParameterNameAware, ParameterValueAware {
private String filter = "-";
private String secret = "-";
private Holder request = new Holder(); // value: "untouched"
@StrutsParameter public void setFilter(String filter) { this.filter = filter; }
@StrutsParameter public void setSecret(String secret) { this.secret = secret; }
@StrutsParameter(depth = 1) public Holder getRequest() { return request; }
@Override public boolean acceptableParameterName(String name) { return !name.equals("secret"); }
@Override public boolean acceptableParameterValue(String value) { return !value.contains("DROP"); }
}
curl -s -G http://localhost:18080/g9params/guarded --data-urlencode 'filter=a' --data-urlencode 'secret=s'
curl -s -G http://localhost:18080/g9params/guarded --data-urlencode 'filter=DROP TABLE'
filter=a
secret=-
request.value=untouched
filter=-
secret=-
request.value=untouched
The Parameters Interceptor page says that ParameterNameAware takes precedence over the interceptor, so that an action
can accept a name that the interceptor excluded. On 7.4.0 that is not what happens: isAcceptableName runs first and
isAcceptableParameterNameAware only afterwards (the code shows isAcceptableName(...) && isAcceptableParameterNameAware(...)), and
the scratch action answers true to request.value, yet /guarded?request.value=X leaves request.value=untouched (the
default excluded pattern wins). Use the interfaces to be stricter, not looser. The Javadoc of ParameterNameAware recommends
the same pattern, a white list of accepted parameters or a black list of rejected ones.
NoParameters and ParametersAware
Two more interfaces of org.apache.struts2.action change what the interceptor does for an action. NoParameters is a
marker: the interceptor is skipped for the whole action, whatever the request carries. ParametersAware has the single
method withParameters(HttpParameters) and receives the whole parameter map of the request without any binding, which is
the clean way to read parameters that are not properties.
public class FrozenAction extends ShelfAction implements NoParameters { }
curl -s 'http://localhost:18080/g9params/frozen?filter=a&edition=x' | head -3
filter=-
limit=10
edition=-
filter and edition are annotated on the parent class and would be set on /shelf; on /frozen nothing is. Use the
marker for a read-only action (a dashboard, a download) where a parameter should never change state.
Values That Do Not Come From the Client
Two interceptors in defaultStack set properties without the request. They run before params, they are not subject to
@StrutsParameter (the values are written by the configuration, which the author controls), and the later params
interceptor cannot override them if its own parameter is refused:
<action name="fixed" class="org.example.bookshelf.ShelfAction">
<param name="sort">by-title</param>
<param name="limit">25</param>
<result>/WEB-INF/views/dump.jsp</result>
</action>
<action name="page-*" class="org.example.bookshelf.ShelfAction">
<param name="sort">{1}</param>
<result>/WEB-INF/views/dump.jsp</result>
</action>
curl -s 'http://localhost:18080/g9params/fixed?sort=hacked&limit=3' | head -4
curl -s http://localhost:18080/g9params/page-title | head -4
filter=-
limit=25
edition=-
sort=by-title
filter=-
limit=10
edition=-
sort=title
staticParams applied <param name="sort"> and <param name="limit"> to the unannotated setters setSort and setLimit, and
the request’s sort=hacked&limit=3 changed nothing. actionMappingParams applied the wildcard value {1} of page-* to
sort in the same way. That is a good place for constants that must not be user-controlled, and a reminder that these two
channels bypass the annotation. The wildcard page of this part covers the {1} syntax.
Parameter Order and the ordered Flag
By default the order in which parameters are applied is the order of the parameter map, which is not something to build
logic on. The params interceptor has the parameter ordered: when true, properties are applied top-down, the action’s
own properties first and then the properties of its sub-objects, so that a modelClass property can choose the model
implementation before the model’s fields are set (the guide’s factory example), or setPrimaryKey can load an entity
before its fields are filled. Without a guaranteed order the alternative is the Preparable interface and the stack
paramsPrepareParamsStack, covered with the model-driven page. The orderedParams stack of the scratch WAR enables it:
<interceptor-stack name="orderedParams">
<interceptor-ref name="defaultStack">
<param name="params.ordered">true</param>
</interceptor-ref>
</interceptor-stack>
<action name="ordered" class="org.example.bookshelf.ShelfAction">
<interceptor-ref name="orderedParams"/>
<result>/WEB-INF/views/dump.jsp</result>
</action>
On /tuned/ordered?filter=a&book.title=D the request is bound as on /shelf (filter=a, book=Book[D,...]); the guarantee is
about order, which a one-property check cannot show, so the example proves only that the stack deploys and binds. The
annotation checks apply unchanged.
A Worked Bookshelf Form
A form is where the names come from. The Struts tags generate the names the interceptor must accept; the tag form and the
action must agree on both name and depth. The edit action shows a form for the list of books, a single book and the
filter:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<s:form action="shelf" method="post" theme="simple">
<s:iterator value="books" status="st">
<s:textfield name="books[%{#st.index}].title"/>
</s:iterator>
<s:textfield name="book.title"/>
<s:textfield name="filter"/>
<s:submit/>
</s:form>
curl -s http://localhost:18080/g9params/edit
<form id="shelf" name="shelf" action="/g9params/shelf.action;jsessionid=..." method="post">
<input type="text" name="books[0].title" value="" id="shelf_books_0__title"/>
<input type="text" name="books[1].title" value="" id="shelf_books_1__title"/>
<input type="text" name="book.title" value="" id="shelf_book_title"/>
<input type="text" name="filter" value="-" id="shelf_filter"/>
<input type="submit" value="Submit" id="shelf_0"/>
</form>
The form is rendered with empty values because the edit action has not been given any. Submitting it posts three
annotated paths and one annotated setter, and the action accepts them all:
curl -s -X POST http://localhost:18080/g9params/shelf \
-d 'books[0].title=Dune&books[1].title=Ubik&book.title=Emma&filter=sf' | grep -E 'filter|book'
filter=sf
book=Book[Emma,null,null,Publisher[null,null]]
books=[Book[Dune,null,null,Publisher[null,null]], Book[Ubik,null,null,Publisher[null,null]]]
Posting books[0].title=Dune&book.title=Emma&filter=sf to /g9params/edit instead redraws the form with the values in
place (books[0].title is Dune, book.title is Emma, filter is sf, books[1].title stays empty), because the tags
read the property back through the value stack (verified). The effect is
the same as the ActionForm redisplay of Struts 1, without a form class. Two habits keep a real form safe: name the
inputs only with paths that the annotations allow (a form field that the action rejects is a silent bug, not an error), and
do not use the submit button’s name as a property (the guide’s Exclude Parameters tutorial exists because
s:submit creates a submit parameter that has no setter).
Source: Apache Struts — textfield tag
Beyond Request Parameters
Since 7.2.0 the @StrutsParameter authorisation is enforced on every channel that can populate an action from request
data, not only on params:
| Channel | Rule (from the official page, not exercised in the scratch WAR) |
|---|---|
|
Governed by |
|
Value-stack copying checks the target’s annotations when |
|
Cookie values are set only on annotated members (a change that surprises upgrades to 7.2) |
JSON and REST plugins |
Per-property authorisation during deserialization: an unauthorised property is not set on the target object |
For the JSON and REST plugins three details matter. Properties are matched by their Java member, not by their wire name
(@JsonProperty, @JsonAlias or a naming strategy do not change which member is authorised; the wire name was used up to
7.3.0, WW-5715). Properties bound by a constructor (Java record components, @JsonCreator constructors and
@ConstructorProperties) bypassed the check up to Struts 7.2.1 and are authorised like any other property since 7.3.0
(WW-5642): an unauthorised component is left unset, and if the record’s own constructor refuses the null the whole object
under construction is dropped. A REST action that relied on record bodies without annotations therefore needs
@StrutsParameter(depth = ...) on the getter that reaches them, or a ModelDriven model, otherwise the values silently
stop arriving. And a class with a Jackson any-setter (@JsonAnySetter) is not wrapped by default: since 7.4.0 the constant
struts.rest.anySetter.requireAnnotations=true (default false, effective only when requireAnnotations is on) makes an
any-setter receive keys only when it carries @StrutsParameter(allowDynamicKeys = true), with depth limiting the nesting
of each key’s value:
@StrutsParameter(allowDynamicKeys = true, depth = 1)
@JsonAnySetter
public void setExtra(String key, Object value) {
extras.put(key, value);
}
<constant name="struts.rest.anySetter.requireAnnotations" value="true"/>
The REST plugin page of this part covers these in context. The snippets above are copied from the guide and were not run: the REST plugin was not part of the scratch WAR.
From the Struts 1 ActionForm to Struts 2 Binding
A reader who knows Struts 1 asks where the ActionForm went. The concepts map one to one, and the old safety argument,
that the form is a firewall between the request and the business objects, is now made by the annotations.
| Struts 1 concept | Struts 7.4 equivalent |
|---|---|
One |
No form class: the action (or a |
|
A new action instance per request needs no reset; the |
|
|
Form scope ( |
Action properties live for the request; a longer-lived form uses |
Nested and indexed properties ( |
The same syntax, authorised by |
|
A |
Populating business objects from the form |
A DTO behind an annotated getter, or a |
Form as a firewall |
|
Struts 1 reached end of life in 2013 and CVE-2014-0114 was never fixed there; this table serves reading and migrating legacy code, not new design.
Seeing the Decisions
Turn the DEBUG level on for the package org.apache.struts2.interceptor.parameter and every parameter prints the sequence
of checks above. The scratch WAR used this log4j2.xml:
<Configuration status="warn">
<Appenders>
<File name="F" fileName="app.log" append="true">
<PatternLayout pattern="%p %c{1} %m%n"/>
</File>
</Appenders>
<Loggers>
<Logger name="org.apache.struts2.interceptor.parameter" level="debug"/>
<Logger name="org.apache.struts2.ognl" level="warn"/>
<Root level="warn"><AppenderRef ref="F"/></Root>
</Loggers>
</Configuration>
Three things to look for: StrutsParameterAuthorizer lines say why an annotation was or was not found, the line
Setting params ... lists what the interceptor tries to apply, and SecurityMemberAccess warnings (from the OGNL package)
say that a class is not allowlisted. In devMode, a parameter that has no setter additionally produces the Developer
Notification that the official page quotes; keep devMode off in production, as the security guide requires.
Source: Apache Struts — Security
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
A form field never reaches the action and the page shows no error (observed) |
The setter has no |
Annotate the setter, or check the DEBUG log of |
|
The annotation is on the getter (indexed access); the whole-list assignment needs the setter annotated |
Annotate the setter for a checkbox list, the getter with |
|
The index counts as a level, so reaching an element’s property needs |
Use |
The depth is right and a nested value is still ignored; the log has |
A class deeper in the graph than the annotated getter’s return type is not allowlisted |
Add it to |
|
Without annotations nothing allowlists the nested types |
Restore the annotations; do not treat the constant as a way to turn the feature off |
A |
Since 7.4.0 only the model is exempt; the action’s members still need |
Annotate the setter, or enable transition mode while migrating |
|
|
Allowlist the class, or review the model design |
|
The setting replaces the default exclusions |
Provide the full list, or use |
A |
The built-in checks run first |
Use the interface only to refuse names |
A value is dropped with no error after adding |
The whole parameter is dropped when the value does not match |
Make the pattern match legitimate values (accents, punctuation) or apply it per action |
|
The accepted name pattern needs a digit or a quoted key inside brackets |
Send |
Parameters with brackets give HTTP 400 on a GET from a script (observed) |
Tomcat refuses raw |
Encode them ( |
A button named |
No setter exists for the button parameter |
Rename the button or exclude the name with a complete exclusion list |
A property set in |
|
Intended; they are written by the configuration author |
Annotating a getter that returns an entity or a service |
|
Return a request DTO; never annotate entities, Spring beans or services |
An old fluent setter is annotated and ignored on 7.3 |
Annotations on setters that return a value were honoured only since 7.4.0 (WW-5709) |
Upgrade, or add a |
Record bodies stop arriving in a REST action after 7.3.0 |
Creator-bound properties are authorised now |
Annotate the getter, or use a |
What Changed Recently
| Release | Change |
|---|---|
Struts 2.0 to 2.3 |
Any public setter is bound; the |
Struts 2.3.20 |
|
Struts 2.5 |
Strict method invocation and |
Struts 6.0 |
Static method access removed from OGNL; Aware interfaces renamed ( |
Struts 6.1.0 |
|
Struts 6.4 |
|
Struts 7.0 |
|
Struts 7.2.0 |
|
Struts 7.3.0 |
Creator-bound properties (records, |
Struts 7.4.0 |
The |
Where the Books Are Outdated
Two of the section’s books deal with binding. Struts 2 in Action (Manning, 2008, chapters 3 and 5) is written for Struts 2.0, where any public setter filled from the request, and Struts in Action (Manning, 2002, chapter 5, ActionForms) is about Struts 1 forms. The concepts carry over, with the exceptions below.
| The book’s era | Today (Struts 7.4) |
|---|---|
Any public setter or getter-reachable property is bound from the request, no annotations |
|
Packages |
|
Excluded names are a short list in |
A larger built-in checker list (context maps, |
OGNL expressions in names and |
Static access removed, the OGNL allowlist and member access guards on by default, maximum expression length, |
Struts 1 |
No form class; DTOs and |
Dojo and |
Removed in 2.5; |
Dynamic method invocation |
Off by default; the |
Notes on the Official Documentation
Reading the official pages against the 7.4.0 jar and the scratch WAR showed inconsistencies that a reader will meet.
-
The Parameters Interceptor page says that
ParameterNameAware.acceptableParameterNametakes precedence over the interceptor and can accept a name that the interceptor excluded. On 7.4.0 the name checks of the interceptor run first and the action’s answer cannot rescue an excluded name (verified withrequest.value). -
The same page lists the parameter as
excludeValuePatternsin prose andexcludedValuePatternsin XML; the 7.4.0 setter issetExcludedValuePatterns. Its Default List of Parameters Excluded is a short older list, not the list ofDefaultExcludedPatternsChecker. -
The Exclude Parameters tutorial and the guide both show
excludeParamswith the old defaults copied in front of the new name; in 7.4.0 the setting replaces the built-in patterns, so a shorter list silently re-opens the context maps. -
The Warning on missing parameters section quotes a stack trace with
com.opensymphony.xwork2.ognl.OgnlValueStack; the class isorg.apache.struts2.ognl.OgnlValueStacksince 7.0. -
The 7.4.0 jar and the example
struts-parameterusestruts.allowlist.packageNamesandstruts.allowlist.classes, which the StrutsParameter Annotation page does not mention; the page shows annotations without saying that a class two levels below the annotated getter also needs allowlisting.
References
-
Apache Struts — StrutsParameter Annotation, Understanding the depth parameter
-
Apache Struts — StrutsParameter Annotation, ModelDriven actions
-
Apache Struts — StrutsParameter Annotation, The action’s own members are not exempt
-
Apache Struts — StrutsParameter Annotation, Where authorization applies
-
Apache Struts — StrutsParameter Annotation, Creator-bound properties
-
Apache Struts — StrutsParameter Annotation, Jackson any-setters
-
Apache Struts — Parameters Interceptor, Excluding parameters
-
Apache Struts — Parameters Interceptor, Excluding parameter values
-
Apache Struts — Parameters Interceptor, Extending the Interceptor
-
Apache Struts — Parameters Interceptor, Warning on missing parameters
-
Apache Struts — Security, Defining and annotating your Action parameters
-
Apache Struts 7.4.0 Javadoc — DefaultExcludedPatternsChecker