SEC2-067: The error channel is outside the fence: text from the answerer's body reaches the model unmarked #19
Labels
No labels
bug
documentation
enhancement
security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo-mcp#19
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-10-16 in a read-only security review of
postulo-mcpat3819dbd, 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/writefence the result, never the exception)Issue
server.pyfences third-party prose in successful answers (fenced(postulo.get(...))), and theINSTRUCTIONStell the model what the markers mean. A refused call takes another road:_callraises
PostuloErrorwhose message is built from the answer's body:The MCP SDK turns the exception into an
isErrorresult whose text the model reads, and nothingon 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/errorstring is. The instance's owndetailsentences are Postulo's andtranslated (
api/auth.py,api/problems.py), so from a healthy Postulo the text is trusted; thegap 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, wherePOSTULO_MCP_INSECUREor plainhttp://is used) reaches the model outside the fence, in thesame session as the write tools when
--writeis on. The README promises that "every piece ofthird-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/_MARKERto a small module thatclient.pyandserver.pyboth import, then:and for
httpx.HTTPErrorkeeptype(error).__name__plus the fencedstr(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 errorandFENCE_CLOSEaround it.Related
#15 (open) wants more of a problem document relayed (scope,
errors,Retry-After); itchanges 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.