Skip to content

add workspace image search and a server-side URL fetch tool - #568

Merged
zomux merged 10 commits into
developfrom
feature/workspace-search-and-fetch
Jul 31, 2026
Merged

add workspace image search and a server-side URL fetch tool#568
zomux merged 10 commits into
developfrom
feature/workspace-search-and-fetch

Conversation

@QuanCheng-QC

Copy link
Copy Markdown
Collaborator

Improves the workspace's shared search and content-fetching so agents can actually read pages and provide images, plus the security that ships with them.

Content fetching:

  • workspace_fetch_url / POST /v1/fetch: read any URL as text without holding a shared browser tab. Static HTTP first; JS-heavy pages (Notion, SPAs) are rendered in an ephemeral browser session and closed within the request.
  • Per-workspace browser tab quota (DB-backed) + an idle-tab reaper, so an agent no longer sees '3/3 full' while its own tab list is empty, and abandoned tabs stop holding slots.
  • claude adapter hard-bans the native WebFetch (can't render JS) in favor of workspace_fetch_url; WebSearch stays allowed.

Images:

  • workspace_image_search / POST /v1/search/images: Brave image search (BRAVE_SEARCH_API_KEY env). Returns image URLs agents embed as markdown, or persist with workspace_image_save.
  • POST /v1/files/from_url downloads a result into workspace storage and, with post_to_channel, posts it into the chat as an inline attachment.

Security that comes with the feature:

  • SSRF-safe outbound fetch (app/net_security.py): scheme allowlist, reject URL credentials, resolve + block private/loopback/link-local/metadata IPs (incl. IPv4-mapped IPv6), re-validate each redirect hop, trust_env=False, and a streamed size cap. Applied to /v1/fetch and /v1/files/from_url.
  • Downloads only render a raster-image allowlist inline; SVG/HTML are served as attachments with X-Content-Type-Options: nosniff (stored-XSS guard).
  • Brave key is read from env only, never workspace settings, so it can't leak through workspace API responses.

@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openagents-workspace Ready Ready Preview Jul 31, 2026 6:07am

Request Review

@zomux zomux left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the static-fetch SSRF work in net_security.py is genuinely well-built (scheme allowlist, credential rejection, resolved-IP validation, per-hop redirect re-validation, streamed byte caps, decimal/octal/IPv4-mapped-IPv6 tricks all caught) and well-tested. But there's a blocking SSRF hole in the browser-render tier:

HIGH (blocking) — the render tier bypasses all SSRF protection.
/v1/fetch validates the entry URL once, then for mode:"render" (and mode:"auto") hands the URL to a headless browser (render_page_text → page.goto), which does its OWN DNS and follows redirects/meta-refresh/JS-nav with NO re-validation. Exploit:
POST /v1/fetch {"url":"http://attacker.com/r","mode":"render"} → 302 Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/
The render tier navigates the redirect and returns the metadata/IAM-credential body to the agent. In the default local-Playwright mode the browser runs on the backend host, so this reads the backend's own cloud metadata / IAM creds / localhost. mode:"auto" reaches it too (serve a JS-shell to trip the render fallback, then redirect). The render_page_text comment 'caller already validated the entry URL' is exactly the flawed assumption — entry validation doesn't constrain where the browser navigates next.

Fix: re-validate every navigation hop inside the browser path (request interceptor that re-runs validate_public_url and aborts private targets, or forbid redirects to non-public IPs), ideally combined with resolve-once-and-pin.

MEDIUM-HIGH — DNS-rebinding TOCTOU on the static tier too. safe_fetch validates the host, then httpx re-resolves independently → a 0-TTL rebind (public on first lookup, 169.254.169.254 on connect) reaches metadata even on the static path. Fix by resolving once and connecting by IP with the original Host header.

Lower: no outbound port restriction; stale search.py docstring (code is the safer env-only behavior — keep the code, fix the doc). Please add render-tier redirect-to-internal and second-resolution-rebinding tests with the fix. Happy to re-review promptly.

@QuanCheng-QC

Copy link
Copy Markdown
Collaborator Author

All confirmed and fixed across a6cdd7a2, 66f60919, 1bd3b2e3, 74cd220e.

HIGH — render tier. Reproduced. mode:"auto" reaches it too — a JS shell is
enough to trip the fallback, no UA sniffing needed.

Fixed, but not with a request interceptor. An interceptor only catches the
mechanisms you enumerate (XHR, iframe, subresources, popups, WebSocket and
service-worker fetches all reach the network without a top-level navigation), and
it can't close the TOCTOU — it resolves, then Chromium resolves again on connect.

So the boundary sits below the browser: Chromium launches behind an in-process
egress policy proxy (app/browser_egress.py), which makes every request pass one
chokepoint and stops Chromium doing DNS at all — the proxy resolves once,
validates, connects to that pinned address. Deny by default. Measured against a
real Chromium, it catches 302, meta-refresh, JS nav, image subresource, iframe,
XHR, WebSocket and popup.

One measured dependency the design rests on: Chromium bypasses the proxy for
loopback unless launched with --proxy-bypass-list=<-loopback>
. Without it,
http://127.0.0.1:... goes direct. There's a regression test whose only job is to
fail if that flag disappears.

MEDIUM-HIGH — rebinding. Fixed as suggested: resolve once, connect to the
pinned IP, original Host header. Needs sni_hostname so certs still verify
against the domain, and a per-hop connection pool — httpcore keys pools by
origin, so two domains sharing an IP would otherwise reuse a connection carrying
the wrong SNI.

Lower. Port allowlist added (default 80,443,8080,8443). search.py
docstring fixed, code unchanged. Resolved internal addresses are no longer echoed
to the caller — that was an internal-DNS oracle.

Tests. Both you asked for, plus the classes the proxy design implies.
test_ssrf_boundary.py is 65 tests, 13 driving a real Chromium and asserting on
the destinations it actually attempted. Every fix was mutation-checked. Full
suite 433 passed; the 20 failures are pre-existing and reproduce on
origin/develop.

Not closed:

  • Browser Fabric path. Rendering there happens on BF infrastructure, which we
    can't intercept, so only the entry URL is validated. Needs a public-only egress
    guarantee from BF, an equivalent policy hook, or disabling render when BF is
    active. Vendor infrastructure doesn't put it outside our responsibility.
  • Which path is live is unconfirmed. BROWSERFABRIC_API_KEY isn't in the repo
    and the image ships no Chromium — so either BF is live and the residual above is
    the production posture, or it isn't and mode:"render" fails outright. Worth
    settling before merge.
  • Chromium sandbox off by default. Now actually controlled by the
    chromium_sandbox launch option rather than by omitting --no-sandbox from
    args (Playwright injects that itself, so the first version of the switch did
    nothing). Enabling it needs a non-root user the image doesn't provide.
  • Python tests don't run on PRs. pytest.yml is workflow_dispatch only. A
    path-filtered job with Playwright Chromium installed should be added — otherwise
    the 13 browser tests skip, and a skip looks like a pass.

Happy to take another round.

@QuanCheng-QC
QuanCheng-QC requested a review from zomux July 29, 2026 10:51
Improves the workspace's shared search and content-fetching so agents can
actually read pages and provide images, plus the security that ships with them.

Content fetching:
- workspace_fetch_url / POST /v1/fetch: read any URL as text without holding a
  shared browser tab. Static HTTP first; JS-heavy pages (Notion, SPAs) are
  rendered in an ephemeral browser session and closed within the request.
- Per-workspace browser tab quota (DB-backed) + an idle-tab reaper, so an agent
  no longer sees '3/3 full' while its own tab list is empty, and abandoned tabs
  stop holding slots.
- claude adapter hard-bans the native WebFetch (can't render JS) in favor of
  workspace_fetch_url; WebSearch stays allowed.

Images:
- workspace_image_search / POST /v1/search/images: Brave image search
  (BRAVE_SEARCH_API_KEY env). Returns image URLs agents embed as markdown, or
  persist with workspace_image_save.
- POST /v1/files/from_url downloads a result into workspace storage and, with
  post_to_channel, posts it into the chat as an inline attachment.

Security that comes with the feature:
- SSRF-safe outbound fetch (app/net_security.py): scheme allowlist, reject URL
  credentials, resolve + block private/loopback/link-local/metadata IPs
  (incl. IPv4-mapped IPv6), re-validate each redirect hop, trust_env=False, and
  a streamed size cap. Applied to /v1/fetch and /v1/files/from_url.
- Downloads only render a raster-image allowlist inline; SVG/HTML are served as
  attachments with X-Content-Type-Options: nosniff (stored-XSS guard).
- Brave key is read from env only, never workspace settings, so it can't leak
  through workspace API responses.
The render tier of POST /v1/fetch validated only the entry URL and then
handed it to a headless browser that did its own DNS and followed
redirects with no re-validation, so an attacker-controlled page could
redirect into cloud metadata or the backend's own network. The shared
browser tools had the same hole and no URL check at all.

Put the boundary below the browser rather than inside the page. Chromium
now launches behind a policy proxy (browser_egress) that sees every
request it makes of every kind, resolves each name once, and refuses
anything that is not a public address. Verified against a real Chromium
that this single chokepoint catches 302 redirects, meta refresh, JS
navigation, image subresources, iframes, XHR and WebSocket. Chromium
bypasses a proxy for loopback unless launched with loopback removed from
its bypass list, so that flag is part of the launch args and has its own
regression test.

Add a shared guard on open_tab, navigate and render_page_text so the
shared browser tools get the same entry check, allowing only the blank
page as a non-http target.

The static tier now resolves once and connects to the pinned address
while keeping the original Host header and TLS server name, closing the
rebinding window between validation and connect. Each redirect hop gets
its own connection pool so two hostnames sharing an address cannot reuse
one another's connection and carry the wrong TLS name.

Also restrict outbound ports, stop echoing the resolved internal address
back to the caller, rate limit fetch per workspace, gate the Chromium
sandbox behind an env flag, and correct the stale image search docstring.

Browser Fabric mode is not covered by the proxy because the page runs on
their infrastructure, and the Chromium sandbox default is unchanged
because the image still runs as root. Both are noted in the code.
The egress proxy spliced the browser socket to the upstream and relayed
raw bytes until EOF, so a second request arriving on that connection was
forwarded to the already-connected upstream without its own destination
check. The injected close header did not prevent this because it only
applied to the upstream request, never to the browser side. Reproduced
with a raw client, where a request addressed to one host and carrying
its cookie was delivered to a different host's server.

The plain HTTP path now relays exactly one request and one response per
connection, parsing the framing the headers declare rather than trusting
the upstream to hang up, and stops reading from the browser after the
request body. CONNECT tunnels keep raw relaying, which is correct there
because the destination is fixed for the life of the tunnel. Upgrade is
stripped so a 101 cannot turn the framed path back into a tunnel.

The sandbox switch did nothing. Playwright defaults chromium_sandbox to
false and injects the no-sandbox flag itself, so dropping that flag from
the argument list left the sandbox off while looking like it had been
enabled. The launch option now carries the decision, and the test reads
the launch option instead of the argument list.

Redirect cookies were lost. Building one client per hop for connection
isolation also built one cookie jar per hop, breaking any flow that sets
a session cookie on the redirect. Hops now share a real CookieJar, which
httpx adopts by reference, while keeping separate transports and pools.

Egress denials were reported as successful renders. A denial is an
ordinary 403 and page.goto does not raise on error status, so the
refusal text was scraped and returned as page content. Denials now carry
a marker header, and a blocked main frame navigation raises. A blocked
subresource deliberately does not, since the page still has real content
worth returning.

Replaces the two tests that asserted the wrong thing. The render test
mocked an exception the real path never raises, so it now drives a real
browser through a real redirect into a real refusal. Each fix was
mutation checked by reverting it and confirming its test fails.
Four defects from the next review round, all reproduced before fixing.

Cookies were still being lost. The pinning transport rewrote the request
URL to the validated address and left it rewritten, so httpx attributed
Set-Cookie to that address rather than to the hostname the caller asked
for, and the next hop never sent the cookie back. The previous test used
the same value for the logical host and the pinned address, which hid
this completely. The transport now restores the logical URL before
returning, and the test pins a named host to a different address so the
two can never be confused again.

The chunked relay could truncate a response. It read the two-byte chunk
terminator with a call that is only required to return at most that many
bytes, so a CR and LF split across TCP segments left a stray LF to be
read as the next chunk size, ending the body early. Reproduced by
delivering the terminator in two segments. It now reads exactly two
bytes and validates them.

Interim responses ended the exchange. Any 1xx was treated as final, so
an upstream sending 103 Early Hints before its real response lost that
response entirely. Interim responses are now relayed and reading
continues, without announcing a close that the following response would
contradict. A 101 is still refused, since accepting one would turn the
framed path back into an opaque tunnel. Requests carrying an expectation
of a continue are answered by the proxy rather than forwarded, which
would otherwise deadlock the exchange.

The denial marker was forgeable. BrowserManager checked only that the
header was present, so any site the policy allows could set it and make
its own page look like a blocked internal address. Over an encrypted
tunnel the proxy cannot strip an upstream header, so the marker carries
a per-proxy random token instead and is compared by value.

Each fix was mutation checked by reverting it and confirming the new
test fails.
The rebase resolved every conflict to develop, which is whole-file, so
the three security commits lost all of their changes to browser.py even
where nothing actually conflicted. This puts them back, adapted to the
structure develop now has rather than restored verbatim.

Adapted rather than reverted because develop reworked the surrounding
code. Cloud detection moved from a manager-wide flag to a per-tab check,
so the navigate guard sits above the new call. Credential handling and
the ephemeral tab quota are develop's, untouched.

Restores the entry guard on open_tab, navigate and render_page_text, the
blank-page allowlist, the egress proxy wiring, the sandbox launch
option, the authenticated denial marker and the proxy shutdown.

Also restores the fetch and search router registration, which the same
whole-file resolution had dropped from main.py. Both endpoints were
returning 404, so the feature this branch exists to add was absent
entirely; only a test asserting a status code caught it.

Adds the missing navigate coverage. Deleting that guard turned nothing
red, because the shared browser tests only ever exercised open. Steering
an already-open tab somewhere internal is the same capability as opening
it there, so it needs its own test.

Drops test_browser_quota.py. It covers the per-workspace cap and the
idle reaper from the first commit, both superseded by develop's
ephemeral quota and maintenance sweep, and neither symbol still exists.
develop's own tab-leak suite covers the same behaviours.
Flagged by git diff --check as a new blank line at EOF.
/v1/fetch and /v1/files/from_url each carried their own copy of the
User-Agent string, so a page read and the image download that follows it
could drift apart. Both now use OUTBOUND_USER_AGENT from app.net_security,
which lives next to safe_fetch because every agent-triggered outbound
fetch has to send the same one.

The platform token also changes from "X11; Linux x86_64" to Windows.
mp.weixin.qq.com answers non-browser clients with a "当前环境异常"
interstitial rather than the article, and it counts the Linux token as
one of them. Measured four requests per UA from a single IP, the article
came back 0/4 with the Linux token and 4/4 with Windows or macOS. The
trailing OpenAgentsFetch/1.0 product token is not what triggers it (3/3
with it on Windows), so we keep it and stay honest about who is calling.
/v1/fetch returned text only, so the direct image URLs were gone by the
time an agent wanted them. /v1/files/from_url needs a direct URL to save
one, which made the image path unreachable in practice no matter what the
prompt said. The read now carries an assets[] of absolute image URLs with
type, guessed mime, alt text and where each was found.

Three shapes are needed to cover real pages, each verified against
captured responses rather than guessed:

  - lazy-loading markup, where the URL is in data-src and there is no src
    at all (every mp.weixin.qq.com article image)
  - embedded JSON, where a gallery never reaches the DOM and the blob
    writes each path separator as an escape sequence (xiaohongshu keeps
    note images in imageList[].urlDefault inside a script tag)
  - the rendered tier, which now enumerates document.images so a browser
    read reports the same assets a static read extracts

An img tag is treated as an image whatever its path looks like. Filtering
markup by file extension is what dropped these two platforms outright,
since neither puts one on its CDN URLs. The extension test survives only
for the JSON sweep, where a bare "url" key on a host named fe-static was
otherwise matching JavaScript bundles.

og:title now fills in when title is empty, which is how mp.weixin.qq.com
ships every article.
Every wall came back as AUTH_REQUIRED or BOT_CHALLENGE with the same hint,
which produced advice that cannot work — telling a user to sign in past a
block that ignores logins, or to re-send a link that was never the problem.
_detect_wall now returns an error code with the hint that belongs to it,
and four codes are added.

  CLIENT_ENV_BLOCKED    mp.weixin.qq.com decided the caller is not a
                        browser. No user action exists; it is ours.
  SHARE_TOKEN_REQUIRED  xiaohongshu only serves a note to a link carrying
                        xsec_token, which app share links already have.
  CONTENT_UNAVAILABLE   the same xiaohongshu page, but the link did carry a
                        token, so the note is deleted rather than
                        under-parameterised.
  IP_OR_REGION_BLOCKED  zhihu refused at the network level.

The xiaohongshu pair matters because both states render the identical
"你访问的页面不见了" body and only the URL distinguishes them. Treating that
string as unconditional proof of a missing token would tell people to
re-share links that will fail again.

Matching is per host. The generic lists are substring-matched against every
page we read, so a phrase added for one site would be free to misclassify
another site's article.

Tests run the classifier over captured responses rather than invented
wording, under tests/fixtures/cn_walls. Zhihu is stored twice on purpose,
because its static 403 body carries no refusal text at all and only the
rendered body does — a fixture set with just the JSON would suggest static
classification works for it, and it does not. Challenge values and trace
ids are redacted; no cookies or share tokens are kept.
There was no way to answer "are xiaohongshu reads failing, and at which
tier" without turning on debug logging and reading page text. One INFO line
per fetch now carries host, tier, HTTP status, content type, byte count,
asset count, error code and duration.

The URL is reduced to scheme, host and path. The query string is dropped
whole rather than filtered, because that is the only version of the rule
that cannot be quietly broken later by a parameter nobody thought to add to
a denylist, and these platforms put share tokens (xsec_token) and
signed-CDN parameters there. Page text, response headers and cookies are
never logged.

Recording happens through fail() and ok() wrappers around the existing
helpers so every exit path is covered, including the ones that degrade from
the render tier back to static. The wrappers take positional-only
parameters after an error_code kwarg collided with a caller passing
status=404 through **extra.

@zomux zomux left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. Re-review confirms both prior SSRF blockers are fully resolved:

  • Render-tier bypass → loopback EgressPolicyProxy (--proxy-server + --proxy-bypass-list=<-loopback>); every Chromium request resolved once and connected by pinned IP, deny-by-default with a per-instance secret deny marker. The exact prior exploit (public→302→169.254.169.254) plus 7 other vectors blocked and asserted against real Chromium.
  • Static-tier DNS rebinding_PinnedTransport resolves once and connects to the pinned IP while preserving Host + SNI.

Also fixed: port allowlist (80/443/8080/8443), no internal-IP leakage in errors, files.py download hardening (SVG/HTML→attachment + nosniff), Brave key env-only. 946-line test_ssrf_boundary.py exercises the real exploit paths.

Remaining nits are non-blocking (BF cloud-render validates entry URL only, documented; search.py trust_env; password-less workspace as a rate-limited fetch proxy).

@zomux
zomux merged commit dca933d into develop Jul 31, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants