Skip to content

fix: enforce per-guest isolation on public share channels (#973)#993

Open
codeacme17 wants to merge 12 commits into
xorbitsai:mainfrom
codeacme17:fix/harden-public-share-channels-973
Open

fix: enforce per-guest isolation on public share channels (#973)#993
codeacme17 wants to merge 12 commits into
xorbitsai:mainfrom
codeacme17:fix/harden-public-share-channels-973

Conversation

@codeacme17

Copy link
Copy Markdown
Contributor

What

PR1 of the public-share-channel hardening pass (#973): per-guest isolation.

A public share link is opened by many anonymous visitors who all share the same owner + agent/workforce id. get_task_for_share_context authorized a task solely by Task.id (a guessable sequential PK) + owner + agent/workforce id, with no per-guest claim — so any visitor holding a valid share JWT for the same shared entity could address another visitor's task id and read/continue that conversation. This affects both the agent-share and workforce-share paths; workforce sessions carry more private multi-turn content, so the blast radius is larger.

How

Backend

  • Mint a server-owned, high-entropy guest_id at POST /api/share/auth (both agent- and workforce-share paths) and sign it into the guest JWT. The endpoint never accepts a client-supplied guest_id: share has no secondary credential (unlike widget's embed ticket / widget key), so guest_id is the sole isolation credential and must be server-minted.
  • get_share_chat_user fails closed on tokens that carry no guest_id (those minted before this change) rather than falling back to the old no-isolation behavior.
  • get_task_for_share_context requires task.agent_config["guest_id"] to match the caller's, on top of the existing checks, in both branches (shared helper _require_share_guest_owns_task). This automatically covers the WS connect + per-message revalidation, since share_chat_websocket_endpoint routes through the same function.
  • Stamp guest_id into the task at create time: the agent-share path assigns server keys last so a client-supplied value can't override; the workforce-share path threads it via extra_agent_config (survives the snapshot merge, which never sets guest_id).

Frontend (share only; widget behavior unchanged)

  • Persist and reuse the guest JWT in localStorage so guest_id is stable across reloads, instead of re-authing on every mount (which would mint a new guest each time and make a returning visitor's own tasks fail the check).
  • Recover a returning visitor whose persisted taskId belongs to a different guest (e.g. a pre-migration task): catch the 4003/403 access denial, drop the stale taskId, and start a fresh session.

Migration / scope

  • No schema change and no migration — guest_id lives in the existing agent_config JSON.
  • Accepted cost: pre-existing anonymous share conversations become unreachable (old tasks have no guest_id), which is acceptable for anonymous public share (no accounts, no persistence promise).
  • Rate limiting / abuse control and public-upload hardening (storage gate + orphan GC) are out of scope for PR1 and land in a follow-up PR.

Tests

  • New tests/web/api/test_share_guest_isolation.py: cross-guest isolation for both agent- and workforce-share, distinct server-minted ids per auth, and fail-closed rejection of legacy tokens without a guest_id.
  • Updated the two agent-share file preview/download tests to stamp the authenticated guest's id (the post-Harden public share channels: per-guest isolation + rate limiting (follow-up to #947/#967) #973 contract).
  • Full tests/web/api suite passes; ruff / mypy / tsc / eslint clean.

Part of #973.

)

PR1 of the share-channel hardening pass. A public share link is opened by
many anonymous visitors sharing the same owner + entity id, but
get_task_for_share_context only authorized a task by owner + agent/workforce
id — so any visitor holding a valid share JWT could address another
visitor's task id and read/continue that conversation.

- Mint a server-owned, high-entropy guest_id at POST /api/share/auth (both
  agent- and workforce-share paths) and sign it into the guest JWT. The
  endpoint never accepts a client-supplied guest_id — share has no secondary
  credential (unlike widget's embed ticket / widget key), so guest_id is the
  sole isolation credential and must be server-minted.
- get_share_chat_user fails closed on tokens without a guest_id (those minted
  before this change) rather than falling back to the old no-isolation path.
- get_task_for_share_context requires task.agent_config["guest_id"] to match
  the caller's, on top of the existing checks, in both branches (shared
  helper). Automatically covers the WS connect + per-message revalidation.
- Stamp guest_id into the task at create time: agent-share assigns server
  keys last so a client value can't override; workforce-share threads it via
  extra_agent_config (survives the snapshot merge).
- Frontend (share only): persist and reuse the guest JWT so guest_id is
  stable across reloads instead of re-authing each mount, and recover a
  returning visitor whose persisted taskId belongs to a different guest —
  catch the 4003/403 access denial, drop the stale taskId, start fresh.
  Widget behavior unchanged.

Stored in agent_config JSON; no schema change / migration. Rate limiting and
upload hardening remain for PR2/PR3.
@XprobeBot XprobeBot added the bug Something isn't working label Jul 24, 2026

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request implements per-guest isolation for public share links to prevent anonymous visitors from accessing each other's tasks. On the backend, a server-minted, high-entropy guest_id is introduced to the guest JWT, and access checks are added to ensure a guest can only access tasks matching their guest_id. On the frontend, guest tokens are persisted and reused across reloads to maintain session continuity, with recovery logic to handle invalid or legacy sessions. The reviewer suggests strengthening the validation of the persisted authentication object retrieved from localStorage to prevent potential runtime errors from corrupted or legacy data.

Comment thread frontend/src/components/widget/public-agent-chat-page.tsx Outdated
Reject corrupt or cross-version localStorage entries in
readPersistedShareAuth instead of type-asserting: require a non-empty
string access_token and keep agent_id/workforce_id to their optional-number
contract. Malformed blobs now fall back to a clean re-auth rather than
flowing bad values downstream. Addresses Gemini review on xorbitsai#993.
@codeacme17

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request implements per-guest isolation for public share links by introducing a server-minted guest_id embedded in the guest JWT, ensuring visitors can only access their own tasks. Legacy tokens lacking this ID are rejected to fail closed. On the frontend, guest tokens are persisted and reused, with recovery flows to clear stale sessions upon authorization failures. Reviewer feedback recommends wrapping localStorage.removeItem calls in try/catch blocks to prevent crashes in restricted environments (such as private browsing) and refactoring test helpers to avoid nested database sessions that could cause SQLite writer-lock contention.

Comment thread frontend/src/components/widget/public-agent-chat-page.tsx Outdated
Comment thread frontend/src/components/widget/public-agent-chat-page.tsx
Comment thread frontend/src/components/widget/public-agent-chat-page.tsx
Comment thread tests/web/api/test_share_guest_isolation.py

@rogercloud rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

This PR closes a per-guest privacy hole on public share channels (agent-share and workforce-share). Previously many anonymous visitors shared one owner+agent/workforce identity, and tasks were authorized only by a guessable Task.id + owner + agent id — so any visitor could address and read another visitor's conversation. The fix mints a server-owned, high-entropy guest_id (secrets.token_urlsafe(32)) at share-auth time, signs it into the guest JWT, stamps it onto the task's agent_config at creation, and requires it to match on every subsequent access via a new shared helper _require_share_guest_owns_task. Legacy tokens minted before this change (no guest_id) now fail closed rather than being silently allowed through.

Design verdict — Sound

The backend isolation mechanism is architecturally solid, verified by tracing the actual code paths (not just the diff):

  • guest_id is server-minted only at both auth endpoints (share.py:78, share.py:121); ShareAuthRequest exposes no client-supplied guest_id. It is bound into the HS256 JWT signature, so it cannot be forged or stripped without invalidating the token, and get_share_chat_user fails closed (401) on a missing/empty guest_id with no fallback to the old behavior.
  • Agent-share task creation copies the client's agent_config first, then assigns auth_mode / share_agent_id / guest_id after (public_chat_access.py:716-719) — server values always win.
  • Workforce-share task creation routes guest_id through extra_agent_config; _merge_agent_config (workforce_runs.py:83) does {**extra_agent_config, **task_config} and build_workforce_task_config never sets guest_id, so the server value survives the snapshot merge untouched.
  • All four consumption sites (WS connect, WS per-message revalidation, share file upload, public file preview/download) go through _require_share_guest_owns_task.
  • Share vs. widget claim safety: both flows use a "widget"-typed JWT internally, but auth_mode ("share" vs "widget") is enforced on both the JWT-consumption side and the stored agent_config.auth_mode, so the two claim spaces cannot be confused.
  • Scope boundary holds: rate-limiting and storage/orphan-GC are explicitly deferred to a follow-up per #973, and nothing in this diff drifts into that territory.

The one real defect is a frontend recovery-UX bug (Finding 1), not a weakness in the isolation mechanism — isolation is never bypassed; an affected returning visitor is simply left stuck instead of gracefully recovered.

Findings (by severity)

Finding 1 — MAJOR — Returning-guest recovery never triggers for its main intended scenario

The connect-time WS handler (public_chat_access.py:826-834) wraps auth in a bare except Exception and always closes with code=4001, reason="Authentication required", discarding the real HTTPException.detail. The 4003-with-reason path exists only in the per-message revalidation loop, which never runs during the initial connect. On the frontend, only the 4003 handler sets connectionError (use-websocket.ts:186-191); the 4001 handler does not, and app-context-chat.tsx:1326-1336 wires no onError. The recovery useEffect (public-agent-chat-page.tsx:131-140) only fires when connectionError is in SHARE_ACCESS_DENIED_REASONS — which never happens on connect. Net effect: a returning visitor whose persisted taskId belongs to another guest (or a pre-#973 legacy task) hits a connect failure that never triggers the recovery flow the PR's own comment (lines 124-130) describes — the comment's claim that "the WS connect closes 4003" is factually wrong; it always closes 4001. Not a security hole; the recovery UX this PR built is simply defeated.
Fix: either have the connect-time handler distinguish auth-denial exceptions and close with 4003 + the real reason (mirroring the revalidation path), or have the frontend 4001 handler also set connectionError / trigger the recovery check.

Finding 2 — MINOR — Overly broad denial-reason string conflates "stale task" with "link genuinely disabled"

"Share link is unavailable" is emitted by many distinct backend causes (channel mismatch, missing workforce_run, unpublished agent/workforce, sharing disabled by owner, etc.) and is included in SHARE_ACCESS_DENIED_REASONS (public-agent-chat-page.tsx:58-62). When a genuinely disabled link triggers it mid-session, the recovery logic fires anyway: clears state, retries task-create, fails, forces re-auth, fails, lands on the terminal error screen. Confirmed not an infinite loop, but an unnecessary extra round-trip, and the comments mischaracterize a non-recoverable case as recoverable.
Fix: narrow SHARE_ACCESS_DENIED_REASONS to the guest-mismatch-specific string only (the backend already emits a distinct "Access denied for this guest"), i.e. drop "Share link is unavailable" from the recoverable set.

Finding 3 — MINOR — No test exercises the core tamper-proof claim for agent-share

None of the 6 tests in tests/web/api/test_share_guest_isolation.py posts a forged guest_id inside the agent_config body of POST /api/share/chat/task/create to assert the server-minted value wins. The surface is real — TaskCreateRequest.agent_config is client-controlled and create_share_chat_task relies on assignment order to override it — so a future reorder would regress silently. (The workforce-share path is not at risk from this vector, since it never reads request.agent_config into the merge.)
Fix: add a case posting agent_config={"guest_id": "<attacker-id>"} and assert the persisted task's guest_id equals the authenticated guest's minted id.

Finding 4 — MINOR — Isolation test assertions check type, not value

test_share_guest_isolation.py:161 and :194 assert isinstance(task.agent_config.get("guest_id"), str) — presence/type only, never comparing to the guest's minted id. Low risk (no realistic bug passes this and the surrounding 403/200 assertions simultaneously, since stamping and checking share the same field), but the test is weaker and less self-documenting than it could be.
Fix (optional, cheap): decode the JWT in the auth helper to extract the minted guest_id and assert equality directly.

Finding 5 — MINOR/NOTE — Concurrent-tab race mints two different guest_ids for one visitor

If a visitor opens two tabs to the same share link near-simultaneously (before the first tab persists auth), each independently calls /api/share/auth and mints a different guest_id, so the tabs behave as separate guests. No BroadcastChannel / storage listener / lock coordinates the share-auth path (the Web Locks mutex in api-wrapper.ts is scoped to logged-in token refresh, not imported here). This edge case is made consequential by this PR's own per-guest isolation but is explicitly within the accepted scope (anonymous, no cross-tab guarantee). Note only — not blocking.

Simplification opportunities

Lean already. net: -0 lines possible.

Re-review / prior bot status

One prior automated review exists (gemini-code-assist, COMMENTED, no blocking inline threads). Its localStorage-validation suggestion is partially addressed: the auth read/write (readPersistedShareAuth/persistShareAuth, ~lines 378-410) got try/catch wrapping, but three localStorage.removeItem calls in the same file (lines 121, 138, 202) remain unwrapped. Low risk in practice (removeItem rarely throws even in restricted storage contexts, unlike getItem/setItem) — a passing nit, not blocking.

Testing

Static review only. The test suite was not executed in this review environment (a fresh dependency install including heavy packages such as torch would be required, and no venv was available — a review-environment limitation, not a comment on the PR). The author reports the full tests/web/api suite passing and ruff/mypy/tsc/eslint clean; this was not independently verified here.

Comment thread src/xagent/web/api/public_chat_access.py
Comment thread frontend/src/components/widget/public-agent-chat-page.tsx
Comment thread tests/web/api/test_share_guest_isolation.py Outdated
Comment thread frontend/src/components/widget/public-agent-chat-page.tsx
…bitsai#973)

Wrap the three recovery-path localStorage.removeItem calls (cross-guest
taskId reset, task-create 401/403 handler, onAuthInvalidated) in try/catch
so a SecurityError in private mode / sandboxed iframes can't abort the
session-reset that follows. Matches the existing try/catch style around
persist/read of the share auth blob. Addresses Gemini review on xorbitsai#993.
get_share_chat_user treated any non-empty string guest_id as valid, so a
whitespace-only value would pass the fail-closed check. Such an id can never
match a server-minted token_urlsafe guest_id, so treat it as absent and
reject the token loudly rather than admitting a guest that can own no task.
The connect-time handler wrapped auth in a bare except and always closed
4001, discarding the HTTPException.detail. The 4003 + reason path only
existed in per-message revalidation, which never runs on initial connect,
so a returning guest whose persisted task belongs to another guest (or a
pre-xorbitsai#973 legacy task) hit a 4001 that never triggered the frontend
recovery flow. Mirror the revalidation path: close 4003 with the real
reason on HTTPException, 4001 otherwise.

Also narrow SHARE_ACCESS_DENIED_REASONS to the guest-mismatch case:
'Share link is unavailable' is emitted by non-recoverable causes (owner
disabled the link, unpublished, channel mismatch), so treating it as
recoverable only forced a pointless clear + re-auth round-trip that still
landed on the terminal error screen.
…sai#973)

Add a case posting a forged guest_id in the agent-share create-task body
and assert the persisted task carries the attacker's own minted id, not
the forged victim id — locking the assignment-order override so a future
reorder cannot regress it silently. Strengthen the two isolation tests to
compare the stamped guest_id against this guest's minted id (decoded from
the JWT) instead of merely asserting a string is present.
@codeacme17

Copy link
Copy Markdown
Contributor Author

@rogercloud thanks for the thorough trace-based review — especially confirming the isolation mechanism by following the real code paths. Addressed the findings in 91130d2a + b958e172:

Finding 1 (MAJOR) — fixed. You're right: the connect-time handler always closed 4001 and discarded the detail, so the recovery flow the PR comment described never fired on connect. Took the first option you suggested — the connect handler now mirrors the per-message revalidation path: HTTPException → close 4003 with the real exc.detail, everything else → 4001. A returning guest holding another guest's (or a pre-#973 legacy) taskId now closes 4003 "Access denied for this guest", which the existing frontend 4003 handler already surfaces as connectionError, triggering recovery. Fixed the inaccurate comment too.

Finding 2 (MINOR) — fixed. Dropped "Share link is unavailable" from SHARE_ACCESS_DENIED_REASONS. It's coupled to Finding 1: now that connect emits 4003 for it too, a genuinely-disabled link would otherwise trigger the pointless clear + re-auth round-trip. The set is now scoped to the guest-mismatch reason (+ the no-reason "Access denied" fallback).

Finding 3 (MINOR) — fixed. Added test_agent_share_task_create_ignores_client_supplied_guest_id: posts agent_config={"guest_id": <victim-id>} to the create endpoint and asserts the persisted task carries the attacker's own minted id (and != the forged victim id). Guards the assignment-order override against a silent reorder regression.

Finding 4 (MINOR) — fixed. The two isolation tests now decode the guest JWT (new _minted_guest_id helper) and assert the stamped guest_id equals this guest's minted id, instead of the type-only isinstance(..., str) check.

Finding 5 (NOTE) — acknowledged, not changed. Agreed it's within the accepted anonymous/no-cross-tab-guarantee scope for PR1.

gemini removeItem nit — the three unwrapped localStorage.removeItem calls were already wrapped in try/catch in 2779777b (guard share localStorage.removeItem against restricted envs).

Verification: full tests/web/api share suite passes (incl. the new tamper case); backend ruff / ruff format / mypy clean; frontend tsc clean, eslint clean apart from a pre-existing unrelated <img> warning.

@rogercloud rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review (round 2) — head cd7915f9 (was ae45073)

What changed since last round

Since my initial review the author addressed all 4 real findings (commits 91130d2a, b958e172), plus landed 1 unprompted hardening fix (c972efeb). I ran a fresh full pass on the current head — a blank-slate re-derivation plus independent verification of every claimed fix and of two newly-surfaced issues.

Design verdict: Sound (third independent confirmation)

The backend per-guest isolation mechanism remains architecturally solid and tamper-proof. This is now the third independent trace (initial review, drafted first re-review, this blank-slate pass) to reach the same conclusion — server-minted guest_id is authoritative, client-supplied values are ignored, and cross-guest access is denied at both the connect and per-message paths.

Status of original findings

# Severity Finding Status
1 MAJOR WS connect always closed 4001, discarding the real denial reason → returning-guest recovery never fired FIXED — verified (91130d2a); connect handler now closes 4003 with the real exc.detail, full recovery chain traced end-to-end
2 MINOR Overly broad "Share link is unavailable" in SHARE_ACCESS_DENIED_REASONS caused needless round-trips FIXED — verified (91130d2a); string removed, all remaining raise-sites confirmed non-recoverable
3 MINOR No test for a forged guest_id override in the agent-share task-create body FIXED — verified (b958e172); new test forges a victim id and asserts the server-minted id wins; full suite passes
4 MINOR Isolation test assertions checked type only, not the minted guest_id value FIXED — verified (b958e172); tests now assert equality against the decoded minted id
5 NOTE Concurrent-tab race mints two guest_ids for one visitor WAIVED — accepted PR1 scope (anonymous, no cross-tab guarantee); untouched, as expected

Note on the unprompted whitespace-guest_id fix (c972efeb)

The change from not guest_id to not guest_id.strip() in get_share_chat_user is correct, tested, and zero-regression. Characterizing it honestly: this is defense-in-depth, not a fix for a reachable vulnerability. Reaching this check requires an already-validly-signed JWT, which requires JWT_SECRET_KEY itself to be compromised — at which point an attacker could forge any non-whitespace guest_id just as easily, so the check doesn't meaningfully raise the bar against a real attacker. Fine to keep (harmless and tested) — just not a closed exploit.

New finding this round

[MEDIUM · NEW] Expired persisted share-auth token has no recovery path.
This PR introduced persisting the guest JWT in localStorage so guest_id stays stable across reloads (a reasonable design choice). But readPersistedShareAuth validates only the persisted object's structure — it never checks the JWT exp claim (30-day TTL). A visitor returning after 30+ days with intact storage reuses the expired token; the backend raises ExpiredSignatureErrorHTTPException(401, "Invalid share token"); the connect handler (via the Finding-1 fix) closes 4003 with that reason — but "Invalid share token" is not in SHARE_ACCESS_DENIED_REASONS, so the recovery effect never fires. The onAuthInvalidated path doesn't help either: it triggers only on a REST 401/403 from task/create, which a returning visitor resuming over the WS connect path never calls. Net effect: dead session, no automatic recovery — only a manual localStorage clear unsticks the visitor.

Severity MEDIUM: the mechanism is fully real with zero mitigation, but likelihood is narrow (requires a 30+ day return with intact storage, e.g. a bookmarked/embedded widget link). Newly introduced by this PR's own persistence feature — the scenario was structurally impossible before (mounts always re-authed fresh). See inline comment for the suggested fix.

Hypothesis checked and ruled out

For transparency: I also examined a hypothesized React effect-ordering race (recovery effect immediately re-clearing a freshly-created task because a stale connectionError lingers in the same commit). Tracing the actual state machine ruled it out — state.taskId cannot jump old→new in one commit; it must pass through an explicit null, and use-websocket.ts clears connectionError on exactly that null transition, before any new task can exist. Not a finding, no change needed.

Simplification opportunities (non-blocking)

  • Still open (raised previously): the three localStorage.removeItem try/catch blocks in public-agent-chat-page.tsx (plus a 4th unwrapped one in the taskId-persistence sync effect) are duplicated structure — extract a single safeRemoveItem(key) helper.
  • New this round: two near-identical JWT-decode-to-guest_id test helpers now exist — _minted_guest_id in tests/web/api/test_share_guest_isolation.py and _share_guest_id in tests/web/api/test_agents_management.py — same decode logic, differing only in headers-dict vs raw-token input. Consolidate into one shared helper.

Merge readiness

Nothing here blocks on security grounds. The security-critical isolation mechanism is solid, and its one major UX gap (Finding 1) is now closed. The remaining MEDIUM (expired-token recovery) is a real but narrow edge case: it could reasonably ship now with a fast-follow, or be closed pre-merge with a ~2-line change (adding one string to a Set). Approving — the MEDIUM item and the two simplification notes are non-blocking follow-ups.

// lands on the terminal error. "Access denied for this guest" is the backend
// HTTPException.detail surfaced as event.reason on a 4003 close; "Access
// denied" is use-websocket.ts's fallback when a 4003 carries no reason.
const SHARE_ACCESS_DENIED_REASONS = new Set([

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MEDIUM · NEW] Expired persisted token isn't recoverable.

Now that this PR persists the guest JWT in localStorage, a visitor returning after the 30-day TTL reuses an expired token. The backend converts ExpiredSignatureError into HTTPException(401, "Invalid share token"), and the connect handler closes 4003 with that reason — but "Invalid share token" isn't in this Set, so the recovery effect never clears the stale auth. The onAuthInvalidated path doesn't cover this either (it fires only on a REST 401/403 from task/create, which a WS-resuming visitor never hits). Result: dead session, recoverable only by manually clearing localStorage.

Two ways to fix:

  1. Simplest — add "Invalid share token" to this Set, reusing the existing recovery effect.
  2. Or check exp client-side in readPersistedShareAuth before reuse and treat an expired token the same as an absent one.

Non-blocking — narrow edge case (requires a 30+ day return with intact storage), but flagging since it has zero current mitigation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden public share channels: per-guest isolation + rate limiting (follow-up to #947/#967)

3 participants