Skip to content

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:

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; }
}
struts-config.xml
<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

struts-config.xml
<!-- 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>
validation.xml
<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 ValidatorForm with no matching entry in validation.xml validates 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 ActionForm with a complete hand-written validate() is a legitimate design. The weakness is unvalidated input, not the class name. Where the form checks every field in code and returns a populated ActionErrors, record a false positive with that reasoning rather than migrating the class to satisfy a rule.
  • Look for the Action that bypasses the form. If the handler calls request.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. Implement reset() 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.xml rule but that no Java code checks - a two-character username against minlength=3 - and assert the response is the input page with the field error. This is the assertion that proves the wiring; a test using a value the custom validate() also rejects passes against the vulnerable version.
  • Assert a valid submission still reaches the Action and 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 "". required rejects 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 the reset() consideration above describes.
  • For any form moved to session scope, submit with a checkbox checked and then unchecked, asserting the second submission sees false. Without reset() it will see true.
  • Grep the Action classes for request.getParameter( and assert the ones covering this form return no hits.

Additional Resources