CVE-2026-32689 Phoenix LongPoll NDJSON DoS: Partial fix via client-side batch splitting
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 100That 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.splitwithString.splittermaterially reduces memory pressure for delimiter-heavy payloads by avoiding immediate full-list construction. - The reduction from
Enum.reduce_whileover 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.findwith the raw return value oftransport_dispatch(...)as the predicate result. Since:okis 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 explicitEnum.reduce_whilecontrol 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.
Recommended Labs
Try this vulnerability pattern yourself with hands-on labs.
- DoS.api
Best direct match for the CVE theme: unauthenticated request-driven denial of service caused by insufficient defensive handling of hostile input. This lab maps to CWE-400 and OWASP A05:2021, making it highly relevant for practicing request size controls, parser hardening, and fail-safe handling around API endpoints like Phoenix LongPoll.
- Panic DoS.go
Useful for learning how malformed or adversarial input can push server code into crash conditions. Although in Go rather than Elixir/Phoenix, it is still strongly applicable to this patch review because the Phoenix issue involves unauthenticated remote node crash behavior triggered by crafted payload structure.
- DoS GraphQL.ts
Relevant as a hands-on exercise in defending higher-level request processing pipelines against amplification and resource exhaustion. It is not Phoenix-specific, but it helps build the same defensive instincts needed for this CVE: bounding expensive parsing behavior, validating request shape early, and preventing attacker-controlled payloads from consuming disproportionate memory or CPU.