Skip to content

Commit d5dfffe

Browse files
committed
Respect length when decoding multipart headers
1 parent 1947edc commit d5dfffe

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.17.1 (2026-05-14)
4+
5+
### Security
6+
7+
* [Plug.Parsers.MULTIPART] Consider overall length when decoding multipart headers
8+
39
## v1.17.0 (2025-03-14)
410

511
### Enhancements

lib/plug/conn.ex

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,8 +1167,10 @@ defmodule Plug.Conn do
11671167
@doc """
11681168
Reads the headers of a multipart request.
11691169
1170-
It returns `{:ok, headers, conn}` with the headers or
1171-
`{:done, conn}` if there are no more parts.
1170+
It returns `{:ok, headers, conn}` with the headers,
1171+
`{:error, :too_large, conn}` if the current multipart header block
1172+
exceeds the configured `:length`, or `{:done, conn}` if there are
1173+
no more parts.
11721174
11731175
Once `read_part_headers/2` is invoked, you may call
11741176
`read_part_body/2` to read the body associated to the headers.
@@ -1177,40 +1179,42 @@ defmodule Plug.Conn do
11771179
11781180
## Options
11791181
1180-
* `:length` - sets the maximum number of bytes to read from the body for
1181-
each chunk, defaults to `64_000` bytes
1182+
* `:length` - sets the maximum number of bytes to read while parsing the
1183+
current multipart header block, defaults to `64_000` bytes
11821184
* `:read_length` - sets the amount of bytes to read at one time from the
11831185
underlying socket to fill the chunk, defaults to `64_000` bytes
11841186
* `:read_timeout` - sets the timeout for each socket read, defaults to
11851187
`5_000` milliseconds
11861188
11871189
"""
1188-
@spec read_part_headers(t, Keyword.t()) :: {:ok, headers, t} | {:done, t}
1190+
@spec read_part_headers(t, Keyword.t()) ::
1191+
{:ok, headers, t} | {:error, :too_large, t} | {:done, t}
11891192
def read_part_headers(%Conn{adapter: {adapter, state}} = conn, opts \\ []) do
1190-
opts = opts ++ [length: 64_000, read_length: 64_000, read_timeout: 5000]
1191-
11921193
case init_multipart(conn) do
11931194
{boundary, buffer} ->
1195+
opts = opts ++ [length: 64_000, read_length: 64_000, read_timeout: 5000]
1196+
length = Keyword.fetch!(opts, :length)
11941197
{data, state} = read_multipart_from_buffer_or_adapter(buffer, adapter, state, opts)
1195-
read_part_headers(conn, data, boundary, adapter, state, opts)
1198+
read_part_headers(conn, data, length, boundary, adapter, state, opts)
11961199

11971200
:done ->
11981201
{:done, conn}
11991202
end
12001203
end
12011204

1202-
defp read_part_headers(conn, data, boundary, adapter, state, opts) do
1205+
defp read_part_headers(conn, data, length, boundary, adapter, state, opts) do
12031206
case :plug_multipart.parse_headers(data, boundary) do
1207+
{:ok, _headers, rest} when byte_size(data) - byte_size(rest) > length ->
1208+
{:error, :too_large, store_multipart(conn, {boundary, data}, adapter, state)}
1209+
12041210
{:ok, headers, rest} ->
12051211
{:ok, headers, store_multipart(conn, {boundary, rest}, adapter, state)}
12061212

12071213
:more ->
1208-
{_, next, state} = next_multipart(adapter, state, opts)
1209-
read_part_headers(conn, data <> next, boundary, adapter, state, opts)
1214+
read_part_headers_more(conn, data, length, boundary, adapter, state, opts)
12101215

12111216
{:more, rest} ->
1212-
{_, next, state} = next_multipart(adapter, state, opts)
1213-
read_part_headers(conn, rest <> next, boundary, adapter, state, opts)
1217+
read_part_headers_more(conn, rest, length, boundary, adapter, state, opts)
12141218

12151219
{:done, _} ->
12161220
{:done, store_multipart(conn, :done, adapter, state)}
@@ -1299,6 +1303,16 @@ defmodule Plug.Conn do
12991303
%{put_in(conn.private[:plug_multipart], multipart) | adapter: {adapter, state}}
13001304
end
13011305

1306+
defp read_part_headers_more(conn, data, length, boundary, adapter, state, _opts)
1307+
when byte_size(data) >= length do
1308+
{:error, :too_large, store_multipart(conn, {boundary, data}, adapter, state)}
1309+
end
1310+
1311+
defp read_part_headers_more(conn, data, length, boundary, adapter, state, opts) do
1312+
{_, next, state} = next_multipart(adapter, state, opts)
1313+
read_part_headers(conn, data <> next, length, boundary, adapter, state, opts)
1314+
end
1315+
13021316
defp read_multipart_from_buffer_or_adapter("", adapter, state, opts) do
13031317
{_, data, state} = adapter.read_req_body(state, opts)
13041318
{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
@@ -153,21 +153,35 @@ defmodule Plug.Parsers.MULTIPART do
153153
end
154154

155155
defp parse_multipart(conn, {m2p, limit, headers_opts, opts}) do
156-
read_result = Plug.Conn.read_part_headers(conn, headers_opts)
157-
{:ok, limit, acc, conn} = parse_multipart(read_result, limit, opts, headers_opts, [])
156+
read_result = read_part_headers(conn, limit, headers_opts, opts)
157+
158+
case parse_multipart(read_result, limit, opts, headers_opts, []) do
159+
{:ok, limit, acc, conn} ->
160+
if limit >= 0 do
161+
{mod, fun, args} = m2p
162+
apply(mod, fun, [acc, conn | args])
163+
else
164+
{:error, :too_large, conn}
165+
end
158166

159-
if limit > 0 do
160-
{mod, fun, args} = m2p
161-
apply(mod, fun, [acc, conn | args])
162-
else
163-
{:error, :too_large, conn}
167+
{:error, :too_large, conn} ->
168+
{:error, :too_large, conn}
164169
end
165170
end
166171

167172
defp parse_multipart({:ok, headers, conn}, limit, opts, headers_opts, acc) when limit >= 0 do
168173
{conn, limit, acc} = parse_multipart_headers(headers, conn, limit, opts, acc)
169-
read_result = Plug.Conn.read_part_headers(conn, headers_opts)
170-
parse_multipart(read_result, limit, opts, headers_opts, acc)
174+
175+
if limit >= 0 do
176+
read_result = read_part_headers(conn, limit, headers_opts, opts)
177+
parse_multipart(read_result, limit, opts, headers_opts, acc)
178+
else
179+
{:ok, limit, acc, conn}
180+
end
181+
end
182+
183+
defp parse_multipart({:error, :too_large, conn}, _limit, _opts, _headers_opts, _acc) do
184+
{:error, :too_large, conn}
171185
end
172186

173187
defp parse_multipart({:ok, _headers, conn}, limit, _opts, _headers_opts, acc) do
@@ -314,4 +328,12 @@ defmodule Plug.Parsers.MULTIPART do
314328
nil -> nil
315329
end
316330
end
331+
332+
defp read_part_headers(conn, limit, headers_opts, opts) do
333+
headers_length = min(limit, Keyword.get(headers_opts, :length, Keyword.fetch!(opts, :length)))
334+
335+
headers_opts
336+
|> Keyword.put(:length, headers_length)
337+
|> then(&Plug.Conn.read_part_headers(conn, &1))
338+
end
317339
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.17.0"
4+
@version "1.17.1"
55
@description "Compose web applications with functions"
66
@xref_exclude [Plug.Cowboy, :ssl]
77

test/plug/conn_test.exs

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

898+
test "read_part_headers/2 returns too_large while accumulating multipart headers" do
899+
body =
900+
[
901+
"--deadbeef\r\ncontent-disposition: form-data; name=\"x\"\r\ncontent-type: ",
902+
String.duplicate("a", 2_000),
903+
"\r\n\r\nabc\r\n--deadbeef--\r\n"
904+
]
905+
|> IO.iodata_to_binary()
906+
907+
conn =
908+
conn(:post, "/", body)
909+
|> put_req_header("content-type", "multipart/form-data; boundary=deadbeef")
910+
911+
assert {:error, :too_large, _conn} = read_part_headers(conn, length: 1_000)
912+
end
913+
914+
test "read_part_headers/2 returns too_large for buffered multipart headers over the limit" do
915+
buffer =
916+
[
917+
"--deadbeef\r\ncontent-disposition: form-data; name=\"x\"\r\ncontent-type: ",
918+
String.duplicate("a", 2_000),
919+
"\r\n\r\nabc\r\n--deadbeef--\r\n"
920+
]
921+
|> IO.iodata_to_binary()
922+
923+
conn =
924+
conn(:post, "/", "")
925+
|> put_req_header("content-type", "multipart/form-data; boundary=deadbeef")
926+
927+
conn = %{conn | private: Map.put(conn.private, :plug_multipart, {"deadbeef", buffer})}
928+
929+
assert {:error, :too_large, _conn} = read_part_headers(conn, length: 1_000)
930+
end
931+
898932
test "query_params/1 and fetch_query_params/1" do
899933
conn = conn(:get, "/foo?a=b&c=d")
900934
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)