From faa8ca515b6bf8b0d6e067c1396f00c8794b760d Mon Sep 17 00:00:00 2001 From: lisry-1 <249596828+lisry-1@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:36:49 +0800 Subject: [PATCH 1/3] Python: fix(devui): compare bearer tokens as bytes so non-ASCII input cannot raise secrets.compare_digest accepts only ASCII str values and raises TypeError when either side holds a non-ASCII character. Uncaught, a merely wrong token answers 500 instead of 401, and a non-ASCII DEVUI_AUTH_TOKEN never matches even when the client presents it correctly. Compare bytes instead: headers travel as bytes and Starlette hands them back decoded as latin-1, while the configured token is text, so UTF-8 is its wire form. ASCII tokens compare exactly as before. --- .../devui/agent_framework_devui/_server.py | 24 ++++++++++++- .../packages/devui/tests/devui/test_server.py | 36 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index 53702f32800..b739344be78 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -164,6 +164,28 @@ def _hostname_from_host_header(host_header: str) -> str: return host.rsplit(":", 1)[0] return host + @staticmethod + def _token_matches(presented: str, expected: str) -> bool: + """Compare two bearer tokens in constant time without raising on non-ASCII input. + + `secrets.compare_digest` accepts only ASCII `str` values and raises `TypeError` when + either side holds a non-ASCII character. Uncaught, that surfaces as HTTP 500 rather + than HTTP 401, and it makes a non-ASCII `DEVUI_AUTH_TOKEN` unusable even when the + client presents it correctly. Comparing bytes has no such restriction. + + Headers travel as bytes and Starlette hands them back decoded as latin-1, so + re-encoding with latin-1 recovers what the client actually put on the wire. The + expected token is text (an argument, or `DEVUI_AUTH_TOKEN` from the environment), so + UTF-8 is its wire form. ASCII tokens behave exactly as before, since both encodings + agree on ASCII. + """ + try: + presented_bytes = presented.encode("latin-1") + except UnicodeEncodeError: + # Not something a header could have carried; treat it as a wrong token. + return False + return secrets.compare_digest(presented_bytes, expected.encode("utf-8")) + @classmethod def _resolve_auth_token(cls, host: str, auth_enabled: bool, auth_token: str | None) -> str | None: """Resolve the active Bearer token. Returns None when auth is disabled.""" @@ -472,7 +494,7 @@ async def auth_middleware(request: Request, call_next: Callable[[Request], Await ) token = auth_header.replace("Bearer ", "", 1).strip() - if not secrets.compare_digest(token, expected_token): + if not self._token_matches(token, expected_token): return JSONResponse( status_code=401, content={ diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index cfcac05d76f..089bed62cda 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -899,6 +899,42 @@ def test_devserver_accepts_request_with_valid_bearer_token(monkeypatch): assert response.status_code == 200 +@pytest.mark.parametrize( + ("configured_token", "presented_token", "expected_status"), + [ + pytest.param("s3cret", "s3cret", 200, id="ascii-matches"), + pytest.param("s3cret", "s\u00e9cret", 401, id="non-ascii-presented-is-rejected"), + pytest.param("s\u00e9cret", "s\u00e9cret", 200, id="non-ascii-configured-matches"), + pytest.param("s\u00e9cret", "s3cret", 401, id="non-ascii-configured-wrong-token"), + ], +) +def test_bearer_token_comparison_survives_non_ascii_tokens( + monkeypatch, + configured_token: str, + presented_token: str, + expected_status: int, +) -> None: + """A non-ASCII bearer token must be compared, not answered with HTTP 500. + + `secrets.compare_digest` raises `TypeError` for any `str` holding a non-ASCII + character, so a merely wrong token produced 500 instead of 401, and a non-ASCII + `DEVUI_AUTH_TOKEN` never matched even when the client presented it correctly. + """ + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = DevServer(auth_token=configured_token) + app = server.get_app() + + with TestClient(app, base_url="http://127.0.0.1", raise_server_exceptions=False) as client: + # Sent as raw bytes: httpx refuses to encode a non-ASCII header value itself. + response = client.get( + "/v1/entities", + headers={"Authorization": b"Bearer " + presented_token.encode("utf-8")}, + ) + + assert response.status_code == expected_status + + def test_meta_endpoint_requires_auth(monkeypatch): """/meta exposes capability flags (deployment, instrumentation, version) — gate it behind auth. From 71983ddde29df5bea1c390989f23ca82eeb86912 Mon Sep 17 00:00:00 2001 From: lisry-1 <249596828+lisry-1@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:07:02 +0800 Subject: [PATCH 2/3] fix(devui): trim only HTTP whitespace from bearer tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare .strip() on the presented token also removed non-ASCII whitespace such as NBSP (U+00A0), which is a valid byte in a UTF-8 token (the second byte of 'à' is 0xA0). A correct non-ASCII token was therefore truncated before the byte comparison and rejected with 401. Trim only space/tab, and switch the test header mapping to a bytes key so the bytes/bytes mapping stays type-consistent for Pyright/Pyrefly. Add regression cases for a token ending in 'à'. --- python/packages/devui/agent_framework_devui/_server.py | 7 ++++++- python/packages/devui/tests/devui/test_server.py | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index b739344be78..c3a26569d82 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -493,7 +493,12 @@ async def auth_middleware(request: Request, call_next: Callable[[Request], Await }, ) - token = auth_header.replace("Bearer ", "", 1).strip() + # Trim only HTTP optional-whitespace (space/tab). A bare `.strip()` + # would also drop non-ASCII whitespace such as NBSP (U+00A0), which + # is a valid byte in a UTF-8 token (e.g. the second byte of "à" is + # 0xA0), so a correct non-ASCII token would be truncated before the + # byte comparison and rejected with 401. + token = auth_header.replace("Bearer ", "", 1).strip(" \t") if not self._token_matches(token, expected_token): return JSONResponse( status_code=401, diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index 089bed62cda..77953ff3882 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -906,6 +906,11 @@ def test_devserver_accepts_request_with_valid_bearer_token(monkeypatch): pytest.param("s3cret", "s\u00e9cret", 401, id="non-ascii-presented-is-rejected"), pytest.param("s\u00e9cret", "s\u00e9cret", 200, id="non-ascii-configured-matches"), pytest.param("s\u00e9cret", "s3cret", 401, id="non-ascii-configured-wrong-token"), + # U+00E0 ("à") encodes to UTF-8 bytes 0xC3 0xA0, whose second byte is NBSP + # (U+00A0). A bare `.strip()` would drop that trailing 0xA0 and reject the + # correct token; trimming only HTTP whitespace must keep it intact. + pytest.param("secret\u00e0", "secret\u00e0", 200, id="trailing-a-grave-matches"), + pytest.param("secret\u00e0", "secret\u00e9", 401, id="trailing-a-grave-wrong-token"), ], ) def test_bearer_token_comparison_survives_non_ascii_tokens( @@ -927,9 +932,11 @@ def test_bearer_token_comparison_survives_non_ascii_tokens( with TestClient(app, base_url="http://127.0.0.1", raise_server_exceptions=False) as client: # Sent as raw bytes: httpx refuses to encode a non-ASCII header value itself. + # A bytes key keeps the header mapping bytes/bytes so the raw token bytes are + # preserved on the wire and the mapping stays type-consistent for Pyright/Pyrefly. response = client.get( "/v1/entities", - headers={"Authorization": b"Bearer " + presented_token.encode("utf-8")}, + headers={b"Authorization": b"Bearer " + presented_token.encode("utf-8")}, ) assert response.status_code == expected_status From 41b5f8d7ea885a40742d19d0b1c8768aee9a1f44 Mon Sep 17 00:00:00 2001 From: lisry-1 <249596828+lisry-1@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:31:18 +0800 Subject: [PATCH 3/3] fix(devui): use a single latin-1 wire encoding for bearer tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled browser client sends Authorization via fetch(), which encodes a header value with ByteString (latin-1) semantics. A token like sécret therefore travels as the single byte 0xE9, while the server compared the expected token's UTF-8 bytes (0xC3 0xA9). The correct token was answered 401 and the UI cleared it. Encode both the presented and the expected token as latin-1 so the browser client and the server share one wire encoding. A token outside latin-1 cannot be expressed by the browser header encoding and is treated as a mismatch rather than raising. ASCII tokens are unchanged. Update the regression to exercise the browser path by sending latin-1 bytes instead of UTF-8 bytes. --- .../devui/agent_framework_devui/_server.py | 16 +++++++++------- python/packages/devui/tests/devui/test_server.py | 9 +++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index c3a26569d82..674296b657f 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -173,18 +173,20 @@ def _token_matches(presented: str, expected: str) -> bool: than HTTP 401, and it makes a non-ASCII `DEVUI_AUTH_TOKEN` unusable even when the client presents it correctly. Comparing bytes has no such restriction. - Headers travel as bytes and Starlette hands them back decoded as latin-1, so - re-encoding with latin-1 recovers what the client actually put on the wire. The - expected token is text (an argument, or `DEVUI_AUTH_TOKEN` from the environment), so - UTF-8 is its wire form. ASCII tokens behave exactly as before, since both encodings - agree on ASCII. + HTTP header values are bytes; Starlette decodes them as latin-1, and the bundled + browser client encodes a header value with fetch()'s ByteString (latin-1) semantics. + To keep one explicit wire encoding across the browser client and the server, encode + both the presented and the expected token as latin-1. ASCII tokens behave exactly as + before, since latin-1 agrees with ASCII there. """ try: presented_bytes = presented.encode("latin-1") + expected_bytes = expected.encode("latin-1") except UnicodeEncodeError: - # Not something a header could have carried; treat it as a wrong token. + # A token outside latin-1 cannot be expressed by the browser client's + # ByteString header encoding; treat it as a mismatch rather than raising. return False - return secrets.compare_digest(presented_bytes, expected.encode("utf-8")) + return secrets.compare_digest(presented_bytes, expected_bytes) @classmethod def _resolve_auth_token(cls, host: str, auth_enabled: bool, auth_token: str | None) -> str | None: diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index 77953ff3882..850b8395399 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -931,12 +931,13 @@ def test_bearer_token_comparison_survives_non_ascii_tokens( app = server.get_app() with TestClient(app, base_url="http://127.0.0.1", raise_server_exceptions=False) as client: - # Sent as raw bytes: httpx refuses to encode a non-ASCII header value itself. - # A bytes key keeps the header mapping bytes/bytes so the raw token bytes are - # preserved on the wire and the mapping stays type-consistent for Pyright/Pyrefly. + # Emulate the bundled browser client: fetch() encodes a header value with + # ByteString (latin-1) semantics, so a non-ASCII token travels as its latin-1 + # bytes. A bytes key keeps the mapping bytes/bytes so the raw token bytes are + # preserved and the mapping stays type-consistent for Pyright/Pyrefly. response = client.get( "/v1/entities", - headers={b"Authorization": b"Bearer " + presented_token.encode("utf-8")}, + headers={b"Authorization": b"Bearer " + presented_token.encode("latin-1")}, ) assert response.status_code == expected_status