You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@struts.apache.org by lu...@apache.org on 2026/08/25 04:33:23 UTC
(struts) 09/24: WW-5695 feat(components): add ECMAScript-safe regex detection
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 <lu...@apache.org>
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 <no...@anthropic.com>
---
.../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();
+ }
+}