Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions python/packages/devui/agent_framework_devui/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,30 @@ def _loopback_allowed_hosts(self) -> frozenset[str] | None:
return None
return self._LOOPBACK_HOSTS

@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.

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:
# 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_bytes)

@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."""
Expand Down Expand Up @@ -444,8 +468,13 @@ 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):
# 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,
content={
Expand Down
44 changes: 44 additions & 0 deletions python/packages/devui/tests/devui/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,50 @@ 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"),
# 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(
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:
# 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("latin-1")},
)

assert response.status_code == expected_status


def test_meta_endpoint_requires_auth(monkeypatch):
"""/meta exposes capability flags (deployment, instrumentation, version) — gate it behind auth.

Expand Down
Loading