Skip to content

actix-ws: aggregate_continuations() should ignore empty continuation frames #651

@shblue21

Description

@shblue21

Expected Behavior

When MessageStream::aggregate_continuations() is used, empty continuation frames (0-byte payload) should not be
stored in the aggregation buffer. A remote client should not be able to cause unbounded memory growth (DoS) by sending
an unbounded stream of empty continuations.

Current Behavior

When MessageStream::aggregate_continuations() is used, AggregatedMessageStream stores each continuation payload in
continuations: Vec<Bytes>, while the size check is based on summing bytes.len() into current_size.

Since 0-byte continuation frames are accepted and passed through as empty Bytes, a client can send an unbounded
stream of empty continuation frames (FIN=0) without increasing current_size. As a result, max_continuation_size is
never hit and the continuations Vec grows until the connection closes.

This happens even though the total aggregated payload size remains 0 bytes: each empty continuation still results in a
Bytes being pushed into the Vec, so memory grows due to Vec element metadata and reallocations.

If the client never sends the terminating continuation frame (FIN=1 / Item::Last), the aggregated message is never
completed and continuations is never cleared, so growth continues for the lifetime of the connection.

Possible Solution

There are a few ways to address this (count empty frames toward the size limit, add a fragment/empty-frame limit, add
an assembly timeout, etc.).

To avoid changing the public API, I suggest the smallest change first: do not store 0-byte continuation payloads in the aggregation buffer, since they add no message content.

  • actix-ws/src/aggregated.rs:120-176 (AggregatedMessageStream::poll_next)
    • Apply to the this.continuations.push(bytes) call in: Item::FirstText, Item::FirstBinary, Item::Continue,
      Item::Last
  Item::FirstText(bytes) => {
      this.continuation_kind = ContinuationKind::Text;
      this.current_size += bytes.len();

      if this.current_size > this.max_size {
          this.continuations.clear();
          return size_error();
      }

     // Empty continuation frames add no content.
      if !bytes.is_empty() {
          this.continuations.push(bytes);
      }

      continue;
  }

This should prevent unbounded memory growth from empty continuations while keeping the aggregated message content the
same
(0-byte frames contribute no bytes). It does not address per-frame CPU overhead from extremely high frame counts.

Steps to Reproduce (for bugs)

  1. Run a minimal server that enables continuation aggregation (and keep the returned Session alive):

    async fn ws(req: HttpRequest, body: web::Payload) -> Result<HttpResponse, Error> {
        let (res, session, msg_stream) = actix_ws::handle(&req, body)?;
        let mut agg_stream = msg_stream.aggregate_continuations();
    
        actix_web::rt::spawn(async move {
            let _session = session; // keep connection alive
            while let Some(_item) = agg_stream.recv().await {}
        });
    
        Ok(res)
    }
  2. Connect using a custom WebSocket client (not a browser client).

  3. Send a fragmented message start frame with FIN=0 and payload length 0, then send an unbounded stream of
    continuation frames with FIN=0 and payload length 0, and never send the terminating continuation (FIN=1 /
    Last).

    Example PoC snippet (tungstenite / tokio-tungstenite):

    let (mut ws, _resp) = connect_async("ws://127.0.0.1:8080/ws").await?;
    
    let first = Frame::message(vec![b'A'], OpCode::Data(Data::Text), false);
    ws.send(Message::Frame(first)).await?;
    
    loop {
        let cont = Frame::message(Vec::new(), OpCode::Data(Data::Continue), false);
        ws.send(Message::Frame(cont)).await?;
    }
  4. Observe the server process memory increasing over time while the connection stays open.

Image

Context

This is remotely triggerable when an application enables MessageStream::aggregate_continuations(). A client that can
send raw WebSocket frames can keep a fragmented message open indefinitely (by never sending FIN=1 / Last) while
sending empty continuation frames that cause the server to grow memory over the lifetime of the connection. With enough
connections, this can lead to a DoS.

Browsers typically won’t generate continuation frames, but a custom client (or proxy/fuzzer) can.

The suggested fix is intentionally small and avoids public API changes: ignore empty continuation payloads when
building
the aggregation buffer. This prevents memory growth from empty frames (though very high frame counts can still cause
per-frame CPU overhead).

Your Environment

  • rustc 1.91.1
  • actix-web 4.12.1
  • actix-ws 0.3.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions