CWE-104: Struts: Form Bean Does Not Extend Validation Class
Overview
A Struts 1 form bean that extends ActionForm directly, rather than one of the
validator base classes, does not enter the Validator framework's lifecycle, so
the rules declared for it in validation.xml are never executed. The rules exist,
the form works, and nothing reports that the two were never connected - the
input reaches the Action exactly as submitted.
This is the wiring failure; CWE-103 is the method failure. Struts 1 had its final release (1.3.10) in 2008 and Apache announced end of life in 2013, so restoring validation here is a compensating control for legacy code. Migrating off the framework is the durable fix and should be tracked separately.
Relationship to Other CWEs
CWE-104 is a Struts-1-specific Variant. The entries below are this page, its two parents - which MITRE draws from different views and which it shares with its sibling - and that sibling:
- CWE-104 (this page) - a form bean sitting outside the Validator lifecycle, so
the rules declared for it in
validation.xmlnever run - CWE-573 (Improper Following of Specification by Caller) - ChildOf in Research Concepts (view 1000)
- CWE-20 (Improper Input Validation) - ChildOf in Seven Pernicious Kingdoms (view 700)
- CWE-103 (Struts: Incomplete
validate()Method Definition) - the sibling, under the same two parents. There the form bean extends a validator base class but itsvalidate()is broken; here the wiring is what is missing
Both disable the Struts Validator, by different mechanisms, and a scanner finding for either looks the same from the outside - unvalidated form data reaching application logic - so confirm which mechanism applies before remediating.
OWASP Classification
A05:2025 - Injection
Risk
High: The application's declared input constraints are not being enforced, so every downstream component receives raw request data while the configuration suggests otherwise. The consequence follows the fields: injection where they reach a query or a page, and business-logic bypass where a value that was never supposed to be representable - a negative amount, an out-of-range state - passes straight through.
Common Vulnerable Patterns
Form bean extends ActionForm while rules are declared for it
import org.apache.struts.action.ActionForm;
// VULNERABLE - ActionForm has no Validator integration, so validation.xml is inert
public class UserForm extends ActionForm {
private String username;
private String password;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
<!-- These rules are never executed for the class above -->
<form name="userForm">
<field property="username" depends="required,minlength,maxlength">
<var><var-name>minlength</var-name><var-value>3</var-value></var>
<var><var-name>maxlength</var-name><var-value>20</var-value></var>
</field>
</form>
Why this is vulnerable: ActionForm is a valid form bean - Struts will
populate it and call its validate() - but the base implementation performs no
validation and knows nothing about validation.xml. The declarative rules are
run by anything descending from ValidatorForm or DynaValidatorForm, which
includes ValidatorActionForm and DynaValidatorActionForm in struts-extras
as well as BeanValidatorForm - so a bean extending one of those is correctly
wired and is not this finding. Which one is in use matters for a second reason:
ValidatorActionForm and DynaValidatorActionForm key their rules on the
action path rather than the form-bean name. The XML stays in the repository
looking authoritative, which is why this survives code review: the reviewer
checks that a rule exists for the field, and it does.
A plain object used outside the form lifecycle
// VULNERABLE - never registered as a form bean, so no lifecycle applies
public class UserData {
private String username;
private String password;
// getters and setters only
}
// VULNERABLE - the Action reads parameters directly, bypassing the form entirely
public class LoginAction extends Action {
public ActionForward execute(ActionMapping mapping, ActionForm form,
HttpServletRequest request, HttpServletResponse response) {
UserData data = new UserData();
data.setUsername(request.getParameter("username"));
data.setPassword(request.getParameter("password"));
return authenticate(data, mapping);
}
}
Why this is vulnerable: The Action takes its values straight from
request.getParameter(), so no form bean is involved.
That path has no validation, no type conversion and no ActionErrors, and it
keeps working after someone repairs the form bean, because the Action was
never using it.
The plain object itself is not the obstacle, and assuming it is leads teams to
rewrite more than they need to. FormBeanConfig.createActionForm wraps a
registered class that is not an ActionForm in BeanValidatorForm, which
extends ValidatorForm - so naming UserData as the form-bean type puts it
inside the Validator lifecycle and runs the validation.xml rules against it
with no change to the class. One restriction travels with that: the wrapper is
not serializable, so a form registered this way belongs in request scope. The
change that matters either way is the Action reading from the form it was
handed.
Secure Patterns
Extend ValidatorForm and declare the rules against the form-bean name
import javax.servlet.http.HttpServletRequest; // Struts 1 predates the jakarta.* namespace
import org.apache.struts.action.ActionErrors;
import org.apache.struts.action.ActionMapping;
import org.apache.struts.action.ActionMessage;
import org.apache.struts.validator.ValidatorForm;
// SECURE - ValidatorForm connects this bean to validation.xml
public class UserForm extends ValidatorForm {
private String username;
private String password;
@Override
public ActionErrors validate(ActionMapping mapping, HttpServletRequest request) {
ActionErrors errors = super.validate(mapping, request);
if (errors == null) {
errors = new ActionErrors(); // defensive: ValidatorForm returns empty, not null
}
if (username != null && isReservedUsername(username)) {
errors.add("username", new ActionMessage("error.username.reserved"));
}
return errors;
}
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
<form-beans>
<form-bean name="userForm" type="com.example.UserForm"/>
</form-beans>
<action-mappings>
<action path="/login"
type="com.example.LoginAction"
name="userForm"
scope="request"
validate="true"
input="/WEB-INF/jsp/login.jsp"/>
</action-mappings>
Why this works: Extending ValidatorForm is what puts the bean inside the
Validator lifecycle, so super.validate() executes the rules declared under the
matching <form name> in validation.xml. Three things have to line up and all
three are in the snippet: the base class, the form-bean name matching the
validation.xml form name exactly (the match is case-sensitive, and a mismatch
produces no field errors at all - though commons-validator does log
Form '<name>' not found for locale '<locale>' on every such request, which is
what to grep for), and validate="true" with an input page for failures to
return to.
The null check on super.validate() is defensive rather than required.
ValidatorForm.validate() constructs an ActionErrors and returns it on every
path, so a valid form yields an empty one and never null.
ActionForm.validate() further up the hierarchy does return null, and so
might a project-specific base class between the two, which is the case the
guard covers.
The Action then reads values from the form bean it was given, never from
request.getParameter(). A single direct parameter read reopens the hole for
that field.
DynaValidatorForm where the bean holds no logic
<!-- SECURE - no Java class to write, and validation still runs -->
<form-bean name="searchForm"
type="org.apache.struts.validator.DynaValidatorForm">
<form-property name="query" type="java.lang.String"/>
<form-property name="maxResults" type="java.lang.String"/>
</form-bean>
<form name="searchForm">
<field property="maxResults" depends="required,integer,intRange">
<arg key="searchForm.maxResults"/>
<var><var-name>min</var-name><var-value>1</var-value></var>
<var><var-name>max</var-name><var-value>200</var-value></var>
</field>
</form>
Why this works: DynaValidatorForm gives the same Validator integration
without a bean class, which removes the opportunity to write one that extends
the wrong base.
Declare the property as String and let a rule check it. Declaring it
java.lang.Integer looks like it recruits the framework as a validator, and it
does the opposite. Struts populates the form through BeanUtils.populate, and
commons-beanutils' registered IntegerConverter carries a default value, so a
non-numeric submission does not fail - maxResults=abc converts to 0 and
proceeds. The submitted text is gone at that point, so no rule can see what
actually arrived: the integer rule inspects 0 and passes it.
Whether anything catches it afterwards depends on where the converter's default
happens to fall. Against the intRange above, 0 is outside 1..200 and is
rejected - but for being out of range rather than for never having been a
number, which masks the real defect behind a misleading message.
Declare depends="required,integer" with no range rule and nothing fires at
all, because 0 is present and is an integer. Keeping the property a String
removes the guesswork: the rules see exactly what was submitted, and the
Action converts once the value is known to be good.
Considerations
- Extending the right class does nothing if no rules are declared. The two
halves are independent: a
ValidatorFormwith no matching entry invalidation.xmlvalidates nothing, and the finding stays open even though the class now looks correct. Confirm the rules exist and the names match before closing. - A plain
ActionFormwith a complete hand-writtenvalidate()is a legitimate design. The weakness is unvalidated input, not the class name. Where the form checks every field in code and returns a populatedActionErrors, record a false positive with that reasoning rather than migrating the class to satisfy a rule. - Look for the
Actionthat bypasses the form. If the handler callsrequest.getParameter()for a field, fixing the form bean does not protect that field. This is the failure mode that survives the obvious fix, and it is worth grepping for across the application rather than only in the reported class. - Session-scoped forms need
reset(). Moving a form into the Validator lifecycle often comes with a scope change, and a session-scoped bean keeps values between requests. Struts populates only the properties present in the request, so every omitted field keeps whatever the last submission left there - an unchecked checkbox is the familiar case, but unselected radios, emptied multi-selects and the fields belonging to another page of a multi-page form all behave the same way. Implementreset()to put every property back to its default, not just the boolean ones, or keep the form request-scoped.
Testing
Whether validation runs depends on a base class, a name match and a mapping attribute agreeing. Only an end-to-end submission tests all three.
- Submit a value that violates a
validation.xmlrule but that no Java code checks - a two-character username againstminlength=3- and assert the response is theinputpage with the field error. This is the assertion that proves the wiring; a test using a value the customvalidate()also rejects passes against the vulnerable version. - Assert a valid submission still reaches the
Actionand completes. Rules that have never executed in production frequently reject data that real users submit, and this is where that surfaces. - Submit with the field omitted entirely, not merely empty. Struts populates
only the parameters the request actually carries, so an omitted field is never
written at all, while an empty one is populated as
"".requiredrejects both, so on a request-scoped form the two look the same. The distinction bites on a session-scoped form, where the field nobody wrote keeps its previous value and passes - so run this one against a session-scoped form, which is the case thereset()consideration above describes. - For any form moved to session scope, submit with a checkbox checked and then
unchecked, asserting the second submission sees
false. Withoutreset()it will seetrue. - Grep the
Actionclasses forrequest.getParameter(and assert the ones covering this form return no hits.
Additional Resources
- CWE-104: Struts: Form Bean Does Not Extend Validation Class
- Apache Struts Security - advisories cover Struts 2; Struts 1 receives no patches
- OWASP Input Validation Cheat Sheet
- OWASP Top 10 2025 A05: Injection