HTTP transport: a bearer value with a non-ASCII byte crashes the shared-secret check with a 500 instead of a 401 #14

Closed
opened 2026-10-01 10:48:04 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo-mcp at 0b29e70b9, reading the shared-secret check and the MCP SDK's bearer middleware it runs under.

Related to #3.

SharedSecret.verify_token compares the presented token with the configured secret using hmac.compare_digest on two str values. compare_digest refuses to compare strings that contain anything outside ASCII and raises TypeError instead of answering. Starlette decodes header bytes as latin-1 and h11 accepts bytes 0x80–0xFF in a header value, so a bearer value carrying such a byte reaches the comparison and the request ends as an unhandled exception rather than a refusal.

  • Where: src/postulo_mcp/serving.py:73-76 (SharedSecret.verify_token); src/postulo_mcp/serving.py:140-157 (plan, which accepts any secret of 16 characters or more)
  • What happens:
    1. A request arrives at the sse or streamable-http port with Authorization: Bearer <value containing a byte above 0x7F>.
    2. The SDK's BearerAuthBackend.authenticate (mcp 2.1.1, mcp/server/auth/middleware/bearer_auth.py:55-58) passes the remainder of the header to verify_token without a try.
    3. compare_digest(token, self._secret) raises TypeError: comparing strings with non-ASCII characters is not supported (checked on Python 3.14). Starlette's AuthenticationMiddleware catches only AuthenticationError, so the error reaches ServerErrorMiddleware: the client gets a 500 and the log a traceback.
    4. If the operator sets POSTULO_MCP_HTTP_TOKEN to a passphrase with an accented letter, plan() accepts it, and then every request that carries a bearer value, the legitimate client's included, fails the same way. The server is unusable and says nothing about why.
  • Who can trigger it: anyone who can reach the port, without the secret. No data is exposed; the cost is a 500 in place of a 401 and a traceback in the log per request.
  • Why it matters: an authentication check should answer yes or no for every input. A crash path in front of authentication fills the log on demand, and a secret the server accepts at start-up but can never match is a configuration trap.
  • Fix: compare bytes, compare_digest(token.encode("utf-8"), self._secret.encode("utf-8")), which never raises and still refuses a mismatch. In plan(), refuse a secret that is not printable ASCII with a sentence beside SHORT_SECRET (a header carries latin-1 at best, so a non-ASCII secret cannot be sent reliably); the secrets.token_urlsafe suggestion already produces one that passes.

Done when

  • tests/test_server.py has a test beside test_the_shared_secret_is_the_only_token_that_opens_the_port that calls verify_token with a value containing a non-ASCII character and expects None, and an end-to-end case in test_the_port_turns_away_whoever_has_not_got_the_secret that sends such a bearer value as bytes and expects 401; both fail today with TypeError.
  • A test that serving.plan("streamable-http", environment={"POSTULO_MCP_HTTP_TOKEN": "<16+ characters with é>"}) raises PostuloError.
Found on 2026-09-29 in a read-only review of postulo-mcp at 0b29e70b9, reading the shared-secret check and the MCP SDK's bearer middleware it runs under. Related to #3. `SharedSecret.verify_token` compares the presented token with the configured secret using `hmac.compare_digest` on two `str` values. `compare_digest` refuses to compare strings that contain anything outside ASCII and raises `TypeError` instead of answering. Starlette decodes header bytes as latin-1 and h11 accepts bytes 0x80–0xFF in a header value, so a bearer value carrying such a byte reaches the comparison and the request ends as an unhandled exception rather than a refusal. - **Where:** `src/postulo_mcp/serving.py:73-76` (`SharedSecret.verify_token`); `src/postulo_mcp/serving.py:140-157` (`plan`, which accepts any secret of 16 characters or more) - **What happens:** 1. A request arrives at the `sse` or `streamable-http` port with `Authorization: Bearer <value containing a byte above 0x7F>`. 2. The SDK's `BearerAuthBackend.authenticate` (mcp 2.1.1, `mcp/server/auth/middleware/bearer_auth.py:55-58`) passes the remainder of the header to `verify_token` without a `try`. 3. `compare_digest(token, self._secret)` raises `TypeError: comparing strings with non-ASCII characters is not supported` (checked on Python 3.14). Starlette's `AuthenticationMiddleware` catches only `AuthenticationError`, so the error reaches `ServerErrorMiddleware`: the client gets a 500 and the log a traceback. 4. If the operator sets `POSTULO_MCP_HTTP_TOKEN` to a passphrase with an accented letter, `plan()` accepts it, and then every request that carries a bearer value, the legitimate client's included, fails the same way. The server is unusable and says nothing about why. - **Who can trigger it:** anyone who can reach the port, without the secret. No data is exposed; the cost is a 500 in place of a 401 and a traceback in the log per request. - **Why it matters:** an authentication check should answer yes or no for every input. A crash path in front of authentication fills the log on demand, and a secret the server accepts at start-up but can never match is a configuration trap. - **Fix:** compare bytes, `compare_digest(token.encode("utf-8"), self._secret.encode("utf-8"))`, which never raises and still refuses a mismatch. In `plan()`, refuse a secret that is not printable ASCII with a sentence beside `SHORT_SECRET` (a header carries latin-1 at best, so a non-ASCII secret cannot be sent reliably); the `secrets.token_urlsafe` suggestion already produces one that passes. ## Done when - `tests/test_server.py` has a test beside `test_the_shared_secret_is_the_only_token_that_opens_the_port` that calls `verify_token` with a value containing a non-ASCII character and expects `None`, and an end-to-end case in `test_the_port_turns_away_whoever_has_not_got_the_secret` that sends such a bearer value as bytes and expects 401; both fail today with `TypeError`. - A test that `serving.plan("streamable-http", environment={"POSTULO_MCP_HTTP_TOKEN": "<16+ characters with é>"})` raises `PostuloError`.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:48:04 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-mcp#14
No description provided.