This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch feature/WW-5695-html5-constraint-validation in repository https://gitbox.apache.org/repos/asf/struts.git
commit c13202c93f6abae7cc5db3e2bbb2e07dd5137a7b Author: Lukasz Lenart <[email protected]> AuthorDate: Mon Aug 24 20:04:22 2026 +0200 WW-5695 feat(components): add ECMAScript-safe regex detection Decides whether a Java regex can become an HTML5 pattern attribute without changing meaning. Allowlist by design: a denylist would violate the never-false-reject rule the first time it missed a construct, and a regex the browser reads differently is a rejection the user cannot get past. Co-Authored-By: Claude Opus 5 <[email protected]> --- .../struts2/components/EcmaScriptSafeRegex.java | 97 ++++++++++++++++++++++ .../components/EcmaScriptSafeRegexTest.java | 75 +++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java b/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java new file mode 100644 index 000000000..523db7eb1 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java @@ -0,0 +1,97 @@ +/* + * 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. */ + private static final String ALLOWED_ESCAPES = "dDwWsSbBnrtf\\.*+?()[]{}|^$/-"; + + private EcmaScriptSafeRegex() { + } + + public static boolean isSafe(String regex) { + if (regex == null || regex.isEmpty()) { + return false; + } + boolean inCharClass = false; + for (int i = 0; i < regex.length(); i++) { + char current = regex.charAt(i); + switch (current) { + case '\\': + if (i + 1 >= regex.length() || ALLOWED_ESCAPES.indexOf(regex.charAt(++i)) < 0) { + return false; + } + break; + case '[': + // Java allows nested classes and POSIX names; ECMAScript allows neither + if (inCharClass || regex.startsWith("[:", i)) { + return false; + } + inCharClass = true; + break; + case ']': + inCharClass = false; + break; + case '&': + // Java character-class intersection + if (inCharClass && i + 1 < regex.length() && regex.charAt(i + 1) == '&') { + return false; + } + break; + case '(': + // only non-capturing groups and lookahead are portable; named groups, + // lookbehind, atomic groups and inline flags are not + if (i + 1 < regex.length() && regex.charAt(i + 1) == '?') { + if (i + 2 >= regex.length()) { + return false; + } + char kind = regex.charAt(i + 2); + if (kind != ':' && kind != '=' && kind != '!') { + return false; + } + } + break; + case '*': + case '+': + case '?': + case '}': + // possessive quantifier + if (i + 1 < regex.length() && regex.charAt(i + 1) == '+') { + return false; + } + break; + default: + break; + } + } + return !inCharClass; + } +} diff --git a/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java new file mode 100644 index 000000000..ebb8058fa --- /dev/null +++ b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java @@ -0,0 +1,75 @@ +/* + * 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.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EcmaScriptSafeRegexTest { + + @Test + public void acceptsPortableConstructs() { + assertThat(EcmaScriptSafeRegex.isSafe("[a-z]+")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("\\d{3}-\\d{4}")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("(foo|bar)?baz")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("^\\w+@\\w+\\.\\w{2,6}$")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("(?:ab)+")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("a(?=b)")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("a(?!b)")).isTrue(); + } + + @Test + public void rejectsJavaOnlyEscapes() { + assertThat(EcmaScriptSafeRegex.isSafe("\\p{Alpha}+")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\A\\d+\\z")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\Qliteral\\E")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\h+")).isFalse(); + } + + @Test + public void rejectsPossessiveQuantifiers() { + assertThat(EcmaScriptSafeRegex.isSafe("\\d++")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("a*+")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("a?+")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("a{2,3}+")).isFalse(); + } + + @Test + public void rejectsNonPortableGroups() { + assertThat(EcmaScriptSafeRegex.isSafe("(?<name>a)")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("(?<=a)b")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("(?>a)")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("(?i)abc")).isFalse(); + } + + @Test + public void rejectsJavaCharacterClassFeatures() { + assertThat(EcmaScriptSafeRegex.isSafe("[[:alpha:]]")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("[a-z&&[^aeiou]]")).isFalse(); + } + + @Test + public void rejectsUnusableInput() { + assertThat(EcmaScriptSafeRegex.isSafe(null)).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("abc\\")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("[abc")).isFalse(); + } +}
