VYPR
Moderate severityNVD Advisory· Published Mar 20, 2026· Updated Mar 20, 2026

ewe has an Overly Permissive List of Allowed Inputs

CVE-2026-32881

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.

PackageAffected versionsPatched versions
eweHex
>= 0.6.0, < 3.0.53.0.5

Affected products

1

Patches

2
94ab6e7bf729

introduce allowlist for trailer headers

https://github.com/vshakitskiy/ewevshakitskiyMar 14, 2026via ghsa
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
       }
     }
    
07dcfd2135fc

handle trailers

https://github.com/vshakitskiy/ewevshakitskiyAug 31, 2025via ghsa
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 @@
    -![ewe](public/banner.jpg)
    +![ewe](https://raw.githubusercontent.com/vshakitskiy/ewe/mistress/public/banner.jpg)
     
     # (⚠️ 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

News mentions

0

No linked articles in our index yet.