Skip to content

Commit 3385842

Browse files
committed
Respect length when decoding multipart headers
1 parent 8723880 commit 3385842

6 files changed

Lines changed: 119 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v1.19.2 (2026-05-14)
4+
5+
### Security
6+
7+
* [Plug.Parsers.MULTIPART] Consider overall length when decoding multipart headers
8+
39
## v1.19.1 (2025-12-09)
410

511
### Bug fixes

lib/plug/conn.ex

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,8 +1196,10 @@ defmodule Plug.Conn do
11961196
@doc """
11971197
Reads the headers of a multipart request.
11981198
1199-
It returns `{:ok, headers, conn}` with the headers or
1200-
`{:done, conn}` if there are no more parts.
1199+
It returns `{:ok, headers, conn}` with the headers,
1200+
`{:error, :too_large, conn}` if the current multipart header block
1201+
exceeds the configured `:length`, or `{:done, conn}` if there are
1202+
no more parts.
12011203
12021204
Once `read_part_headers/2` is invoked, you may call
12031205
`read_part_body/2` to read the body associated to the headers.
@@ -1206,40 +1208,42 @@ defmodule Plug.Conn do
12061208
12071209
## Options
12081210
1209-
* `:length` - sets the maximum number of bytes to read from the body for
1210-
each chunk, defaults to `64_000` bytes
1211+
* `:length` - sets the maximum number of bytes to read while parsing the
1212+
current multipart header block, defaults to `64_000` bytes
12111213
* `:read_length` - sets the amount of bytes to read at one time from the
12121214
underlying socket to fill the chunk, defaults to `64_000` bytes
12131215
* `:read_timeout` - sets the timeout for each socket read, defaults to
12141216
`5_000` milliseconds
12151217
12161218
"""
1217-
@spec read_part_headers(t, Keyword.t()) :: {:ok, headers, t} | {:done, t}
1219+
@spec read_part_headers(t, Keyword.t()) ::
1220+
{:ok, headers, t} | {:error, :too_large, t} | {:done, t}
12181221
def read_part_headers(%Conn{adapter: {adapter, state}} = conn, opts \\ []) do
1219-
opts = opts ++ [length: 64_000, read_length: 64_000, read_timeout: 5000]
1220-
12211222
case init_multipart(conn) do
12221223
{boundary, buffer} ->
1224+
opts = opts ++ [length: 64_000, read_length: 64_000, read_timeout: 5000]
1225+
length = Keyword.fetch!(opts, :length)
12231226
{data, state} = read_multipart_from_buffer_or_adapter(buffer, adapter, state, opts)
1224-
read_part_headers(conn, data, boundary, adapter, state, opts)
1227+
read_part_headers(conn, data, length, boundary, adapter, state, opts)
12251228

12261229
:done ->
12271230
{:done, conn}
12281231
end
12291232
end
12301233

1231-
defp read_part_headers(conn, data, boundary, adapter, state, opts) do
1234+
defp read_part_headers(conn, data, length, boundary, adapter, state, opts) do
12321235
case :plug_multipart.parse_headers(data, boundary) do
1236+
{:ok, _headers, rest} when byte_size(data) - byte_size(rest) > length ->
1237+
{:error, :too_large, store_multipart(conn, {boundary, data}, adapter, state)}
1238+
12331239
{:ok, headers, rest} ->
12341240
{:ok, headers, store_multipart(conn, {boundary, rest}, adapter, state)}
12351241

12361242
:more ->
1237-
{_, next, state} = next_multipart(adapter, state, opts)
1238-
read_part_headers(conn, data <> next, boundary, adapter, state, opts)
1243+
read_part_headers_more(conn, data, length, boundary, adapter, state, opts)
12391244

12401245
{:more, rest} ->
1241-
{_, next, state} = next_multipart(adapter, state, opts)
1242-
read_part_headers(conn, rest <> next, boundary, adapter, state, opts)
1246+
read_part_headers_more(conn, rest, length, boundary, adapter, state, opts)
12431247

12441248
{:done, _} ->
12451249
{:done, store_multipart(conn, :done, adapter, state)}
@@ -1328,6 +1332,16 @@ defmodule Plug.Conn do
13281332
%{put_in(conn.private[:plug_multipart], multipart) | adapter: {adapter, state}}
13291333
end
13301334

1335+
defp read_part_headers_more(conn, data, length, boundary, adapter, state, _opts)
1336+
when byte_size(data) >= length do
1337+
{:error, :too_large, store_multipart(conn, {boundary, data}, adapter, state)}
1338+
end
1339+
1340+
defp read_part_headers_more(conn, data, length, boundary, adapter, state, opts) do
1341+
{_, next, state} = next_multipart(adapter, state, opts)
1342+
read_part_headers(conn, data <> next, length, boundary, adapter, state, opts)
1343+
end
1344+
13311345
defp read_multipart_from_buffer_or_adapter("", adapter, state, opts) do
13321346
{_, data, state} = adapter.read_req_body(state, opts)
13331347
{data, state}

lib/plug/parsers/multipart.ex

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ defmodule Plug.Parsers.MULTIPART do
120120
parse_multipart(conn, opts_tuple)
121121
rescue
122122
# Do not ignore upload errors
123-
e in [Plug.UploadError, Plug.Parsers.BadEncodingError] ->
123+
e in [Plug.UploadError, Plug.Parsers.BadEncodingError, Plug.Parsers.RequestTooLargeError] ->
124124
reraise e, __STACKTRACE__
125125

126126
# All others are wrapped
@@ -147,21 +147,35 @@ defmodule Plug.Parsers.MULTIPART do
147147
## Multipart
148148

149149
defp parse_multipart(conn, {m2p, limit, headers_opts, opts}) do
150-
read_result = Plug.Conn.read_part_headers(conn, headers_opts)
151-
{:ok, limit, acc, conn} = parse_multipart(read_result, limit, opts, headers_opts, [])
150+
read_result = read_part_headers(conn, limit, headers_opts, opts)
151+
152+
case parse_multipart(read_result, limit, opts, headers_opts, []) do
153+
{:ok, limit, acc, conn} ->
154+
if limit >= 0 do
155+
{mod, fun, args} = m2p
156+
apply(mod, fun, [acc, conn | args])
157+
else
158+
{:error, :too_large, conn}
159+
end
152160

153-
if limit > 0 do
154-
{mod, fun, args} = m2p
155-
apply(mod, fun, [acc, conn | args])
156-
else
157-
{:error, :too_large, conn}
161+
{:error, :too_large, conn} ->
162+
{:error, :too_large, conn}
158163
end
159164
end
160165

161166
defp parse_multipart({:ok, headers, conn}, limit, opts, headers_opts, acc) when limit >= 0 do
162167
{conn, limit, acc} = parse_multipart_headers(headers, conn, limit, opts, acc)
163-
read_result = Plug.Conn.read_part_headers(conn, headers_opts)
164-
parse_multipart(read_result, limit, opts, headers_opts, acc)
168+
169+
if limit >= 0 do
170+
read_result = read_part_headers(conn, limit, headers_opts, opts)
171+
parse_multipart(read_result, limit, opts, headers_opts, acc)
172+
else
173+
{:ok, limit, acc, conn}
174+
end
175+
end
176+
177+
defp parse_multipart({:error, :too_large, conn}, _limit, _opts, _headers_opts, _acc) do
178+
{:error, :too_large, conn}
165179
end
166180

167181
defp parse_multipart({:ok, _headers, conn}, limit, _opts, _headers_opts, acc) do
@@ -308,4 +322,12 @@ defmodule Plug.Parsers.MULTIPART do
308322
nil -> nil
309323
end
310324
end
325+
326+
defp read_part_headers(conn, limit, headers_opts, opts) do
327+
headers_length = min(limit, Keyword.get(headers_opts, :length, Keyword.fetch!(opts, :length)))
328+
329+
headers_opts
330+
|> Keyword.put(:length, headers_length)
331+
|> then(&Plug.Conn.read_part_headers(conn, &1))
332+
end
311333
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Plug.MixProject do
22
use Mix.Project
33

4-
@version "1.19.1"
4+
@version "1.19.2"
55
@description "Compose web applications with functions"
66
@xref_exclude [Plug.Cowboy, :ssl]
77
@source_url "https://github.com/elixir-plug/plug"

test/plug/conn_test.exs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,40 @@ defmodule Plug.ConnTest do
902902
assert {:more, _, _} = read_body(conn, length: 100)
903903
end
904904

905+
test "read_part_headers/2 returns too_large while accumulating multipart headers" do
906+
body =
907+
[
908+
"--deadbeef\r\ncontent-disposition: form-data; name=\"x\"\r\ncontent-type: ",
909+
String.duplicate("a", 2_000),
910+
"\r\n\r\nabc\r\n--deadbeef--\r\n"
911+
]
912+
|> IO.iodata_to_binary()
913+
914+
conn =
915+
conn(:post, "/", body)
916+
|> put_req_header("content-type", "multipart/form-data; boundary=deadbeef")
917+
918+
assert {:error, :too_large, _conn} = read_part_headers(conn, length: 1_000)
919+
end
920+
921+
test "read_part_headers/2 returns too_large for buffered multipart headers over the limit" do
922+
buffer =
923+
[
924+
"--deadbeef\r\ncontent-disposition: form-data; name=\"x\"\r\ncontent-type: ",
925+
String.duplicate("a", 2_000),
926+
"\r\n\r\nabc\r\n--deadbeef--\r\n"
927+
]
928+
|> IO.iodata_to_binary()
929+
930+
conn =
931+
conn(:post, "/", "")
932+
|> put_req_header("content-type", "multipart/form-data; boundary=deadbeef")
933+
934+
conn = %{conn | private: Map.put(conn.private, :plug_multipart, {"deadbeef", buffer})}
935+
936+
assert {:error, :too_large, _conn} = read_part_headers(conn, length: 1_000)
937+
end
938+
905939
test "query_params/1 and fetch_query_params/1" do
906940
conn = conn(:get, "/foo?a=b&c=d")
907941
assert conn.query_params == %Plug.Conn.Unfetched{aspect: :query_params}

test/plug/parsers_test.exs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,25 @@ defmodule Plug.ParsersTest do
389389
assert Plug.Exception.status(exception) == 413
390390
end
391391

392+
test "raises on multipart headers larger than the parser length" do
393+
multipart =
394+
[
395+
"--deadbeef\r\ncontent-disposition: form-data; name=\"x\"\r\ncontent-type: ",
396+
String.duplicate("a", 2_000),
397+
"\r\n\r\nabc\r\n--deadbeef--\r\n"
398+
]
399+
|> IO.iodata_to_binary()
400+
401+
exception =
402+
assert_raise Plug.Parsers.RequestTooLargeError, ~r/the request is too large/, fn ->
403+
conn(:post, "/", multipart)
404+
|> put_req_header("content-type", "multipart/form-data; boundary=deadbeef")
405+
|> parse(length: 1_000)
406+
end
407+
408+
assert Plug.Exception.status(exception) == 413
409+
end
410+
392411
test "raises when request cannot be processed" do
393412
message = "unsupported media type text/plain"
394413

0 commit comments

Comments
 (0)