Skip to content

fix: guard regex matching against catastrophic backtracking (ReDoS)#2012

Merged
gnodet merged 4 commits into
jline:masterfrom
gnodet:can-you-have-a-look-at-the-security-advisories
Jun 30, 2026
Merged

fix: guard regex matching against catastrophic backtracking (ReDoS)#2012
gnodet merged 4 commits into
jline:masterfrom
gnodet:can-you-have-a-look-at-the-security-advisories

Conversation

@gnodet

@gnodet gnodet commented Jun 30, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds SafeRegex utility class in the terminal module that wraps regex input in a TimeoutCharSequence to enforce a wall-clock deadline during matching, preventing denial-of-service via patterns with catastrophic backtracking (e.g. (a+)+b).
  • Fixes 8 locations across terminal, reader, and builtins modules where user-controlled input was compiled/matched as a Java regex without validation.
  • Removes the unsafe .* pattern wrapping in the built-in grep command, using Matcher.find() instead.
  • Properly quotes regex metacharacters in HISTORY_IGNORE glob patterns and the history -m flag.
  • Replaces incomplete manual regex escaping in matchPreviousCommand() with Pattern.quote().

Addresses: GHSA-r2xf-8xr9-62gw, GHSA-2v9w-34q6-wpqx, GHSA-ph9c-7hw9-vhhw, GHSA-5q95-hrpc-m3w3 and additional unreported locations.

Approach

Rather than adding a dependency on RE2/J, the fix uses a TimeoutCharSequence wrapper that checks System.nanoTime() every 1024 charAt() calls during regex matching. When the JDK regex engine backtracks catastrophically, the call count explodes and the deadline is hit within milliseconds. No extra threads or thread interruption needed.

Fixed locations

Module File Issue
terminal AttributedString.styleMatches() Search highlight rendering
builtins Less.java Search and display-filter matching
builtins Nano.java Regex search mode
builtins PosixCommands.java (grep) .* wrapping amplified backtracking
builtins Commands.java (history -m) Unescaped glob pattern
reader DefaultHistory.matchPatterns() HISTORY_IGNORE glob injection
reader LineReaderImpl.matches() Ctrl-R/Ctrl-S history search
reader LineReaderImpl.matchPreviousCommand() Incomplete manual regex escaping

Test plan

  • ./mvx mvn install -DskipTests — full build passes
  • ./mvx mvn test -pl terminal — 372 tests pass
  • ./mvx mvn test -pl reader — 210 tests pass
  • ./mvx mvn test -pl builtins — 1026 tests pass (23 pre-existing SwingTerminal/Fontconfig errors in headless env)

Summary by CodeRabbit

  • New Features

    • Safer pattern matching for history search, autosuggestions, and text highlighting, including improved glob (*) handling in relevant commands.
  • Bug Fixes

    • Reduced freezes and excessive slowdowns caused by complex regular expressions by adding timeout-aware matching.
    • Regex highlighting and match evaluation now fail gracefully on timeouts (including continued scanning where applicable), keeping the interface responsive.

Add SafeRegex utility that wraps input in a TimeoutCharSequence to
enforce a wall-clock deadline during regex matching, preventing
denial-of-service via patterns like (a+)+b.

Fixed locations:
- Less: search and display-filter pattern matching
- Nano: regex search mode
- grep: removed unsafe .* wrapping, use find() instead of matches()
- DefaultHistory: HISTORY_IGNORE glob patterns now properly quoted
- Commands: history -m glob pattern now uses SafeRegex.compileGlob()
- LineReaderImpl: Ctrl-R/Ctrl-S search and history suggestion matching
- AttributedString.styleMatches: search highlight rendering
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7cf911a9-9ec8-44aa-a1a2-67ac6747af03

📥 Commits

Reviewing files that changed from the base of the PR and between 20aec53 and 2b7deac.

📒 Files selected for processing (2)
  • builtins/src/main/java/org/jline/builtins/Nano.java
  • terminal/src/main/java/org/jline/utils/SafeRegex.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • terminal/src/main/java/org/jline/utils/SafeRegex.java
  • builtins/src/main/java/org/jline/builtins/Nano.java

📝 Walkthrough

Walkthrough

Adds SafeRegex and RegexTimeoutException, then updates regex matching in builtins and reader to use timeout-aware helpers and preserve partial results when matching times out.

Changes

SafeRegex ReDoS Protection

Layer / File(s) Summary
SafeRegex and RegexTimeoutException utilities
terminal/src/main/java/org/jline/utils/RegexTimeoutException.java, terminal/src/main/java/org/jline/utils/SafeRegex.java
New RegexTimeoutException unchecked exception and SafeRegex class with TimeoutCharSequence wrapper enforcing wall-clock deadlines; public API includes matcher, find, matches, and compileGlob overloads.
AttributedString.styleMatches timeout handling
terminal/src/main/java/org/jline/utils/AttributedString.java
styleMatches switches to SafeRegex.matcher and wraps both the initial and loop find() calls in try/catch for RegexTimeoutException, preserving partial styles on timeout.
LineReaderImpl and DefaultHistory safe matching
reader/src/main/java/org/jline/reader/impl/LineReaderImpl.java, reader/src/main/java/org/jline/reader/impl/history/DefaultHistory.java
History-search matches() and matchPreviousCommand() use SafeRegex.matcher with timeout catch; DefaultHistory.matchPatterns rebuilt with Pattern.quote and SafeRegex.matches.
Builtins commands safe regex migration
builtins/src/main/java/org/jline/builtins/Commands.java, builtins/src/main/java/org/jline/builtins/Less.java, builtins/src/main/java/org/jline/builtins/Nano.java, builtins/src/main/java/org/jline/builtins/PosixCommands.java
Commands, Less, Nano, and PosixCommands migrate regex calls to SafeRegex.find/matches/matcher/compileGlob with RegexTimeoutException handling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hop through patterns both wild and small,
With SafeRegex guarding each backtracking fall.
If time runs out, I still keep what I found,
And style the bright trails left on the ground.
No endless loops can trap my fuzzy feet —
Just safe little matches and a happier treat.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding safe regex handling to guard against catastrophic backtracking and ReDoS.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.44.0)
builtins/src/main/java/org/jline/builtins/Nano.java

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

- Refactor compileGlob to use a while loop instead of modifying the
  for-loop counter inside the body (S127)
- Remove unused constructor parameter by consolidating TimeoutCharSequence
  to a single constructor taking a pre-computed deadline (S1172)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
reader/src/main/java/org/jline/reader/impl/LineReaderImpl.java (1)

4406-4416: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wrap matcher.matches() in matchPreviousCommand() with RegexTimeoutException handling.
SafeRegex.matcher(...) can throw RegexTimeoutException, and this redisplay path should treat timeouts as a miss instead of bubbling out of getDisplayedBufferWithPrompts().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reader/src/main/java/org/jline/reader/impl/LineReaderImpl.java` around lines
4406 - 4416, Handle RegexTimeoutException in matchPreviousCommand() by wrapping
the SafeRegex.matcher(...)/matcher.matches() check so a regex timeout is treated
as a non-match instead of escaping into getDisplayedBufferWithPrompts(). Update
the logic around the History.Entry iteration in LineReaderImpl to catch the
timeout, skip that candidate, and continue searching just like a miss.
🧹 Nitpick comments (2)
terminal/src/main/java/org/jline/utils/SafeRegex.java (1)

119-131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid mutating the for loop counter inside the body.

Sonar flags Line 124. A while loop keeps the escape-consumption logic explicit without changing behavior.

♻️ Proposed refactor
-        for (int i = 0; i < globPattern.length(); i++) {
-            char ch = globPattern.charAt(i);
-            if (ch == '\\' && i + 1 < globPattern.length()) {
-                appendQuoted(sb, globPattern.charAt(++i));
+        int i = 0;
+        while (i < globPattern.length()) {
+            char ch = globPattern.charAt(i++);
+            if (ch == '\\' && i < globPattern.length()) {
+                appendQuoted(sb, globPattern.charAt(i++));
             } else if (ch == '*') {
                 sb.append(".*");
             } else {
                 appendQuoted(sb, ch);
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@terminal/src/main/java/org/jline/utils/SafeRegex.java` around lines 119 -
131, Refactor SafeRegex.compileGlob to avoid incrementing the for-loop index
inside the loop body when consuming an escape sequence. Replace the current
for-loop with an explicit while-loop (or equivalent) so the backslash handling
remains clear and behavior stays unchanged, keeping the logic in compileGlob and
appendQuoted intact.

Source: Linters/SAST tools

builtins/src/main/java/org/jline/builtins/Nano.java (1)

1344-1346: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use matcher indices for matchedLength. m.end() - m.start() gives the same span length without materializing group(0).

♻️ Proposed refactor
-                    matchedLength = m.group(0).length();
+                    matchedLength = m.end() - m.start();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@builtins/src/main/java/org/jline/builtins/Nano.java` around lines 1344 -
1346, Update the matching logic in Nano’s regex handling so matchedLength is
derived from the matcher positions instead of calling m.group(0).length(). In
the while (m.find()) loop, use m.end() - m.start() for matchedLength, keeping
the existing res.add(m.start()) behavior unchanged and preserving the
surrounding matcher flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@terminal/src/main/java/org/jline/utils/SafeRegex.java`:
- Around line 31-32: Update the Javadoc in SafeRegex, especially the
compileGlob() reference and the related documentation around
matcher/matches/find, to clearly state that compileGlob() only builds a safe
Pattern and does not itself enforce timeout protection. Mention that callers
must use SafeRegex.matcher/matches/find on the returned Pattern to get the
protection, and adjust the existing glob-style guidance accordingly so it cannot
be misread as safe to match directly.
- Around line 52-66: `LineReaderImpl.matchPreviousCommand` still uses
`SafeRegex.matcher(...)` and calls `matches()` without handling
`RegexTimeoutException`, so update that history-suggestion matching path to
catch the timeout and degrade safely. Mirror the existing handling used at the
other `SafeRegex.matcher` call sites in `LineReaderImpl`, either by wrapping the
match in a try/catch for `RegexTimeoutException` or by treating a timeout as no
suggestion so the interactive search does not fail with an uncaught runtime
exception.
- Around line 155-175: The timeout in SafeRegex.TimeoutCharSequence is currently
started in the constructor, so a Matcher created early can expire before
matching begins; move deadline initialization into the first charAt() access and
keep that deadline shared through subSequence() by reusing the same
TimeoutCharSequence state. Update the TimeoutCharSequence constructors and
charAt() logic so the deadline is lazily set on first use rather than at
construction time.

---

Outside diff comments:
In `@reader/src/main/java/org/jline/reader/impl/LineReaderImpl.java`:
- Around line 4406-4416: Handle RegexTimeoutException in matchPreviousCommand()
by wrapping the SafeRegex.matcher(...)/matcher.matches() check so a regex
timeout is treated as a non-match instead of escaping into
getDisplayedBufferWithPrompts(). Update the logic around the History.Entry
iteration in LineReaderImpl to catch the timeout, skip that candidate, and
continue searching just like a miss.

---

Nitpick comments:
In `@builtins/src/main/java/org/jline/builtins/Nano.java`:
- Around line 1344-1346: Update the matching logic in Nano’s regex handling so
matchedLength is derived from the matcher positions instead of calling
m.group(0).length(). In the while (m.find()) loop, use m.end() - m.start() for
matchedLength, keeping the existing res.add(m.start()) behavior unchanged and
preserving the surrounding matcher flow.

In `@terminal/src/main/java/org/jline/utils/SafeRegex.java`:
- Around line 119-131: Refactor SafeRegex.compileGlob to avoid incrementing the
for-loop index inside the loop body when consuming an escape sequence. Replace
the current for-loop with an explicit while-loop (or equivalent) so the
backslash handling remains clear and behavior stays unchanged, keeping the logic
in compileGlob and appendQuoted intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1bf96d9f-8623-44d9-a625-03414e05ef7d

📥 Commits

Reviewing files that changed from the base of the PR and between 23862e3 and 35aacce.

📒 Files selected for processing (9)
  • builtins/src/main/java/org/jline/builtins/Commands.java
  • builtins/src/main/java/org/jline/builtins/Less.java
  • builtins/src/main/java/org/jline/builtins/Nano.java
  • builtins/src/main/java/org/jline/builtins/PosixCommands.java
  • reader/src/main/java/org/jline/reader/impl/LineReaderImpl.java
  • reader/src/main/java/org/jline/reader/impl/history/DefaultHistory.java
  • terminal/src/main/java/org/jline/utils/AttributedString.java
  • terminal/src/main/java/org/jline/utils/RegexTimeoutException.java
  • terminal/src/main/java/org/jline/utils/SafeRegex.java

Comment thread terminal/src/main/java/org/jline/utils/SafeRegex.java Outdated
Comment thread terminal/src/main/java/org/jline/utils/SafeRegex.java
Comment thread terminal/src/main/java/org/jline/utils/SafeRegex.java
- Clarify compileGlob Javadoc: it only builds the pattern, timeout
  protection requires using SafeRegex.matcher/matches/find
- Lazy deadline in TimeoutCharSequence: start the timeout on first
  charAt() call, not at Matcher creation time, so the window is not
  consumed before matching begins; share deadline across subSequence()
- Catch RegexTimeoutException in matchPreviousCommand() so a timeout
  degrades to no-suggestion instead of crashing the display loop
- Use m.end() - m.start() in Nano doSearch instead of m.group(0).length()
  to avoid an unnecessary String allocation
@gnodet gnodet marked this pull request as ready for review June 30, 2026 16:47
@gnodet

gnodet commented Jun 30, 2026

Copy link
Copy Markdown
Member Author

Addressed all CodeRabbit findings in 20aec53:

Inline comments (replied above):

  • compileGlob() Javadoc clarified
  • matchPreviousCommand() catch for RegexTimeoutException added
  • ✅ Lazy deadline init in TimeoutCharSequence

Outside-diff comment:

  • matchPreviousCommand() in LineReaderImpl (line ~4406) — same fix as the inline comment; try-catch wraps the SafeRegex.matcher()/matches() call so a timeout degrades to empty suggestion

Nitpick comments:

  • Nano.doSearch()m.group(0).length() replaced with m.end() - m.start()
  • compileGlob loop structure — already a while loop (fixed in the earlier SonarCloud commit 7684b34)

@augmentcode

augmentcode Bot commented Jun 30, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: Hardens user-controlled regex usage across JLine to mitigate ReDoS (catastrophic backtracking) without introducing RE2/J.
Key change: Introduces SafeRegex + RegexTimeoutException in terminal to enforce a wall-clock deadline during regex matching via a CharSequence wrapper.
Terminal: Uses SafeRegex in AttributedString.styleMatches() so highlighting can time out gracefully.
Builtins: Updates less, nano, and grep to use timeout-protected matches/find and to degrade gracefully on timeouts (partial results / no highlighting).
Grep behavior: Removes .... wrapping and switches to Matcher.find() for non---line-regexp matching to avoid amplifying backtracking.
Reader/history: Switches Ctrl-R history search and previous-command matching to SafeRegex and Pattern.quote() to avoid regex injection.
History patterns: Treats HISTORY_IGNORE and history -m as glob-like patterns (* wildcard, </code> escape) and quotes other metacharacters.
Error handling: Call sites that need groups/loops catch RegexTimeoutException; boolean helpers return false on timeout.
Security: Addresses multiple GHSA reports and additional user-input regex call sites.
Compatibility: Keeps Java regex engine semantics while adding a safety budget rather than changing regex implementation.
Testing: PR description notes module test runs for terminal, reader, and builtins.
Notes: compileGlob() produces a quoted Pattern, but callers still must use SafeRegex matching APIs to get timeout protection.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

/**
* Create a {@link Matcher} that will throw {@link RegexTimeoutException}
* if matching exceeds the given timeout. The timeout starts on the
* first {@code charAt} call (i.e. when matching actually begins), not

@augmentcode augmentcode Bot Jun 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

terminal/.../SafeRegex.java:65-67 The Javadoc says the timeout starts on the first charAt call, but TimeoutCharSequence only initializes the deadline when calls % CHECK_INTERVAL == 0, so the effective start can be delayed by up to CHECK_INTERVAL calls.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the Javadoc was imprecise. The deadline is lazily initialized on the first check (every 1024 charAt calls), not on the very first charAt call. Fixed the wording in 2b7deac to say "first deadline check during matching" instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the Javadoc was imprecise. The deadline is lazily initialized on the first check (every 1024 charAt calls), not on the very first charAt call. Fixed the wording in 2b7deac to say "first deadline check during matching" instead.

matchedLength = m.end() - m.start();
}
} catch (RegexTimeoutException e) {
// Return whatever matches were found before timeout

@augmentcode augmentcode Bot Jun 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

builtins/.../Nano.java:1344-1351 If a RegexTimeoutException happens before any successful match, matchedLength can remain from a previous search even though res may be empty/partial, which can desync highlighting/selection state.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid point. doSearch() now resets matchedLength = 0 before the find loop (2b7deac), so a timeout before any successful match leaves a clean state. highlightEnd() checks matchedLength > 0, so a zero value correctly prevents stale highlighting.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid point. doSearch() now resets matchedLength = 0 before the find loop (2b7deac), so a timeout before any successful match leaves a clean state. highlightEnd() checks matchedLength > 0, so a zero value correctly prevents stale highlighting.

- SafeRegex.matcher() Javadoc now accurately says the timeout starts
  on the first deadline check, not the first charAt call.
- Nano.doSearch() resets matchedLength before the find loop so a
  RegexTimeoutException before any match doesn't leave a stale value
  that could desync highlighting.
@sonarqubecloud

Copy link
Copy Markdown

@gnodet gnodet added the bug label Jun 30, 2026
@gnodet gnodet merged commit 1d5fc30 into jline:master Jun 30, 2026
15 checks passed
@gnodet gnodet deleted the can-you-have-a-look-at-the-security-advisories branch June 30, 2026 18:01
gnodet added a commit that referenced this pull request Jun 30, 2026
Backport of #2012 to jline-3.x.

Add SafeRegex utility that wraps regex input in a TimeoutCharSequence
to enforce a wall-clock deadline during matching. Java's backtracking
NFA engine can take exponential time on patterns with nested quantifiers
(e.g. (a+)+b); the wrapper checks System.nanoTime() every 1024 charAt()
calls and throws RegexTimeoutException if the deadline is exceeded.

Fixed 8 locations where user-controlled input was matched as a regex
without protection: Less, Nano, grep, history -m, HISTORY_IGNORE,
Ctrl-R/Ctrl-S search, matchPreviousCommand, and styleMatches.

Addresses GHSA-r2xf-8xr9-62gw, GHSA-2v9w-34q6-wpqx,
GHSA-ph9c-7hw9-vhhw, GHSA-5q95-hrpc-m3w3.

Closes #2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant