This is an automated email from the ASF dual-hosted git repository.
smarthi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp.git
The following commit(s) were added to refs/heads/main by this push:
new 8f7befe44 OPENNLP-1922: Fix ReDoS / stack exhaustion in
RegexNameFinderFactory EMAIL and URL patterns (#1250)
8f7befe44 is described below
commit 8f7befe4439530eda66221b1f4a927a00fd91a20
Author: Suneel Marthi <[email protected]>
AuthorDate: Sun Aug 30 09:11:08 2026 -0400
OPENNLP-1922: Fix ReDoS / stack exhaustion in RegexNameFinderFactory EMAIL
and URL patterns (#1250)
* Fix ReDoS / stack exhaustion in RegexNameFinderFactory EMAIL and URL
patterns
The built-in EMAIL and URL regexes in DEFAULT_REGEX_NAME_FINDER were
vulnerable to catastrophic backtracking and deep recursion
(CWE-1333 / CWE-400, and CWE-674 for URL). RegexNameFinder feeds the
concatenated token string straight into Matcher.find(), so a single
crafted long token drives the matcher into quadratic backtracking
(EMAIL) or a StackOverflowError (URL).
- EMAIL: bounded, possessive quantifiers with RFC 5321 length limits;
drop the malformed host:garbage sub-branch of the IP-literal
alternative. Quoted-string local part is retained.
- URL: flatten the nested (((/(...)+)+|/)+ ...) and (...(&...)*)*
groups to single-level possessive repetition with segment caps.
Also fixes two latent bugs: unescaped dot in "www." and the
"%[a-f\d{2}]" character class that never matched a percent octet.
- Capturing groups made non-capturing (unused by RegexNameFinder).
Adds a regression test asserting the built-in finders complete
promptly on 100k-char attack inputs.
* Fix ReDoS / stack exhaustion in RegexNameFinderFactory EMAIL and URL
patterns
The built-in EMAIL and URL regexes in DEFAULT_REGEX_NAME_FINDER were
vulnerable to catastrophic backtracking and deep recursion
(CWE-1333 / CWE-400, and CWE-674 for URL). RegexNameFinder feeds the
concatenated token string straight into Matcher.find(), so a single
crafted long token drives the matcher into quadratic backtracking
(EMAIL) or a StackOverflowError (URL).
- EMAIL: bounded, possessive quantifiers with RFC 5321 length limits;
drop the malformed host:garbage sub-branch of the IP-literal
alternative. Quoted-string local part is retained.
- URL: flatten the nested (((/(...)+)+|/)+ ...) and (...(&...)*)*
groups to single-level possessive repetition with segment caps.
Also fixes two latent bugs: unescaped dot in "www." and the
"%[a-f\d{2}]" character class that never matched a percent octet.
- Capturing groups made non-capturing (unused by RegexNameFinder).
Adds a regression test asserting the built-in finders complete
promptly on 100k-char attack inputs.
# Conflicts:
#
opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/namefind/RegexNameFinderFactoryTest.java
* Fix ReDoS / stack exhaustion in RegexNameFinderFactory EMAIL and URL
patterns
The built-in EMAIL and URL regexes in DEFAULT_REGEX_NAME_FINDER were
vulnerable to catastrophic backtracking and deep recursion
(CWE-1333 / CWE-400, and CWE-674 for URL). RegexNameFinder feeds the
concatenated token string straight into Matcher.find(), so a single
crafted long token drives the matcher into quadratic backtracking
(EMAIL) or a StackOverflowError (URL).
Both patterns are rewritten so that every quantifier is bounded by a
constant. Bounds alone remove the exponential backtracking and cap the
recursion depth; the repetitions stay bounded ({0,255} / {1,63}) rather
than becoming * / + so that the trailing \b can still backtrack into a
matched run (a plain * reintroduces the overflow on a long path segment).
- EMAIL: bounded quantifiers with RFC 5321 length limits; drop the
malformed host:garbage sub-branch of the IP-literal alternative.
Quoted-string local part is retained.
- URL: flatten the nested (((/(...)+)+|/)+ ...) and (...(&...)*)* groups
to single-level bounded repetition with segment caps. Also fixes two
latent bugs: unescaped dot in "www." and the "%[a-f\d{2}]" character
class that never matched a percent octet. Capturing groups made
non-capturing (unused by RegexNameFinder).
Adds regression tests: the built-in finders complete promptly on
100k-char attack inputs (incl. the reported ?a&a&a&... query string),
and the URL finder still matches URLs with a trailing slash, sentence
period, '#' or '&'.
* Apply suggestion from @rzo1
Co-authored-by: Richard Zowalla <[email protected]>
* Apply suggestion from @rzo1
Co-authored-by: Richard Zowalla <[email protected]>
---------
Co-authored-by: Richard Zowalla <[email protected]>
---
.../tools/namefind/RegexNameFinderFactory.java | 39 +++++----
.../tools/namefind/RegexNameFinderFactoryTest.java | 94 ++++++++++++++++++++++
2 files changed, 118 insertions(+), 15 deletions(-)
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/namefind/RegexNameFinderFactory.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/namefind/RegexNameFinderFactory.java
index 7c2bc79ec..0890ae051 100644
---
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/namefind/RegexNameFinderFactory.java
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/namefind/RegexNameFinderFactory.java
@@ -107,12 +107,18 @@ public class RegexNameFinderFactory {
@Override
public Map<String, Pattern[]> getRegexMap() {
Pattern[] p = new Pattern[1];
- p[0] =
Pattern.compile("([a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*"
+
-
"|\"([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09"
+
-
"\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9]([a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]"
+
-
"*[a-z0-9])?|\\[((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]"
+
-
"?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]"
+
- "|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])",
Pattern.CASE_INSENSITIVE);
+ // Every quantifier is bounded by a constant, which removes both the
exponential
+ // backtracking and the recursion depth that made the old pattern a
ReDoS vector.
+ // Limits follow RFC 5321: local part <= 64 chars, each domain label
<= 63 chars.
+ p[0] = Pattern.compile(
+ "(?<![a-z0-9!#$%&'*+/=?^_`{|}~.-])" +
+
"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]{1,64}(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]{1,64}){0,10}"
+
+
"|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]" +
+ "|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f]){0,255}\")" +
+ "@(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.){1,20}" +
+ "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" +
+ "|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}" +
+ "(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\])",
Pattern.CASE_INSENSITIVE);
Map<String, Pattern[]> regexMap = new HashMap<>();
regexMap.put(getType(), p);
return regexMap;
@@ -127,16 +133,19 @@ public class RegexNameFinderFactory {
@Override
public Map<String, Pattern[]> getRegexMap() {
Pattern[] p = new Pattern[1];
- p[0] = Pattern.compile("\\b(((ht|f)tp(s?)\\:\\/\\/|~\\/|\\/)|www.)"
- + "(\\w+:\\w+@)?(([-\\w]+\\.)+(com|org|net|gov"
+ // Flattened to single-level groups and every quantifier bounded by a
constant.
+ // The bounds alone remove the nested-quantifier backtracking and the
recursion
+ // depth that caused the StackOverflowError; they must stay bounded
({0,255} /
+ // {1,63} rather than * / +) or a long path segment reintroduces the
overflow.
+ p[0] = Pattern.compile("\\b(?:(?:ht|f)tps?://|~/|/|www\\.)"
+ + "(?:\\w{1,63}:\\w{1,63}@)?"
+ + "(?:[-\\w]{1,63}\\.){1,20}(?:com|org|net|gov"
+ "|mil|biz|info|mobi|name|aero|jobs|museum"
- + "|travel|[a-z]{2}))(:[\\d]{1,5})?"
- + "(((\\/([-\\w~!$+|.,=]|%[a-f\\d]{2})+)+|\\/)+|\\?|#)?"
- + "((\\?([-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
- + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)"
- + "(&(?:[-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
- + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)*)*"
- + "(#([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)?\\b",
Pattern.CASE_INSENSITIVE);
+ + "|travel|[a-z]{2})(?::\\d{1,5})?"
+ + "(?:/(?:[-\\w~!$+|.,=]|%[a-f\\d]{2}){0,255}){0,50}"
+ + "(?:\\?(?:[-\\w~!$+|.,*:=]|%[a-f\\d]{2}){0,255}"
+ + "(?:&(?:[-\\w~!$+|.,*:=]|%[a-f\\d]{2}){0,255}){0,50})?"
+ + "(?:#(?:[-\\w~!$+|.,*:=]|%[a-f\\d]{2}){0,255})?\\b",
Pattern.CASE_INSENSITIVE);
Map<String, Pattern[]> regexMap = new HashMap<>();
regexMap.put(getType(), p);
return regexMap;
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/namefind/RegexNameFinderFactoryTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/namefind/RegexNameFinderFactoryTest.java
index 809429925..6fdea6414 100644
---
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/namefind/RegexNameFinderFactoryTest.java
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/namefind/RegexNameFinderFactoryTest.java
@@ -17,6 +17,7 @@
package opennlp.tools.namefind;
+import java.time.Duration;
import java.util.Arrays;
import java.util.List;
@@ -90,6 +91,99 @@ public class RegexNameFinderFactoryTest {
Assertions.assertEquals("45", tokens[latLongSpan2.getStart()]);
}
+ /**
+ * Crafted inputs that used to drive the built-in EMAIL and URL patterns into
+ * catastrophic backtracking / deep recursion (ReDoS, CWE-1333 / CWE-400 /
CWE-674).
+ * The hardened patterns must finish quickly regardless of input length.
+ */
+ @Test
+ void testBuiltinPatternsAreNotVulnerableToReDoS() {
+ final String emailLocalBlowup = "a".repeat(100_000) + "@ ";
+ final String emailDomainBlowup = "x@a" + "-a".repeat(60_000) + " ";
+ final String urlPathRecursion = "http://a.com/" + "a".repeat(100_000) + "
";
+ final String urlNestedBlowup = "http://a.com" + "/a".repeat(50_000) + "%z";
+ // The reported StackOverflowError repro: a long ?a&a&a&... query string,
which
+ // drove the nested (&(...)+ ... )* group in the old URL pattern into deep
recursion.
+ final String urlQueryRecursion = "http://a.com/p?a" + "&a".repeat(50_000)
+ "= ";
+
+ Assertions.assertTimeoutPreemptively(Duration.ofSeconds(2), () -> {
+ for (String attack : new String[] {
+ emailLocalBlowup, emailDomainBlowup, urlPathRecursion,
urlNestedBlowup, urlQueryRecursion}) {
+ String[] tokens = WhitespaceTokenizer.INSTANCE.tokenize(attack);
+ regexNameFinder.find(tokens);
+ }
+ });
+ }
+
+ /**
+ * Regression tests for the hardened built-in patterns: a trailing slash or a
+ * sentence-final period must not cause the match to be abandoned entirely.
+ */
+ @Test
+ void testUrlWithTrailingSlashOrSentencePunctuation() {
+ RegexNameFinder urlFinder =
RegexNameFinderFactory.getDefaultRegexNameFinders(
+ RegexNameFinderFactory.DEFAULT_REGEX_NAME_FINDER.URL);
+ final String input = "see http://a.com/ or http://example.com/path/ or
www.google.com.";
+ Span[] spans = urlFinder.find(input);
+ Assertions.assertEquals(3, spans.length);
+ Assertions.assertEquals("http://a.com",
+ input.substring(spans[0].getStart(), spans[0].getEnd()));
+ Assertions.assertEquals("http://example.com/path",
+ input.substring(spans[1].getStart(), spans[1].getEnd()));
+ Assertions.assertEquals("www.google.com",
+ input.substring(spans[2].getStart(), spans[2].getEnd()));
+ }
+
+ @Test
+ void testUrlKeepsPortWhenFollowedBySlash() {
+ RegexNameFinder urlFinder =
RegexNameFinderFactory.getDefaultRegexNameFinders(
+ RegexNameFinderFactory.DEFAULT_REGEX_NAME_FINDER.URL);
+ final String input = "http://example.com:8080/";
+ Span[] spans = urlFinder.find(input);
+ Assertions.assertEquals(1, spans.length);
+ Assertions.assertEquals("http://example.com:8080",
+ input.substring(spans[0].getStart(), spans[0].getEnd()));
+ }
+
+ @Test
+ void testEmailAtEndOfSentence() {
+ RegexNameFinder emailFinder =
RegexNameFinderFactory.getDefaultRegexNameFinders(
+ RegexNameFinderFactory.DEFAULT_REGEX_NAME_FINDER.EMAIL);
+ final String input = "mail me at [email protected]. and [email protected].";
+ Span[] spans = emailFinder.find(input);
+ Assertions.assertEquals(2, spans.length);
+ Assertions.assertEquals("[email protected]", input.substring(spans[0].getStart(),
spans[0].getEnd()));
+ Assertions.assertEquals("[email protected]",
+ input.substring(spans[1].getStart(), spans[1].getEnd()));
+ }
+
+ /**
+ * The hardened URL pattern must still match a URL that is followed by a
trailing
+ * delimiter (a path slash, a sentence-final period, {@code #} or {@code
&}), stopping
+ * the span before the delimiter rather than abandoning the match.
+ */
+ @Test
+ void testUrlTrailingDelimiters() {
+ assertFirstUrl("http://a.com", "http://a.com/");
+ assertFirstUrl("http://example.com/path", "http://example.com/path/");
+ assertFirstUrl("ftp://files.example.com/pub",
"ftp://files.example.com/pub/");
+ assertFirstUrl("www.google.com", "check www.google.com/ now");
+ assertFirstUrl("http://example.com", "I saw http://example.com. Then");
+ assertFirstUrl("www.google.com", "www.google.com.");
+ assertFirstUrl("http://a.com/path", "http://a.com/path.");
+ assertFirstUrl("http://a.com", "http://a.com/#");
+ assertFirstUrl("http://example.com:8080", "http://example.com:8080/");
+ assertFirstUrl("http://a.com/p?q=1", "http://a.com/p?q=1&");
+ }
+
+ private static void assertFirstUrl(String expected, String input) {
+ RegexNameFinder urlFinder =
RegexNameFinderFactory.getDefaultRegexNameFinders(
+ RegexNameFinderFactory.DEFAULT_REGEX_NAME_FINDER.URL);
+ Span[] spans = urlFinder.find(input);
+ Assertions.assertTrue(spans.length > 0, "no URL match for: " + input);
+ Assertions.assertEquals(expected, input.substring(spans[0].getStart(),
spans[0].getEnd()));
+ }
+
@Test
void testMgrs() {
String[] tokens = WhitespaceTokenizer.INSTANCE.tokenize(text);