Since the actix-web maintainers have protected themselves from the "neuroslop" I'm sharing the finding here.
========== Agent - generated content ========== AI assistance disclosure / credit: discovered and verified using rust-in-peace (https://github.com/scadastrangelove/rust-in-peace), an AI-agent-based Rust security review pipeline. Every cited line was re-read against the exact published crate source and the behavior below was reproduced through three independent dynamic reproductions. Re-tested 2026-08-10 with actix-web 4.14.1, actix-http 3.13.3, actix-server 2.7.0, and actix-multipart 0.8.0. Component: actix-multipart field stream parser (actix-multipart/src/field.rs, InnerField::read_stream) Affected: confirmed at actix-multipart 0.8.0 (current crates.io release as of 2026-08-10, verified against the exact package fetched by cargo. Re-tested against GitHub main at actix/actix-web@4d435abc; the vulnerable code paths are still present. Class: CWE-400 Uncontrolled Resource Consumption / CWE-667-adjacent stuck-future (remote, per-connection availability). Severity (self-assessed): Medium. Deterministic, single-request trigger (no timing/race precondition), remotely reachable on any route using the Multipart extractor, requires zero ongoing attacker resources after the initial packet. Estimated CVSS: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L ≈ 5.3 for a single exploitation ===Summary=== actix-multipart's InnerField::read_stream parses a multipart field body of unknown length by scanning for the next boundary. When the residual buffer looks like the start of a boundary marker (\r, optionally followed by \n--) but doesn't yet contain enough bytes to confirm or reject it, the code correctly returns Poll::Pending — that part is right, because normally more bytes are still coming. But if the transport has already reached EOF (payload.eof == true) and no more bytes are ever coming (e.g. the client sent a body truncated mid-boundary), the code still returns Poll::Pending indefinitely. Nothing will ever wake this task, because the underlying stream is already exhausted; a re-poll just re-observes the same exhausted stream and repeats eof = true without re-checking it in these branches. Two independent call sites in the same function have this defect. A client sends one ~70-byte truncated multipart request, closes the connection (even an immediate RST), and the server-side handler remains wedged until the worker process is restarted — along with the file descriptor and the connection-count slot it holds. The defective code (actix-multipart 0.8.0, src/field.rs) // field.rs:288-302 — read_stream entry. The len==0 branch gets this right: pub(crate) fn read_stream( payload: &mut PayloadBuffer, boundary: &str, ) -> Poll<Option<Result<Bytes, Error>>> { let mut pos = 0; let len = payload.buf.len(); if len == 0 { return if payload.eof { Poll::Ready(Some(Err(Error::Incomplete))) // <-- correctly handles EOF } else { Poll::Pending }; } // field.rs:305-323 — Instance 2: early boundary-prefix check if len > 4 && payload.buf[0] == b'\r' { let b_len = if payload.buf.starts_with(b"\r\n") && &payload.buf[2..4] == b"--" { Some(4) } else if &payload.buf[1..3] == b"--" { Some(3) } else { None }; if let Some(b_len) = b_len { let b_size = boundary.len() + b_len; if len < b_size { return Poll::Pending; // <-- BUG: no payload.eof check } else if &payload.buf[b_len..b_size] == boundary.as_bytes() { return Poll::Ready(None); } } } loop { return if let Some(idx) = memchr::memmem::find(&payload.buf[pos..], b"\r") { let cur = pos + idx; // field.rs:330-335 — Instance 1: main-loop boundary check if cur + 4 > len { if cur > 0 { Poll::Ready(Some(Ok(payload.buf.split_to(cur).freeze()))) } else { Poll::Pending // <-- BUG: no payload.eof check } } else { // ...boundary match / not-boundary handling, elided — unaffected by this bug... } } else { Poll::Ready(Some(Ok(payload.buf.split().freeze()))) }; } } Both Poll::Pending returns marked <-- BUG are missing the same check the len == 0 branch already has three lines above them: if payload.eof is true, there is no more data coming, so Pending must become Error::Incomplete instead. Which of the two sites fires depends only on how many bytes of the boundary prefix survive truncation relative to the configured boundary's length — both are independently reachable with an attacker-chosen boundary string, demonstrated separately below. ===Demonstration=== All three reproductions run against actix-multipart 0.8.0, the current crates.io release as of 2026-08-10 (verified against the exact source cargo fetches for that version, at registry/src/.../actix-multipart-0.8.0/src/field.rs — not merely the GitHub main branch, though the same vulnerable code is still present on GitHub main at actix/actix-web@4d435abc). Instance 1 — main-loop branch (field.rs:334) poc/instance1_cr_hang.rs: a one-field multipart body truncated to leave \r\n- (3 bytes) after the field data, fed through the public Multipart/Field API. first chunk = b"data\r\n" TIMEOUT: field.next() stayed pending after EOF on truncated CR-boundary Instance 2 — early boundary-check branch (field.rs:316-317), independently confirmed poc/instance2_early_boundary_hang.rs: uses a realistic 10-character boundary (abcdefghij) so the truncated suffix (\r\n--abc, 7 bytes) is shorter than boundary.len() + 4 (14) — landing in the different early-check branch, not the main loop. This rules out "instance 2 is just instance 1 restated": the trigger condition, the code path, and the buffer shape are all different. first chunk = b"DATA" TIMEOUT: field.next() stayed pending -- Instance 2 CONFIRMED (line 316-317, distinct from line 334) End-to-end, real HTTP stack, immediate hard disconnect poc/e2e_rst_hang/: a real actix-web 4.14.1 / actix-http 3.13.3 / actix-server 2.7.0 / actix-multipart 0.8.0 server (1 worker) with a POST /upload handler instrumented with entry/exit counters, driven over real TCP sockets (no mock transport). Re-tested 2026-08-10. Control: a well-formed multipart request → handler enters, processes the field, exits. 200 OK in hundreds of microseconds (latest retest: 421.917µs, then 165.25µs after the attack). Attack: a truncated (\r\n-) multipart request, then the client immediately closes the socket with SO_LINGER(0) — this sends a TCP RST, not a graceful FIN, the strongest "attacker is completely gone" signal the OS can produce. Measured wall-clock time from the last write() to the RST: 38.542 microseconds in the latest retest. Control again: a second well-formed request on a fresh connection succeeds normally — the server is not crashed or globally stalled; only the one wedged handler is affected. Observation window: the server's own instrumentation is polled every 2 seconds for 30 seconds after the RST. Every single sample reports the handler as still not exited. [attack] sending truncated multipart (body 73 bytes) [attack] sent RST (full close) at t=38.542µs ... [monitor] handlers: entered=3 exited=2 stuck=1 (repeated, unchanged, for the full 30s window) Independent OS-level confirmation. Running lsof against the live server process 18 seconds after the RST: COMMAND PID USER FD TYPE ... NAME actix-mp- 92854 serg 16u IPv4 ... TCP localhost:19998->localhost:64200 (CLOSED) The kernel has fully torn down the TCP connection (state CLOSED) — but the server process still holds the file descriptor open, because nothing in the wedged code path ever calls close() on it. This is a direct OS-level observation of the leak, independent of any assumption about Rust future or waker semantics. Independent source-level confirmation that the connection-count slot is lost, not just the fd. actix-server 2.7.0 accepts each connection by creating a WorkerCounterGuard — an RAII guard that decrements the server's max_connections counter on Drop — and moves that guard into the per-connection service future itself: // actix-server-2.7.0/src/worker.rs:695-699 let guard = this.counter.guard(); let _ = this.services[msg.token].service.call((guard, msg.io)).into_inner(); The guard is only dropped when that future completes. Our future never completes, so the guard is never dropped, so the connection-count slot (max_connections, default 25,600 per worker, worker.rs:256) is never freed — for the lifetime of the worker process. Why no existing timeout catches this Checked against actix-web 4.14.1's own defaults and actix-http 3.13.3's dispatcher source, not assumed: === Timeout Default === client_request_timeout 5000ms "reading client request head" only — already satisfied by the time our handler runs client_disconnect_timeout 1000ms connection-shutdown linger — traced in h1/dispatcher.rs: only consulted in the SendPayload/SendErrorPayload states, i.e. after a response has already been sent. Never reached while stuck in ServiceCall tls_handshake_timeout 3000ms TLS handshake only shutdown_timeout — graceful worker shutdown drain, unrelated to a single request The dispatcher also does not force-close on a bare peer disconnect while a service call is still in flight: h1_allow_half_closed defaults to true (actix-web 4.14.1 server.rs:127-129, actix-http 3.13.3 config.rs:196-207), and the READ_DISCONNECT-triggered shutdown path only fires when the dispatcher's state is None (no in-flight service call; h1/dispatcher.rs:1413-1425). While our handler is wedged, that condition never holds. actix_multipart::MultipartConfig, the crate's own extractor configuration, has exactly one knob — buffer_limit (bytes, default 64 KiB) — no timeout option. A ~70-byte truncated body never approaches it. ===Impact=== Cost asymmetry: one ~70-byte request plus an RST (38.542µs of attacker-side work in the latest retest, zero held attacker resources afterward) consumes one of the server's 25,600-per-worker connection slots, one file descriptor, and one task until the worker process restarts. This is cheaper than classic slowloris, which requires the attacker to keep a socket open for the duration. Reach: any route using the Multipart extractor. Not: a full-service crash (other connections continue to be served normally); not memory corruption; not a panic. Escalation path: the per-request cost to the attacker is small and constant, so exhausting a worker's full 25,600-slot budget is a matter of repeating the same ~70-byte request that many times, subject to deployment-level rate limits and file-descriptor ceilings. ===Suggested fix=== In both Poll::Pending return sites within read_stream (field.rs:316-317 and field.rs:334), check payload.eof first, mirroring the existing len == 0 branch three lines above: if payload.eof { return Poll::Ready(Some(Err(Error::Incomplete))); } return Poll::Pending; Happy to submit this as a PR once a preferred direction is confirmed. ===Disclosure=== Found and verified with rust-in-peace (https://github.com/scadastrangelove/rust-in-peace).
