SEC2-067: The error channel is outside the fence: text from the answerer's body reaches the model unmarked #19

Closed
opened 2026-10-17 10:40:00 +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: Injection · Reference: CWE-74 (Improper Neutralization of Special Elements in Output Used by a Downstream Component); OWASP A03:2021 (Injection)

Location:

  • src/postulo_mcp/client.py:115-125 (_call: f"Could not reach Postulo: {error}", f"Postulo answered {response.status_code}: {_detail(response)}")
  • src/postulo_mcp/client.py:134-143 (_detail: return response.text[:200], return str(body[key])[:300], return str(body)[:300])
  • src/postulo_mcp/server.py:127-133 (read/write fence the result, never the exception)

Issue

server.py fences third-party prose in successful answers (fenced(postulo.get(...))), and the
INSTRUCTIONS tell the model what the markers mean. A refused call takes another road: _call
raises PostuloError whose message is built from the answer's body:

if response.status_code >= 400:
    raise PostuloError(f"Postulo answered {response.status_code}: {_detail(response)}")

def _detail(response):
    try:
        body = response.json()
    except ValueError:
        return response.text[:200]
    if isinstance(body, dict):
        for key in ("detail", "message", "error"):
            if key in body:
                return str(body[key])[:300]
    return str(body)[:300]

The MCP SDK turns the exception into an isError result whose text the model reads, and nothing
on that path calls fence(). For a non-JSON body the first 200 characters of whatever a proxy,
portal or captive page wrote are handed over verbatim; for a JSON body any
detail/message/error string is. The instance's own detail sentences are Postulo's and
translated (api/auth.py, api/problems.py), so from a healthy Postulo the text is trusted; the
gap is what answers when it is not Postulo, and the cases #7 lists (a forward-auth portal, an
http:// address) are exactly those.

Impact

Text controlled by whatever answers at POSTULO_URL (or on the path to it, where
POSTULO_MCP_INSECURE or plain http:// is used) reaches the model outside the fence, in the
same session as the write tools when --write is on. The README promises that "every piece of
third-party prose is fenced before the model sees it"; the error channel is the one path where
it is not. No exploit against a correctly addressed instance over TLS; recorded as the missing
fence.

Fix

Fence what the answerer said and keep the server's own sentence outside it. Move
fence/_MARKER to a small module that client.py and server.py both import, then:

raise PostuloError(f"Postulo answered {response.status_code}:\n{fence(_detail(response), what='error')}")

and for httpx.HTTPError keep type(error).__name__ plus the fenced str(error).

Done when a test answers a 503 with an HTML body containing an instruction-like sentence and
asserts that the tool's error text carries <<<untrusted error and FENCE_CLOSE around it.

#15 (open) wants more of a problem document relayed (scope, errors, Retry-After); it
changes what goes into the message, this changes how it is wrapped, and the two should land
together. #7 (open) wants non-JSON and 3xx answers refused. #9 and #12 (open) are about fenced
fields and marker look-alikes in successful answers.

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:** Injection · **Reference:** CWE-74 (Improper Neutralization of Special Elements in Output Used by a Downstream Component); OWASP A03:2021 (Injection) **Location:** - `src/postulo_mcp/client.py:115-125` (`_call`: `f"Could not reach Postulo: {error}"`, `f"Postulo answered {response.status_code}: {_detail(response)}"`) - `src/postulo_mcp/client.py:134-143` (`_detail`: `return response.text[:200]`, `return str(body[key])[:300]`, `return str(body)[:300]`) - `src/postulo_mcp/server.py:127-133` (`read`/`write` fence the *result*, never the exception) ## Issue `server.py` fences third-party prose in *successful* answers (`fenced(postulo.get(...))`), and the `INSTRUCTIONS` tell the model what the markers mean. A refused call takes another road: `_call` raises `PostuloError` whose message is built from the answer's body: ```python if response.status_code >= 400: raise PostuloError(f"Postulo answered {response.status_code}: {_detail(response)}") def _detail(response): try: body = response.json() except ValueError: return response.text[:200] if isinstance(body, dict): for key in ("detail", "message", "error"): if key in body: return str(body[key])[:300] return str(body)[:300] ``` The MCP SDK turns the exception into an `isError` result whose text the model reads, and nothing on that path calls `fence()`. For a non-JSON body the first 200 characters of whatever a proxy, portal or captive page wrote are handed over verbatim; for a JSON body any `detail`/`message`/`error` string is. The instance's own `detail` sentences are Postulo's and translated (`api/auth.py`, `api/problems.py`), so from a healthy Postulo the text is trusted; the gap is what answers when it is not Postulo, and the cases #7 lists (a forward-auth portal, an `http://` address) are exactly those. ## Impact Text controlled by whatever answers at `POSTULO_URL` (or on the path to it, where `POSTULO_MCP_INSECURE` or plain `http://` is used) reaches the model outside the fence, in the same session as the write tools when `--write` is on. The README promises that "every piece of third-party prose is fenced before the model sees it"; the error channel is the one path where it is not. No exploit against a correctly addressed instance over TLS; recorded as the missing fence. ## Fix Fence what the answerer said and keep the server's own sentence outside it. Move `fence`/`_MARKER` to a small module that `client.py` and `server.py` both import, then: ```python raise PostuloError(f"Postulo answered {response.status_code}:\n{fence(_detail(response), what='error')}") ``` and for `httpx.HTTPError` keep `type(error).__name__` plus the fenced `str(error)`. Done when a test answers a 503 with an HTML body containing an instruction-like sentence and asserts that the tool's error text carries `<<<untrusted error` and `FENCE_CLOSE` around it. ## Related #15 (open) wants *more* of a problem document relayed (scope, `errors`, `Retry-After`); it changes what goes into the message, this changes how it is wrapped, and the two should land together. #7 (open) wants non-JSON and 3xx answers refused. #9 and #12 (open) are about fenced fields and marker look-alikes in successful answers.
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#19
No description provided.