Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
30947b0
WW-5695 docs(validation): design HTML5 constraint validation and reti…
lukaszlenart Aug 24, 2026
510f31a
WW-5695 docs(validation): correct the UIBean hook point and control-t…
lukaszlenart Aug 24, 2026
a365be8
WW-5695 docs(validation): add the 7.4.0 implementation plan
lukaszlenart Aug 24, 2026
81db2b2
WW-5694 refactor(validation): deprecate JavaScript client-side valida…
lukaszlenart Aug 24, 2026
0e8b4b3
WW-5694 docs(validation): scope the deprecation banner to the validat…
lukaszlenart Aug 24, 2026
01417ea
WW-5695 feat(components): add HtmlControlType
lukaszlenart Aug 24, 2026
60dfbc7
WW-5695 docs(validation): require the licence header on every new fil…
lukaszlenart Aug 24, 2026
00dd06e
WW-5695 chore(components): add the missing licence header to HtmlCont…
lukaszlenart Aug 24, 2026
c13202c
WW-5695 feat(components): add ECMAScript-safe regex detection
lukaszlenart Aug 24, 2026
07dbf7b
WW-5695 fix(validation): drop \s and \S from the ECMAScript-safe esca…
lukaszlenart Aug 24, 2026
c67aa67
WW-5695 fix(components): drop \s and \S from the portable-escape allo…
lukaszlenart Aug 24, 2026
e9b8fb4
WW-5695 feat(components): map validators onto HTML5 constraint attrib…
lukaszlenart Aug 24, 2026
fcdc518
WW-5695 fix(components): close three false-reject and injection gaps …
lukaszlenart Aug 24, 2026
57c92f3
WW-5695 feat(components): add Form.getFieldValidators with per-render…
lukaszlenart Aug 24, 2026
88162c3
WW-5695 docs(validation): make the plan's memoisation test actually t…
lukaszlenart Aug 24, 2026
ee8c25a
WW-5695 test(components): make FormFieldValidatorsTest prove memoisation
lukaszlenart Aug 24, 2026
2292e92
WW-5695 feat(components): resolve the HTML control type per component
lukaszlenart Aug 24, 2026
6eec8eb
WW-5695 feat(components): derive constraint attributes during tag eva…
lukaszlenart Aug 24, 2026
584f406
WW-5695 test(components): pin the constraint hook's evaluateExtraPara…
lukaszlenart Aug 24, 2026
79e2772
WW-5695 feat(html5): render derived constraint attributes
lukaszlenart Aug 24, 2026
d0d0ce4
WW-5695 docs(validation): drop the ?html builtin from the template gu…
lukaszlenart Aug 24, 2026
febd4c6
WW-5695 fix(html5): stop constraints.ftl leaking a newline into every…
lukaszlenart Aug 24, 2026
1245212
WW-5695 fix(components): close false-reject and duplicate-attribute g…
lukaszlenart Aug 24, 2026
184006c
WW-5695 docs(design): correct the spec and plan to match the post-fix…
lukaszlenart Aug 24, 2026
1cf6198
WW-5695 refactor(components): address SonarQube findings
lukaszlenart Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions core/src/main/java/org/apache/struts2/StrutsConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ public final class StrutsConstants {
*/
public static final String STRUTS_UI_STATIC_CONTENT_PATH = "struts.ui.staticContentPath";

/**
* Whether the html5 theme emits HTML5 constraint attributes derived from the action's validators.
* Defaults to {@code false}; the default is expected to flip in a future major release.
*
* @since 7.4.0
*/
public static final String STRUTS_UI_HTML5_CONSTRAINTS = "struts.ui.html5.constraints";

/**
* Whether WebJars support is enabled (serving and URL building)
*/
Expand All @@ -218,6 +226,14 @@ public final class StrutsConstants {
*/
public static final String STRUTS_UI_ESCAPE_HTML_BODY = "struts.ui.escapeHtmlBody";

/**
* The {@link org.apache.struts2.components.HtmlConstraintProvider} implementation used to derive
* HTML5 constraint attributes from an action's validators.
*
* @since 7.4.0
*/
public static final String STRUTS_HTML_CONSTRAINT_PROVIDER = "struts.htmlConstraintProvider";

/**
* The maximum size of a multipart request (file upload)
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.struts2.components;

/**
* Decides whether a Java regular expression can be handed to a browser as an HTML5 {@code pattern}
* attribute without changing meaning.
* <p>
* This is an allowlist by design. A denylist of Java-only constructs would violate the
* never-false-reject rule the first time it missed one, because a missed construct becomes a pattern
* the browser interprets differently and the user cannot get past. Anything not provably common to
* both engines is rejected, and the field simply gets no client-side check.
*
* @since 7.4.0
*/
public final class EcmaScriptSafeRegex {

/**
* Escapes with identical meaning in both engines.
* <p>
* {@code \s} and {@code \S} are deliberately absent. Java's {@code \s} is ASCII-only by default
* while ECMAScript's is the wider Unicode set, so {@code ^\S+$} accepts a value containing NBSP
* on the server and rejects it in the browser. {@code \d} and {@code \w} are safe — both engines
* are ASCII-only for those, and JavaScript never widens them.
*/
private static final String ALLOWED_ESCAPES = "dDwWbBnrtf\\.*+?()[]{}|^$/-";

private EcmaScriptSafeRegex() {
}

public static boolean isSafe(String regex) {
if (regex == null || regex.isEmpty()) {
return false;
}
boolean inCharClass = false;
int i = 0;
while (i < regex.length()) {
char current = regex.charAt(i);
if (!isPortable(regex, i, current, inCharClass)) {
return false;
}
if (current == '[') {
inCharClass = true;
} else if (current == ']') {
inCharClass = false;
}
// an escape consumes the character it escapes, which must not be scanned again
i += (current == '\\') ? 2 : 1;
}
return !inCharClass;
}

/**
* Whether the construct starting at {@code index} means the same thing to both engines. This is
* the whole allowlist: anything that reaches {@code default} is a character with no special
* meaning in either engine, or one whose meaning is shared.
*/
private static boolean isPortable(String regex, int index, char current, boolean inCharClass) {
switch (current) {
case '\\':
return isAllowedEscape(regex, index);
case '[':
// Java allows nested classes and POSIX names; ECMAScript allows neither
return !inCharClass && !regex.startsWith("[:", index);
case '&':
// Java character-class intersection
return !inCharClass || !isFollowedBy(regex, index, '&');
case '(':
return isPortableGroup(regex, index);
case '*', '+', '?', '}':
// possessive quantifier
return !isFollowedBy(regex, index, '+');
default:
return true;
}
}

private static boolean isAllowedEscape(String regex, int index) {
return index + 1 < regex.length() && ALLOWED_ESCAPES.indexOf(regex.charAt(index + 1)) >= 0;
}

/**
* Only non-capturing groups and lookahead are portable; named groups, lookbehind, atomic groups
* and inline flags are not. A plain capturing group is always fine.
*/
private static boolean isPortableGroup(String regex, int index) {
if (!isFollowedBy(regex, index, '?')) {
return true;
}
if (index + 2 >= regex.length()) {
return false;
}
char kind = regex.charAt(index + 2);
return kind == ':' || kind == '=' || kind == '!';
}

private static boolean isFollowedBy(String regex, int index, char expected) {
return index + 1 < regex.length() && regex.charAt(index + 1) == expected;
}
}
5 changes: 5 additions & 0 deletions core/src/main/java/org/apache/struts2/components/File.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ protected String getDefaultTemplate() {
return TEMPLATE;
}

@Override
protected HtmlControlType getControlType() {
return HtmlControlType.FILE;
}

public void evaluateParams() {
super.evaluateParams();

Expand Down
72 changes: 71 additions & 1 deletion core/src/main/java/org/apache/struts2/components/Form.java
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
* </li>
* </ol>
* <p>
* <strong>The client-side JS <code>validate</code> attribute is deprecated since 7.4.0 — use the html5 theme's
* constraint attributes instead. Removed in 8.0.0.</strong>
* </p>
* <!-- END SNIPPET: javadoc -->
*
* <p><b>Examples</b></p>
Expand All @@ -98,6 +101,8 @@
public static final String OPEN_TEMPLATE = "form";
public static final String TEMPLATE = "form-close";

private static final String ATTR_ACTION_CLASS = "actionClass";

private int sequence = 0;

protected String onsubmit;
Expand All @@ -119,6 +124,10 @@
protected UrlRenderer urlRenderer;
protected ActionValidatorManager actionValidatorManager;

private List<Validator> cachedActionValidators;
private String cachedActionName;
private boolean actionValidatorsResolved;

public Form(ValueStack stack, HttpServletRequest request, HttpServletResponse response) {
super(stack, request, response);
}
Expand Down Expand Up @@ -238,7 +247,12 @@
* @param actionName the actioName to check for
* @param namespace the namespace to check for
* @param actionMethod the method to ckeck for
* @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever
* covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with
* {@code struts.ui.html5.constraints=true}, which derives native HTML5 constraint attributes
* per field instead.
*/
@Deprecated(since = "7.4.0", forRemoval = true)
protected void evaluateClientSideJsEnablement(String actionName, String namespace, String actionMethod) {

// Only evaluate if Client-Side js is to be enable when validate=true
Expand Down Expand Up @@ -268,8 +282,17 @@
}
}

/**
* Looks up the validators for a field, for the deprecated client-side JavaScript validator.
*
* @param name the field name to look up
* @return the validators applying to the field, never null
* @deprecated since 7.4.0, for removal in 8.0.0. Use {@link #getFieldValidators(String)}, which
* is generically typed and resolves the action's validators once per form rather than per field.
*/
@Deprecated(since = "7.4.0", forRemoval = true)
public List getValidators(String name) {

Check warning on line 294 in core/src/main/java/org/apache/struts2/components/Form.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=apache_struts&issues=AaA3N7Jz0hcBejaCBDhy&open=AaA3N7Jz0hcBejaCBDhy&pullRequest=1865
Class actionClass = (Class) getAttributes().get("actionClass");
Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
if (actionClass == null) {
return Collections.EMPTY_LIST;
}
Expand Down Expand Up @@ -300,6 +323,47 @@
return validators;
}

/**
* Returns the validators declared for a single field, resolving the action's validator list at
* most once per form render.
*
* @since 7.4.0
*/
public List<Validator> getFieldValidators(String name) {
resolveActionValidators();
if (cachedActionValidators.isEmpty()) {
return Collections.emptyList();
}
Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
List<Validator> validators = new ArrayList<>();
findFieldValidators(name, actionClass, cachedActionName, cachedActionValidators, validators, "");
return validators;
}

private void resolveActionValidators() {
if (actionValidatorsResolved) {
return;
}
actionValidatorsResolved = true;
cachedActionValidators = Collections.emptyList();

Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
if (actionClass == null) {
return;
}
ActionMapping mapping = actionMapper.getMappingFromActionName(findString(action));
if (mapping == null) {
mapping = actionMapper.getMappingFromActionName((String) getAttributes().get("actionName"));
}
if (mapping == null) {
return;
}
cachedActionName = mapping.getName();
String methodName = isValidateAnnotatedMethodOnly(cachedActionName) ? mapping.getMethod() : null;
cachedActionValidators =
actionValidatorManager.getValidators(actionClass, cachedActionName, methodName);
}

private boolean isValidateAnnotatedMethodOnly(String actionName) {
RuntimeConfiguration runtimeConfiguration = configuration.getRuntimeConfiguration();
String actionNamespace = getNamespace(stack);
Expand Down Expand Up @@ -507,8 +571,14 @@
this.namespace = namespace;
}

/**
* @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever
* covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with
* {@code struts.ui.html5.constraints=true} instead.
*/
@StrutsTagAttribute(description = "Whether client side/remote validation should be performed. Only" +
" useful with theme xhtml/ajax", type = "Boolean", defaultValue = "false")
@Deprecated(since = "7.4.0", forRemoval = true)
public void setValidate(String validate) {
this.validate = validate;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.struts2.components;

import org.apache.struts2.validator.Validator;

import java.util.List;
import java.util.Map;

/**
* Maps a field's validators onto the HTML attributes a theme should render for it.
* <p>
* The default implementation is deliberately conservative — see {@link StrutsHtmlConstraintProvider}.
* Applications wanting a best-effort mapping (an {@code email} validator becoming
* {@code type="email"}, say) should register their own implementation instead.
Comment on lines +29 to +31
*
* @since 7.4.0
*/
public interface HtmlConstraintProvider {

/**
* @param validators the field's validators; may be null or empty
* @param control the kind of control being rendered
* @param action the action instance, used to resolve i18n validator messages; may be null
* @return attribute name to value; never null, possibly empty
*/
Map<String, String> constraintsFor(List<Validator> validators, HtmlControlType control, Object action);
}
Loading
Loading