Add an opt-in timeout parameter to re to mitigate catastrophic backtracking

There’s a suggestion, timeout parameter to the matching functions in the re module, as a mitigation for catastrophic backtracking (ReDoS).

The Problem

time. When the pattern or the input comes from outside the program, user-submitted search strings, scraped HTML, log lines, config values, a single pathological case can pin a CPU core for minutes or hours. And since the match runs in C while holding the GIL, every other thread in the process stalls with it: health checks stop responding, and the worker usually has to be killed externally.

This isn’t hypothetical. I work at Arbisoft, a software services company, and across several Python services we maintain that process user-supplied text, we’ve had production incidents where one crafted input froze an entire worker. The current workarounds are all unsatisfying in their own way:

  • “Just write safe patterns” works until a pattern is user-supplied, generated, or inherited from a dependency. Auditing every regex in a large codebase plus its dependencies is not realistic.

  • Running matches in a subprocess with a kill timer is the only reliable containment today, but it adds IPC overhead and operational complexity that feels disproportionate for calling a stdlib function.

  • signal.alarm is Unix-only, main-thread-only, and fragile.

  • Switching to the third-party regex module, which already supports a timeout argument, works, but many teams can’t add a C-extension dependency just to make re.search interruptible.

The Proposed Solution

Add an optional, keyword-only timeout parameter (seconds, float) to the module-level functions and Pattern methods that execute a match (match, search, fullmatch, findall, finditer, sub, subn, split):

import re

try:

    m = re.search(user_pattern, user_text, timeout=0.5)

except re.TimeoutError:

    log.warning("pattern exceeded time budget, rejecting")

  • Default is None, meaning current behavior, zero overhead, full backward compatibility.

  • On expiry, raise a new re.TimeoutError (subclassing the builtin TimeoutError) so callers can distinguish it from a non-match.

  • Implementation-wise, the engine already counts steps internally; the check could piggyback on existing periodic bookkeeping (e.g., test a deadline every N opcodes) so the no-timeout path pays nothing and the timeout path pays a near-constant cost.

Prior art

  • The third-party regex module has had a timeout parameter for years.

  • .NET’s Regex accepts a matchTimeout and throws RegexMatchTimeoutException; Microsoft added it specifically as a ReDoS mitigation.

  • Java doesn’t have one, and “how do I time out a Pattern match” is a perennial question there, which I’d argue is evidence for building it in rather than against.

Anticipated objections

“The real fix is a linear-time engine (RE2-style) or memoization.” Agreed that would be strictly better, but it’s a far larger project with API-compatibility questions (backreferences, lookarounds), and previous attempts to rewrite the engine haven’t landed. A timeout is a small, contained mitigation that doesn’t preclude that work later.

“Per-call deadline checks will slow down matching.” The check only needs to run when timeout is passed, and only every N steps. Happy to help benchmark this if there’s interest.

“Use the regex module.” That’s a fine answer for some projects, but re is the tool the vast majority of code reaches for, including stdlib modules and frameworks that accept user patterns. A safety valve in the default tool benefits the whole ecosystem.

I’d appreciate feedback on whether this seems worth pursuing, and especially on whether the deadline-check approach is feasible in sre without measurable cost to the default path.

Thanks for reading.

8 Likes

If someone doesn’t know about this problem, they won’t add the timeout. If they do, and they want the timeout, they can get the regex module. The stdlib re module is the right tool for many jobs, but not all of them.

2 Likes

The following is probably not acceptable for stdlib, but IMO it would be more useful to have a really high limit - say 10 seconds (*), dedicated BacktrackingError and opt-out.

(*) Alternatively, the limit could be internally set as a number of internal function calls.

Update: I had in mind a loose analogy with the recursion depth limit. It prevents catastrophic behavior, the default is fine for almost everybody and who needs a special setting is supposed to know what to do.

Seems like the wrong scope to add this to re. Something more generic along the lines of

with timeout(0.5):
    m = re.search(user_pattern, user_text)

where the timeout context manager raises an exception if it takes too long would be more appropriate. How this interacts with SIGSTOP etc is up to you.

2 Likes

For something completely generic like this, signal.alarm is probably the right choice. It wouldn’t be hard to make a context manager that sets an alarm and cancels it on exit.

Aside from alarm(), you’d probably have to spin off a thread, although there’s no guarantee that that will work (depending on what the parent thread is doing). And actually, just the act of raising an exception is not guaranteed to be able to work.

If you have the patience to do even a quick-and-dirty implementation, it will be easier for the core devs to see if the performance are really not degraded, and if it adds not so much complexity to the code.

But such a proposal was already rejected. So I suppose your chances are low.

This sort of reminds me of how ints have high though non infinite limits for going to str: (set via sys.set_int_max_str_digits(..)). If you want something higher you would set it otherwise you may get a ValueError at higher than that.

I could see some sort of default recursion, timeout, or other limit of some sort for regexes to prevent a DDOS in a similar vein.

I’m not sure how we’d chose those default limits but I see a bit of a parallel here between that and this.

3 Likes

This would still require cooperation from the C code doing the regex matching. Python signal handlers just set a flag and wait for the interpreter loop or something else to notice.

1 Like

I don’t think there’s any way around that.

The problem I see with such a generic solution is that it is quite brittle. The context manager with timeout could also be used to run any Python code. If the timeout fires, the cancellation (like triggered by a signal SIGALRM) could happen between any two bytecode instructions. So, if it was used in a generic way, it could leave any python object that is manipulated within the context manager in an invalid state (not keeping the invariants of the class). The Python docs explicitly warns about this problem. If it was restricted to the regex matching function, the semantics could be defined.

That would not work under Windows, see docs.

Correct, but this code exists and this is what allows you to abort your Python script also during Regex matches (like with Ctrl+C on Linux).

Not quite. Tim Peters asked for it to be discussed here first. This is what the OP did.

5 Likes

I think Tim’s concerns are fair. This isn’t likely to be something people will use by default. Instead, they’ll just use a regex, and when they have a problem with untrusted input, they will look for a solution. At that point, adding a dependency on the 3rd party regex module seems like a perfectly reasonable solution. Adding the capability into the stdlib gains little, and adds a bunch of relatively fragile code that’s difficult to test, while being viewed as a security mitigation. That’s quite a high maintenance cost for something that people can easily get with a simple extra dependency.

And of course all of this ignores the real answer here, which is that you should sanitise any untrusted input that you accept!

4 Likes

Of course, Tim’s concerns are fair.

That is perfectly correct, so I would 100% agree not to simply use an untrusted, user provided input as a regex-pattern. However, when it is about the data, I would say that regexes are commonly used as a tool to validate untrusted, user provided data. That is ok if the regex-pattern is carefully crafted (as Tim has pointed out in his response to the bug report). One key ingredient are possessive quantifiers as introduced in Python 3.11. However, getting it right seams still be so hard such that many people get it wrong.

All complexity issues related to backtracking can be fully avoided, if it was possible to easily deactivate backtracking entirely using a flag. As Tim pointed out here, backtracking is not actually required for many applications.

My concern is not to massively expand CPython’s regex engine, however it might make sense to provide and promote means that are safer than the status quo.

4 Likes

Or putting it another way: A regex should be considered code, not data.

I don’t know if a flag is quite what I would want for this, but I’d be 100% for the standard library having it’s own way to have non-backtracking regex. This would significantly help developers avoid accidental catastrophic backtracking unrelated to untrusted user input.

I still wouldn’t want to advertise this as “safe for user-provided patterns”[1], as for that there are many considerations beyond backtracking, and there are existing libraries that cover that niche in different ways depending on the use case.

In the interest of pragmatism, even if this is accepted, users with use cases where this comes up should be aware of better options that work now.

For dealing with user-supplied patterns, I’d suggest using vectorscan[2] if there are multiple patterns to attempt matching against any given input. For single regex, regex with a timeout, or re2 with a limit on pattern length if not.

I would be especially careful if both the pattern and the text to scan are untrusted, and have in the past handled a situation like this, combining vectorscan use with having the scanning running in a dedicated worker with cgroups enforced resource limits.

If it’s running on a user’s local machine and you just want to be able to notify them of a pattern’s potential complexity with a “are you sure”, or if you otherwise want to audit patterns in projects for reasonable complexity, there are pure Python libraries for analyzing this.


  1. There are certainly good reasons to allow user-defined patterns, but these need to come with safeguards in most applications that I’m not sure are fully appropriate for the standard library because they need to be “on by default” to protect users, but on by default would likely also break searches expected to take a long time that already exist. ReDOS tends to be something many developers aren’t aware of as an issue prior to being bitten by it. The best way to ensure users don’t get this wrong is likely to keep the advice simple: don’t allow user-provided patterns when using the standard library’s re module. ↩︎

  2. Python wheels with vectorscan library embedded available. ↩︎

2 Likes

I suspect that “non-backtracking regex” is likely to cause some confusion. What I’d rather see is a somewhat different parser minilanguage, thus preventing ambiguity. My personal preference would be sscanf-style tokens, but other notations (eg borrowing from string templating) could also work.

1 Like

I don’t know if that was intended as an objection to the idea, but if it is, it is not a compelling one. “A non-project wrecking solution for this exists outside of the stdlib” - could as well be used as an argument for removing `re` completely.

Hang on…

For the majority of use cases, regexes are just fine. If you write an over-complex regex with the potential for catastrophic behaviour, and feed it untrusted data, it could behave catastrophically. That’s hardly surprising, and assuming you take the view that regexes are code, you should probably simply not write over-complex regexes (just like you shouldn’t write over-complex code).

The issue here is that regexes are a pretty hostile programming language, and it’s easy for unsophisticated users to write code that is less safe than they think it is. That’s a genuine problem, and one we should be addressing. But the correct way (IMO) isn’t to add a subtle and complex feature (timeouts) to regexes that unsophisticated users simply won’t know they need, but rather to guide users towards other, better solutions than regexes for their parsing problems when the power of a regex isn’t needed (sscanf-style parsers, for example). That’s not “removing re completely”, but rather “having a sophisticated tool in the stdlib for people who need it, but also providing better tools for less demanding use cases”.

5 Likes

It’s an argument for keeping the re module simple and straight-forward. It is never going to have all the tools you might want when dealing with untrusted regexen, and there ARE alternatives on PyPI for that use-case. So I would be inclined to (a) keep re simple, acknowledging that a malicious regex can cause many problems; (b) possibly bless a specific alternative with a docs recommendation (though that’s a two-edged sword); and (c) introduce a new and much safer parser, much much simpler, with no backtracking and therefore a much less serious worst-case. Ideally, the new parser would match something else that is also found in the core language, which is why I recommend either sscanf or template strings.

(To clarify what I’m talking about, an sscanf-like string would be eg "%d%% %s" to match "47% Blue" or "%[0-9A-Fa-f]" to match hex characters, etc. And a template-like string would be eg "{percent:int}% {color}" (and thus could have keywords, not just positions), or "{:[0-9A-Fa-f]}".)

For applications that accept untrusted regexen and run them on trusted systems (this is rare - if you’re running the app on the user’s system, they can only hurt themselves), “get a hardened regex engine off PyPI” seems pretty reasonable to me.

6 Likes

Getting a BacktrackingError would be a quite clear message pointing any “unsophisticated” user to the right direction.

The catastrophic backtracking is considered a programming error, but bugs happen. It could exist unnoticed in a program for years. Maybe in a wide-spread program. An attacker (old-school or AI-powered) could find a “carefully crafted input” that will bring the CPU usage up to 100% practically forever and cause a DoS. Yes, it’s a worst-case scenario, but please consider this argument of adding a line of defense against such threats as well.

Sure, but as it’s opt-in, you don’t get the error unless you already know enough to set the option, and therefore you already know what the potential problem is…

And making it on by default would be a significant backward compatibility break, one that’s probably not going to happen.

1 Like