Found by a six-lens audit of the desktop app on 27 Aug 2026. Every finding was reproduced against the code, not inferred.
Severity: high · audit rank 1 of 16.
Breaks: the app's only human-in-the-loop control. The user approves http://example.com/blog; a 302 sends the fetch to http://127.0.0.1:<engine port>/chats/<id>, /settings, /logs, or any other loopback service, and the body lands back in the model's context. No re-gate, no loopback refusal, no scheme check after the first hop.
Who / likelihood: any user who approves one fetch of a page whose author (or a prior tool result the model echoes) controls the redirect. Verified end to end with r2.py: card showed {"url": "http://127.0.0.1:63316/company-blog"}, return value was another chat's transcript including its fake SSN.
Where: engine/server.py:605 gates on url; :608 urllib.request.urlopen(url) follows 3xx anywhere.
--- a/engine/server.py
+++ b/engine/server.py
@@ -600,6 +600,7 @@ def fetch_url(url: str) -> str:
import re as _re
+ import urllib.error as _e
import urllib.request as _r
if not url.startswith(("http://", "https://")):
return "Only http and https URLs are supported."
if not _gate("fetch_url", {"url": url}):
return "The user declined this tool call."
+ # The user approved *this* URL. A 3xx would fetch a different one --
+ # any host, any port, including this engine on loopback -- under the
+ # same approval, which makes the card a lie about what happens.
+ class _NoRedirect(_r.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ raise _e.HTTPError(req.full_url, code,
+ f"redirect to {newurl} was not approved",
+ headers, fp)
+
try:
- with _r.urlopen(url, timeout=20) as resp:
+ with _r.build_opener(_NoRedirect).open(url, timeout=20) as resp:
body = resp.read(400_000).decode("utf-8", "replace")
The honest error text reaches the model, which can then ask for the new URL and get a second card. (Hardening beyond the minimum, not required for the fix: refuse a first-hop host that resolves to loopback/link-local, so a directly-approved 127.0.0.1:PORT is not merely visible but refused.)
Test — engine/test_chat_stream.py (already stubs the agent and never reaches a provider): stand up two ThreadingHTTPServers on 127.0.0.1 — A returns 302 Location: <B>, B returns SECRET-BODY. Set approval_mode: "never" so the gate allows. Call the fetch_url closure out of _builtin_tools() on A's URL and assert "SECRET-BODY" not in result. Fails today (the secret is returned), passes after. Asserts the effect — what came back — not that a check was called.
Not yet fixed. Filed so it is not lost with the session that found it. The fix and the test above are proposals from the audit — worth re-checking against current main before implementing, since the file has moved since.
Breaks: the app's only human-in-the-loop control. The user approves
http://example.com/blog; a 302 sends the fetch tohttp://127.0.0.1:<engine port>/chats/<id>,/settings,/logs, or any other loopback service, and the body lands back in the model's context. No re-gate, no loopback refusal, no scheme check after the first hop.Who / likelihood: any user who approves one fetch of a page whose author (or a prior tool result the model echoes) controls the redirect. Verified end to end with
r2.py: card showed{"url": "http://127.0.0.1:63316/company-blog"}, return value was another chat's transcript including its fake SSN.Where:
engine/server.py:605gates onurl;:608urllib.request.urlopen(url)follows 3xx anywhere.The honest error text reaches the model, which can then ask for the new URL and get a second card. (Hardening beyond the minimum, not required for the fix: refuse a first-hop host that resolves to loopback/link-local, so a directly-approved
127.0.0.1:PORTis not merely visible but refused.)Test —
engine/test_chat_stream.py(already stubs the agent and never reaches a provider): stand up twoThreadingHTTPServers on 127.0.0.1 — A returns302 Location: <B>, B returnsSECRET-BODY. Setapproval_mode: "never"so the gate allows. Call thefetch_urlclosure out of_builtin_tools()on A's URL and assert"SECRET-BODY" not in result. Fails today (the secret is returned), passes after. Asserts the effect — what came back — not that a check was called.Not yet fixed. Filed so it is not lost with the session that found it. The fix and the test above are proposals from the audit — worth re-checking against current
mainbefore implementing, since the file has moved since.