From 0007d6e8b554587af3b499cfe6483cc902f36c91 Mon Sep 17 00:00:00 2001 From: Shivam Date: Thu, 28 May 2026 15:53:27 +0530 Subject: [PATCH] fix: preserve IPv6 hosts in URL query redaction --- CHANGELOG.md | 5 +++++ src/redactkit/url_query.py | 15 ++++++++++++++- tests/test_url_query.py | 24 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a143f34..7d64aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `redact_url_query` now preserves bracketed IPv6 hosts when redacting query + parameters, including coverage for host literals that contain `?`. + ## [0.1.1] - 2026-05-28 ### Changed diff --git a/src/redactkit/url_query.py b/src/redactkit/url_query.py index fd17580..bf6ca07 100644 --- a/src/redactkit/url_query.py +++ b/src/redactkit/url_query.py @@ -13,6 +13,19 @@ from .core import MASK, SENSITIVE_KEY_RE +def _query_separator_index(url: str) -> int: + """Return the first query separator that is not inside IPv6 brackets.""" + in_brackets = False + for index, char in enumerate(url): + if char == "[": + in_brackets = True + elif char == "]": + in_brackets = False + elif char == "?" and not in_brackets: + return index + return -1 + + def redact_url_query(url: str) -> str: """Redact sensitive query-parameter values from a URL. @@ -24,7 +37,7 @@ def redact_url_query(url: str) -> str: Pure function — returns a new string, never mutates the input. """ - qmark = url.find("?") + qmark = _query_separator_index(url) if qmark == -1: return url path, query = url[:qmark], url[qmark + 1 :] diff --git a/tests/test_url_query.py b/tests/test_url_query.py index 1a7407a..fcd8f56 100644 --- a/tests/test_url_query.py +++ b/tests/test_url_query.py @@ -65,3 +65,27 @@ def test_path_and_scheme_are_preserved() -> None: assert out.startswith("https://example.com:8443/api/v1/foo?") parsed = dict(parse_qsl(urlparse(out).query)) assert parsed == {"token": MASK, "page": "1"} + + +def test_ipv6_host_and_port_are_preserved() -> None: + out = redact_url_query("http://[::1]:8080/api?token=abc") + assert out.startswith("http://[::1]:8080/api?") + assert "abc" not in out + parsed = dict(parse_qsl(urlparse(out).query)) + assert parsed == {"token": MASK} + + +def test_ipv6_host_with_safe_query_param() -> None: + out = redact_url_query("http://[2001:db8::1]/api?api_key=abc&page=1") + assert out.startswith("http://[2001:db8::1]/api?") + assert "abc" not in out + parsed = dict(parse_qsl(urlparse(out).query)) + assert parsed == {"api_key": MASK, "page": "1"} + + +def test_question_mark_inside_bracketed_host_is_not_query_separator() -> None: + out = redact_url_query("http://[::?1]:8080/api?token=abc&page=1") + assert out.startswith("http://[::?1]:8080/api?") + assert "abc" not in out + assert "token=%2A%2A%2A" in out + assert "page=1" in out