Skip to content

Commit f58845f

Browse files
committed
Add :recursion_limit option to Protobuf.JSON decoding
Closes #434. Also knocks out a few conformance tests.
1 parent d9f7ab9 commit f58845f

5 files changed

Lines changed: 148 additions & 53 deletions

File tree

conformance/exemptions.txt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ Recommended.Proto2.ProtobufInput.ValidMessageSetEncoding.SubmessageEncoding.NotU
66
Recommended.Proto3.JsonInput.FieldNameDuplicate
77
Recommended.Proto3.JsonInput.FieldNameDuplicateDifferentCasing1
88
Recommended.Proto3.JsonInput.FieldNameDuplicateDifferentCasing2
9-
Recommended.Proto3.JsonInput.ListValueDeepNesting200
10-
Recommended.Proto3.JsonInput.StructDeepNesting200
11-
Recommended.Proto3.JsonInput.ValueDeepNesting200
129
Required.Proto2.ProtobufInput.BadTag_FieldNumberSlightlyTooHigh
1310
Required.Proto2.ProtobufInput.BadTag_FieldNumberTooHigh
1411
Required.Proto2.ProtobufInput.BadTag_OverlongVarint

lib/protobuf/json.ex

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ defmodule Protobuf.JSON do
110110
A decoding option.
111111
"""
112112
@typedoc since: "0.17.0"
113-
@type decode_opt() :: {:ignore_unknown_fields, boolean()}
113+
@type decode_opt() ::
114+
{:ignore_unknown_fields, boolean()}
115+
| {:recursion_limit, pos_integer()}
114116

115117
@type json_data() :: %{optional(binary) => any}
116118

@@ -289,7 +291,14 @@ defmodule Protobuf.JSON do
289291
* `:ignore_unknown_fields` (boolean): when `true`, unknown enum string values are
290292
treated as if the field was unset (and skipped from repeated fields and
291293
map values) instead of raising. Unknown JSON object keys are always
292-
ignored regardless of this option. Defaults to `false`.
294+
ignored regardless of this option. Defaults to `false`. *Available
295+
since v0.17.0*.
296+
297+
* `:recursion_limit` (positive integer): the maximum nesting depth allowed
298+
when decoding the dynamically-typed `Google.Protobuf.Value`,
299+
`Google.Protobuf.ListValue`, and `Google.Protobuf.Struct` wrappers.
300+
Exceeding it raises a `Protobuf.JSON.DecodeError`. Defaults to `100`. *Available
301+
since v0.17.0*.
293302
294303
## Examples
295304
@@ -376,23 +385,29 @@ defmodule Protobuf.JSON do
376385
@spec from_decoded(json_data(), module(), [decode_opt]) ::
377386
{:ok, struct()} | {:error, DecodeError.t()}
378387
def from_decoded(json_data, module, options \\ []) when is_atom(module) and is_list(options) do
379-
decode_opts =
380-
Enum.reduce(options, %{ignore_unknown_fields: false}, fn
381-
{:ignore_unknown_fields, value}, acc when is_boolean(value) ->
382-
Map.put(acc, :ignore_unknown_fields, value)
388+
Enum.each(options, fn
389+
{:ignore_unknown_fields, value} when is_boolean(value) ->
390+
:ok
391+
392+
{:ignore_unknown_fields, value} ->
393+
raise ArgumentError,
394+
"option :ignore_unknown_fields must be a boolean, got: #{inspect(value)}"
395+
396+
{:recursion_limit, value} when is_integer(value) and value > 0 ->
397+
:ok
383398

384-
{:ignore_unknown_fields, value}, _acc ->
385-
raise ArgumentError,
386-
"option :ignore_unknown_fields must be a boolean, got: #{inspect(value)}"
399+
{:recursion_limit, value} ->
400+
raise ArgumentError,
401+
"option :recursion_limit must be a positive integer, got: #{inspect(value)}"
387402

388-
{key, _value}, _acc ->
389-
raise ArgumentError, "unknown option: #{inspect(key)}"
403+
{key, _value} ->
404+
raise ArgumentError, "unknown option: #{inspect(key)}"
390405

391-
other, _acc ->
392-
raise ArgumentError, "invalid element in options list: #{inspect(other)}"
393-
end)
406+
other ->
407+
raise ArgumentError, "invalid element in options list: #{inspect(other)}"
408+
end)
394409

395-
{:ok, Decode.from_json_data(json_data, module, decode_opts)}
410+
{:ok, Decode.from_json_data(json_data, module, options)}
396411
catch
397412
error -> {:error, DecodeError.new(error)}
398413
end

lib/protobuf/json/decode.ex

Lines changed: 66 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,30 @@ defmodule Protobuf.JSON.Decode do
5050

5151
@duration_seconds_range -315_576_000_000..315_576_000_000
5252

53-
@type opts() :: %{ignore_unknown_fields: boolean()}
53+
# Default recursion limit, matching upstream Protobuf
54+
# (ParseOptions::recursion_depth in C++, .recursion_limit in Java/Go).
55+
# Applies to the dynamically-typed wrappers (Value/ListValue/Struct) where
56+
# nesting is bounded only by the input, not the schema.
57+
@default_recursion_limit 100
5458

55-
@spec from_json_data(term(), module(), opts()) :: struct()
56-
def from_json_data(term, module, opts \\ %{ignore_unknown_fields: false})
59+
@spec from_json_data(term(), module(), keyword()) :: struct()
60+
def from_json_data(term, module, opts) when is_list(opts) and is_atom(module) do
61+
state = %{
62+
ignore_unknown_fields: Keyword.get(opts, :ignore_unknown_fields, false),
63+
recursion_limit: Keyword.get(opts, :recursion_limit, @default_recursion_limit),
64+
depth: 0
65+
}
66+
67+
internal_from_json_data(term, module, state)
68+
end
5769

5870
# We start with all the Google built-in types that have specially-defined JSON decoding rules.
5971
# These rules are listed here: https://developers.google.com/protocol-buffers/docs/proto3#json
6072
# Note that we always have to keep the module names for the built-in types dynamic because
6173
# these built-in types **do not ship with our library**.
6274

63-
def from_json_data(string, Google.Protobuf.Duration = mod, _opts) when is_binary(string) do
75+
defp internal_from_json_data(string, Google.Protobuf.Duration = mod, _state)
76+
when is_binary(string) do
6477
# We need to check the sign from the raw string itself and can't rely on Integer.parse/1. This
6578
# is because if seconds is 0, then we couldn't determine whether it was "0" or "-0". For
6679
# example, "-0.5s".
@@ -81,63 +94,70 @@ defmodule Protobuf.JSON.Decode do
8194
end
8295
end
8396

84-
def from_json_data(string, Google.Protobuf.Timestamp = mod, _opts) when is_binary(string) do
97+
defp internal_from_json_data(string, Google.Protobuf.Timestamp = mod, _state)
98+
when is_binary(string) do
8599
case Protobuf.JSON.RFC3339.decode(string) do
86100
{:ok, seconds, nanos} -> struct!(mod, seconds: seconds, nanos: nanos)
87101
{:error, reason} -> throw({:bad_timestamp, string, reason})
88102
end
89103
end
90104

91-
def from_json_data(map, Google.Protobuf.Empty = mod, _opts) when map == %{} do
105+
defp internal_from_json_data(map, Google.Protobuf.Empty = mod, _state) when map == %{} do
92106
struct!(mod)
93107
end
94108

95-
def from_json_data(int, Google.Protobuf.Int32Value = mod, _opts),
109+
defp internal_from_json_data(int, Google.Protobuf.Int32Value = mod, _state),
96110
do: struct!(mod, value: decode_scalar(:int32, :unknown_name, int))
97111

98-
def from_json_data(int, Google.Protobuf.UInt32Value = mod, _opts),
112+
defp internal_from_json_data(int, Google.Protobuf.UInt32Value = mod, _state),
99113
do: struct!(mod, value: decode_scalar(:uint32, :unknown_name, int))
100114

101-
def from_json_data(int, Google.Protobuf.UInt64Value = mod, _opts),
115+
defp internal_from_json_data(int, Google.Protobuf.UInt64Value = mod, _state),
102116
do: struct!(mod, value: decode_scalar(:uint64, :unknown_name, int))
103117

104-
def from_json_data(int, Google.Protobuf.Int64Value = mod, _opts),
118+
defp internal_from_json_data(int, Google.Protobuf.Int64Value = mod, _state),
105119
do: struct!(mod, value: decode_scalar(:int64, :unknown_name, int))
106120

107-
def from_json_data(number, mod, _opts)
108-
when mod in [
109-
Google.Protobuf.FloatValue,
110-
Google.Protobuf.DoubleValue
111-
] and (is_float(number) or is_integer(number)) do
121+
defp internal_from_json_data(number, mod, _state)
122+
when mod in [
123+
Google.Protobuf.FloatValue,
124+
Google.Protobuf.DoubleValue
125+
] and (is_float(number) or is_integer(number)) do
112126
struct!(mod, value: number * 1.0)
113127
end
114128

115-
def from_json_data(bool, Google.Protobuf.BoolValue = mod, _opts) when is_boolean(bool) do
129+
defp internal_from_json_data(bool, Google.Protobuf.BoolValue = mod, _state)
130+
when is_boolean(bool) do
116131
struct!(mod, value: decode_scalar(:bool, :unknown_field, bool))
117132
end
118133

119-
def from_json_data(string, Google.Protobuf.StringValue = mod, _opts) when is_binary(string) do
134+
defp internal_from_json_data(string, Google.Protobuf.StringValue = mod, _state)
135+
when is_binary(string) do
120136
struct!(mod, value: decode_scalar(:string, :unknown_field, string))
121137
end
122138

123-
def from_json_data(bytes, Google.Protobuf.BytesValue = mod, _opts) when is_binary(bytes) do
139+
defp internal_from_json_data(bytes, Google.Protobuf.BytesValue = mod, _state)
140+
when is_binary(bytes) do
124141
struct!(mod, value: decode_scalar(:bytes, :unknown_field, bytes))
125142
end
126143

127-
def from_json_data(list, Google.Protobuf.ListValue = mod, opts) when is_list(list) do
128-
struct!(mod, values: Enum.map(list, &from_json_data(&1, Google.Protobuf.Value, opts)))
144+
defp internal_from_json_data(list, Google.Protobuf.ListValue = mod, state) when is_list(list) do
145+
state = increase_depth_and_maybe_throw(state)
146+
struct!(mod, values: Enum.map(list, &internal_from_json_data(&1, Google.Protobuf.Value, state)))
129147
end
130148

131-
def from_json_data(struct, Google.Protobuf.Struct = mod, opts) when is_map(struct) do
149+
defp internal_from_json_data(struct, Google.Protobuf.Struct = mod, state) when is_map(struct) do
150+
state = increase_depth_and_maybe_throw(state)
151+
132152
fields =
133153
Map.new(struct, fn {key, val} ->
134-
{key, from_json_data(val, Google.Protobuf.Value, opts)}
154+
{key, internal_from_json_data(val, Google.Protobuf.Value, state)}
135155
end)
136156

137157
struct!(mod, fields: fields)
138158
end
139159

140-
def from_json_data(term, Google.Protobuf.Value = mod, opts) do
160+
defp internal_from_json_data(term, Google.Protobuf.Value = mod, state) do
141161
cond do
142162
is_nil(term) ->
143163
struct!(mod, kind: {:null_value, :NULL_VALUE})
@@ -155,17 +175,22 @@ defmodule Protobuf.JSON.Decode do
155175
struct!(mod, kind: {:bool_value, term})
156176

157177
is_list(term) ->
158-
struct!(mod, kind: {:list_value, from_json_data(term, Google.Protobuf.ListValue, opts)})
178+
struct!(mod,
179+
kind: {:list_value, internal_from_json_data(term, Google.Protobuf.ListValue, state)}
180+
)
159181

160182
is_map(term) ->
161-
struct!(mod, kind: {:struct_value, from_json_data(term, Google.Protobuf.Struct, opts)})
183+
struct!(mod,
184+
kind: {:struct_value, internal_from_json_data(term, Google.Protobuf.Struct, state)}
185+
)
162186

163187
true ->
164188
throw({:bad_message, term, mod})
165189
end
166190
end
167191

168-
def from_json_data(data, Google.Protobuf.FieldMask = mod, _opts) when is_binary(data) do
192+
defp internal_from_json_data(data, Google.Protobuf.FieldMask = mod, _state)
193+
when is_binary(data) do
169194
paths = String.split(data, ",")
170195

171196
cond do
@@ -175,7 +200,7 @@ defmodule Protobuf.JSON.Decode do
175200
end
176201
end
177202

178-
def from_json_data(%{"@type" => type_url} = data, Google.Protobuf.Any = mod, opts) do
203+
defp internal_from_json_data(%{"@type" => type_url} = data, Google.Protobuf.Any = mod, state) do
179204
data = Map.delete(data, "@type")
180205
message_mod = Protobuf.Any.type_url_to_module(type_url)
181206

@@ -186,35 +211,41 @@ defmodule Protobuf.JSON.Decode do
186211
# See: https://developers.google.com/protocol-buffers/docs/proto3#json
187212
{:ok, value} ->
188213
value
189-
|> from_json_data(message_mod, opts)
214+
|> internal_from_json_data(message_mod, state)
190215
|> message_mod.encode()
191216

192217
# When a message doesn't have a built-in JSON representation (like
193218
# google.protobuf.Timestamp), then it's encoded as a JSON object and then a @type field is
194219
# added with the type_url for that message.
195220
:error ->
196221
data
197-
|> from_json_data(message_mod, opts)
222+
|> internal_from_json_data(message_mod, state)
198223
|> message_mod.encode()
199224
end
200225

201226
struct!(mod, type_url: type_url, value: encoded)
202227
end
203228

204-
def from_json_data(data, module, opts) when is_map(data) and is_atom(module) do
229+
defp internal_from_json_data(data, module, state) when is_map(data) and is_atom(module) do
205230
message_props = module.__message_props__()
206-
regular = decode_regular_fields(data, message_props, opts)
207-
oneofs = decode_oneof_fields(data, message_props, opts)
231+
regular = decode_regular_fields(data, message_props, state)
232+
oneofs = decode_oneof_fields(data, message_props, state)
208233

209234
module
210235
|> struct(regular)
211236
|> struct(oneofs)
212237
|> transform_module(module)
213238
end
214239

215-
def from_json_data(data, module, _opts) when is_atom(module),
240+
defp internal_from_json_data(data, module, _state) when is_atom(module),
216241
do: throw({:bad_message, data, module})
217242

243+
defp increase_depth_and_maybe_throw(%{depth: depth, recursion_limit: limit} = opts) do
244+
depth = depth + 1
245+
if depth > limit, do: throw({:recursion_limit_exceeded, limit})
246+
%{opts | depth: depth}
247+
end
248+
218249
defp convert_field_mask_to_underscore(mask) do
219250
if mask =~ ~r/^[a-zA-Z0-9\.]+$/ do
220251
String.split(mask, ".")
@@ -353,8 +384,8 @@ defmodule Protobuf.JSON.Decode do
353384
end)
354385
end
355386

356-
defp decode_singular(%{type: module, embedded?: true}, value, opts) do
357-
from_json_data(value, module, opts)
387+
defp decode_singular(%{type: module, embedded?: true}, value, state) do
388+
internal_from_json_data(value, module, state)
358389
end
359390

360391
defp decode_scalar(:string, name, value) do

lib/protobuf/json/decode_error.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ defmodule Protobuf.JSON.DecodeError do
7070
%__MODULE__{message: "Repeated field '#{field}' expected a list, got #{inspect(value)}"}
7171
end
7272

73+
def new({:recursion_limit_exceeded, limit}) do
74+
%__MODULE__{message: "JSON value exceeds the recursion limit of #{limit}"}
75+
end
76+
7377
def new({:unexpected_end, position}) do
7478
%__MODULE__{message: "Unexpected end at position #{inspect(position)}"}
7579
end

test/protobuf/json/decode_test.exs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,4 +998,52 @@ defmodule Protobuf.JSON.DecodeTest do
998998
}
999999
end
10001000
end
1001+
1002+
describe ":recursion_limit option" do
1003+
@default_limit 100
1004+
defp depth_error(limit), do: error("JSON value exceeds the recursion limit of #{limit}")
1005+
1006+
test "nested Google.Protobuf.ListValue beyond the default limit is rejected" do
1007+
nested = Enum.reduce(1..(@default_limit + 1), 1, fn _, acc -> [acc] end)
1008+
assert decode(nested, Google.Protobuf.ListValue) == depth_error(@default_limit)
1009+
end
1010+
1011+
test "nested Google.Protobuf.Struct beyond the default limit is rejected" do
1012+
nested = Enum.reduce(1..(@default_limit + 1), 1, fn _, acc -> %{"k" => acc} end)
1013+
assert decode(nested, Google.Protobuf.Struct) == depth_error(@default_limit)
1014+
end
1015+
1016+
test "nested Google.Protobuf.Value beyond the default limit is rejected" do
1017+
# Value itself doesn't add a level; nesting comes from the wrapped lists.
1018+
nested = Enum.reduce(1..(@default_limit + 1), 1, fn _, acc -> [acc] end)
1019+
assert decode(nested, Google.Protobuf.Value) == depth_error(@default_limit)
1020+
end
1021+
1022+
test "nesting up to the default limit is accepted" do
1023+
nested = Enum.reduce(1..@default_limit, 1, fn _, acc -> [acc] end)
1024+
assert {:ok, %Google.Protobuf.ListValue{}} = decode(nested, Google.Protobuf.ListValue)
1025+
end
1026+
1027+
test "custom :recursion_limit caps nesting at the requested depth" do
1028+
nested = Enum.reduce(1..6, 1, fn _, acc -> [acc] end)
1029+
1030+
assert Protobuf.JSON.from_decoded(nested, Google.Protobuf.ListValue, recursion_limit: 5) ==
1031+
depth_error(5)
1032+
1033+
assert {:ok, %Google.Protobuf.ListValue{}} =
1034+
Protobuf.JSON.from_decoded(nested, Google.Protobuf.ListValue, recursion_limit: 6)
1035+
end
1036+
1037+
test "invalid :recursion_limit raises ArgumentError" do
1038+
for bad <- [0, -1, 1.0, "100", nil] do
1039+
assert_raise ArgumentError,
1040+
"option :recursion_limit must be a positive integer, got: #{inspect(bad)}",
1041+
fn ->
1042+
Protobuf.JSON.from_decoded(%{}, Google.Protobuf.Struct,
1043+
recursion_limit: bad
1044+
)
1045+
end
1046+
end
1047+
end
1048+
end
10011049
end

0 commit comments

Comments
 (0)