SEC2-069: The HTTP transports accept unlimited wrong bearer secrets; the core throttles wrong API tokens per address #21

Closed
opened 2026-10-17 10:40:02 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo-mcp at 3819dbd, checked by a second, independent pass.

Severity: Informational · Category: Authentication/Authorization · Reference: CWE-307 (Improper Restriction of Excessive Authentication Attempts); OWASP A07:2021 (Identification and Authentication Failures)

Location:

  • src/postulo_mcp/serving.py:42 (MINIMUM_SECRET = 16)
  • src/postulo_mcp/serving.py:62-76 (SharedSecret.verify_token)
  • src/postulo_mcp/serving.py:140-157 (plan: len(secret) < MINIMUM_SECRET is the only check)

Issue

SharedSecret.verify_token answers each request on its own:

async def verify_token(self, token: str) -> AccessToken | None:
    if not compare_digest(token, self._secret):
        return None
    return AccessToken(token=token, client_id="postulo-mcp", scopes=[SCOPE])

The comparison is constant-time, which is right, but nothing counts refusals: the SDK's
BearerAuthBackend answers 401 and the next attempt is free. plan accepts any secret of sixteen
characters "of anything", so a person who sets a passphrase rather than the token_urlsafe(32)
the message suggests has a secret that can be guessed online at line rate, and a --host other
than loopback puts that port on the network (the README says so; __main__ warns on stderr). The
core holds its own API to a different standard: api/auth.py bars an address after too many
wrong tokens (postulo/postulo#799).

Impact

Only with --transport sse|streamable-http and a weak secret, and over the network only with
--host set away from loopback: somebody who can reach the port can try secrets without limit
until one opens the person's whole job search (and, with --write, the right to change it). With
the suggested random secret the attempt is hopeless, which is why this is recorded rather than
rated.

Fix

Refuse weak secrets in plan rather than only short ones: require at least 128 bits, for example
len(secret) >= 22 and more than ten distinct characters, and change the README's sentence to
match. This needs nothing from the MCP SDK. Optionally, add a refusal counter in SharedSecret
keyed by client address with a back-off after N misses, if the SDK makes the address available
to a TokenVerifier. #14 touches the same lines and should land together.

Done when plan refuses a three-word passphrase of over sixteen characters and accepts a
token_urlsafe(32) value, in a test beside the existing plan tests.

#14 (open) is the non-ASCII TypeError in verify_token (a crash, not a limit); #13 (open) is
the Host check; #3 (closed) introduced the shared secret and asked for no throttle.

Found on 2026-10-16 in a read-only security review of `postulo-mcp` at `3819dbd`, checked by a second, independent pass. **Severity:** Informational · **Category:** Authentication/Authorization · **Reference:** CWE-307 (Improper Restriction of Excessive Authentication Attempts); OWASP A07:2021 (Identification and Authentication Failures) **Location:** - `src/postulo_mcp/serving.py:42` (`MINIMUM_SECRET = 16`) - `src/postulo_mcp/serving.py:62-76` (`SharedSecret.verify_token`) - `src/postulo_mcp/serving.py:140-157` (`plan`: `len(secret) < MINIMUM_SECRET` is the only check) ## Issue `SharedSecret.verify_token` answers each request on its own: ```python async def verify_token(self, token: str) -> AccessToken | None: if not compare_digest(token, self._secret): return None return AccessToken(token=token, client_id="postulo-mcp", scopes=[SCOPE]) ``` The comparison is constant-time, which is right, but nothing counts refusals: the SDK's `BearerAuthBackend` answers 401 and the next attempt is free. `plan` accepts any secret of sixteen characters "of anything", so a person who sets a passphrase rather than the `token_urlsafe(32)` the message suggests has a secret that can be guessed online at line rate, and a `--host` other than loopback puts that port on the network (the README says so; `__main__` warns on stderr). The core holds its own API to a different standard: `api/auth.py` bars an address after too many wrong tokens (postulo/postulo#799). ## Impact Only with `--transport sse|streamable-http` and a weak secret, and over the network only with `--host` set away from loopback: somebody who can reach the port can try secrets without limit until one opens the person's whole job search (and, with `--write`, the right to change it). With the suggested random secret the attempt is hopeless, which is why this is recorded rather than rated. ## Fix Refuse weak secrets in `plan` rather than only short ones: require at least 128 bits, for example `len(secret) >= 22` and more than ten distinct characters, and change the README's sentence to match. This needs nothing from the MCP SDK. Optionally, add a refusal counter in `SharedSecret` keyed by client address with a back-off after N misses, if the SDK makes the address available to a `TokenVerifier`. #14 touches the same lines and should land together. Done when `plan` refuses a three-word passphrase of over sixteen characters and accepts a `token_urlsafe(32)` value, in a test beside the existing `plan` tests. ## Related #14 (open) is the non-ASCII `TypeError` in `verify_token` (a crash, not a limit); #13 (open) is the Host check; #3 (closed) introduced the shared secret and asked for no throttle.
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#21
No description provided.