Skip to content

desktop: fetch_url follows redirects, so the approval card authorises one URL and the engine fetches another #4490

Description

@MervinPraison

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeAuto-trigger Claude analysis

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions