fix(http): enforce max_body again under Hackney 4
Hackney 4 ignores `with_body` and always returns a fully read binary, so Tesla.Adapter.Hackney never reaches the clause that honours :max_body and the option became a no-op. Only the gun path still enforced it, through Pleroma.HTTP.Adapter.Gun. Pleroma.Web.RelMe relies on nothing else, so it was fetching remote pages unbounded. Add a middleware that applies the limit for every adapter: reject when content-length already announces too much, and reject a body that came back over the limit. Streaming bodies are left alone -- neither adapter limits those and the caller owns the lease, so failing here on the header would strand a connection RichMedia is still expected to release. On the hackney path this bounds what callers are handed rather than peak memory, since the adapter has read the body before any middleware runs.
This commit is contained in:
parent
7d1cc56a78
commit
0615025f92
5 changed files with 330 additions and 1 deletions
1
changelog.d/max-body-hackney.fix
Normal file
1
changelog.d/max-body-hackney.fix
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Enforce the max_body HTTP option again, which Hackney 4 silently stopped honouring because it always returns a fully read body
|
||||||
|
|
@ -130,5 +130,9 @@ defmodule Pleroma.HTTP do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp default_middleware,
|
defp default_middleware,
|
||||||
do: [Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.EncodeUrl]
|
do: [
|
||||||
|
Tesla.Middleware.FollowRedirects,
|
||||||
|
Pleroma.Tesla.Middleware.EncodeUrl,
|
||||||
|
Pleroma.Tesla.Middleware.MaxBody
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
|
||||||
72
lib/pleroma/tesla/middleware/max_body.ex
Normal file
72
lib/pleroma/tesla/middleware/max_body.ex
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
defmodule Pleroma.Tesla.Middleware.MaxBody do
|
||||||
|
@moduledoc """
|
||||||
|
Enforces the `:max_body` adapter option for every adapter.
|
||||||
|
|
||||||
|
`Pleroma.HTTP.Adapter.Gun` applies the limit as it accumulates a `:plain`
|
||||||
|
body, so an oversized response is abandoned mid-transfer. Hackney cannot:
|
||||||
|
since 4.0 it ignores `with_body` and always hands back a fully read binary
|
||||||
|
(`hackney:request/5`), so `Tesla.Adapter.Hackney` never takes the branch that
|
||||||
|
would honour `:max_body`. Without this middleware the option is silently
|
||||||
|
unenforced there.
|
||||||
|
|
||||||
|
The check therefore bounds what callers are handed, and refuses up front when
|
||||||
|
`content-length` already announces too much. It cannot bound peak memory on
|
||||||
|
the hackney path, because the adapter has read the body before any middleware
|
||||||
|
runs.
|
||||||
|
|
||||||
|
Streaming bodies are left alone: neither adapter limits those, and the caller
|
||||||
|
that owns the stream is the one that has to drain and release it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@behaviour Tesla.Middleware
|
||||||
|
|
||||||
|
@impl Tesla.Middleware
|
||||||
|
def call(%Tesla.Env{opts: opts} = env, next, _opts) do
|
||||||
|
case opts[:adapter][:max_body] do
|
||||||
|
limit when is_integer(limit) and limit >= 0 -> run(env, next, limit)
|
||||||
|
_ -> Tesla.run(env, next)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run(env, next, limit) do
|
||||||
|
with {:ok, env} <- Tesla.run(env, next),
|
||||||
|
:ok <- check(env, limit) do
|
||||||
|
{:ok, env}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check(%Tesla.Env{body: body} = env, limit) when is_binary(body) do
|
||||||
|
with :ok <- check_content_length(env, limit) do
|
||||||
|
check_body(body, limit)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# body_as: :chunks and response: :stream hand back a client the caller drains
|
||||||
|
# itself, applying its own limit (Pleroma.Web.RichMedia.Helpers). There is
|
||||||
|
# nothing to measure, and failing on the header alone would strand the pool
|
||||||
|
# lease that only the caller holds a handle to.
|
||||||
|
defp check(_env, _limit), do: :ok
|
||||||
|
|
||||||
|
# A bodyless response may still announce the entity's size.
|
||||||
|
defp check_content_length(%Tesla.Env{method: :head}, _limit), do: :ok
|
||||||
|
|
||||||
|
defp check_content_length(%Tesla.Env{status: status}, _limit) when status in [204, 304],
|
||||||
|
do: :ok
|
||||||
|
|
||||||
|
defp check_content_length(env, limit) do
|
||||||
|
with value when is_binary(value) <- Tesla.get_header(env, "content-length"),
|
||||||
|
{length, ""} <- Integer.parse(value) do
|
||||||
|
if length > limit, do: {:error, :body_too_large}, else: :ok
|
||||||
|
else
|
||||||
|
_ -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_body(body, limit) do
|
||||||
|
if byte_size(body) > limit, do: {:error, :body_too_large}, else: :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
165
test/pleroma/http/hackney_max_body_regression_test.exs
Normal file
165
test/pleroma/http/hackney_max_body_regression_test.exs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
# Hackney 4 ignores `with_body` and always returns a fully read binary, so
|
||||||
|
# `Tesla.Adapter.Hackney` never reaches the clause that honours `:max_body`.
|
||||||
|
# The limit is enforced by Pleroma.Tesla.Middleware.MaxBody instead; these
|
||||||
|
# exercise it against the real adapter.
|
||||||
|
defmodule Pleroma.HTTP.HackneyMaxBodyRegressionTest do
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias Pleroma.HTTP.AdapterHelper.Hackney, as: HackneyAdapterHelper
|
||||||
|
|
||||||
|
@body_bytes 64 * 1024
|
||||||
|
|
||||||
|
setup do
|
||||||
|
{:ok, _} = Application.ensure_all_started(:hackney)
|
||||||
|
|
||||||
|
{:ok, server} = start_server()
|
||||||
|
on_exit(fn -> stop_server(server) end)
|
||||||
|
|
||||||
|
{:ok, server: server}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request(url, extra_opts) do
|
||||||
|
adapter_opts =
|
||||||
|
HackneyAdapterHelper.options(
|
||||||
|
[pool: :media, no_proxy_env: true] ++ extra_opts,
|
||||||
|
URI.parse(url)
|
||||||
|
)
|
||||||
|
|
||||||
|
client =
|
||||||
|
Tesla.client(
|
||||||
|
[Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.MaxBody],
|
||||||
|
Tesla.Adapter.Hackney
|
||||||
|
)
|
||||||
|
|
||||||
|
Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a body over max_body is refused", %{server: server} do
|
||||||
|
assert {:error, :body_too_large} =
|
||||||
|
request("#{server.base_url}/big", max_body: 1024)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Without a Content-Length the header pre-check cannot fire, so this is the
|
||||||
|
# case that proves hackney's fully-read binary is measured.
|
||||||
|
test "a chunked body over max_body is refused on size alone", %{server: server} do
|
||||||
|
assert {:error, :body_too_large} =
|
||||||
|
request("#{server.base_url}/chunked", max_body: 1024)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a body under max_body is returned", %{server: server} do
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: body}} =
|
||||||
|
request("#{server.base_url}/big", max_body: @body_bytes * 2)
|
||||||
|
|
||||||
|
assert byte_size(body) == @body_bytes
|
||||||
|
end
|
||||||
|
|
||||||
|
test "without max_body the body is returned whatever its size", %{server: server} do
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: body}} = request("#{server.base_url}/big", [])
|
||||||
|
assert byte_size(body) == @body_bytes
|
||||||
|
end
|
||||||
|
|
||||||
|
# Pins why the middleware is needed. Tesla.Adapter.Hackney only honours
|
||||||
|
# :max_body on the branch where hackney hands back a ref, which 4.x never
|
||||||
|
# does for a synchronous request. Should this ever start failing, hackney or
|
||||||
|
# Tesla have regained streaming and the middleware can go.
|
||||||
|
test "the bare adapter does not enforce max_body", %{server: server} do
|
||||||
|
url = "#{server.base_url}/big"
|
||||||
|
|
||||||
|
adapter_opts =
|
||||||
|
HackneyAdapterHelper.options(
|
||||||
|
[pool: :media, no_proxy_env: true, max_body: 1024],
|
||||||
|
URI.parse(url)
|
||||||
|
)
|
||||||
|
|
||||||
|
client = Tesla.client([], Tesla.Adapter.Hackney)
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: body}} =
|
||||||
|
Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts])
|
||||||
|
|
||||||
|
assert byte_size(body) == @body_bytes
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_server do
|
||||||
|
{:ok, listener} =
|
||||||
|
:gen_tcp.listen(0, [
|
||||||
|
:binary,
|
||||||
|
active: false,
|
||||||
|
packet: :raw,
|
||||||
|
reuseaddr: true,
|
||||||
|
ip: {127, 0, 0, 1}
|
||||||
|
])
|
||||||
|
|
||||||
|
{:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener)
|
||||||
|
{:ok, acceptor} = Task.start(fn -> accept_loop(listener) end)
|
||||||
|
|
||||||
|
{:ok, %{listener: listener, acceptor: acceptor, base_url: "http://127.0.0.1:#{port}"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Closing the listener makes the blocked accept/1 return {:error, :closed},
|
||||||
|
# which ends the loop.
|
||||||
|
defp stop_server(%{listener: listener, acceptor: acceptor}) do
|
||||||
|
:ok = :gen_tcp.close(listener)
|
||||||
|
if Process.alive?(acceptor), do: Process.exit(acceptor, :kill)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp accept_loop(listener) do
|
||||||
|
case :gen_tcp.accept(listener) do
|
||||||
|
{:ok, socket} ->
|
||||||
|
serve(socket)
|
||||||
|
accept_loop(listener)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp serve(socket) do
|
||||||
|
with {:ok, data} <- recv_headers(socket) do
|
||||||
|
body = :binary.copy("x", @body_bytes)
|
||||||
|
|
||||||
|
if String.contains?(data, "/chunked") do
|
||||||
|
# No Content-Length, so only the body-size check can catch this.
|
||||||
|
:gen_tcp.send(socket, [
|
||||||
|
"HTTP/1.1 200 OK\r\n",
|
||||||
|
"Content-Type: text/plain\r\n",
|
||||||
|
"Transfer-Encoding: chunked\r\n",
|
||||||
|
"Connection: close\r\n\r\n",
|
||||||
|
:erlang.integer_to_list(byte_size(body), 16),
|
||||||
|
"\r\n",
|
||||||
|
body,
|
||||||
|
"\r\n0\r\n\r\n"
|
||||||
|
])
|
||||||
|
else
|
||||||
|
:gen_tcp.send(socket, [
|
||||||
|
"HTTP/1.1 200 OK\r\n",
|
||||||
|
"Content-Type: text/plain\r\n",
|
||||||
|
"Content-Length: #{byte_size(body)}\r\n",
|
||||||
|
"Connection: close\r\n\r\n",
|
||||||
|
body
|
||||||
|
])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
:gen_tcp.close(socket)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp recv_headers(socket, acc \\ <<>>) do
|
||||||
|
case :gen_tcp.recv(socket, 0, 1_000) do
|
||||||
|
{:ok, data} ->
|
||||||
|
acc = acc <> data
|
||||||
|
|
||||||
|
cond do
|
||||||
|
:binary.match(acc, "\r\n\r\n") != :nomatch -> {:ok, acc}
|
||||||
|
byte_size(acc) > 8_192 -> {:error, :too_large}
|
||||||
|
true -> recv_headers(socket, acc)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _} = error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
87
test/pleroma/tesla/middleware/max_body_test.exs
Normal file
87
test/pleroma/tesla/middleware/max_body_test.exs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
defmodule Pleroma.Tesla.Middleware.MaxBodyTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Pleroma.Tesla.Middleware.MaxBody
|
||||||
|
|
||||||
|
defp client(adapter_opts, response) do
|
||||||
|
Tesla.client([{MaxBody, nil}], fn env ->
|
||||||
|
{:ok, %{env | status: 200, headers: response.headers, body: response.body}}
|
||||||
|
end)
|
||||||
|
|> then(fn c -> {c, [opts: [adapter: adapter_opts]]} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get(adapter_opts, response) do
|
||||||
|
{c, opts} = client(adapter_opts, response)
|
||||||
|
Tesla.get(c, "http://example.com/", opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "passes a body within the limit through untouched" do
|
||||||
|
assert {:ok, %Tesla.Env{status: 200, body: "hello"}} =
|
||||||
|
get([max_body: 100], %{headers: [], body: "hello"})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects a body larger than the limit" do
|
||||||
|
assert {:error, :body_too_large} =
|
||||||
|
get([max_body: 4], %{headers: [], body: "hello"})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects when content-length exceeds the limit" do
|
||||||
|
assert {:error, :body_too_large} =
|
||||||
|
get([max_body: 4], %{headers: [{"content-length", "1000"}], body: ""})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows a content-length equal to the limit" do
|
||||||
|
assert {:ok, %Tesla.Env{status: 200}} =
|
||||||
|
get([max_body: 5], %{headers: [{"content-length", "5"}], body: "hello"})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores an unparsable content-length" do
|
||||||
|
assert {:ok, %Tesla.Env{status: 200}} =
|
||||||
|
get([max_body: 100], %{headers: [{"content-length", "banana"}], body: "hi"})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does nothing without a max_body option" do
|
||||||
|
big = String.duplicate("x", 10_000)
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{body: ^big}} =
|
||||||
|
get([], %{headers: [{"content-length", "10000"}], body: big})
|
||||||
|
end
|
||||||
|
|
||||||
|
# body_as: :chunks hands back a client map, not a binary; the caller streams
|
||||||
|
# it and enforces its own limit (see Pleroma.Web.RichMedia.Helpers).
|
||||||
|
test "leaves a non-binary streaming body alone" do
|
||||||
|
body = %{pid: self(), stream: make_ref()}
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{body: ^body}} =
|
||||||
|
get([max_body: 1], %{headers: [], body: body})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Rejecting a stream on the header alone would strand the connection lease:
|
||||||
|
# the env carrying the pid/stream is the caller's only handle to release it.
|
||||||
|
test "does not reject a streaming body on content-length alone" do
|
||||||
|
body = %{pid: self(), stream: make_ref()}
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{body: ^body}} =
|
||||||
|
get([max_body: 1], %{headers: [{"content-length", "20000000"}], body: body})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores content-length on a HEAD response" do
|
||||||
|
{c, opts} = client([max_body: 4], %{headers: [{"content-length", "9000000"}], body: ""})
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 200}} = Tesla.head(c, "http://example.com/", opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores content-length on a bodyless status" do
|
||||||
|
client =
|
||||||
|
Tesla.client([{MaxBody, nil}], fn env ->
|
||||||
|
{:ok, %{env | status: 304, headers: [{"content-length", "9000000"}], body: ""}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, %Tesla.Env{status: 304}} =
|
||||||
|
Tesla.get(client, "http://example.com/", opts: [adapter: [max_body: 4]])
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue