Skip to content

Commit aa69c5e

Browse files
committed
Respect length when decoding multipart headers
1 parent 98043e0 commit aa69c5e

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

511
### Bug fixes

lib/plug/conn.ex

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,8 +1163,10 @@ defmodule Plug.Conn do
11631163
@doc """
11641164
Reads the headers of a multipart request.
11651165
1166-
It returns `{:ok, headers, conn}` with the headers or
1167-
`{:done, conn}` if there are no more parts.
1166+
It returns `{:ok, headers, conn}` with the headers,
1167+
`{:error, :too_large, conn}` if the current multipart header block
1168+
exceeds the configured `:length`, or `{:done, conn}` if there are
1169+
no more parts.
11681170
11691171
Once `read_part_headers/2` is invoked, you may call
11701172
`read_part_body/2` to read the body associated to the headers.
@@ -1173,40 +1175,42 @@ defmodule Plug.Conn do
11731175
11741176
## Options
11751177
1176-
* `:length` - sets the maximum number of bytes to read from the body for
1177-
each chunk, defaults to `64_000` bytes
1178+
* `:length` - sets the maximum number of bytes to read while parsing the
1179+
current multipart header block, defaults to `64_000` bytes
11781180
* `:read_length` - sets the amount of bytes to read at one time from the
11791181
underlying socket to fill the chunk, defaults to `64_000` bytes
11801182
* `:read_timeout` - sets the timeout for each socket read, defaults to
11811183
`5_000` milliseconds
11821184
11831185
"""
1184-
@spec read_part_headers(t, Keyword.t()) :: {:ok, headers, t} | {:done, t}
1186+
@spec read_part_headers(t, Keyword.t()) ::
1187+
{:ok, headers, t} | {:error, :too_large, t} | {:done, t}
11851188
def read_part_headers(%Conn{adapter: {adapter, state}} = conn, opts \\ []) do
1186-
opts = opts ++ [length: 64_000, read_length: 64_000, read_timeout: 5000]
1187-
11881189
case init_multipart(conn) do
11891190
{boundary, buffer} ->
1191+
opts = opts ++ [length: 64_000, read_length: 64_000, read_timeout: 5000]
1192+
length = Keyword.fetch!(opts, :length)
11901193
{data, state} = read_multipart_from_buffer_or_adapter(buffer, adapter, state, opts)
1191-
read_part_headers(conn, data, boundary, adapter, state, opts)
1194+
read_part_headers(conn, data, length, boundary, adapter, state, opts)
11921195

11931196
:done ->
11941197
{:done, conn}
11951198
end
11961199
end
11971200

1198-
defp read_part_headers(conn, data, boundary, adapter, state, opts) do
1201+
defp read_part_headers(conn, data, length, boundary, adapter, state, opts) do
11991202
case :plug_multipart.parse_headers(data, boundary) do
1203+
{:ok, _headers, rest} when byte_size(data) - byte_size(rest) > length ->
1204+
{:error, :too_large, store_multipart(conn, {boundary, data}, adapter, state)}
1205+
12001206
{:ok, headers, rest} ->
12011207
{:ok, headers, store_multipart(conn, {boundary, rest}, adapter, state)}
12021208

12031209
:more ->
1204-
{_, next, state} = next_multipart(adapter, state, opts)
1205-
read_part_headers(conn, data <> next, boundary, adapter, state, opts)
1210+
read_part_headers_more(conn, data, length, boundary, adapter, state, opts)
12061211

12071212
{:more, rest} ->
1208-
{_, next, state} = next_multipart(adapter, state, opts)
1209-
read_part_headers(conn, rest <> next, boundary, adapter, state, opts)
1213+
read_part_headers_more(conn, rest, length, boundary, adapter, state, opts)
12101214

12111215
{:done, _} ->
12121216
{:done, store_multipart(conn, :done, adapter, state)}
@@ -1295,6 +1299,16 @@ defmodule Plug.Conn do
12951299
%{put_in(conn.private[:plug_multipart], multipart) | adapter: {adapter, state}}
12961300
end
12971301

1302+
defp read_part_headers_more(conn, data, length, boundary, adapter, state, _opts)
1303+
when byte_size(data) >= length do
1304+
{:error, :too_large, store_multipart(conn, {boundary, data}, adapter, state)}
1305+
end
1306+
1307+
defp read_part_headers_more(conn, data, length, boundary, adapter, state, opts) do
1308+
{_, next, state} = next_multipart(adapter, state, opts)
1309+
read_part_headers(conn, data <> next, length, boundary, adapter, state, opts)
1310+
end
1311+
12981312
defp read_multipart_from_buffer_or_adapter("", adapter, state, opts) do
12991313
{_, data, state} = adapter.read_req_body(state, opts)
13001314
{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.16.2"
4+
@version "1.16.3"
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
@@ -894,6 +894,40 @@ defmodule Plug.ConnTest do
894894
assert {:more, _, _} = read_body(conn, length: 100)
895895
end
896896

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

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

0 commit comments

Comments
 (0)