Skip to content

Harden URL reads against rebinding and compression bombs - #372

Merged
vinitkumar merged 7 commits into
masterfrom
fix/coordinated-review-findings
Aug 4, 2026
Merged

Harden URL reads against rebinding and compression bombs#372
vinitkumar merged 7 commits into
masterfrom
fix/coordinated-review-findings

Conversation

@vinitkumar

@vinitkumar vinitkumar commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • pin public URL requests to the address validated by DNS while preserving the Host header and TLS hostname
  • incrementally decode gzip and deflate responses with a strict decoded-output bound
  • require an actual boolean for allow_private_networks
  • wrap malformed Unicode hostname failures in URLReadError
  • add regression coverage, README guidance, and unreleased migration notes

Verification

  • uv run ruff check json2xml tests
  • uvx ty check json2xml tests
  • uv run pytest --cov=json2xml --cov-report=term --cov-fail-under=100 -q tests (486 passed, 100% coverage)
  • cargo test --manifest-path rust/Cargo.toml (48 passed)
  • lat check
  • live HTTPS read from https://pypi.org/pypi/json2xml/json

Release prerequisite

Before the next Python release, publish a compatible accelerator newer than json2xml-rs==0.4.2, then raise the json2xml[fast] and uv.lock minimum to that published version. The current compatibility probe safely disables 0.4.2 in the meantime.

Summary by Sourcery

Harden URL-based JSON reads against DNS rebinding and compressed-response abuse while tightening URL validation and documenting the new security constraints.

Bug Fixes:

  • Ensure malformed Unicode/IDNA hostnames consistently raise URLReadError instead of surfacing low-level errors.
  • Require allow_private_networks to be a real boolean so truthy non-booleans cannot silently bypass private-network protections.
  • Honor max_response_bytes for uncompressed responses without Content-Length by enforcing a strict byte cap.
  • Reject invalid or unsafe compressed responses and unsupported content encodings with URLReadError.

Enhancements:

  • Pin public URL requests to previously validated DNS addresses while preserving the original Host header and HTTPS certificate hostname.
  • Introduce bounded, incremental decoding for gzip and deflate responses so decompressed bodies cannot exceed the configured size limit.
  • Refactor URL reading into helpers for validation, request dispatch, compression handling, and bounded response reading.

Documentation:

  • Add unreleased release notes describing the new URL security behavior, compression handling, and migration guidance.
  • Extend LAT behavior and tests documentation to cover DNS pinning, boolean-only private-network opt-in, and invalid Unicode hostname handling.

Tests:

  • Add regression tests covering DNS-pinned URL requests, invalid Unicode hostnames, strict boolean private-network opt-in, compressed-response limits, and invalid compression handling.

@sourcery-ai

sourcery-ai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Hardens json2xml URL reading by pinning public HTTP(S) connections to DNS-validated addresses, adding bounded incremental decompression for gzip/deflate, tightening configuration validation, and updating tests and docs to cover the new behavior and migration guidance.

Sequence diagram for hardened readfromurl URL reading

sequenceDiagram
    actor Caller
    participant readfromurl
    participant _validate_url
    participant _request_url
    participant _read_response_data

    Caller->>readfromurl: readfromurl(url, allow_private_networks, max_response_bytes, params)
    readfromurl->>_validate_url: _validate_url(url, allow_private_networks)
    _validate_url-->>readfromurl: parsed, validated_address

    readfromurl->>_request_url: _request_url(http, parsed, validated_address, params, timeout)
    _request_url->>http: connection_from_host(validated_address, port, scheme, pool_kwargs)
    _request_url->>pool: request("GET", request_target, headers, request_options)
    _request_url-->>readfromurl: response

    readfromurl->>_read_response_data: _read_response_data(response, max_response_bytes)
    alt identity_or_no_encoding
        _read_response_data->>response: read(max_response_bytes + 1, decode_content=False)
        _read_response_data-->>readfromurl: response_data
    else gzip_or_deflate
        _read_response_data->>response: read(COMPRESSED_READ_CHUNK_BYTES, decode_content=False)
        _read_response_data->>_compression_decoder: _compression_decoder(encoding, first_chunk)
        loop while compressed_chunk
            _read_response_data->>response: read(COMPRESSED_READ_CHUNK_BYTES, decode_content=False)
        end
        _read_response_data-->>readfromurl: response_data
    end

    readfromurl-->>Caller: parsed JSON data
Loading

File-Level Changes

Change Details Files
Harden URL validation and routing, including DNS pinning and private network checks.
  • Refactored _validate_url to return the parsed URL and a validated public address and to wrap IDNA/DNS errors in URLReadError.
  • Changed private-network validation to collect resolved addresses as an ordered list and require all to be global, returning the first address for pinning.
  • Added _request_url helper to route public requests via connection_from_host using the validated address while preserving Host header, TLS hostname, and original request target.
json2xml/utils.py
tests/test_utils.py
lat.md/tests.md
lat.md/behavior.md
Introduce bounded, incremental response reading with explicit compression handling.
  • Added COMPRESSED_READ_CHUNK_BYTES constant and _read_response_data helper to enforce max_response_bytes on decoded output.
  • Implemented _compression_decoder to support gzip/x-gzip and deflate (with or without zlib headers) using zlib, rejecting unsupported encodings.
  • Updated readfromurl to delegate to _read_response_data instead of relying on urllib3 decode_content, ensuring strict size limits for both compressed and uncompressed responses.
json2xml/utils.py
tests/test_utils.py
Tighten readfromurl input validation and error contracts.
  • Require allow_private_networks to be a real boolean and raise URLReadError otherwise.
  • Harden max_response_bytes validation to reject booleans and non-positive or non-int values.
  • Clarified behavior documentation to include hostname encoding failures as URLReadError and describe compression and pinning semantics.
json2xml/utils.py
tests/test_utils.py
lat.md/behavior.md
lat.md/tests.md
Expand and align test coverage, release notes, and documentation for the new URL behavior.
  • Added tests covering DNS address pinning, invalid Unicode hostnames, non-boolean private-network opt-in, uncompressed bodies without Content-Length, incremental compression decoding, and rejection of invalid/unsupported encodings.
  • Adjusted existing tests to account for the new allow_private_networks requirement when targeting private IPs and for the new compressed size-limiting behavior.
  • Documented the new security and migration behavior in RELEASE_NOTES.md and updated behavior and test LAT docs to describe pinning, compression limits, and boolean opt-in requirements.
tests/test_utils.py
RELEASE_NOTES.md
lat.md/tests.md
lat.md/behavior.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (dc63f3a) to head (f5784ad).

Additional details and impacted files
@@            Coverage Diff            @@
##            master      #372   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            7         7           
  Lines          833       920   +87     
=========================================
+ Hits           833       920   +87     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In _request_url, the IDNA encoding of parsed.hostname can raise UnicodeError that is not wrapped in URLReadError, which breaks the documented error contract for readfromurl; consider catching UnicodeError there and re-raising URLReadError similar to _validate_url.
  • In _read_response_data, the compressed path ignores a valid Content-Length header and will continue reading until EOF; if you want to bound I/O as well as decoded size, you might want to respect Content-Length or cap the total compressed bytes read.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_request_url`, the IDNA encoding of `parsed.hostname` can raise `UnicodeError` that is not wrapped in `URLReadError`, which breaks the documented error contract for `readfromurl`; consider catching `UnicodeError` there and re-raising `URLReadError` similar to `_validate_url`.
- In `_read_response_data`, the compressed path ignores a valid `Content-Length` header and will continue reading until EOF; if you want to bound I/O as well as decoded size, you might want to respect `Content-Length` or cap the total compressed bytes read.

## Individual Comments

### Comment 1
<location path="tests/test_utils.py" line_range="254-263" />
<code_context>
+
+        assert result == {"ok": True}
+        http.request.assert_not_called()
+        http.connection_from_host.assert_called_once_with(
+            "93.184.216.34",
+            port=8443,
+            scheme="https",
+            pool_kwargs={
+                "assert_hostname": "rebind.example",
+                "server_hostname": "rebind.example",
+            },
+        )
+        pool.request.assert_called_once_with(
+            "GET",
+            "/data.json?existing=yes",
+            fields={"added": "yes"},
+            headers={"Host": "rebind.example:8443"},
+            timeout=timeout,
+            retries=False,
+            redirect=False,
+            preload_content=False,
+        )
+        response.close.assert_called_once_with()
+
     def test_readfromurl_rejects_unsupported_schemes_and_credentials(self) -> None:
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding coverage for `_request_url` behavior with default ports, IPv6 literals, and non-pinned (public) URLs.

This test covers the pinned-address path for a hostname with an explicit non-standard HTTPS port. To more fully exercise `_request_url`, consider a parametrized test including: (1) HTTPS without an explicit port (expect `port=443` and no port in the `Host` header), (2) HTTP without an explicit port (expect `port=80`), (3) an IPv6 literal hostname (ensure `Host` is wrapped in `[]` and the pool target uses the validated address), and (4) `validated_address is None` (allow_private_networks=True) where `http.request` is called directly with the full URL and `connection_from_host` is not used. This would cover all branches and help prevent regressions in authority/Host handling.

Suggested implementation:

```python
        assert result == {"ok": True}
        http.request.assert_not_called()
        http.connection_from_host.assert_called_once_with(
            "93.184.216.34",
            port=8443,
            scheme="https",
            pool_kwargs={
                "assert_hostname": "rebind.example",
                "server_hostname": "rebind.example",
            },
        )
        pool.request.assert_called_once_with(
            "GET",
            "/data.json?existing=yes",
            fields={"added": "yes"},
            headers={"Host": "rebind.example:8443"},
            timeout=timeout,
            retries=False,
            redirect=False,
            preload_content=False,
        )
        response.close.assert_called_once_with()

    @pytest.mark.parametrize(
        "url, validated_address, allow_private_networks, expected_scheme, expected_port, expected_host_header, expects_pinned",
        [
            # (1) HTTPS without explicit port -> port=443, Host header without port
            (
                "https://rebind.example/data.json?existing=yes",
                "93.184.216.34",
                False,
                "https",
                443,
                "rebind.example",
                True,
            ),
            # (2) HTTP without explicit port -> port=80, Host header without port
            (
                "http://rebind.example/data.json?existing=yes",
                "93.184.216.34",
                False,
                "http",
                80,
                "rebind.example",
                True,
            ),
            # (3) IPv6 literal with explicit port -> Host wrapped in [], pool uses validated address
            (
                "https://[2001:db8::1]:8443/data.json?existing=yes",
                "2001:db8::1",
                False,
                "https",
                8443,
                "[2001:db8::1]:8443",
                True,
            ),
            # (4) Non-pinned (public) URL, validated_address is None and allow_private_networks=True
            #     -> http.request called directly, connection_from_host not used
            (
                "https://public.example/data.json?existing=yes",
                None,
                True,
                "https",
                None,
                "public.example",
                False,
            ),
        ],
    )
    @patch("json2xml.utils.get_http_client")
    @patch("json2xml.utils.socket.getaddrinfo")
    def test_request_url_handles_authority_and_host_header(
        self,
        mock_getaddrinfo: Mock,
        mock_get_http_client: Mock,
        url: str,
        validated_address: str | None,
        allow_private_networks: bool,
        expected_scheme: str,
        expected_port: int | None,
        expected_host_header: str,
        expects_pinned: bool,
    ) -> None:
        """Exercise `_request_url` authority handling for default ports, IPv6 literals, and public URLs."""
        urllib3 = Mock()
        http = Mock()
        timeout = Mock()
        pool = Mock()
        response = Mock()
        response.data = b'{"ok": true}'
        pool.request.return_value = response

        # When validated_address is provided, emulate successful resolution to that address.
        # When it's None, emulate no pinned address resolution so the public URL path is taken.
        if validated_address is not None:
            mock_getaddrinfo.return_value = [
                (None, None, None, None, (validated_address, 0)),
            ]
        else:
            mock_getaddrinfo.return_value = []

        http.connection_from_host.return_value = pool
        mock_get_http_client.return_value = (urllib3, http, timeout)

        result = readfromurl(
            url,
            params={"added": "yes"},
            allow_private_networks=allow_private_networks,
        )

        assert result == {"ok": True}

        if expects_pinned:
            # Pinned address path: connection_from_host is used, http.request is not.
            http.request.assert_not_called()
            http.connection_from_host.assert_called_once_with(
                validated_address,
                port=expected_port,
                scheme=expected_scheme,
                pool_kwargs={
                    "assert_hostname": urlsplit(url).hostname,
                    "server_hostname": urlsplit(url).hostname,
                },
            )
            # Path and query should be stripped from the URL and passed to the pool.
            parsed = urlsplit(url)
            path_and_query = parsed.path
            if parsed.query:
                path_and_query += f"?{parsed.query}"

            pool.request.assert_called_once_with(
                "GET",
                path_and_query,
                fields={"added": "yes"},
                headers={"Host": expected_host_header},
                timeout=timeout,
                retries=False,
                redirect=False,
                preload_content=False,
            )
            response.close.assert_called_once_with()
        else:
            # Non-pinned path: http.request is called directly with the full URL,
            # and connection_from_host is not used at all.
            http.connection_from_host.assert_not_called()
            http.request.assert_called_once_with(
                "GET",
                url,
                fields={"added": "yes"},
                timeout=timeout,
                retries=False,
                redirect=False,
                preload_content=False,
            )
            response.close.assert_called_once_with()

    def test_readfromurl_rejects_unsupported_schemes_and_credentials(self) -> None:

```

1. The new test uses `urlsplit` from `urllib.parse`. If this is not already imported at the top of `tests/test_utils.py`, add:
   `from urllib.parse import urlsplit`.
2. Ensure `pytest`, `patch`, and `Mock` are imported consistently with the rest of the file (e.g., `from unittest.mock import Mock, patch`).
3. The behavior of `readfromurl` regarding `validated_address` and `allow_private_networks` may depend on how `socket.getaddrinfo` results are interpreted. Adjust the `mock_getaddrinfo.return_value` setup if your implementation uses a different pattern to derive `validated_address` (for example, multiple entries or additional flags).
4. If `_request_url` is a separate helper and you prefer testing it directly, you can refactor this parametrized test to call `_request_url` instead of `readfromurl`, while keeping the same expectations for `connection_from_host`, `http.request`, and the `Host` header.
</issue_to_address>

### Comment 2
<location path="json2xml/utils.py" line_range="64" />
<code_context>
 # @lat: [[behavior#URL security boundaries]]
-def _validate_url(url: str, allow_private_networks: bool) -> None:
-    """Reject URL forms that can escape the intended public HTTP boundary."""
+def _validate_url(
+    url: str, allow_private_networks: bool
+) -> tuple[SplitResult, str | None]:
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the new URL and decompression helpers into smaller, single-responsibility functions to clarify control flow and make the logic easier to follow and maintain.

1. Split `_validate_url` into pure validation and address resolution  

Right now `_validate_url` both validates and returns connection metadata `(parsed, validated_address)`. You can keep behaviour while separating responsibilities and simplifying call sites:

```python
def _validate_url(url: str) -> SplitResult:
    try:
        parsed = urlsplit(url)
        port = parsed.port
    except (TypeError, ValueError) as error:
        raise URLReadError("URL is not valid") from error

    if parsed.scheme not in {"http", "https"}:
        raise URLReadError("URL must use HTTP or HTTPS")
    if parsed.username is not None or parsed.password is not None:
        raise URLReadError("URL must not contain credentials")
    if parsed.hostname is None:
        raise URLReadError("URL must include a hostname")

    return parsed
```

```python
def _resolve_validated_address(parsed: SplitResult, allow_private_networks: bool) -> str | None:
    if allow_private_networks:
        return None

    hostname = parsed.hostname
    port = parsed.port or (443 if parsed.scheme == "https" else 80)

    try:
        addresses = [ip_address(hostname)]
    except ValueError:
        try:
            address_info = socket.getaddrinfo(
                hostname,
                port,
                type=socket.SOCK_STREAM,
            )
        except (OSError, UnicodeError) as error:
            raise URLReadError("URL hostname could not be resolved") from error
        addresses = [
            ip_address(str(info[4][0]).split("%", 1)[0])
            for info in address_info
        ]

    if not addresses or any(not address.is_global for address in addresses):
        raise URLReadError("URL must resolve only to a public network address")

    return str(addresses[0])
```

Call site in `readfromurl` becomes simpler and matches the names:

```python
parsed = _validate_url(url)
validated_address = _resolve_validated_address(parsed, allow_private_networks)
```


2. Narrow `_request_url` to only the “validated address” path  

You can keep the trivial path (`http.request("GET", parsed.geturl(), ...)`) directly in `readfromurl`, and have `_request_url` only handle the more complex routing. That avoids branching inside `_request_url` and makes control flow clearer:

```python
def _request_via_validated_address(
    http: Any,
    parsed: SplitResult,
    validated_address: str,
    params: dict[str, str] | None,
    timeout: Any,
) -> Any:
    request_options = {
        "fields": params,
        "timeout": timeout,
        "retries": False,
        "redirect": False,
        "preload_content": False,
    }

    assert parsed.hostname is not None
    hostname = parsed.hostname.encode("idna").decode("ascii")

    port = parsed.port or (443 if parsed.scheme == "https" else 80)
    authority = f"[{hostname}]" if ":" in hostname else hostname
    if parsed.port is not None:
        authority = f"{authority}:{parsed.port}"

    pool_kwargs = None
    if parsed.scheme == "https":
        pool_kwargs = {
            "assert_hostname": hostname,
            "server_hostname": hostname,
        }

    pool = http.connection_from_host(
        validated_address,
        port=port,
        scheme=parsed.scheme,
        pool_kwargs=pool_kwargs,
    )
    request_target = urlunsplit(("", "", parsed.path or "/", parsed.query, ""))
    return pool.request(
        "GET",
        request_target,
        headers={"Host": authority},
        **request_options,
    )
```

Then `readfromurl` becomes:

```python
urllib3, http, timeout = _get_http_client()
request_options = {
    "fields": params,
    "timeout": timeout,
    "retries": False,
    "redirect": False,
    "preload_content": False,
}

if validated_address is None:
    response = http.request("GET", parsed.geturl(), **request_options)
else:
    response = _request_via_validated_address(
        http, parsed, validated_address, params, timeout
    )
```


3. Isolate deflate header detection out of `_compression_decoder`  

You can keep `_compression_decoder` readable by pushing the bit-twiddling into a tiny helper:

```python
def _has_zlib_header(data: bytes) -> bool:
    if len(data) < 2:
        return False
    cmf, flg = data[0], data[1]
    # Compression method 8 (DEFLATE) and header checksum multiple of 31
    return (cmf & 0x0F) == 8 and ((cmf << 8) | flg) % 31 == 0
```

```python
def _compression_decoder(encoding: str, first_chunk: bytes) -> Any:
    if encoding in {"gzip", "x-gzip"}:
        return zlib.decompressobj(16 + zlib.MAX_WBITS)
    if encoding == "deflate":
        window_bits = zlib.MAX_WBITS if _has_zlib_header(first_chunk) else -zlib.MAX_WBITS
        return zlib.decompressobj(window_bits)
    raise URLReadError(f"Unsupported Content-Encoding: {encoding}")
```


4. Extract the decompression state machine from `_read_response_data`  

The nested loops are correct but hard to follow. You can move the inner state machine into a focused helper that returns bytes, keeping `_read_response_data` thin:

```python
def _decompress_with_limit(
    response: Any,
    decoder: Any,
    first_chunk: bytes,
    max_response_bytes: int,
) -> bytes:
    response_data = bytearray()
    compressed_chunk = first_chunk

    try:
        while compressed_chunk:
            pending = compressed_chunk
            while pending:
                remaining = max_response_bytes + 1 - len(response_data)
                decoded = decoder.decompress(pending, remaining)
                response_data.extend(decoded)
                if len(response_data) > max_response_bytes:
                    raise URLReadError("URL response exceeds maximum size")
                pending = decoder.unconsumed_tail

            compressed_chunk = response.read(
                COMPRESSED_READ_CHUNK_BYTES,
                decode_content=False,
            )
    except zlib.error as error:
        raise URLReadError("URL returned invalid compressed data") from error

    if not decoder.eof or decoder.unused_data:
        raise URLReadError("URL returned invalid compressed data")

    return bytes(response_data)
```

Then `_read_response_data` focuses on “choose encoding path”:

```python
def _read_response_data(response: Any, max_response_bytes: int) -> bytes:
    encoding = response.headers.get("Content-Encoding", "").strip().lower()
    if encoding in {"", "identity"}:
        data = response.read(max_response_bytes + 1, decode_content=False)
        if len(data) > max_response_bytes:
            raise URLReadError("URL response exceeds maximum size")
        return data

    first_chunk = response.read(
        COMPRESSED_READ_CHUNK_BYTES,
        decode_content=False,
    )
    decoder = _compression_decoder(encoding, first_chunk)
    return _decompress_with_limit(response, decoder, first_chunk, max_response_bytes)
```

These changes preserve the new security and size-limit behaviour while flattening control flow and separating concerns into smaller, purpose-specific helpers.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread tests/test_utils.py
Comment thread json2xml/utils.py Outdated
@vinitkumar
vinitkumar merged commit 3762517 into master Aug 4, 2026
48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant