diff --git a/README.rst b/README.rst index ddabea8..190b2a5 100644 --- a/README.rst +++ b/README.rst @@ -93,8 +93,24 @@ This depth can be overridden by setting the `depth `__ to throw an error +when parsing nested input beyond this depth using `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 `__ 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 `__ +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 `__ will only parse up to 1000 parameters. This can be overridden by passing a `parameter_limit `__ option: diff --git a/docs/README.rst b/docs/README.rst index 97910fb..573dccc 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -67,8 +67,25 @@ This depth can be overridden by setting the :py:attr:`depth ` is used to parse user -input, and it is recommended to keep it a reasonably small number. + +You can configure :py:attr:`decode ` to throw an error +when parsing nested input beyond this depth using :py:attr:`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 ` is used to parse user input, and it is recommended +to keep it a reasonably small number. :py:attr:`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 ` will only parse up to 1000 parameters. This can be overridden by passing a :py:attr:`parameter_limit ` option: diff --git a/src/qs_codec/decode.py b/src/qs_codec/decode.py index 6fad395..0eccbdc 100644 --- a/src/qs_codec/decode.py +++ b/src/qs_codec/decode.py @@ -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) diff --git a/src/qs_codec/models/decode_options.py b/src/qs_codec/models/decode_options.py index bd07a7d..533b017 100644 --- a/src/qs_codec/models/decode_options.py +++ b/src/qs_codec/models/decode_options.py @@ -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``.""" diff --git a/tests/unit/decode_test.py b/tests/unit/decode_test.py index 8a9d9e5..6f85149 100644 --- a/tests/unit/decode_test.py +++ b/tests/unit/decode_test.py @@ -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"}}}