FastAPI · Regular expression denial of service (ReDoS)

Regular expression denial of service (ReDoS) in FastAPI

Patterns declared on FastAPI parameters are checked by an engine that does not backtrack, so ReDoS in a FastAPI service comes from the places that fall back to Python's re module, some of them without the author noticing.

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

How it arises in FastAPI

Query(pattern="^fixedquery$") and Field(pattern=...) are FastAPI's documented way to constrain a string. Validation is done by Pydantic, whose regex_engine setting defaults to rust-regex. Pydantic describes that engine as non-backtracking and therefore more DDoS resistant, with the trade-off that it does not support all regex features. For declared patterns, that default removes the class.

Backtracking returns through four doors.

regex_engine="python-re". Lookarounds and backreferences are not available in the default engine. A team that needs one sets the model's config to python-re, which Pydantic describes as supporting all regex features. Every pattern on that model now runs on the backtracking engine.

Compiled patterns. Pydantic notes that if you use a compiled regex pattern, the python-re engine is used regardless of the setting, so that flags such as re.IGNORECASE are respected. Writing pattern=re.compile(...) to get case-insensitivity silently changes engines.

Custom validators. @field_validator functions call re.match() or re.fullmatch() directly. These are ordinary Python and always backtrack.

Handler code. Tokenisers, log parsers, Markdown or mention extraction on request text, and search endpoints that accept a user's pattern.

One event loop. In an async def endpoint a catastrophic match does not just occupy a worker. It blocks the event loop, so every other in-flight request on that process stalls until the match finishes.

Vulnerable and fixed

import re

from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field, field_validator

app = FastAPI()
TAGS = re.compile(r"^(\w+\s?)+$")


class Post(BaseModel):
    model_config = ConfigDict(regex_engine="python-re")

    slug: str = Field(pattern=r"^([a-z0-9]+-?)+$")
    tags: str

    @field_validator("tags")
    @classmethod
    def check_tags(cls, value: str) -> str:
        if not TAGS.match(value):
            raise ValueError("bad tags")
        return value


@app.post("/posts")
async def create(post: Post):
    return {"slug": post.slug}

Keep the default engine, rewrite both patterns without a quantified group that contains a quantifier, and bound the lengths so the input size is fixed before any match:

import re

from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator

app = FastAPI()
TAG = re.compile(r"\w{1,32}")


class Post(BaseModel):
    slug: str = Field(max_length=80, pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$")
    tags: str = Field(max_length=200)

    @field_validator("tags")
    @classmethod
    def check_tags(cls, value: str) -> str:
        if not all(TAG.fullmatch(part) for part in value.split()):
            raise ValueError("bad tags")
        return value


@app.post("/posts")
async def create(post: Post):
    return {"slug": post.slug}

What FastAPI and Pydantic already do for you

The default regex engine is the main protection, and it applies to every pattern you declare on a parameter or field without any configuration. max_length and min_length sit beside pattern in the same call, so bounding input is one more keyword. Declarative constraints also appear in the OpenAPI schema, which keeps hand-written validators to a minimum. Pydantic documents the engine choice and the compiled-pattern exception clearly; the work is knowing which of your models have opted out.

What es-python adds

The engine switch is invisible at the call site: Field(pattern=...) reads the same whichever engine runs it. es-python reports from behaviour. When a match in Python's re engine backtracks catastrophically over data from the request, it records a redos event. The decision uses the engine's own step count, which must pass an absolute threshold and a steps-per-character threshold; the second one keeps a plain linear scan of a large body from being reported. Patterns checked by the non-backtracking default engine do not go through Python's re at all, so they produce no regex events.

In practice this sorts a codebase for you. Run the test suite or a fuzzing pass under es-python with the socket source on. A redos event on a model means that model fell back to python-re and has a dangerous pattern. A redos event in a validator or handler points at the re call. No events on the declared patterns is the expected result.

The Low noise template drops ordinary re match and re search events, which routing produces on every request, and leaves redos visible. The redos impact tag is always paired with regex bypass, since a regex on untrusted input may also be a filter being evaded. One limit to keep in mind: a blow-up contained entirely inside a single bounded repeat is not counted.

With a Raise rule on redos, the match is aborted with a RuntimeError. In an async service that matters more than usual, because it releases the event loop for everyone else.

Review checklist

  1. Search for regex_engine and for pattern=re.compile(.
  2. Search validators and handlers for direct re. calls on request data.
  3. Add max_length wherever there is a pattern.
  4. Rewrite (x+y?)+ shapes as x+(yx+)*.
  5. Move unavoidable heavy matching off the event loop and give it a timeout.

References

See it on your own FastAPI 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