Django · Regular expression denial of service (ReDoS)

Regular expression denial of service (ReDoS) in Django

Django runs developer-written regular expressions against attacker-chosen strings on every request, in URL routing and in validation, so one pattern with nested repetition can let a single request hold a worker for seconds.

Class
Regular expression denial of service (ReDoS) in Python
CWE
CWE-1333
web framework
Django documentation
On this page

How it arises in Django

re_path() routes. The route argument of re_path() is a regular expression compatible with Python's re module, matched against the request path. The path is the most attacker-controlled string there is: no login, no CSRF token, no form. A route such as ^docs/(?P<slug>([\w-]+/?)+)$ looks reasonable and backtracks exponentially on a long run of word characters followed by a character that cannot match.

RegexValidator. RegexValidator(regex=...) accepts a pattern string or a pre-compiled expression and runs it on the submitted value. Validators for SKUs, phone numbers and usernames are written once and never timed. URLValidator and EmailValidator are regex-based too; Django's security team has shipped fixes for potential regular expression denial of service in them and in text utilities such as Truncator.words(), which is a good reason to stay on a patched release.

User-supplied patterns. Admin search boxes and "advanced filter" features that pass a user's text to re.search() or re.compile(). Here the attacker writes the pattern, so no review of your own patterns helps.

Length is the multiplier. Django limits request body size through DATA_UPLOAD_MAX_MEMORY_SIZE, but a path or a field of a few thousand characters is more than enough for a quadratic or exponential pattern.

Synchronous workers. A typical deployment has a small fixed number of worker processes. Four slow requests against four workers is an outage.

Vulnerable and fixed

import re

from django.core.validators import RegexValidator
from django.http import JsonResponse
from django.urls import re_path

sku_validator = RegexValidator(r"^([A-Z0-9]+-?)+$")


def search(request):
    pattern = re.compile(request.GET["q"])
    names = ["alpha", "beta", "gamma"]
    return JsonResponse({"hits": [n for n in names if pattern.search(n)]})


urlpatterns = [
    re_path(r"^docs/(?P<slug>([\w-]+/?)+)$", search),
]

Remove the nested quantifier, bound the lengths, and treat the user's search text as a literal with re.escape(). Where a route does not need a regular expression, a path() converter avoids the engine's backtracking altogether:

import re

from django.core.validators import RegexValidator
from django.http import JsonResponse
from django.urls import path

sku_validator = RegexValidator(r"^[A-Z0-9]{1,12}(-[A-Z0-9]{1,12}){0,5}$")


def search(request, slug=""):
    needle = request.GET.get("q", "")[:64]
    pattern = re.compile(re.escape(needle))
    names = ["alpha", "beta", "gamma"]
    return JsonResponse({"hits": [n for n in names if pattern.search(n)]})


urlpatterns = [
    path("docs/<path:slug>", search),
]

What Django already does for you

path() with converters is the default routing API and covers most routes without hand-written expressions. Form and model fields have max_length, which caps the input before a validator sees it. Upload and field-count limits bound the request as a whole. And the project treats ReDoS in its own code as a security issue with advisories and patch releases.

What es-python adds

Static checks reason about the shape of a pattern. es-python measures what the engine did. A redos event fires only when a match actually backtracked catastrophically over untrusted input: es-python counts the engine's steps and reports when the count passes both an absolute threshold and a steps-per-character threshold. The second threshold is what separates genuine super-linear backtracking from an ordinary scan of a long string. So a redos event names a real pattern, a real input and the place in your project that ran them together.

This suits Django particularly well, because Django performs a great many harmless regex matches on request data. The Low noise template drops the routine re match and re search events from routing and form validation, and keeps redos visible. You get the one route or validator that blew up, not thousands of rows.

Two reading notes. The redos impact tag always travels with a regex bypass tag, because a regex event can also mean a filter pattern that an attacker relaxed; if the call site validates input, consider that reading first. For the user-supplied pattern case, re compile fires when request text becomes the pattern itself. And one limit: a pattern whose blow-up lives entirely inside a single bounded repeat is not counted, so redos covers the common nested-quantifier shape, not every possible one.

Under a Raise rule, the match is aborted with a RuntimeError, which frees the worker.

Review checklist

  1. Search for re_path( and replace with path() where a converter is enough.
  2. Look for a quantifier applied to a group that itself contains a quantifier.
  3. Add max_length to every field with a RegexValidator.
  4. Never compile request text as a pattern; use re.escape().
  5. Load-test validators with long near-matching inputs.

References

See it on your own Django code

es-python runs your application unchanged and reports when request data reaches the operation, with where the data came from and the line that used it. A live demo walks through it on a real application.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo