Skip to content

Build1 publisher3 min readPublished

py-libp2p bounds a dev-only /sdp endpoint that believed whatever Content-Length it was told

A temporary signaling harness in the Python libp2p stack buffered exactly as many bytes as an unauthenticated caller claimed. The fix is three constants and an early rejection.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

Illustration accompanying py-libp2p bounds a dev-only /sdp endpoint that believed whatever Content-Length it was told
Generated illustration

What happened

  • py-libp2p is the Python implementation of libp2p, the peer-to-peer networking stack that underpins IPFS, Filecoin, and Ethereum-class nodes.
  • The WebRTC-Direct transport lets two peers connect without a certificate authority: the peer's multiaddr carries a hash of its TLS cert, and the DTLS handshake is verified against it.
  • Until the STUN-based listener lands (#1352), py-libp2p ships a minimal dev harness for SDP exchange: a tiny hand-rolled HTTP server with no aiohttp dependency that accepts an SDP offer over POST /sdp and hands the body to an offer handler.
  • The POST /sdp handler read the caller's Content-Length and buffered exactly that many bytes, with no upper bound, before the handshake ever happened.
  • reader.readexactly(content_length) accumulates that many bytes into memory and bypasses the StreamReader's default 64 KiB flow-control limit, so nothing throttles it.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A merged patch to py-libp2p has put size bounds on a development-only HTTP endpoint that previously read exactly as many body bytes as an unauthenticated caller declared in its `Content-Length` header, with no ceiling and before any handshake [4][12]. py-libp2p is the Python implementation of libp2p, the peer-to-peer stack that underpins IPFS, Filecoin and Ethereum-class nodes, which is why a throwaway harness in it is worth more than a style note [1].

The context is WebRTC-Direct, a transport that lets two peers connect without a certificate authority: the peer's multiaddr carries a hash of its TLS certificate and the DTLS handshake is checked against that hash [2]. Before any of that encryption exists, the two sides still have to exchange SDP offer and answer blobs. Until a STUN-based listener lands, py-libp2p ships a minimal stand-in for that exchange: a hand-rolled HTTP server with no aiohttp dependency, accepting an offer over `POST /sdp` and passing the body to an offer handler [3].

Two inputs on that path were attacker-controlled and uncounted. The body length came straight from the request header, and `readexactly(content_length)` accumulates that many bytes while bypassing the `StreamReader` default 64 KiB flow-control limit, so nothing upstream throttled the read [5]. According to the author's write-up, `body.decode()` then makes a second copy and the handler string a third, for roughly 2x amplification of the bytes on the wire with no upper bound [6]. Separately, the header loop was a `while True` with only a per-line timeout, so a caller could keep sending header lines indefinitely without the loop ever counting them [7]. All of this sits ahead of the handshake, which means anyone who can reach the port can drive it [8].

The provenance is the useful part. A reviewer had already flagged the shape of this inside the author's own WebRTC pull request as a one-line comment about reading the whole body into memory with no cap and the resulting availability risk [9]. That is the entire exploit, written as a nit.

The fix rejects before any body buffer exists: malformed or negative `Content-Length` returns 400, oversized returns 413 [15]. Headers are bounded by both line count and cumulative bytes, with a `for/else` returning 400 if the terminator never arrives [14]. The three new constants are a 32 KiB body cap, 64 header lines, and 8 KiB total across all header lines, with the code noting that real SDP offers run 1 to 4 KiB [13]. That leaves about 8x headroom over the largest expected offer [17], and sets the cap at half the `StreamReader` limit that `readexactly` was skipping [18]. Carried through the stated 2x copy factor, peak allocation per request lands near 64 KiB instead of unbounded [19].

The measurement discipline is worth copying: the author reports building a harness that imports the real `run_signaling_server` at both the pre-fix parent commit and the merged fix, fires the malicious request and samples RSS on a 20 ms timer [10]. The figures come from a Python 3.11 and aiortc 1.15 sandbox on a single event loop, and the author says to re-run locally for real numbers while the roughly 2x ratio holds [11].

Watch whether the STUN-based listener actually lands and deletes this harness, or whether the bounded version becomes the thing everyone runs in production. Also watch the review habit: this was caught as a drive-by comment in an unrelated pull request [9], which is a weak channel for pre-handshake code. The disclosure itself came via a bug-hunting contest submission rather than a security process [16].

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories