CVE Patch Review

CVE-2026-32689 Phoenix LongPoll NDJSON DoS: Partial fix via client-side batch splitting

CVE-2026-32689 · GHSA-628H-Q48J-JR6Q · Updated 2026-05-09 Partial fix

Summary

The patch reduces LongPoll NDJSON amplification by splitting client-side batches into chunks of 100 messages and by replacing eager server-side line splitting with a lazy splitter plus early termination. However, the server-side batch limit is explicitly deferred and commented out, so unauthenticated attackers can still submit oversized newline-heavy request bodies directly to the endpoint. This is a mitigation, not a complete root-cause fix.

Analysis

Vulnerability

CVE-2026-32689 and GHSA-628H-Q48J-JR6Q describe a denial-of-service condition in Phoenix Framework LongPoll transport where an unauthenticated attacker can send a large NDJSON body composed primarily of newline delimiters to the LongPoll endpoint. The vulnerable server path in lib/phoenix/transports/long_poll.ex eagerly materialized the entire request into a list with String.split(...) and then mapped every element, which is especially pathological for delimiter-dense input because it creates a very large number of empty strings and intermediate list cells before dispatch.

The vulnerable logic shown in the patch source used full eager evaluation:

batch =
  body
  |> String.split(["\n", "\r\n"])
  |> Enum.map(fn
    "[" <> _ = txt -> {txt, :text}
    base64 -> {safe_decode64!(base64), :binary}
  end)

{conn, status} =
  Enum.reduce_while(batch, {conn, nil}, fn msg, {conn, _status} ->
    case transport_dispatch(endpoint, server_ref, msg, opts) do
      :ok -> {:cont, {conn, :ok}}
      :request_timeout = timeout -> {:halt, {conn, timeout}}
    end
  end)

That implementation is consistent with the reported memory exhaustion vector: a large body of newline characters causes excessive allocation during split/map processing before meaningful protocol handling occurs. The issue is reachable remotely because the endpoint accepts POSTed NDJSON and the advisory states no authentication is required. See the upstream commit 1a67c61ff9ce0a7711662ac7354861917a7c80f7, the CVE record, and the GitHub advisory.

Patch

The patch changes both the JavaScript client and the Elixir server implementation in the upstream commit 1a67c61ff9ce0a7711662ac7354861917a7c80f7.

On the client side, a new constant limits LongPoll batch size to 100 messages, and batchSend now slices and sends messages in chunks rather than joining an arbitrarily large queue into one NDJSON payload:

export const MAX_LONGPOLL_BATCH_SIZE = 100;

batchSend(messages, offset = 0){
  const next = offset + MAX_LONGPOLL_BATCH_SIZE
  const batch = messages.slice(offset, next)
  this.ajax("POST", {"Content-Type": "application/x-ndjson"}, batch.join("\n"), () => this.onerror("timeout"), resp => {
    ...
    } else if(next < messages.length){
      this.batchSend(messages, next)
    } else {
      this.awaitingBatchAck = false
    }
  })
}

The added tests verify coalescing behavior and confirm that 150 rapid sends are emitted as two ordered requests of 100 and 50 messages respectively. This is a sound compatibility-preserving change for official clients.

On the server side, the patch replaces eager String.split plus Enum.map with String.splitter and Enum.find, which makes parsing lazier and avoids constructing the full batch list up front:

status =
  body
  |> String.splitter(["\n", "\r\n"])
  |> Enum.find(fn part ->
    msg =
      case part do
        "[" <> _ = txt -> {txt, :text}
        base64 -> {safe_decode64!(base64), :binary}
      end

    transport_dispatch(endpoint, server_ref, msg, opts)
  end)

conn |> put_status(status || :ok) |> status_json()

However, the same patch also contains an explicit comment that server-side batch-size enforcement is not yet implemented:

# The maximum is 10MB but read_body will cap the whole request at ~8MB,
# so this acts as a secondary protection mechanism.
# TODO: enforce batch size on the server in the next release
# @max_poll_batch_size 100

That comment is important for review because it acknowledges the residual gap directly in the patched source.

Review

Pros

  • The server-side change removes the most obvious eager-allocation pattern. Replacing String.split with String.splitter materially reduces memory pressure for delimiter-heavy payloads by avoiding immediate full-list construction.
  • The reduction from Enum.reduce_while over a prebuilt batch to direct iteration over a lazy splitter is source-grounded and aligned with the reported failure mode.
  • The client-side cap of 100 messages per POST reduces the likelihood that official Phoenix JavaScript clients generate oversized NDJSON bodies under bursty send patterns.
  • The new tests are meaningful: they validate batching semantics, ordering, buffering during in-flight acknowledgements, and chunking behavior at the 100-message threshold.
  • The patch is low-risk for protocol behavior because it preserves message ordering and existing request/ack flow.

Cons

  • The fix does not enforce a server-side maximum batch count or line count. The patch itself says TODO: enforce batch size on the server in the next release, and the intended limit is commented out rather than active.
  • Because the vulnerability is unauthenticated and network-reachable, relying on client-side chunking is insufficient. Attackers do not need to use the official JavaScript client and can POST arbitrary NDJSON directly to the LongPoll endpoint.
  • The server still accepts a request body up to the configured body-read cap, noted in the comment as approximately 8MB. A newline-only or newline-dense body can still force substantial iteration and per-part processing even if the worst eager materialization has been reduced.
  • The patched server path appears to use Enum.find with the raw return value of transport_dispatch(...) as the predicate result. Since :ok is truthy in Elixir, this likely stops after the first successfully dispatched message and returns :ok. That may be intentional only if the callback returns a non-truthy value for normal continuation, but the provided snippet does not show such normalization. At minimum, this is harder to reason about than the previous explicit Enum.reduce_while control flow and deserves close regression testing.
  • The patch mitigates memory amplification but does not fully address malformed-input economics at the endpoint boundary. A robust fix would reject excessive line counts or empty-frame floods before dispatch.

Verdict

Partial fix.

This patch addresses the immediate memory-amplification mechanism by making server parsing lazy and by reducing batch sizes generated by the official JavaScript client, but it does not close the root cause at the trust boundary because the server still lacks enforced batch-size limits for arbitrary inbound requests. The explicit deferred TODO in the patched Elixir source is strong evidence that upstream also views this as incomplete. For a complete remediation, Phoenix should enforce a server-side maximum number of NDJSON records, ideally reject pathological empty-line floods early, and keep the client-side chunking as a defense-in-depth measure rather than the primary control.

References: upstream patch commit, GitHub advisory, NVD entry, CVE record.

Sources