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.

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

/shelf

ShelfAction

Setters, a public field, a fluent setter, lists, maps and nested objects at several depths

/model

ShelfModelAction

A ModelDriven action whose model is a Book

/guarded

GuardedAction

ParameterNameAware and ParameterValueAware on an action

/frozen

FrozenAction

A subclass of ShelfAction that implements NoParameters

/fixed, /page-*

ShelfAction

<param> elements of the action and a wildcard value as parameters

/edit

ShelfAction

A form built with Struts tags, posted back to /shelf

/tuned/shelf, /tuned/guarded

ShelfAction, GuardedAction

A stack that sets paramNameMaxLength, acceptedValuePatterns and excludedValuePatterns

/tuned/weak

GuardedAction

A stack that sets excludeParams and so replaces the default exclusions

/tuned/ordered

ShelfAction

A stack that sets ordered

Table 2. The four deployments (the three variants differ only in struts.properties)
Context Binding constants

/g9params

Struts 7.4.0 defaults: struts.parameters.requireAnnotations=true, struts.parameters.requireAnnotations.transitionMode=false, struts.allowlist.enable=true

/g9paramsoff

struts.parameters.requireAnnotations=false

/g9paramstm

struts.parameters.requireAnnotations.transitionMode=true

/g9paramsal

struts.allowlist.classes=org.example.bookshelf.Publisher

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) and actionMappingParams (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.

  • validation and workflow (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.

flowchart TD R["Request parameter: name and value"] --> NP{"Action implements NoParameters?"} NP -- yes --> SK["params interceptor skipped, nothing is bound"] NP -- no --> N1{"Name short enough and matching the accepted name pattern?"} N1 -- no --> DR["Dropped, WARN or DEBUG log"] N1 -- yes --> N2{"Name matches an excluded pattern?"} N2 -- yes --> DR N2 -- no --> N3{"ParameterNameAware. acceptableParameterName?"} N3 -- no --> DR N3 -- yes --> A1{"requireAnnotations enabled?"} A1 -- no --> V1 A1 -- yes --> A2{"Property on the ModelDriven model, or transition mode and a non-nested name?"} A2 -- yes --> V1 A2 -- no --> A3{"Setter, public field or getter with enough depth annotated @StrutsParameter?"} A3 -- no --> DR A3 -- yes --> V1{"Value accepted by the value patterns and ParameterValueAware?"} V1 -- no --> DR V1 -- yes --> OG{"OGNL member access and allowlist accept the classes on the path?"} OG -- no --> BL["Blocked, WARN SecurityMemberAccess"] OG -- yes --> OK["setValue is called, the property is set"]

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.

Table 3. The checks of the decision, with their configuration and default
Check What it does Configured with

NoParameters

A marker interface (org.apache.struts2.action.NoParameters): the whole interceptor is skipped for this action

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 .name, [0], (0) and ['key'] suffixes, so labels[en] is refused and labels['en'] is accepted

params.paramNameMaxLength (default 100), params.acceptParamNames

Excluded name patterns

A name that starts with or contains a reference to session, request, application, parameters, context, struts, dojo, servletRequest, servletResponse, servletContext, _memberAccess, a top. or class reference, or (with DMI off) the prefixes action: and method: is dropped

params.excludeParams; the default list is built into DefaultExcludedPatternsChecker

ParameterNameAware

The action itself vetoes a name; it cannot rescue a name that the checks above dropped

Implement acceptableParameterName(String)

@StrutsParameter and ModelDriven exemption

The property must be annotated, unless it belongs to the model of a ModelDriven action (or transition mode applies)

struts.parameters.requireAnnotations, struts.parameters.requireAnnotations.transitionMode

Value patterns and ParameterValueAware

A value that does not match any accepted pattern, or matches an excluded pattern, or is refused by the action, drops the whole parameter

params.acceptedValuePatterns, params.excludedValuePatterns, acceptableParameterValue(String)

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)

struts.allowlist.enable, struts.allowlist.classes, struts.allowlist.packageNames

The @StrutsParameter Annotation

org.apache.struts2.interceptor.parameter.StrutsParameter is a runtime annotation with two members:

Member Meaning

int depth()

How many periods or brackets may appear in the parameter name below the annotated property. The default is 0: only the property itself (a setter or a public field on the action). Every navigation step, a property or an index into a list or map, counts as one

boolean allowDynamicKeys()

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, an int, a checkbox boolean, a List<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 depth levels; 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.

ShelfAction.java, one member of every kind (getters for the plain fields are as usual)
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&note=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.

Table 4. What each parameter does on the default deployment (Struts 7.4.0, annotations required, allowlist on)
Parameter Result Why

filter=dune

Set

@StrutsParameter on setFilter, depth 0

limit=5

Ignored

setLimit has no annotation

edition=2nd

Set

Annotated fluent setter that returns ShelfAction (7.4.0)

sort=title

Ignored

No annotation

note=hi

Set

Annotation on the public field

tags=sf&tags=classic

[sf, classic]

Annotated setter takes the whole list, as a checkbox list submits it

genres=space

Ignored

The getter is annotated but the whole list is assigned through a setter, which has none

genres[0]=space

Set

One bracket in the name, depth = 1 on the getter

book.title=Dune

Set

One period, depth = 1 on getBook

book.publisher.name=Tor

Ignored

Two periods need depth = 2

featured.title=T

Set

depth = 2 allows one and two levels

featured.publisher.name=Tor

Ignored

Depth is enough, but Publisher is not allowlisted: see the next section

hidden.title=X

Ignored

The getter has no annotation

books[0].title=Ubik

Set

Index and property are two levels, depth = 2

books[1].publisher.name=P

Ignored

Three levels

labels.en=Hello

Set

A map key as a property, depth = 1

labels[en]=Hello

Ignored

The unquoted bracket key does not match the accepted name pattern

badges['x']=1, badges.x=1

Set

depth = 1; allowDynamicKeys made no difference on request parameters

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.

Table 5. Three configurations, the same request
Deployment filter limit sort hidden.title genres[0] book.title books[0].title

Default (annotations required)

set

ignored

ignored

ignored

set

set

set

requireAnnotations=false

set

set

set

ignored

set

ignored

ignored

transitionMode=true

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:

form.jsp, the view of /edit
<%@ 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).

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:

Table 6. Channels that obey @StrutsParameter
Channel Rule (from the official page, not exercised in the scratch WAR)

params interceptor

Governed by struts.parameters.requireAnnotations (the part verified above)

chain interceptor (action chaining)

Value-stack copying checks the target’s annotations when struts.chaining.requireAnnotations=true (default false); rejected properties are logged

cookie interceptor

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.

Table 7. ActionForm concepts and their Struts 7 equivalent
Struts 1 concept Struts 7.4 equivalent

One ActionForm class per page, populated by Commons BeanUtils

No form class: the action (or a ModelDriven model, or a DTO reached through an annotated getter) is populated by OGNL through the params interceptor

reset() to clear checkboxes and multiple selects

A new action instance per request needs no reset; the checkbox and multiselect interceptors add the missing values for unchecked boxes

validate() on the form

validate() on the action, validators and the validation interceptor; the validation page of this part covers them

Form scope (request or session)

Action properties live for the request; a longer-lived form uses ScopedModelDriven or the session (the scopes page of this part)

Nested and indexed properties (address.city, items[0].name), map-backed forms

The same syntax, authorised by depth; a Map property takes labels.en or labels['en']

DynaActionForm (properties declared in XML)

A Map property with depth = 1, or a Map-backed model; there is no XML declaration of properties

Populating business objects from the form

A DTO behind an annotated getter, or a ModelDriven model that is a request DTO; the guide forbids annotating getters that return entities or services

Form as a firewall

@StrutsParameter, the name and value patterns and the OGNL allowlist, all on by default

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.

Pitfalls

Table 8. Parameter injection pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Struts 7.4.0 and Tomcat 11.0.26)
Symptom Cause Fix

A form field never reaches the action and the page shows no error (observed)

The setter has no @StrutsParameter; the interceptor drops it and logs at DEBUG only

Annotate the setter, or check the DEBUG log of StrutsParameterAuthorizer

genres[0]=x arrives but genres=x does not (observed)

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 depth = 1 for indexed elements, or both

books[0].title is ignored with depth = 1 (observed)

The index counts as a level, so reaching an element’s property needs depth = 2

Use depth = 2; the guide states it explicitly for flat DTOs

The depth is right and a nested value is still ignored; the log has SecurityMemberAccess ... is not allowlisted (observed)

A class deeper in the graph than the annotated getter’s return type is not allowlisted

Add it to struts.allowlist.classes or its package to struts.allowlist.packageNames

requireAnnotations=false makes plain setters work but nested objects still fail (observed)

Without annotations nothing allowlists the nested types

Restore the annotations; do not treat the constant as a way to turn the feature off

A ModelDriven action ignores a property of the action itself that the model does not declare (observed)

Since 7.4.0 only the model is exempt; the action’s members still need @StrutsParameter

Annotate the setter, or enable transition mode while migrating

publisher.name on a model works in one deployment and not in another (observed)

Publisher is allowlisted in one only

Allowlist the class, or review the model design

params.excludeParams with one pattern re-opens request.* (observed)

The setting replaces the default exclusions

Provide the full list, or use ParameterNameAware and annotations instead

A ParameterNameAware action cannot accept an excluded name (observed)

The built-in checks run first

Use the interface only to refuse names

A value is dropped with no error after adding acceptedValuePatterns (observed)

The whole parameter is dropped when the value does not match

Make the pattern match legitimate values (accents, punctuation) or apply it per action

labels[en]=x is dropped (observed)

The accepted name pattern needs a digit or a quoted key inside brackets

Send labels.en or labels['en']

Parameters with brackets give HTTP 400 on a GET from a script (observed)

Tomcat refuses raw [ and ] in the request target

Encode them (%5B, %5D) or use POST; browsers encode form fields

A button named submit logs warnings in devMode

No setter exists for the button parameter

Rename the button or exclude the name with a complete exclusion list

A property set in <param> or by a wildcard appears even though it is not annotated (observed)

staticParams and actionMappingParams bypass the annotation

Intended; they are written by the configuration author

Annotating a getter that returns an entity or a service

depth lets a request write into every public setter below it

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 void setter

Record bodies stop arriving in a REST action after 7.3.0

Creator-bound properties are authorised now

Annotate the getter, or use a ModelDriven model

What Changed Recently

Table 9. Changes to parameter binding between Struts 2.0 and 7.4
Release Change

Struts 2.0 to 2.3

Any public setter is bound; the params interceptor excludes a few context names (dojo, struts, session, request, application, servletRequest)

Struts 2.3.20

AcceptedPatternsChecker and ExcludedPatternsChecker with default implementations, used by the params and cookie interceptors

Struts 2.5

Strict method invocation and <allowed-methods>; the Dojo plugin and the s:datetimepicker tag are removed

Struts 6.0

Static method access removed from OGNL; Aware interfaces renamed (ParametersAware.withParameters)

Struts 6.1.0

ParameterValueAware, acceptedValuePatterns and excludedValuePatterns

Struts 6.4

struts.parameters.requireAnnotations and struts.allowlist.enable opt-in; the first @StrutsParameter

Struts 7.0

requireAnnotations and the allowlist on by default; the package com.opensymphony.xwork2 is renamed to org.apache.struts2, so ParameterNameAware is org.apache.struts2.action.ParameterNameAware and the interceptor is org.apache.struts2.interceptor.parameter.ParametersInterceptor

Struts 7.2.0

@StrutsParameter authorisation on every channel: chaining (opt-in), cookie, JSON and REST

Struts 7.3.0

Creator-bound properties (records, @JsonCreator) of REST bodies are authorised (WW-5642)

Struts 7.4.0

The ModelDriven exemption covers the model only (the action’s own members need annotations); fluent setters honour @StrutsParameter (WW-5709); matching by Java member name for REST (WW-5715); struts.rest.anySetter.requireAnnotations (WW-5712); @JsonIdentityInfo ids and polymorphic mergeable properties are authorised (WW-5727, WW-5726)

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.

Table 10. Struts 2.0 and Struts 1 material against Struts 7.4
The book’s era Today (Struts 7.4)

Any public setter or getter-reachable property is bound from the request, no annotations

@StrutsParameter is required by default; unannotated members are dropped silently

Packages com.opensymphony.xwork2.interceptor and com.opensymphony.xwork2.ognl for the parameters interceptor and ParameterNameAware

org.apache.struts2.interceptor.parameter.ParametersInterceptor and org.apache.struts2.action.ParameterNameAware

Excluded names are a short list in excludeParams (the dojo and struts entries)

A larger built-in checker list (context maps, top, class, _memberAccess, action: and method: prefixes); excludeParams replaces it

OGNL expressions in names and # references limited only by a few regular expressions

Static access removed, the OGNL allowlist and member access guards on by default, maximum expression length, NoParameters, value patterns

Struts 1 ActionForm, DynaActionForm, reset(), BeanUtils population

No form class; DTOs and ModelDriven models; reset() is unnecessary; BeanUtils is not used

Dojo and s:datetimepicker fields that send extra parameters

Removed in 2.5; dojo.* names are excluded by default

Dynamic method invocation action!method as a parameter-like selector

Off by default; the action: and method: parameter prefixes are excluded

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.acceptableParameterName takes 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 with request.value).

  • The same page lists the parameter as excludeValuePatterns in prose and excludedValuePatterns in XML; the 7.4.0 setter is setExcludedValuePatterns. Its Default List of Parameters Excluded is a short older list, not the list of DefaultExcludedPatternsChecker.

  • The Exclude Parameters tutorial and the guide both show excludeParams with 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 is org.apache.struts2.ognl.OgnlValueStack since 7.0.

  • The 7.4.0 jar and the example struts-parameter use struts.allowlist.packageNames and struts.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