Skip to content

fix(security): replace backtracking regex in ParametersUtils with a linear scanner (Fixes #1638) - #1654

Merged
nthmost-orkes merged 3 commits into
conductor-oss:mainfrom
Naman-Gururani:fix/parameters-utils-redos
Sep 24, 2026
Merged

nthmost-orkes merged 3 commits into
conductor-oss:mainfrom
Naman-Gururani:fix/parameters-utils-redos

Conversation

@Naman-Gururani

Copy link
Copy Markdown
Contributor

Pull Request type

  • Bugfix
  • Feature
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • WHOSUSING.md
  • Other (please describe):

NOTE: Please remember to run ./gradlew spotlessApply to fix any format violations.

Changes in this PR

ParametersUtils.PATTERN found ${...} expressions with nested lookaheads and backreferences, and it is applied to every string task parameter, including output returned by workers. Its matching time grows polynomially with the nesting depth: on my machine ${${...}} nested 500 deep takes ~2.4 s and nested 1000 deep (a ~3 KB string) ~37 s, per parameter.

This PR removes the pattern and finds the expressions with a single pass over the string (ParametersUtils.findExpressions): an expression starts at a ${ that is not preceded by $ and ends at its matching }, tracked with a brace-depth counter. replaceVariables iterates over the resulting ranges instead of regex matches; the substitution logic itself is unchanged.

Semantics are preserved, including $${ escaping, nested expressions being part of the outer range, and the existing behaviour that an unclosed ${ hides everything after it. To check this I ran the old pattern and the scanner over every string up to length 9 over the alphabet $ { } a, plus runs including newlines and brace-only strings up to length 12 (~2.6M inputs in total) with identical results.

Tests added to ParametersUtilsTest:

  • testFindExpressions: expected ranges for representative inputs.
  • testFindExpressionsMatchesLegacyPattern: differential test against the previous pattern (kept in the test only, as the reference for its semantics) on every string up to length 6 over $ { } a \n.
  • testReplaceIsNotVulnerableToReDoS: adversarial inputs through replace() (depth-1000 nesting, 100k unclosed braces, 50k expressions, 100k closing braces) under a 10 s timeout. It does not complete with the previous pattern.

./gradlew :conductor-core:test passes locally (972 tests), spotlessApply applied.

Issue #1638

Alternatives considered

  • A bounded, backtracking-free regex: ${...} with nesting is not a regular language, so a regex cannot match balanced braces without lookaheads/backreferences of the kind that caused the problem; only a depth-limited approximation would be possible, which would change behaviour for deeper nesting.
  • Rejecting or truncating suspicious inputs: would change behaviour for legitimate parameters and not address the root cause.

Naman-Gururani and others added 2 commits September 22, 2026 03:05
…inear scanner

The pattern used to find ${...} expressions relied on nested lookaheads and
backreferences. It is applied to every string task parameter, including task
output returned by workers, and its matching time grows polynomially with
the nesting depth: ${${...}} nested 1000 deep (~3 KB) takes over 30 seconds.

Find the expressions with a single pass that tracks the brace depth instead.
The semantics are unchanged, including $${ escaping, nested expressions and
an unclosed expression hiding the rest of the string; a test compares the
scanner with the previous pattern on every string of a small alphabet up to
length 6.

Fixes conductor-oss#1638

@nthmost-orkes nthmost-orkes 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.

Approving. Thanks for the thorough writeup and testing.

This should also result in a small performance boost for most scenarios -- adversarial was polynomial (now O(n)), and benign lookups have now improved from worse-than-linear to strictly O(n) as well.

One thing worth noting, now there's an extreme-depth stack limit that will throw StackOverflowError -- which is a strict upgrade over just burning a core to the ground :) but just thought it was worth noting here as an acknowledgement of behavior change that COULD be noticed.

@nthmost-orkes
nthmost-orkes merged commit e171f9d into conductor-oss:main Sep 24, 2026
9 checks passed
@Naman-Gururani

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Good call on the StackOverflowError: the nested-expression substitution recurses once per nesting level, so at those depths it now fails fast instead of hanging in the regex. Happy to add an explicit depth limit in a follow-up if you think it's worth it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants