diff --git a/browser_panel.py b/browser_panel.py index 4f96705..4624b6a 100644 --- a/browser_panel.py +++ b/browser_panel.py @@ -15,13 +15,17 @@ from __future__ import annotations import asyncio +import hashlib +import hmac import json import logging +import secrets import subprocess +import time # Module-scope fastapi imports (the host always provides fastapi; __init__.py catches # ImportError so the tools still serve if the panel can't import). -from fastapi import APIRouter, Body, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Body, Request, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, JSONResponse from . import browser_stream @@ -30,6 +34,62 @@ log = logging.getLogger("protoagent.plugins.agent_browser") +# ── /panel/dash auth gate: a short-lived, HMAC-signed session cookie ────────────── +# The full-mode dashboard proxy (``/panel/dash`` + its CDP screencast WS) loads in an +# iframe, which cannot carry an ``Authorization: Bearer`` header — so on a token-gated +# deployment it would be reachable *unauthenticated*. Gate it in two steps: the panel +# mints a signed token via the bearer-gated ``POST /dash-session``, then presents it +# once as ``?dash=`` on the proxy URL, whose response sets the ``ab_session`` cookie. +# The cookie MUST be set from the proxy's own URL — RFC 6265 path-matching means a +# cookie Path-scoped to the page surface set from a ``/api/...`` response is dropped +# by browsers. The signing key is per-boot + in-memory ONLY: a cookie can never +# outlive a restart, and there is no key at rest to steal. Non-token-gated +# deployments leave the gate open (backward compatible — nothing has to mint a cookie). +_DASH_COOKIE = "ab_session" +_DASH_COOKIE_PATH = "/plugins/agent_browser/" # cookie scope: the plugin's page surface only +_DASH_TTL = 300 # ~5 min — long enough to load, short enough to leak-proof +_DASH_KEY = secrets.token_bytes(32) # per-boot random HMAC key; in-memory, never persisted + + +def _dash_cookie_path(request_path: str) -> str: + """Cookie Path for ``ab_session``, derived from the URL that sets it so it always + path-matches (RFC 6265) — ``/plugins/agent_browser/`` on the host, + ``/agents//plugins/agent_browser/`` through the fleet proxy.""" + i = request_path.find(_DASH_COOKIE_PATH) + return request_path[: i + len(_DASH_COOKIE_PATH)] if i >= 0 else _DASH_COOKIE_PATH + + +def _dash_sig(expiry: str) -> str: + return hmac.new(_DASH_KEY, expiry.encode(), hashlib.sha256).hexdigest() + + +def mint_dash_token(now: float | None = None, ttl: int = _DASH_TTL) -> str: + """Mint an HMAC-signed ``.`` token good for ~5 min. Called only from the + bearer-gated ``POST /dash-session``, so possession proves the caller cleared the gate.""" + exp = int((time.time() if now is None else now) + ttl) + return f"{exp}.{_dash_sig(str(exp))}" + + +def verify_dash_token(token: str, now: float | None = None) -> bool: + """True iff ``token`` carries a valid signature (constant-time compare) and hasn't + expired. A missing/tampered signature or a re-dated expiry can't forge the HMAC.""" + exp_s, _, sig = (token or "").partition(".") + if not sig or not hmac.compare_digest(sig, _dash_sig(exp_s)): + return False + try: + exp = int(exp_s) + except ValueError: + return False + return exp >= int(time.time() if now is None else now) + + +def _dash_auth_required(cfg: dict | None) -> bool: + """The gate bites only on a token-gated deployment — the host signals that by setting + ``require_auth`` truthy in the plugin config (the same host that applies the operator + bearer gate). Absent/false ⇒ the proxy stays open (backward compatible).""" + return bool((cfg or {}).get("require_auth")) + + def build_panel_router(cfg: dict | None): cfg = cfg or {} home = str(cfg.get("home_url") or "").strip() @@ -44,6 +104,33 @@ def build_panel_router(cfg: dict | None): async def _panel(): return HTMLResponse(_INTERACTIVE_PAGE.replace("__HOME_URL__", home_literal)) + @router.get("/panel/dash") + async def _panel_dash(request: Request): + """Full-mode dashboard proxy entry — loaded in an iframe, so it can't carry a + bearer. On a token-gated deployment it admits the signed ``ab_session`` cookie + or a fresh ``?dash=`` minted by the bearer-gated ``POST /dash-session`` + (401 on a missing / tampered / expired one); a ``?dash=`` entry answers with the + ``Set-Cookie`` — it must happen HERE, not on the ``/api`` POST, because browsers + drop a cookie whose Path doesn't path-match the URL that set it — so reloads and + the plugin's sub-requests ride the cookie without re-minting. A non-gated + deployment serves openly (backward compatible).""" + page = _INTERACTIVE_PAGE.replace("__HOME_URL__", home_literal) + if not _dash_auth_required(cfg) or verify_dash_token(request.cookies.get(_DASH_COOKIE, "")): + return HTMLResponse(page) + token = request.query_params.get("dash", "") + if not verify_dash_token(token): + return JSONResponse({"error": "unauthorized"}, status_code=401) + # First entry via the minted token: exchange it for the path-scoped session + # cookie. Secure when the origin — or the TLS-terminating proxy's + # ``X-Forwarded-Proto`` — is HTTPS. + secure = (request.url.scheme == "https" + or request.headers.get("x-forwarded-proto", "").lower() == "https") + resp = HTMLResponse(page) + resp.set_cookie(_DASH_COOKIE, token, max_age=_DASH_TTL, + path=_dash_cookie_path(request.url.path), + httponly=True, samesite="strict", secure=secure) + return resp + return router @@ -72,6 +159,18 @@ def _run(*args: str) -> tuple[int, str]: except subprocess.TimeoutExpired: return 124, "timed out" + # ── /panel/dash gate: mint the signed session token (bearer-gated route) ───────── + @router.post("/dash-session") + async def _dash_session(): + """Mint the short-lived signed token that unlocks the ``/panel/dash`` proxy. + HTTP + under ``/api`` ⇒ it rides the host operator-bearer gate, so only an + authenticated console can mint one. The token rides the body, NOT a Set-Cookie: + a cookie Path-scoped to the page surface would not path-match this ``/api/...`` + URL, and browsers drop such a cookie (RFC 6265). The panel presents it once as + ``?dash=`` on the proxy URL, whose response sets the real path-matched + ``ab_session`` cookie (HttpOnly + SameSite=Strict + Secure-on-HTTPS).""" + return JSONResponse({"ok": True, "dash": mint_dash_token()}) + # ── interactive stream: a single-use ticket (gated) + the WS bridge (self-gated) ── @router.post("/stream-ticket") async def _stream_ticket(): @@ -260,8 +359,24 @@ async def _nav(body: dict = Body(...)): u.searchParams.set("ticket", ticket); return u.toString(); } +// ── /panel/dash auth: set the short-lived ab_session cookie BEFORE pointing the +// iframe/WS at the full-mode proxy. On a token-gated deployment the proxy 401s a +// cookieless request; apiFetch (operator bearer) mints a signed token, then ONE +// same-origin GET presents it as ?dash= — the proxy answers THAT request with the +// path-scoped cookie (a Set-Cookie must path-match its own URL or browsers drop it, +// so the /api POST can't set it). A non-gated deployment doesn't need it, and a +// failure must not block the stream — best-effort, awaited only to order it before +// the connect. ── +async function ensureDashSession(){ + try{ + const r=await kit.apiFetch("/api/plugins/agent_browser/dash-session",{method:"POST"}); + const t=(await r.json()).dash; + if(t) await fetch(BASE+"/plugins/agent_browser/panel/dash?dash="+encodeURIComponent(t)); + }catch(_){} +} async function connect(){ clearTimeout(retry); + await ensureDashSession(); // set the ab_session cookie before opening the (proxied) stream try{ const r=await kit.apiFetch("/api/plugins/agent_browser/stream-ticket",{method:"POST"}); const ticket=(await r.json()).ticket; diff --git a/tests/test_agent_browser.py b/tests/test_agent_browser.py index de726f1..06b3bdc 100644 --- a/tests/test_agent_browser.py +++ b/tests/test_agent_browser.py @@ -361,3 +361,141 @@ def test_nav_open_applies_launch_flags(monkeypatch): assert rec[-1] == ["agent-browser", "reload"] +# ── /panel/dash signed-cookie auth gate (mint token + proxy gate) ───────────────── + + +def test_dash_token_roundtrips_and_expires(): + # a fresh token verifies through its ~5-min window; one minted in the past is expired; + # a flipped signature byte or a re-dated expiry can never forge the HMAC. + tok = bp.mint_dash_token(now=1000, ttl=300) + assert bp.verify_dash_token(tok, now=1000) is True + assert bp.verify_dash_token(tok, now=1299) is True + assert bp.verify_dash_token(tok, now=1301) is False # past expiry → rejected + exp, _, sig = tok.partition(".") + bad_sig = sig[:-1] + ("1" if sig.endswith("0") else "0") + assert bp.verify_dash_token(f"{exp}.{bad_sig}", now=1000) is False # tampered signature + assert bp.verify_dash_token(f"99999999999.{sig}", now=1000) is False # re-dated expiry + assert bp.verify_dash_token("", now=1000) is False + assert bp.verify_dash_token("garbage-no-dot", now=1000) is False + + +def test_dash_session_mints_signed_token_without_api_cookie(): + from fastapi.testclient import TestClient + + r = TestClient(_app({"require_auth": True})).post("/api/plugins/agent_browser/dash-session") + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert bp.verify_dash_token(body["dash"]) is True # a real, verifiable signed token + # NO Set-Cookie on the /api response: a Path=/plugins/agent_browser/ cookie would + # not path-match this /api/... URL and browsers drop it (RFC 6265) — the ?dash= + # exchange on the proxy itself sets the cookie instead. + assert "set-cookie" not in r.headers + + +def test_dash_query_token_exchanges_for_path_matched_cookie(): + from fastapi.testclient import TestClient + + c = TestClient(_app({"require_auth": True})) + r = c.get(f"/plugins/agent_browser/panel/dash?dash={bp.mint_dash_token()}") + assert r.status_code == 200 and 'id="cv"' in r.text # a valid minted token admits entry + sc = r.headers["set-cookie"] + low = sc.lower() + assert sc.startswith("ab_session=") + assert "httponly" in low # not readable from JS + assert "samesite=strict" in low # no cross-site send + assert "path=/plugins/agent_browser/" in low # scoped to the plugin's page surface + assert "max-age=300" in low # ~5-min TTL + # the Path path-matches the URL that set it (RFC 6265 — else browsers drop the cookie) + cookie_path = next(p.split("=", 1)[1] for p in sc.split(";") + if p.strip().lower().startswith("path=")) + assert "/plugins/agent_browser/panel/dash".startswith(cookie_path) + # the cookie value is a real, verifiable signed token… + token = sc.split("ab_session=", 1)[1].split(";", 1)[0] + assert bp.verify_dash_token(token) is True + # …and the retained cookie admits a plain reload with no query token + assert c.get("/plugins/agent_browser/panel/dash").status_code == 200 + + +def test_dash_cookie_path_follows_proxy_prefix(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + # through the fleet proxy the page surface lives under /agents//… — the cookie + # Path must follow the actual request URL or the browser would never send it back. + app = FastAPI() + app.include_router(bp.build_panel_router({"require_auth": True}), + prefix="/agents/slug/plugins/agent_browser") + r = TestClient(app).get( + f"/agents/slug/plugins/agent_browser/panel/dash?dash={bp.mint_dash_token()}") + assert "path=/agents/slug/plugins/agent_browser/" in r.headers["set-cookie"].lower() + + +def test_dash_cookie_is_secure_only_on_https(): + from fastapi.testclient import TestClient + + app = _app({"require_auth": True}) + url = "/plugins/agent_browser/panel/dash" + http = TestClient(app).get(f"{url}?dash={bp.mint_dash_token()}") + assert "secure" not in http.headers["set-cookie"].lower() # plain HTTP → no Secure flag + https = TestClient(app, base_url="https://testserver").get(f"{url}?dash={bp.mint_dash_token()}") + assert "secure" in https.headers["set-cookie"].lower() # HTTPS origin → Secure + xfp = TestClient(app).get(f"{url}?dash={bp.mint_dash_token()}", + headers={"X-Forwarded-Proto": "https"}) + assert "secure" in xfp.headers["set-cookie"].lower() # TLS-terminating proxy → Secure + + +def test_dash_proxy_rejects_without_cookie_when_gated(): + from fastapi.testclient import TestClient + + # token-gated deployment: the iframe proxy is unreachable without a valid cookie + # or a valid minted ?dash= token. + c = TestClient(_app({"require_auth": True})) + assert c.get("/plugins/agent_browser/panel/dash").status_code == 401 + assert c.get("/plugins/agent_browser/panel/dash?dash=9999999999.forged").status_code == 401 + expired = bp.mint_dash_token(now=0) + assert c.get(f"/plugins/agent_browser/panel/dash?dash={expired}").status_code == 401 + + +def test_dash_proxy_serves_with_valid_cookie_when_gated(): + from fastapi.testclient import TestClient + + c = TestClient(_app({"require_auth": True})) + token = bp.mint_dash_token() + r = c.get("/plugins/agent_browser/panel/dash", headers={"Cookie": f"ab_session={token}"}) + assert r.status_code == 200 and 'id="cv"' in r.text # the drivable dashboard served + + +def test_dash_proxy_rejects_expired_or_tampered_cookie_when_gated(): + from fastapi.testclient import TestClient + + c = TestClient(_app({"require_auth": True})) + expired = bp.mint_dash_token(now=0) # exp far in the past + assert c.get("/plugins/agent_browser/panel/dash", + headers={"Cookie": f"ab_session={expired}"}).status_code == 401 + assert c.get("/plugins/agent_browser/panel/dash", + headers={"Cookie": "ab_session=9999999999.deadbeef"}).status_code == 401 # bad signature + + +def test_dash_proxy_open_when_not_gated(): + from fastapi.testclient import TestClient + + # backward compatible: a deployment that doesn't require bearer auth serves the proxy + # even with no cookie at all. + r = TestClient(_app({})).get("/plugins/agent_browser/panel/dash") + assert r.status_code == 200 and 'id="cv"' in r.text + + +def test_panel_page_mints_dash_session_before_stream(): + from fastapi.testclient import TestClient + + html = TestClient(_app({})).get("/plugins/agent_browser/panel").text + # the panel mints a token via the (bearer-gated) dash-session route, then presents + # it as ?dash= to the proxy — whose response sets the path-matched ab_session cookie… + assert "/api/plugins/agent_browser/dash-session" in html and "ensureDashSession" in html + assert "/plugins/agent_browser/panel/dash?dash=" in html + # …and it does so from connect(), before the stream WS handshake. + assert "await ensureDashSession();" in html + assert html.index("await ensureDashSession();") < html.index("new WebSocket(") + +