close
Skip to content

Commit 49e18c3

Browse files
authored
fix: limit request body size (#542)
* fix: limit request body size * chore: format * format * fix test * fix test
1 parent 33b6a09 commit 49e18c3

6 files changed

Lines changed: 228 additions & 8 deletions

File tree

‎grpc/test/grpc/integration/server_test.exs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,8 @@ defmodule GRPC.Integration.ServerTest do
557557

558558
{:ok, conn_pid} = :gun.open(~c"localhost", port)
559559

560+
assert_receive {:gun_up, ^conn_pid, :http}
561+
560562
stream_ref =
561563
:gun.get(conn_pid, "/v1/messages/#{name}", [
562564
{"accept", "application/json"}

‎grpc_server/lib/grpc/server.ex‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,14 @@ defmodule GRPC.Server do
434434
@spec start_endpoint(atom(), non_neg_integer(), Keyword.t()) ::
435435
{atom(), any(), non_neg_integer()}
436436
def start_endpoint(endpoint, port, opts \\ []) do
437-
opts = Keyword.validate!(opts, adapter: GRPC.Server.Adapters.Cowboy)
437+
opts =
438+
Keyword.validate!(opts,
439+
adapter: GRPC.Server.Adapters.Cowboy,
440+
adapter_opts: [],
441+
exception_log_filter: nil,
442+
max_body_size: nil
443+
)
444+
438445
adapter = opts[:adapter]
439446
servers = endpoint.__meta__(:servers)
440447
servers = GRPC.Server.servers_to_map(servers)

‎grpc_server/lib/grpc/server/adapters/cowboy/handler.ex‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
1414
@default_trailers HTTP2.server_trailers()
1515
@trailers_flag 0b1000_0000
1616

17+
# 4 MB – matches gRPC-Go's default max receive message size.
18+
# Override per-server with the :max_body_size option (bytes).
19+
@default_max_body_size 4 * 1024 * 1024
20+
1721
@type init_state :: {
1822
endpoint :: atom(),
1923
server :: {name :: String.t(), module()},
@@ -96,6 +100,8 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
96100
)
97101
end
98102

103+
max_body_size = Map.get(opts, :max_body_size, @default_max_body_size)
104+
99105
{
100106
:cowboy_loop,
101107
req,
@@ -105,7 +111,8 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
105111
pending_reader: nil,
106112
access_mode: access_mode,
107113
codec: codec,
108-
exception_log_filter: exception_log_filter
114+
exception_log_filter: exception_log_filter,
115+
max_body_size: max_body_size
109116
}
110117
}
111118
else
@@ -341,13 +348,19 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
341348
# APIs end
342349

343350
def info({:read_full_body, ref, pid}, req, state) do
344-
{s, body, req} = read_full_body(req, "", state[:handling_timer])
351+
{s, body, req} = read_full_body(req, <<>>, state[:handling_timer], state.max_body_size)
345352
send(pid, {ref, {s, body}})
346353
{:ok, req, state}
347354
catch
348355
:exit, :timeout ->
349356
Logger.warning("Timeout when reading full body")
350357
info({:handling_timeout, self()}, req, state)
358+
359+
:throw, {:body_too_large, _size} ->
360+
Logger.warning("Request body exceeded max_body_size (#{state.max_body_size} bytes)")
361+
error = RPCError.exception(status: :resource_exhausted, message: "Request body too large")
362+
req = send_error(req, error, state, :body_too_large)
363+
{:stop, req, state}
351364
end
352365

353366
def info({:read_body, ref, pid}, req, state) do
@@ -617,12 +630,27 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
617630
end
618631
end
619632

620-
defp read_full_body(req, body, timer) do
633+
defp read_full_body(req, body, timer, max_bytes) do
621634
result = :cowboy_req.read_body(req, timeout_left_opt(timer))
622635

623636
case result do
624-
{:ok, data, req} -> {:ok, body <> data, req}
625-
{:more, data, req} -> read_full_body(req, body <> data, timer)
637+
{:ok, data, req} ->
638+
total = body <> data
639+
640+
if byte_size(total) > max_bytes do
641+
throw({:body_too_large, byte_size(total)})
642+
else
643+
{:ok, total, req}
644+
end
645+
646+
{:more, data, req} ->
647+
total = body <> data
648+
649+
if byte_size(total) > max_bytes do
650+
throw({:body_too_large, byte_size(total)})
651+
else
652+
read_full_body(req, total, timer, max_bytes)
653+
end
626654
end
627655
end
628656

@@ -664,7 +692,10 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
664692
defp timeout_left_opt(timer, opts \\ %{}) do
665693
case timer do
666694
nil ->
667-
Map.put(opts, :timeout, :infinity)
695+
# No grpc-timeout header was supplied. Do not override cowboy's built-in
696+
# per-chunk read timeout (15 s by default) with :infinity, which would
697+
# allow a slow-trickle client to hold the connection open indefinitely.
698+
opts
668699

669700
timer ->
670701
case Process.read_timer(timer) do

‎grpc_server/mix.exs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ defmodule GRPC.Server.MixProject do
4141
{:flow, "~> 1.2"},
4242
{:protobuf_generate, "~> 0.1.3", only: [:dev, :test]},
4343
{:ex_parameterized, "~> 1.3.7", only: :test},
44+
{:gun, "~> 2.0", only: :test},
4445
{:mox, "~> 1.2", only: :test},
4546
{:ex_doc, "~> 0.39", only: [:dev, :docs], runtime: false},
4647
{:makeup, "~> 1.2.1", only: [:dev, :docs], runtime: false},
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
defmodule GRPC.Server.Adapters.Cowboy.HandlerTest do
2+
use ExUnit.Case, async: false
3+
4+
import ExUnit.CaptureLog
5+
6+
# --------------------------------------------------------------------------
7+
# Minimal server used across all tests
8+
# --------------------------------------------------------------------------
9+
10+
defmodule HelloServer do
11+
use GRPC.Server, service: Helloworld.Greeter.Service
12+
13+
def say_hello(req, _stream) do
14+
%Helloworld.HelloReply{message: "Hello, #{req.name}"}
15+
end
16+
end
17+
18+
# --------------------------------------------------------------------------
19+
# Helpers
20+
# --------------------------------------------------------------------------
21+
22+
# Build a gRPC length-prefixed message frame (no compression).
23+
defp grpc_frame(proto_binary) do
24+
<<0::8, byte_size(proto_binary)::32, proto_binary::binary>>
25+
end
26+
27+
defp grpc_request_headers do
28+
[
29+
{"content-type", "application/grpc+proto"},
30+
{"te", "trailers"}
31+
]
32+
end
33+
34+
# Open an HTTP/2 cleartext connection to the server and return the conn pid.
35+
defp open_h2(port) do
36+
{:ok, conn} = :gun.open(~c"localhost", port, %{protocols: [:http2]})
37+
{:ok, :http2} = :gun.await_up(conn, 5_000)
38+
conn
39+
end
40+
41+
# Collect all gun frames for *stream_ref* until END_STREAM, then return the
42+
# final grpc-status value found in either the response headers or trailers.
43+
defp collect_grpc_status(conn, stream_ref) do
44+
collect_grpc_status(conn, stream_ref, nil)
45+
end
46+
47+
defp collect_grpc_status(conn, stream_ref, last_status) do
48+
case :gun.await(conn, stream_ref, 5_000) do
49+
{:response, :fin, _http_status, headers} ->
50+
find_grpc_status(headers) || last_status
51+
52+
{:response, :nofin, _http_status, headers} ->
53+
collect_grpc_status(conn, stream_ref, find_grpc_status(headers))
54+
55+
{:data, :fin, _data} ->
56+
last_status
57+
58+
{:data, :nofin, _data} ->
59+
collect_grpc_status(conn, stream_ref, last_status)
60+
61+
{:trailers, trailers} ->
62+
find_grpc_status(trailers) || last_status
63+
64+
{:error, reason} ->
65+
flunk("gun error: #{inspect(reason)}")
66+
end
67+
end
68+
69+
defp find_grpc_status(headers) do
70+
case List.keyfind(headers, "grpc-status", 0) do
71+
{"grpc-status", v} -> v
72+
nil -> nil
73+
end
74+
end
75+
76+
# --------------------------------------------------------------------------
77+
# Tests: max_body_size enforcement
78+
# --------------------------------------------------------------------------
79+
80+
describe "max_body_size" do
81+
test "rejects a body that exceeds max_body_size with RESOURCE_EXHAUSTED (8)" do
82+
capture_log(fn ->
83+
run_server_with_opts([HelloServer], [max_body_size: 64], fn port ->
84+
# Build a gRPC frame whose total size is well above the 64-byte cap.
85+
large_name = String.duplicate("x", 200)
86+
87+
body =
88+
grpc_frame(Protobuf.encode(%Helloworld.HelloRequest{name: large_name}))
89+
90+
assert byte_size(body) > 64,
91+
"test body (#{byte_size(body)} bytes) must exceed max_body_size: 64"
92+
93+
conn = open_h2(port)
94+
ref = :gun.post(conn, "/helloworld.Greeter/SayHello", grpc_request_headers(), body)
95+
96+
assert collect_grpc_status(conn, ref) == "8"
97+
98+
:gun.close(conn)
99+
end)
100+
end)
101+
end
102+
103+
test "allows a body within max_body_size and returns OK (0)" do
104+
run_server_with_opts([HelloServer], [max_body_size: 4096], fn port ->
105+
body = grpc_frame(Protobuf.encode(%Helloworld.HelloRequest{name: "hi"}))
106+
107+
assert byte_size(body) < 4096,
108+
"test body (#{byte_size(body)} bytes) must fit within max_body_size: 4096"
109+
110+
conn = open_h2(port)
111+
ref = :gun.post(conn, "/helloworld.Greeter/SayHello", grpc_request_headers(), body)
112+
113+
assert collect_grpc_status(conn, ref) == "0"
114+
115+
:gun.close(conn)
116+
end)
117+
end
118+
119+
test "default max_body_size is 4 MB – normal requests succeed without explicit option" do
120+
run_server_with_opts([HelloServer], [], fn port ->
121+
body = grpc_frame(Protobuf.encode(%Helloworld.HelloRequest{name: "default limit"}))
122+
123+
conn = open_h2(port)
124+
ref = :gun.post(conn, "/helloworld.Greeter/SayHello", grpc_request_headers(), body)
125+
126+
assert collect_grpc_status(conn, ref) == "0"
127+
128+
:gun.close(conn)
129+
end)
130+
end
131+
end
132+
133+
# --------------------------------------------------------------------------
134+
# Tests: read timeout – no :infinity when grpc-timeout is absent
135+
# --------------------------------------------------------------------------
136+
137+
describe "read timeout" do
138+
test "omitting grpc-timeout header still completes a normal request" do
139+
# If timeout_left_opt/1 incorrectly passed :infinity to cowboy for a
140+
# nil timer, normal unary requests would still succeed – the regression
141+
# is that a slow-trickle attack could hold the connection indefinitely.
142+
# This smoke-test verifies the nil-timer path doesn't break normal calls.
143+
run_server_with_opts([HelloServer], [], fn port ->
144+
# Deliberately omit the grpc-timeout header.
145+
headers = grpc_request_headers()
146+
body = grpc_frame(Protobuf.encode(%Helloworld.HelloRequest{name: "no timeout header"}))
147+
148+
conn = open_h2(port)
149+
ref = :gun.post(conn, "/helloworld.Greeter/SayHello", headers, body)
150+
151+
assert collect_grpc_status(conn, ref) == "0"
152+
153+
:gun.close(conn)
154+
end)
155+
end
156+
end
157+
158+
# --------------------------------------------------------------------------
159+
# Private helper: start a server with specific opts and run a test function
160+
# --------------------------------------------------------------------------
161+
162+
defp run_server_with_opts(servers, opts, func) do
163+
{:ok, _pid, port} =
164+
start_supervised(%{
165+
id: {GRPC.Server, System.unique_integer([:positive])},
166+
start: {GRPC.Server, :start, [servers, 0, opts]},
167+
type: :worker,
168+
restart: :permanent,
169+
shutdown: 500
170+
})
171+
172+
try do
173+
func.(port)
174+
after
175+
GRPC.Server.stop(servers)
176+
end
177+
end
178+
end

‎interop/script/run.exs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ alias GRPC.Client.Adapters.Gun
2020
alias GRPC.Client.Adapters.Mint
2121
alias Interop.Client
2222

23-
{:ok, _pid, port} = GRPC.Server.start_endpoint(Interop.Endpoint, port)
23+
# large_unary2! sends an 8 MB payload; allow up to 32 MB to keep headroom.
24+
{:ok, _pid, port} = GRPC.Server.start_endpoint(Interop.Endpoint, port, max_body_size: 32 * 1024 * 1024)
2425

2526
defmodule InteropTestRunner do
2627
def run(_cli, adapter, port, rounds) do

0 commit comments

Comments
 (0)