ewe has an Overly Permissive List of Allowed Inputs
Description
ewe is a Gleam web server. ewe is a Gleam web server. Versions 0.6.0 through 3.0.4 are vulnerable to authentication bypass or spoofed proxy-trust headers. Chunked transfer encoding trailer handling merges declared trailer fields into req.headers after body parsing, but the denylist only blocks 9 header names. A malicious client can exploit this by declaring these headers in the Trailer field and appending them after the final chunk, causing request.set_header to overwrite legitimate values (e.g., those set by a reverse proxy). This enables attackers to forge authentication credentials, hijack sessions, bypass IP-based rate limiting, or spoof proxy-trust headers in any downstream middleware that reads headers after ewe.read_body is called. This issue has been fixed in version 3.0.5.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
eweHex | >= 0.6.0, < 3.0.5 | 3.0.5 |
Affected products
1- Range: >= 0.6.0, < 3.0.5
Patches
294ab6e7bf729introduce allowlist for trailer headers
3 files changed · +8 −16
CHANGELOG.md+1 −1 modified@@ -3,7 +3,7 @@ # Unreleased - Remove all usage of `string.inspect` as it's a huge anti-pattern for logging. -- Patch infinite loop on rejected trailer headers inside `handle_trailers`. +- Patch infinite loops and adjust allowed entries for trailer headers. # v3.0.4 - 13.03.2026
dev/benchmark.gleam+2 −0 modified@@ -24,6 +24,8 @@ fn handle_request(req: Request) -> Response { http.Post, ["user"] -> { case ewe.read_body(req, 40_000_000) { Ok(req) -> { + echo req.headers + let content_type = req |> request.get_header("content-type")
src/ewe/internal/http1.gleam+5 −15 modified@@ -508,9 +508,7 @@ fn handle_trailers( case field_name { Ok(field_name) -> { - case - set.contains(set, field_name) && !is_forbidden_trailer(field_name) - { + case set.contains(set, field_name) && is_allowed_trailer(field_name) { True -> { case bit_array.to_string(value) { Ok(value) -> { @@ -530,18 +528,10 @@ fn handle_trailers( } } -/// Checks if a header field is forbidden in trailers. -fn is_forbidden_trailer(field: String) -> Bool { - case string.lowercase(field) { - "transfer-encoding" - | "content-length" - | "host" - | "cache-control" - | "expect" - | "max-forwards" - | "pragma" - | "range" - | "te" -> True +/// Checks if a trailer field is allowed. +fn is_allowed_trailer(field: String) -> Bool { + case field { + "server-timing" | "content-digest" | "repr-digest" -> True _ -> False } }
5 files changed · +133 −9
CHANGELOG.md+4 −0 modified@@ -1,5 +1,9 @@ # Changelog +# Unreleased + +- HTTP parser now handles trailers when handling chunked encoding. + # v0.5.0 - Response body must now be of type `ResponseBody`. To set the response body, use the following functions: `ewe.text`, `ewe.bytes`, `ewe.bits`, `ewe.string_tree`, `ewe.empty`, `ewe.json`.
gleam.toml+5 −1 modified@@ -1,10 +1,14 @@ name = "ewe" version = "0.5.0" + description = "[WIP] 🐑 a fluffy Gleam web server" target = "erlang" - licences = ["Apache-2.0"] repository = { type = "github", user = "vshakitskiy", repo = "ewe" } +links = [ + { title = "Gleam", href = "https://gleam.run" }, + { title = "Mist", href = "https://github.com/rawhat/mist" } +] [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0"
README.md+1 −1 modified@@ -1,4 +1,4 @@ - + # (⚠️ WIP) 🐑 ewe
src/ewe/internal/http.gleam+74 −7 modified@@ -15,6 +15,7 @@ import gleam/int import gleam/list import gleam/option import gleam/result.{replace_error, try} +import gleam/set import gleam/string import gleam/string_tree import gleam/uri @@ -282,7 +283,7 @@ pub fn read_body( case transfer_encoding { Ok("chunked") -> { - use body <- try(read_chunked_body( + use #(body, rest) <- try(read_chunked_body( transport, socket, req.body.buffer, @@ -291,7 +292,21 @@ pub fn read_body( 0, )) - Ok(request.set_body(req, body)) + let req = request.set_body(req, body) + + case list.key_find(req.headers, "trailer") { + Ok(trailer) -> { + let set = + trailer + |> string.split(",") + |> list.fold(set.new(), fn(set, field) { + set.insert(set, string.trim(field) |> string.lowercase()) + }) + + Ok(handle_trailers(req, set, rest)) + } + Error(Nil) -> Ok(req) + } } _ -> { let content_length = @@ -346,11 +361,11 @@ fn read_chunked_body( body: BitArray, size_limit: Int, total_size: Int, -) -> Result(BitArray, ParseError) { +) -> Result(#(BitArray, BitArray), ParseError) { use <- bool.guard(total_size > size_limit, Error(BodyTooLarge)) case parse_body_chunk(buffer) { - Ok(Done) -> Ok(body) + Ok(Done(rest)) -> Ok(#(body, rest)) Ok(Incomplete) -> { use buffer <- try(read_from_socket( transport, @@ -373,15 +388,14 @@ fn read_chunked_body( } type BodyChunk { - Done + Done(rest: BitArray) Incomplete Chunk(BitArray, rest: BitArray) } fn parse_body_chunk(buffer: BitArray) -> Result(BodyChunk, ParseError) { case split(buffer, <<"\r\n">>, []) { - // TODO: trailers - [<<"0">>, _] -> Ok(Done) + [<<"0">>, rest] -> Ok(Done(rest)) [chunk_size, rest] -> { use size <- try( bit_array.to_string(chunk_size) @@ -403,6 +417,59 @@ fn parse_body_chunk(buffer: BitArray) -> Result(BodyChunk, ParseError) { } } +fn handle_trailers( + req: Request(BitArray), + set: set.Set(String), + rest: BitArray, +) -> Request(BitArray) { + case decoder.decode_packet(HttphBin, rest, []) { + Ok(Packet(HttpEoh, _)) -> req + Ok(Packet(HttpHeader(idx, field, value), rest)) -> { + let field = case decoder.formatted_field_by_idx(idx) { + Ok(field) -> Ok(field) + Error(Nil) -> { + bit_array.to_string(field) + |> result.map(string.lowercase) + } + } + + case field { + Ok(field) -> { + case set.contains(set, field) && !is_forbidden_trailer(field) { + True -> { + case bit_array.to_string(value) { + Ok(value) -> { + request.set_header(req, field, value) + |> handle_trailers(set, rest) + } + Error(Nil) -> handle_trailers(req, set, rest) + } + } + False -> handle_trailers(req, set, rest) + } + } + Error(Nil) -> handle_trailers(req, set, rest) + } + } + _ -> req + } +} + +fn is_forbidden_trailer(field: String) -> Bool { + case string.lowercase(field) { + "transfer-encoding" + | "content-length" + | "host" + | "cache-control" + | "expect" + | "max-forwards" + | "pragma" + | "range" + | "te" -> True + _ -> False + } +} + pub type UpgradeWebsocketError { VersionNot11OrGreater MethodNotGet
test/http_test.gleam+49 −0 modified@@ -1 +1,50 @@ // TODO: fill http tests + +import client/tcp as client +import ewe +import gleam/bytes_tree +import gleam/http/response +import glisten/tcp + +pub fn chunked_test() { + let _ = + ewe.new(fn(req) { + case ewe.read_body(req, 1024) { + Ok(req) -> { + echo req.headers + + response.new(200) |> ewe.text("ok") + } + Error(_) -> response.new(500) |> ewe.empty() + } + }) + |> ewe.with_port(8080) + |> ewe.start() + + let req = + "GET / HTTP/1.1\r\n" + <> "Host: localhost:8080\r\n" + <> "Content-Type: text/plain\r\n" + <> "Transfer-Encoding: chunked\r\n" + <> "Trailer: Server-Timing\r\n" + <> "\r\n" + <> "7\r\n" + <> "Mozilla\r\n" + <> "11\r\n" + <> "Developer Network\r\n" + <> "0\r\n" + <> "Server-Timing: total;dur=1000\r\n" + <> "\r\n" + + let req = bytes_tree.from_string(req) + + use socket <- client.with_socket(8080, active: False) + + let assert Ok(Nil) = tcp.send(socket, req) + + let assert Ok(resp) = tcp.receive(socket, 0) + + echo resp + + Nil +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-9w88-79f8-m3vpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32881ghsaADVISORY
- github.com/vshakitskiy/ewe/commit/07dcfd2135fc95f38c17a9d030de3d7efee1ee39ghsax_refsource_MISCWEB
- github.com/vshakitskiy/ewe/commit/94ab6e7bf7293e987ae98b4daa51ea131c2671baghsax_refsource_MISCWEB
- github.com/vshakitskiy/ewe/releases/tag/v3.0.5ghsax_refsource_MISCWEB
- github.com/vshakitskiy/ewe/security/advisories/GHSA-9w88-79f8-m3vpghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.