diff --git a/CHANGELOG.md b/CHANGELOG.md index af33bed16..cd9687e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Auth: Sanitize HTTP header values to strip control characters (newline, carriage return, null bytes, etc.) that could cause h11/aiohttp validation errors when system information contains them (e.g., kernel version with trailing newline on some Ubuntu systems) + ## 1.18.0 (2026-03-09) - ACP: Support embedded resource content in ACP mode so that Zed's `@` file references correctly include file contents diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 2ce50b56a..a7020495e 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Auth: Sanitize HTTP header values to strip control characters (newline, carriage return, null bytes, etc.) that could cause h11/aiohttp validation errors when system information contains them (e.g., kernel version with trailing newline on some Ubuntu systems) + ## 1.18.0 (2026-03-09) - ACP: Support embedded resource content in ACP mode so that Zed's `@` file references correctly include file contents diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index c21cfbdfc..21127d2e8 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,8 @@ ## 未发布 +- Auth:清理 HTTP 头部值中的控制字符(换行符、回车符、空字节等),修复当系统信息包含这些字符时(如某些 Ubuntu 系统的内核版本带有尾随换行符)导致 h11/aiohttp 验证错误的问题 + ## 1.18.0 (2026-03-09) - ACP:支持 ACP 模式下的嵌入式资源内容,使 Zed 的 `@` 文件引用能够正确包含文件内容 diff --git a/src/kimi_cli/auth/oauth.py b/src/kimi_cli/auth/oauth.py index 96d025d96..0f77b0173 100644 --- a/src/kimi_cli/auth/oauth.py +++ b/src/kimi_cli/auth/oauth.py @@ -193,12 +193,8 @@ def get_device_id() -> str: def _ascii_header_value(value: str, *, fallback: str = "unknown") -> str: - try: - value.encode("ascii") - return value - except UnicodeEncodeError: - sanitized = value.encode("ascii", errors="ignore").decode("ascii").strip() - return sanitized or fallback + sanitized = "".join(c for c in value if c == "\t" or "\x20" <= c <= "\x7e").strip() + return sanitized or fallback def _common_headers() -> dict[str, str]: diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/auth/test_oauth_headers.py b/tests/auth/test_oauth_headers.py new file mode 100644 index 000000000..8b7ff9ff2 --- /dev/null +++ b/tests/auth/test_oauth_headers.py @@ -0,0 +1,188 @@ +"""Tests for HTTP header value sanitization. + +Covers three layers: +1. Unit tests — verify _ascii_header_value() behavior +2. h11 integration — sanitized values pass h11 validation +3. aiohttp integration — sanitized values pass aiohttp validation +""" + +import pytest + +from kimi_cli.auth.oauth import _ascii_header_value + +# --------------------------------------------------------------------------- +# Layer 1: Unit tests +# --------------------------------------------------------------------------- + + +class TestAsciiHeaderValue: + """Unit tests for _ascii_header_value().""" + + def test_normal_ascii_passthrough(self): + assert _ascii_header_value("Darwin 24.5.0") == "Darwin 24.5.0" + + def test_newline_removed(self): + val = "#101~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC\n" + result = _ascii_header_value(val) + assert "\n" not in result + assert result == "#101~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC" + + def test_carriage_return_removed(self): + assert _ascii_header_value("value\r") == "value" + + def test_crlf_removed(self): + assert _ascii_header_value("value\r\n") == "value" + + def test_null_byte_removed(self): + assert _ascii_header_value("val\x00ue") == "value" + + def test_control_characters_removed(self): + # \x01-\x08, \x0e-\x1f, \x7f are control characters + assert _ascii_header_value("a\x01b\x08c\x0ed\x1fe\x7f") == "abcde" + + def test_non_ascii_removed(self): + assert _ascii_header_value("value日本語end") == "valueend" + + def test_empty_string_returns_fallback(self): + assert _ascii_header_value("") == "unknown" + + def test_only_control_chars_returns_fallback(self): + assert _ascii_header_value("\n\r\x00") == "unknown" + + def test_only_non_ascii_returns_fallback(self): + assert _ascii_header_value("日本語") == "unknown" + + def test_custom_fallback(self): + assert _ascii_header_value("", fallback="n/a") == "n/a" + + def test_internal_spaces_preserved(self): + assert _ascii_header_value("a b c") == "a b c" + + def test_internal_tab_preserved(self): + assert _ascii_header_value("a\tb") == "a\tb" + + def test_leading_trailing_whitespace_stripped(self): + assert _ascii_header_value(" hello ") == "hello" + + def test_hash_preserved(self): + """# is VCHAR (0x23), perfectly valid in HTTP header values.""" + val = "#101~22.04.1-Ubuntu" + assert _ascii_header_value(val) == val + + def test_special_chars_preserved(self): + """Parentheses, braces, @, etc. are all valid VCHAR.""" + val = "Linux (x86_64) {test} @host" + assert _ascii_header_value(val) == val + + def test_realistic_ubuntu_kernel_version(self): + """The exact scenario from issue #1368.""" + val = "#101~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Jan 13 17:42:59 UTC 2\n" + result = _ascii_header_value(val) + assert "\n" not in result + assert result.startswith("#101") + assert result.endswith("UTC 2") + + +# --------------------------------------------------------------------------- +# Layer 2: h11 integration tests +# --------------------------------------------------------------------------- + + +class TestH11Integration: + """Verify sanitized values pass h11's header validation.""" + + @pytest.fixture() + def h11_validate(self): + from h11._headers import normalize_and_validate + + def _validate(value: str): + headers = [(b"X-Test-Header", value.encode("ascii"))] + normalize_and_validate(headers, _parsed=False) + + return _validate + + @pytest.mark.parametrize( + "raw", + [ + "Darwin 24.5.0", + "#101~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC\n", + "value\r\ninjection", + "val\x00ue", + "a\x01b\x08c", + "日本語mixed", + "", + "\n\r\x00", + " spaces ", + "a\tb", + ], + ids=[ + "normal_ascii", + "trailing_newline", + "crlf_injection", + "null_byte", + "control_chars", + "non_ascii_mixed", + "empty", + "only_control", + "leading_trailing_spaces", + "internal_tab", + ], + ) + def test_sanitized_value_accepted_by_h11(self, h11_validate, raw): + sanitized = _ascii_header_value(raw) + # Should not raise + h11_validate(sanitized) + + +# --------------------------------------------------------------------------- +# Layer 3: aiohttp integration tests +# --------------------------------------------------------------------------- + + +class TestAiohttpIntegration: + """Verify sanitized values pass aiohttp's header validation.""" + + @pytest.fixture() + def aiohttp_validate(self): + try: + from aiohttp.http_writer import _safe_header # type: ignore[attr-defined] + except ImportError: + pytest.skip("aiohttp _safe_header not available") + + def _validate(value: str): + # _safe_header validates the full "Name: value" string + _safe_header(f"X-Test-Header: {value}") + + return _validate + + @pytest.mark.parametrize( + "raw", + [ + "Darwin 24.5.0", + "#101~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC\n", + "value\r\ninjection", + "val\x00ue", + "a\x01b\x08c", + "日本語mixed", + "", + "\n\r\x00", + " spaces ", + "a\tb", + ], + ids=[ + "normal_ascii", + "trailing_newline", + "crlf_injection", + "null_byte", + "control_chars", + "non_ascii_mixed", + "empty", + "only_control", + "leading_trailing_spaces", + "internal_tab", + ], + ) + def test_sanitized_value_accepted_by_aiohttp(self, aiohttp_validate, raw): + sanitized = _ascii_header_value(raw) + # Should not raise + aiohttp_validate(sanitized)