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();
+    }
+}

Reply via email to