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.
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.
PR #21829 (merged) closes the hole with three independent layers, so no single missed check reopens it:
create_block_document_reference walks the existing reference graph forward and refuses any new edge that would close a cycle, returning HTTP 400. The same check covers both creation and update paths.parent_block_document_id != reference_block_document_id)._max_depth=50) in _construct_full_block_document raises a clean error instead of recursing forever, catching anything the first two layers miss.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.
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.
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