Unbounded recursion Fixed · merged Python · orchestration

A cycle in Prefect block references could hang the server

TargetPrefect (PrefectHQ)
ClassUnbounded recursion
Triggera cycle in block_document references
ReportedPrivately, before fix
FixedPR #21829 (merged)

Prefect stores reusable configuration as blocks. A block is persisted as a block_document, and one block can reference another, so the references between blocks form a graph. When Prefect reads a block, it resolves those references to assemble the full document.

We found that the resolver walked the reference graph recursively with no check that the graph was acyclic. Arrange two blocks to reference each other, or a block to reference itself, and reading it recurses without a base case, overflowing the stack or hanging the request.

Block A references block B, and block B references block A. Now read either one: the resolver follows A to B, B back to A, A to B, and never stops.

A graph walk with no cycle check

Assembling the full block document meant following each reference and resolving the block it pointed at, recursively. Nothing tracked which blocks had already been visited, and nothing bounded the depth:

# _construct_full_block_document, before the fix
def resolve(doc):
    for ref in doc.references:
        resolve(ref.document)   # no visited-set, no depth bound
    # a reference cycle recurses until the stack blows

Creating and linking blocks is an ordinary authenticated action through Prefect's API, and a cycle is just references arranged pathologically, in the simplest case a single self-reference. The graph is written by the user; the bad state is reached later, on the read path, when the server tries to expand it.

The fix

PR #21829 (merged) closes the hole with three independent layers, so no single missed check reopens it:

The result is a consistent HTTP 400 on both the POST and PATCH paths when a cycle is detected, and a guaranteed-terminating read even in the worst case.

Why this is the kind of bug RedMirror finds

Resolving references is a walk over a graph the user gets to shape, and the invariant that matters is simply that the walk terminates. A recursive resolver with no visited-set assumes the graph is a tree; the moment a user can introduce a back-edge, that assumption is a denial of service. RedMirror looks for a reachable input, here a set of references, that drives a walk into non-termination, and reports the configuration that triggers it. The fix is textbook defense in depth: make the cycle impossible to write, impossible to store, and harmless to read.

Find the recursions that assume a tree

Anywhere your code walks a user-shaped graph, reference resolution, nested config, dependency expansion, a cycle can turn a normal read into a hang. RedMirror finds the input that makes the walk run forever and reports it as a concrete path.

Start an audit Read the docs