SEC2-066: Postulo._call reads every answer whole, with no size ceiling and no deadline for the whole exchange #18

Closed
opened 2026-10-17 10:39:59 +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: Input Validation · Reference: CWE-400 (Uncontrolled Resource Consumption); OWASP A05:2021 (Security Misconfiguration)

Location:

  • src/postulo_mcp/client.py:19 (TIMEOUT = 20.0)
  • src/postulo_mcp/client.py:74-85 (httpx.Client(... timeout=TIMEOUT ...))
  • src/postulo_mcp/client.py:112-131 (_call: response = self.client.request(...), response.json(), response.text)

Issue

The client is built with a single float timeout and reads bodies the default way:

self.client = httpx.Client(base_url=..., timeout=TIMEOUT, verify=settings.verify,
                           transport=transport, follow_redirects=False)
...
response = self.client.request(method, path, **kwargs)
...
return response.json()

timeout=20.0 bounds each wait (connect, one read, one write), not their sum, and
client.request has read the whole body before it returns. Nothing caps the body, and a check
of Content-Length after request() would come too late for the same reason. The core closed
the same two gaps in its own outbound client (plugins/http.py: total= and
read_body(limit=), postulo/postulo#321, #781), whose docstring explains that "a size limit
applied to what [client.get] hands back limits nothing" and that "a server sending a byte every
few seconds never trips [a per-wait timeout]".

The answerer is normally the person's own instance, but POSTULO_URL may point at a reverse
proxy, a forward-auth portal or a mistyped host (the cases #7 describes), and over the HTTP
transports the server runs unattended.

Impact

Whatever answers at POSTULO_URL can make one tool call hold the MCP server (and the agent
waiting on it) for as long as it likes by dripping bytes inside the 20 s read timeout, or make
it buffer an arbitrarily large body in memory. No data is exposed. On stdio that is the person's
own session; on --transport streamable-http it is a server other clients share.

Fix

In _call, read the body streamed, under a ceiling and a deadline for the whole exchange:

MAX_BODY = 8 * 1024 * 1024   # list answers are bounded by the API (postulo/postulo#434)
TOTAL = 60.0

deadline = time.monotonic() + TOTAL
with self.client.stream(method, path, **kwargs) as response:
    declared = response.headers.get("Content-Length", "")
    if declared.isdecimal() and int(declared) > MAX_BODY:
        raise PostuloError("Postulo's answer is too large to read.")
    body = bytearray()
    for chunk in response.iter_bytes():
        body += chunk
        if len(body) > MAX_BODY or time.monotonic() > deadline:
            raise PostuloError("Postulo's answer is too large or too slow to read.")
# then the status handling as today, on `body`

keeping httpx.HTTPError handling as it is. The client's timeout=TIMEOUT stays; the check
inside the loop is what bounds the sum.

Done when tests/test_server.py has a MockTransport that answers a body larger than
MAX_BODY and one that declares a Content-Length over it, and each tool call ends in a
PostuloError rather than a dictionary.

#7 (open) is about a 3xx or an HTML 200 being handed back as success; its fix (refuse non-JSON)
does not bound size or total time. #15 (open) is about which fields of a problem document reach
the agent.

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:** Input Validation · **Reference:** CWE-400 (Uncontrolled Resource Consumption); OWASP A05:2021 (Security Misconfiguration) **Location:** - `src/postulo_mcp/client.py:19` (`TIMEOUT = 20.0`) - `src/postulo_mcp/client.py:74-85` (`httpx.Client(... timeout=TIMEOUT ...)`) - `src/postulo_mcp/client.py:112-131` (`_call`: `response = self.client.request(...)`, `response.json()`, `response.text`) ## Issue The client is built with a single float timeout and reads bodies the default way: ```python self.client = httpx.Client(base_url=..., timeout=TIMEOUT, verify=settings.verify, transport=transport, follow_redirects=False) ... response = self.client.request(method, path, **kwargs) ... return response.json() ``` `timeout=20.0` bounds each *wait* (connect, one read, one write), not their sum, and `client.request` has read the whole body before it returns. Nothing caps the body, and a check of `Content-Length` after `request()` would come too late for the same reason. The core closed the same two gaps in its own outbound client (`plugins/http.py`: `total=` and `read_body(limit=)`, postulo/postulo#321, #781), whose docstring explains that "a size limit applied to what [`client.get`] hands back limits nothing" and that "a server sending a byte every few seconds never trips [a per-wait timeout]". The answerer is normally the person's own instance, but `POSTULO_URL` may point at a reverse proxy, a forward-auth portal or a mistyped host (the cases #7 describes), and over the HTTP transports the server runs unattended. ## Impact Whatever answers at `POSTULO_URL` can make one tool call hold the MCP server (and the agent waiting on it) for as long as it likes by dripping bytes inside the 20 s read timeout, or make it buffer an arbitrarily large body in memory. No data is exposed. On stdio that is the person's own session; on `--transport streamable-http` it is a server other clients share. ## Fix In `_call`, read the body streamed, under a ceiling and a deadline for the whole exchange: ```python MAX_BODY = 8 * 1024 * 1024 # list answers are bounded by the API (postulo/postulo#434) TOTAL = 60.0 deadline = time.monotonic() + TOTAL with self.client.stream(method, path, **kwargs) as response: declared = response.headers.get("Content-Length", "") if declared.isdecimal() and int(declared) > MAX_BODY: raise PostuloError("Postulo's answer is too large to read.") body = bytearray() for chunk in response.iter_bytes(): body += chunk if len(body) > MAX_BODY or time.monotonic() > deadline: raise PostuloError("Postulo's answer is too large or too slow to read.") # then the status handling as today, on `body` ``` keeping `httpx.HTTPError` handling as it is. The client's `timeout=TIMEOUT` stays; the check inside the loop is what bounds the sum. Done when `tests/test_server.py` has a `MockTransport` that answers a body larger than `MAX_BODY` and one that declares a `Content-Length` over it, and each tool call ends in a `PostuloError` rather than a dictionary. ## Related #7 (open) is about a 3xx or an HTML 200 being handed back as success; its fix (refuse non-JSON) does not bound size or total time. #15 (open) is about which fields of a problem document reach the agent.
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#18
No description provided.