This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new a2837aabc WW-5703 fix(core): keep the HTML5 pattern from rejecting
blank input the regex validator skips (#1938)
a2837aabc is described below
commit a2837aabc6ff07776d434fafb63db8e21547dcc6
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Sep 14 13:53:19 2026 +0200
WW-5703 fix(core): keep the HTML5 pattern from rejecting blank input the
regex validator skips (#1938)
* WW-5703 fix(core): keep the HTML5 pattern from rejecting blank input the
regex validator skips
RegexFieldValidator.validateFieldValue returns before it consults its
trim param whenever the value trims to empty, so a whitespace-only value
passes the server in every configuration - including the only one
(trim=false, case-sensitive, portable) in which a pattern is emitted.
The browser skips pattern only for the empty string, so a single space
was blocked client-side and accepted server-side, violating the
never-false-reject rule.
Unless the field also carries a trimming requiredstring validator, which
rejects blank input server-side, the pattern now reads
(?:<regex>)|[\x00-\x20]* - the regex, or a value made only of the
characters String.trim() strips. Verified with node 24 under the v flag:
space, tab and vertical-tab-only values pass, a non-breaking space and
non-matching values still fail, matching values are unaffected. The
presence check is a pre-scan of the field's validators so the order in
validation.xml does not matter.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5703 refactor(core): admit blank input as a post-step instead of a flag
addConstraints and addPattern keep their original signatures; the
whitespace-only alternative is applied once per field after the loop by
admitBlankInPattern, guarded by rejectsBlank.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5703 refactor(core): name the pattern attribute once
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5703 refactor(core): qualify the attribute-name constants
ATTRIBUTE_NAME in the same class is a regex Pattern, so a bare PATTERN
invited the wrong reading.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* WW-5703 refactor(core): wrap the pattern with computeIfPresent
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../components/StrutsHtmlConstraintProvider.java | 40 ++++++++++++++++++---
.../StrutsHtmlConstraintProviderTest.java | 42 +++++++++++++++++++++-
.../struts2/validator/RegexFieldValidatorTest.java | 17 +++++++++
.../views/jsp/ui/Html5ConstraintRenderingTest.java | 7 +++-
4 files changed, 100 insertions(+), 6 deletions(-)
diff --git
a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
index 4f538037c..775939d8e 100644
---
a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
+++
b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
@@ -51,12 +51,18 @@ public class StrutsHtmlConstraintProvider implements
HtmlConstraintProvider {
/**
* The HTML5 boolean attribute; its canonical serialisation repeats the
attribute name as the value.
*/
- private static final String REQUIRED = "required";
+ private static final String REQUIRED_ATTRIBUTE = "required";
+ private static final String PATTERN_ATTRIBUTE = "pattern";
/**
* What a validator type may contain to become part of a {@code
data-msg-*} name: no character that
* ends or splits an attribute name, and no colon, which an XML parser
reads as a namespace prefix.
*/
private static final Pattern ATTRIBUTE_NAME =
Pattern.compile("[A-Za-z0-9_.-]+");
+ /**
+ * The characters {@link String#trim()} strips: a value made only of these
is skipped by
+ * {@code RegexFieldValidator} whatever its {@code trim} param says.
+ */
+ private static final String BLANK = "[\\x00-\\x20]*";
@Override
public Map<String, String> constraintsFor(List<Validator> validators,
HtmlControlType control, Object action) {
@@ -70,9 +76,35 @@ public class StrutsHtmlConstraintProvider implements
HtmlConstraintProvider {
addMessage(attributes, validator, action);
}
}
+ if (!rejectsBlank(validators)) {
+ admitBlankInPattern(attributes);
+ }
return attributes;
}
+ /**
+ * Only a trimming {@code requiredstring} fails a whitespace-only value
server-side; with
+ * {@code trim=false} it counts as non-empty and falls through to the
other validators.
+ */
+ protected boolean rejectsBlank(List<Validator> validators) {
+ for (Validator validator : validators) {
+ if (validator instanceof RequiredStringValidator required &&
required.isTrim()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * {@code RegexFieldValidator} skips a value that trims to empty before it
looks at {@code trim},
+ * while the browser skips {@code pattern} only for the empty string.
Unless another validator on
+ * the field rejects blank input, the pattern therefore gets a
whitespace-only alternative so that
+ * a single space is not blocked client-side and accepted server-side.
+ */
+ protected void admitBlankInPattern(Map<String, String> attributes) {
+ attributes.computeIfPresent(PATTERN_ATTRIBUTE, (name, regex) -> "(?:"
+ regex + ")|" + BLANK);
+ }
+
protected void addConstraints(Map<String, String> attributes, Validator
validator, HtmlControlType control) {
if (validator instanceof RequiredStringValidator) {
addRequiredString(attributes, control);
@@ -97,7 +129,7 @@ public class StrutsHtmlConstraintProvider implements
HtmlConstraintProvider {
if (!control.supportsLength()) {
return;
}
- attributes.put(REQUIRED, REQUIRED);
+ attributes.put(REQUIRED_ATTRIBUTE, REQUIRED_ATTRIBUTE);
}
/**
@@ -111,7 +143,7 @@ public class StrutsHtmlConstraintProvider implements
HtmlConstraintProvider {
if (control != HtmlControlType.RADIO && control !=
HtmlControlType.FILE) {
return;
}
- attributes.put(REQUIRED, REQUIRED);
+ attributes.put(REQUIRED_ATTRIBUTE, REQUIRED_ATTRIBUTE);
}
protected void addLength(Map<String, String> attributes,
StringLengthFieldValidator validator, HtmlControlType control) {
@@ -146,7 +178,7 @@ public class StrutsHtmlConstraintProvider implements
HtmlConstraintProvider {
}
String regex = validator.getRegex();
if (EcmaScriptSafeRegex.isSafe(regex)) {
- attributes.put("pattern", regex);
+ attributes.put(PATTERN_ATTRIBUTE, regex);
}
}
diff --git
a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
index 357accbc0..23ad65374 100644
---
a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
+++
b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
@@ -152,16 +152,56 @@ public class StrutsHtmlConstraintProviderTest {
}
@Test
- public void regexEmitsPatternWhenPortableAndCaseSensitive() {
+ public void regexAloneEmitsPatternThatAlsoAcceptsBlankInput() {
+ // RegexFieldValidator skips a value that trims to empty regardless of
its trim param, so a
+ // bare pattern would block whitespace-only input the server lets
through
RegexFieldValidator validator = new RegexFieldValidator();
validator.setRegex("[a-z]+");
validator.setCaseSensitive(true);
validator.setTrim(false);
assertThat(constraints(validator, HtmlControlType.TEXT))
+ .containsEntry("pattern", "(?:[a-z]+)|[\\x00-\\x20]*");
+ }
+
+ @Test
+ public void regexWithTrimmingRequiredStringEmitsBarePattern() {
+ // a trimming requiredstring rejects blank input server-side, so the
two sides agree again
+ RegexFieldValidator regex = new RegexFieldValidator();
+ regex.setRegex("[a-z]+");
+ regex.setCaseSensitive(true);
+ regex.setTrim(false);
+
+ assertThat(provider.constraintsFor(List.of(new
RequiredStringValidator(), regex), HtmlControlType.TEXT, null))
.containsEntry("pattern", "[a-z]+");
}
+ @Test
+ public void regexWithRequiredStringListedAfterItStillEmitsBarePattern() {
+ RegexFieldValidator regex = new RegexFieldValidator();
+ regex.setRegex("[a-z]+");
+ regex.setCaseSensitive(true);
+ regex.setTrim(false);
+
+ assertThat(provider.constraintsFor(List.of(regex, new
RequiredStringValidator()), HtmlControlType.TEXT, null))
+ .containsEntry("pattern", "[a-z]+");
+ }
+
+ @Test
+ public void regexWithNonTrimmingRequiredStringKeepsTheBlankAlternative() {
+ // requiredstring with trim=false accepts " " as non-empty, so blank
input still reaches the
+ // regex validator's unconditional skip
+ RegexFieldValidator regex = new RegexFieldValidator();
+ regex.setRegex("[a-z]+");
+ regex.setCaseSensitive(true);
+ regex.setTrim(false);
+ RequiredStringValidator required = new RequiredStringValidator();
+ required.setTrim(false);
+
+ assertThat(provider.constraintsFor(List.of(required, regex),
HtmlControlType.TEXT, null))
+ .containsEntry("pattern", "(?:[a-z]+)|[\\x00-\\x20]*");
+ }
+
@Test
public void regexEmitsNothingWhenCaseInsensitive() {
RegexFieldValidator validator = new RegexFieldValidator();
diff --git
a/core/src/test/java/org/apache/struts2/validator/RegexFieldValidatorTest.java
b/core/src/test/java/org/apache/struts2/validator/RegexFieldValidatorTest.java
index c2988efa0..686c0dde3 100644
---
a/core/src/test/java/org/apache/struts2/validator/RegexFieldValidatorTest.java
+++
b/core/src/test/java/org/apache/struts2/validator/RegexFieldValidatorTest.java
@@ -82,6 +82,23 @@ public class RegexFieldValidatorTest extends XWorkTestCase {
assertFalse(validator.getValidatorContext().hasFieldErrors());
}
+ public void testBlankValueIsSkippedEvenWithoutTrim() throws Exception {
+ // StrutsHtmlConstraintProvider mirrors this skip in the emitted HTML5
pattern; if the
+ // validator ever starts matching blank input, that mirror has to go
too
+ MyTestPerson testPerson = new MyTestPerson();
+ testPerson.setUsername(" \t ");
+
+ RegexFieldValidator validator = new RegexFieldValidator();
+ validator.setTrim(false);
+ validator.setRegex("^\\d{5}$");
+ validator.setValidatorContext(new DummyValidatorContext(new Object(),
tpf));
+ validator.setFieldName("username");
+ validator.setValueStack(ActionContext.getContext().getValueStack());
+ validator.validate(testPerson);
+
+ assertFalse(validator.getValidatorContext().hasFieldErrors());
+ }
+
public void testFail() throws Exception {
MyTestPerson testPerson = new MyTestPerson();
testPerson.setUsername("Superman");
diff --git
a/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
index b6717b2f4..bc85f1926 100644
---
a/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
+++
b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
@@ -86,10 +86,15 @@ public class Html5ConstraintRenderingTest extends
AbstractUITagTest {
output.contains("Contains \"quotes\" and <brackets>"));
}
+ /**
+ * The field carries only a regex validator, so the pattern also has to
admit the whitespace-only
+ * input the server skips; the suffix must survive FreeMarker's attribute
escaping unchanged.
+ */
public void testRendersPatternOnATextField() throws Exception {
String output = render("true", "code", null);
- assertTrue("expected pattern in: " + output,
output.contains("pattern=\"^[A-Z]{3}\\d{2}$\""));
+ assertTrue("expected pattern in: " + output,
+
output.contains("pattern=\"(?:^[A-Z]{3}\\d{2}$)|[\\x00-\\x20]*\""));
}
/**