Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ DecodeOptions.strict_depth option to throw when input is beyond depth #8

Merged
merged 1 commit into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,24 @@ This depth can be overridden by setting the `depth <https://techouse.github.io/q
qs.DecodeOptions(depth=1),
) == {'a': {'b': {'[c][d][e][f][g][h][i]': 'j'}}}

You can configure `decode <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.decode>`__ to throw an error
when parsing nested input beyond this depth using `strict_depth <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.decode_options.DecodeOptions.strict_depth>`__ (defaults to ``False``):

.. code:: python

import qs_codec as qs

try:
qs.decode(
'a[b][c][d][e][f][g][h][i]=j',
qs.DecodeOptions(depth=1, strict_depth=True),
)
except IndexError as e:
assert str(e) == 'Input depth exceeded depth option of 1 and strict_depth is True'

The depth limit helps mitigate abuse when `decode <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.decode>`__ is used to parse user
input, and it is recommended to keep it a reasonably small number.
input, and it is recommended to keep it a reasonably small number. `strict_depth <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.decode_options.DecodeOptions.strict_depth>`__
adds a layer of protection by throwing a ``IndexError`` when the limit is exceeded, allowing you to catch and handle such cases.

For similar reasons, by default `decode <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.decode>`__ will only parse up to 1000 parameters. This can be overridden by passing a
`parameter_limit <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.decode_options.DecodeOptions.parameter_limit>`__ option:
Expand Down
21 changes: 19 additions & 2 deletions docs/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,25 @@ This depth can be overridden by setting the :py:attr:`depth <qs_codec.models.dec
qs.DecodeOptions(depth=1),
) == {'a': {'b': {'[c][d][e][f][g][h][i]': 'j'}}}

The depth limit helps mitigate abuse when :py:attr:`decode <qs_codec.decode>` is used to parse user
input, and it is recommended to keep it a reasonably small number.

You can configure :py:attr:`decode <qs_codec.decode>` to throw an error
when parsing nested input beyond this depth using :py:attr:`strict_depth <qs_codec.models.decode_options.DecodeOptions.strict_depth>` (defaults to ``False``):

.. code:: python

import qs_codec as qs

try:
qs.decode(
'a[b][c][d][e][f][g][h][i]=j',
qs.DecodeOptions(depth=1, strict_depth=True),
)
except IndexError as e:
assert str(e) == 'Input depth exceeded depth option of 1 and strict_depth is True'

The depth limit helps mitigate abuse when :py:attr:`decode <qs_codec.decode>` is used to parse user input, and it is recommended
to keep it a reasonably small number. :py:attr:`strict_depth <qs_codec.models.decode_options.DecodeOptions.strict_depth>`
adds a layer of protection by throwing a ``IndexError`` when the limit is exceeded, allowing you to catch and handle such cases.

For similar reasons, by default :py:attr:`decode <qs_codec.decode>` will only parse up to 1000 parameters. This can be overridden by passing a
:py:attr:`parameter_limit <qs_codec.models.decode_options.DecodeOptions.parameter_limit>` option:
Expand Down
2 changes: 2 additions & 0 deletions src/qs_codec/decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ def _parse_keys(given_key: t.Optional[str], val: t.Any, options: DecodeOptions,

# If there's a remainder, just add whatever is left
if segment is not None:
if options.strict_depth:
raise IndexError(f"Input depth exceeded depth option of {options.depth} and strict_depth is True")
keys.append(f"[{key[segment.start():]}]")

return _parse_object(keys, val, options, values_parsed)
3 changes: 3 additions & 0 deletions src/qs_codec/models/decode_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ class DecodeOptions:
parse_lists: bool = True
"""To disable ``list`` parsing entirely, set ``parse_lists`` to ``False``."""

strict_depth: bool = False
"""Set to ``True`` to throw an error when the input exceeds the ``depth`` limit."""

strict_null_handling: bool = False
"""Set to true to decode values without ``=`` to ``None``."""

Expand Down
36 changes: 36 additions & 0 deletions tests/unit/decode_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,39 @@ def test_first(self) -> None:

def test_last(self) -> None:
assert decode("foo=bar&foo=baz", DecodeOptions(duplicates=Duplicates.LAST)) == {"foo": "baz"}


class TestStrictDepthOption:
def test_raises_index_error_for_multiple_nested_objects_with_strict_depth(self) -> None:
with pytest.raises(IndexError):
decode("a[b][c][d][e][f][g][h][i]=j", DecodeOptions(depth=1, strict_depth=True))

def test_raises_index_error_for_multiple_nested_lists_with_strict_depth(self) -> None:
with pytest.raises(IndexError):
decode("a[0][1][2][3][4]=b", DecodeOptions(depth=3, strict_depth=True))

def test_raises_index_error_for_nested_dicts_and_lists_with_strict_depth(self) -> None:
with pytest.raises(IndexError):
decode("a[b][c][0][d][e]=f", DecodeOptions(depth=3, strict_depth=True))

def test_raises_index_error_for_different_types_of_values_with_strict_depth(self) -> None:
with pytest.raises(IndexError):
decode("a[b][c][d][e]=true&a[b][c][d][f]=42", DecodeOptions(depth=3, strict_depth=True))

def test_when_depth_is_0_and_strict_depth_true_do_not_throw(self) -> None:
with does_not_raise():
decode("a[b][c][d][e]=true&a[b][c][d][f]=42", DecodeOptions(depth=0, strict_depth=True))

def test_decodes_successfully_when_depth_is_within_the_limit_with_strict_depth(self) -> None:
assert decode("a[b]=c", DecodeOptions(depth=1, strict_depth=True)) == {"a": {"b": "c"}}

def test_does_not_throw_an_exception_when_depth_exceeds_the_limit_with_strict_depth_false(self) -> None:
assert decode("a[b][c][d][e][f][g][h][i]=j", DecodeOptions(depth=1)) == {
"a": {"b": {"[c][d][e][f][g][h][i]": "j"}}
}

def test_decodes_successfully_when_depth_is_within_the_limit_with_strict_depth_false(self) -> None:
assert decode("a[b]=c", DecodeOptions(depth=1)) == {"a": {"b": "c"}}

def test_does_not_throw_when_depth_is_exactly_at_the_limit_with_strict_depth_true(self) -> None:
assert decode("a[b][c]=d", DecodeOptions(depth=2, strict_depth=True)) == {"a": {"b": {"c": "d"}}}