mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 02:37:45 +02:00
[eric] security: lock down the local websocket + http api with a per-install auth token
This commit is contained in:
@@ -1075,11 +1075,14 @@ class AgentManager:
|
||||
)
|
||||
backend_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
pre_selected_bids = self._get_pre_selected_browser_ids(session.dashboard_id)
|
||||
from backend.auth import get_auth_token as _get_auth_token
|
||||
_auth_tok = _get_auth_token()
|
||||
mcp_servers["openswarm-browser-agent"] = {
|
||||
"command": sys.executable,
|
||||
"args": [browser_agent_server_path],
|
||||
"env": {
|
||||
"OPENSWARM_PORT": backend_port,
|
||||
"OPENSWARM_AUTH_TOKEN": _auth_tok,
|
||||
"OPENSWARM_AGENT_MODEL": session.model,
|
||||
"OPENSWARM_DASHBOARD_ID": session.dashboard_id or "",
|
||||
"OPENSWARM_PRE_SELECTED_BROWSER_IDS": ",".join(pre_selected_bids),
|
||||
@@ -1099,11 +1102,13 @@ class AgentManager:
|
||||
os.path.dirname(__file__), "invoke_agent_mcp_server.py"
|
||||
)
|
||||
backend_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
from backend.auth import get_auth_token as _get_auth_token2
|
||||
mcp_servers["openswarm-invoke-agent"] = {
|
||||
"command": sys.executable,
|
||||
"args": [invoke_agent_server_path],
|
||||
"env": {
|
||||
"OPENSWARM_PORT": backend_port,
|
||||
"OPENSWARM_AUTH_TOKEN": _get_auth_token2(),
|
||||
"OPENSWARM_PARENT_SESSION_ID": session.id,
|
||||
"OPENSWARM_DASHBOARD_ID": session.dashboard_id or "",
|
||||
},
|
||||
@@ -1179,9 +1184,21 @@ class AgentManager:
|
||||
# WebSearch delegation through 9Router would fail. Only
|
||||
# consider Anthropic reachable if 9Router can actually serve
|
||||
# the Anthropic-format request.
|
||||
# Deliberately exclude openswarm-pro from the "has anthropic
|
||||
# path" heuristic when the primary is non-Claude. Reason: if
|
||||
# a Pro user picks GPT or Gemini as their primary, we
|
||||
# shouldn't drag their WebSearch/subagent calls through our
|
||||
# Pro Anthropic pool — they're already paying for a
|
||||
# ChatGPT/Gemini subscription we can use for free. Pro still
|
||||
# kicks in when they switch the primary to a Claude model.
|
||||
_primary_is_claude = _m.startswith("cc/") or (
|
||||
isinstance(_router_model_id, str)
|
||||
and not _router_model_id.startswith(("cc/", "cx/", "gc/", "ag/", "gemini/"))
|
||||
and _api_type_for_session == "anthropic"
|
||||
)
|
||||
_has_anthropic_path = (
|
||||
bool(getattr(global_settings, "anthropic_api_key", None)) # direct env bypass
|
||||
or _9r_has_anthropic
|
||||
or (_9r_has_anthropic and _primary_is_claude)
|
||||
)
|
||||
|
||||
_need_web_mcp = not _has_anthropic_path
|
||||
@@ -1197,11 +1214,13 @@ class AgentManager:
|
||||
_primary_hint = "openai"
|
||||
else:
|
||||
_primary_hint = ""
|
||||
from backend.auth import get_auth_token as _get_auth_token3
|
||||
mcp_servers["openswarm-web"] = {
|
||||
"command": sys.executable,
|
||||
"args": [web_mcp_server_path],
|
||||
"env": {
|
||||
"OPENSWARM_PORT": backend_port,
|
||||
"OPENSWARM_AUTH_TOKEN": _get_auth_token3(),
|
||||
"OPENSWARM_PRIMARY_API": _primary_hint,
|
||||
},
|
||||
"type": "stdio",
|
||||
@@ -1375,26 +1394,61 @@ class AgentManager:
|
||||
# `cc/*` pinned-Claude routes (user has a real Claude
|
||||
# sub via 9Router) we stay on 9Router directly so that
|
||||
# sub quota is used — no need to proxy through Pro.
|
||||
_is_pro = getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro"
|
||||
_has_bearer = bool(getattr(global_settings, "openswarm_bearer_token", None))
|
||||
_use_proxy = _is_pro and _has_bearer and api_type != "anthropic"
|
||||
if _use_proxy:
|
||||
env = {
|
||||
"ANTHROPIC_API_KEY": "anthropic-proxy",
|
||||
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{backend_port}/api/anthropic-proxy",
|
||||
# See claude-sonnet branch above. Pinning these
|
||||
# ensures subagents and CLI's WebSearch
|
||||
# delegation use model IDs the Pro cloud accepts.
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5-20251001",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
logger.info(f"[MCP-DEBUG] Using anthropic-proxy (Pro bearer + non-Claude primary)")
|
||||
# Pro + non-Claude primary: intentionally do NOT route
|
||||
# subagents/WebSearch through our Pro Anthropic pool.
|
||||
# The user is already paying for a ChatGPT or Gemini
|
||||
# subscription — use that lane (free to us) and keep
|
||||
# the Pro credit for when they actually select a Claude
|
||||
# primary. The plain 9Router branch below picks a
|
||||
# subagent model that matches whichever OAuth lane they
|
||||
# have active.
|
||||
if False: # reserved for future Pro-only routing cases
|
||||
pass
|
||||
else:
|
||||
env = {
|
||||
"ANTHROPIC_API_KEY": "9router",
|
||||
"ANTHROPIC_BASE_URL": "http://localhost:20128",
|
||||
}
|
||||
# No Pro bearer → the CLI's default subagent model
|
||||
# (`claude-haiku-4-5-20251001`) would hit 9Router
|
||||
# with no Anthropic route and fail with "No
|
||||
# credentials for provider: anthropic". Pick a
|
||||
# subagent model that matches whatever lane the
|
||||
# user DOES have, in priority order: own Anthropic
|
||||
# key > Claude-sub > ChatGPT-Plus > Antigravity >
|
||||
# Gemini-CLI. If none of those are connected, leave
|
||||
# it unset and the CLI will fail gracefully.
|
||||
try:
|
||||
_sub_conns = _conns # reuse the list fetched above
|
||||
except NameError:
|
||||
_sub_conns = []
|
||||
_active = {c.get("provider") for c in _sub_conns
|
||||
if isinstance(c, dict) and c.get("isActive")}
|
||||
_sub_model = None
|
||||
_small_model = None
|
||||
if global_settings.anthropic_api_key:
|
||||
_sub_model = "claude-sonnet-4-6"
|
||||
_small_model = "claude-haiku-4-5-20251001"
|
||||
elif "claude" in _active or "anthropic" in _active:
|
||||
_sub_model = "cc/claude-sonnet-4-6"
|
||||
_small_model = "cc/claude-haiku-4-5-20251001"
|
||||
elif "antigravity" in _active:
|
||||
_sub_model = "ag/gemini-3-flash"
|
||||
_small_model = "ag/gemini-3-flash"
|
||||
elif "gemini-cli" in _active:
|
||||
_sub_model = "gc/gemini-2.5-flash"
|
||||
_small_model = "gc/gemini-2.5-flash"
|
||||
elif "codex" in _active:
|
||||
_sub_model = "cx/gpt-5.4-mini"
|
||||
_small_model = "cx/gpt-5.4-mini"
|
||||
if _sub_model:
|
||||
env["CLAUDE_CODE_SUBAGENT_MODEL"] = _sub_model
|
||||
if _small_model:
|
||||
env["ANTHROPIC_SMALL_FAST_MODEL"] = _small_model
|
||||
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = _small_model
|
||||
logger.info(
|
||||
f"[MCP-DEBUG] 9Router direct — subagent_model={_sub_model}, small_fast={_small_model}"
|
||||
)
|
||||
# ENABLE_TOOL_SEARCH=auto is Claude-specific. It keeps the
|
||||
# deferred-tool pool (WebSearch, NotebookEdit, TodoWrite,
|
||||
# EnterPlanMode, Cron*, Task*, etc.) reachable via the
|
||||
|
||||
@@ -122,6 +122,15 @@ async def proxy(rest: str, request: Request):
|
||||
for k, v in request.headers.items():
|
||||
if k.lower() in _HOP_HEADERS:
|
||||
continue
|
||||
# The CLI we spawn carries our per-install auth token via
|
||||
# `x-api-key` (we set `ANTHROPIC_API_KEY=<our_token>` on the
|
||||
# spawn env, and the CLI forwards that value as x-api-key). We
|
||||
# must NOT forward that header to the real upstream — it would
|
||||
# leak our local token to api.openswarm.com / 9Router, AND it
|
||||
# would shadow the real upstream auth (bearer or `9router`
|
||||
# literal) that `_pick_upstream` wants to set. Strip it here.
|
||||
if k.lower() == "x-api-key":
|
||||
continue
|
||||
forward_headers[k] = v
|
||||
forward_headers.update(auth_headers)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
||||
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/browser-agent/run"
|
||||
MODEL = os.environ.get("OPENSWARM_AGENT_MODEL", "sonnet")
|
||||
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
|
||||
@@ -138,10 +139,13 @@ def call_backend(tasks: list[dict]) -> dict:
|
||||
"pre_selected_browser_ids": pre_selected,
|
||||
"parent_session_id": PARENT_SESSION_ID,
|
||||
}).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if BACKEND_AUTH:
|
||||
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
|
||||
req = urllib.request.Request(
|
||||
BACKEND_URL,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -14,6 +14,7 @@ import urllib.request
|
||||
import urllib.error
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
||||
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/invoke-agent/run"
|
||||
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
|
||||
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
|
||||
@@ -69,10 +70,13 @@ def call_backend(session_id: str, message: str) -> dict:
|
||||
"parent_session_id": PARENT_SESSION_ID,
|
||||
"dashboard_id": DASHBOARD_ID,
|
||||
}).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if BACKEND_AUTH:
|
||||
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
|
||||
req = urllib.request.Request(
|
||||
BACKEND_URL,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -28,6 +28,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
||||
SEARCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/search"
|
||||
FETCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/fetch"
|
||||
|
||||
@@ -100,10 +101,13 @@ def send_response(id_, result=None, error=None):
|
||||
|
||||
def _post(url: str, body: dict, timeout: float = 60.0) -> dict:
|
||||
payload = json.dumps(body).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if BACKEND_AUTH:
|
||||
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
"""Per-install auth token for the localhost API.
|
||||
|
||||
OpenSwarm's backend runs a FastAPI server on `127.0.0.1:<random-port>`
|
||||
and streams sensitive agent data (tool inputs, approval requests,
|
||||
messages) over WebSockets. Without auth, any webpage loaded in any
|
||||
browser on the same machine can connect to those endpoints — WebSockets
|
||||
aren't subject to Same-Origin Policy — and impersonate the user.
|
||||
|
||||
This module issues a cryptographically random token at backend startup,
|
||||
writes it 0600 to `<DATA_ROOT>/auth.token`, and provides validation
|
||||
helpers. The token changes every backend restart. Only code running as
|
||||
the same OS user can read the file.
|
||||
|
||||
Delivery to legitimate consumers:
|
||||
|
||||
- Electron main process reads the file and exposes it to the renderer
|
||||
via a contextBridge method in preload.js (NOT plain window global).
|
||||
- Our Python MCP subprocesses receive it via env var
|
||||
`OPENSWARM_AUTH_TOKEN` that agent_manager passes when spawning.
|
||||
- The Claude Code CLI we spawn receives it as `ANTHROPIC_API_KEY` in
|
||||
env; the anthropic-proxy route trusts that value.
|
||||
|
||||
None of those paths are accessible from a third-party webpage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from backend.config.paths import AUTH_TOKEN_FILE, DATA_ROOT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TOKEN: str = ""
|
||||
|
||||
|
||||
def _write_atomic(path: str, data: str, mode: int = 0o600) -> None:
|
||||
"""Write `data` to `path` atomically with the given file mode.
|
||||
|
||||
Uses `os.open(..., O_CREAT|O_WRONLY|O_TRUNC, mode)` + rename so the
|
||||
final file is never world-readable and never left half-written if
|
||||
the backend crashes mid-write. Windows-safe (rename of a file over
|
||||
an existing one works on NTFS when the source was just closed).
|
||||
"""
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, mode)
|
||||
try:
|
||||
os.write(fd, data.encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.chmod(tmp, mode)
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def init_auth_token() -> str:
|
||||
"""Generate a fresh token, persist to disk, return it.
|
||||
|
||||
Called once at backend startup before the HTTP port is bound.
|
||||
"""
|
||||
global _TOKEN
|
||||
_TOKEN = secrets.token_urlsafe(32)
|
||||
try:
|
||||
_write_atomic(AUTH_TOKEN_FILE, _TOKEN, mode=0o600)
|
||||
logger.info(f"auth: wrote token to {AUTH_TOKEN_FILE} (mode 0600)")
|
||||
except Exception as e:
|
||||
# Fail open is NOT an option here — if we can't write the file,
|
||||
# Electron can't read it, and the user sees a broken app. But
|
||||
# don't hard-crash the backend either; log loudly.
|
||||
logger.error(f"auth: failed to write token file: {e}")
|
||||
return _TOKEN
|
||||
|
||||
|
||||
def get_auth_token() -> str:
|
||||
"""Return the current token. Empty string if init_auth_token() hasn't run."""
|
||||
return _TOKEN
|
||||
|
||||
|
||||
# Paths that never require auth. These are the public surface.
|
||||
_AUTH_EXEMPT_EXACT = {
|
||||
# External OAuth providers redirect the user's browser here. The
|
||||
# browser has no way to inject our bearer token (it's a 302 from
|
||||
# Google/Anthropic/etc). The `state` query param is already a
|
||||
# one-time nonce validated against `_pending_oauth`.
|
||||
"/api/subscriptions/callback",
|
||||
# Electron's boot handshake pings these before it has a token —
|
||||
# the HTTP port is up before the token file is readable in some
|
||||
# races. Safe to expose since they don't return any session data.
|
||||
"/api/health",
|
||||
"/api/version",
|
||||
}
|
||||
|
||||
# Path prefixes that never require auth. Trailing slash optional.
|
||||
_AUTH_EXEMPT_PREFIX = (
|
||||
# FastAPI's default health/docs/schema surface (packaged app never
|
||||
# ships /docs, but be defensive).
|
||||
"/docs",
|
||||
"/openapi",
|
||||
"/redoc",
|
||||
"/favicon",
|
||||
)
|
||||
|
||||
|
||||
def is_path_exempt(path: str) -> bool:
|
||||
"""True if this request path bypasses token auth."""
|
||||
if path in _AUTH_EXEMPT_EXACT:
|
||||
return True
|
||||
for p in _AUTH_EXEMPT_PREFIX:
|
||||
if path.startswith(p):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_bearer(header_value: str | None) -> str:
|
||||
"""Pull the token out of `Authorization: Bearer <token>`."""
|
||||
if not header_value:
|
||||
return ""
|
||||
if header_value.startswith("Bearer "):
|
||||
return header_value[len("Bearer "):].strip()
|
||||
if header_value.startswith("bearer "):
|
||||
return header_value[len("bearer "):].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def request_matches_token(request_headers: dict, query_params: dict | None = None) -> bool:
|
||||
"""Validate that an incoming HTTP / WS request carries our token.
|
||||
|
||||
Accepts any of:
|
||||
- `Authorization: Bearer <token>`
|
||||
- `x-openswarm-token: <token>` (custom header for callers that
|
||||
can't easily set Authorization — e.g. future CLI clients)
|
||||
- `?token=<token>` query param (WS only; browsers can't easily
|
||||
set custom WS headers, so the token rides in the URL)
|
||||
|
||||
The token comparison is constant-time via `secrets.compare_digest`.
|
||||
"""
|
||||
if not _TOKEN:
|
||||
# Backend started without auth init — fail closed. This should
|
||||
# only happen in test fixtures that intentionally bypass main.
|
||||
return False
|
||||
|
||||
candidates: list[str] = []
|
||||
|
||||
auth = request_headers.get("authorization") or request_headers.get("Authorization")
|
||||
bearer = extract_bearer(auth)
|
||||
if bearer:
|
||||
candidates.append(bearer)
|
||||
|
||||
openswarm_header = (
|
||||
request_headers.get("x-openswarm-token")
|
||||
or request_headers.get("X-OpenSwarm-Token")
|
||||
)
|
||||
if openswarm_header:
|
||||
candidates.append(openswarm_header.strip())
|
||||
|
||||
if query_params:
|
||||
qp_token = query_params.get("token")
|
||||
if qp_token:
|
||||
candidates.append(qp_token)
|
||||
|
||||
for candidate in candidates:
|
||||
if secrets.compare_digest(candidate, _TOKEN):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Origin allowlist for WS handshakes. Electron's renderer loads from
|
||||
# `file://` when packaged; `http://localhost:3000` (Vite dev server) and
|
||||
# `http://127.0.0.1:3000` in dev. A bare `null` Origin is sent by some
|
||||
# Electron contexts.
|
||||
_ORIGIN_ALLOWLIST_DEV = {
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
# Electron may load prod build from file:// or an app:// scheme.
|
||||
"file://",
|
||||
"null",
|
||||
}
|
||||
|
||||
|
||||
def is_origin_allowed(origin: str | None) -> bool:
|
||||
"""True if the WS connection's Origin header is from our app."""
|
||||
if origin is None:
|
||||
# No Origin header = curl / native WS client / MCP subprocess.
|
||||
# Token check is still required, so allow.
|
||||
return True
|
||||
if origin in _ORIGIN_ALLOWLIST_DEV:
|
||||
return True
|
||||
# file:// origins in Electron prod sometimes include paths like
|
||||
# file:///Applications/OpenSwarm.app/... — match by prefix.
|
||||
if origin.startswith("file://"):
|
||||
return True
|
||||
# localhost + any port (dev servers, tools the developer is running).
|
||||
if origin.startswith("http://localhost:") or origin.startswith("http://127.0.0.1:"):
|
||||
return True
|
||||
return False
|
||||
@@ -35,4 +35,11 @@ SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace")
|
||||
DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
|
||||
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
|
||||
|
||||
# Per-install auth token for the localhost WS + HTTP API. Regenerated
|
||||
# every backend start. Only code running as the current OS user (Electron
|
||||
# main process, our Python MCP subprocesses, the Claude Code CLI we
|
||||
# spawn) can read this file. Webpages loaded in any browser on the
|
||||
# machine cannot — which is the whole point. See auth.py.
|
||||
AUTH_TOKEN_FILE = os.path.join(DATA_ROOT, "auth.token")
|
||||
|
||||
BACKEND_DIR = _BACKEND_DIR
|
||||
|
||||
+95
-9
@@ -48,28 +48,90 @@ import json
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics, subscription, web, anthropic_proxy])
|
||||
app = main_app.app
|
||||
|
||||
# Generate per-install auth token BEFORE we bind the HTTP port. By the
|
||||
# time any request lands, the token file exists. See backend/auth.py.
|
||||
from backend.auth import (
|
||||
init_auth_token,
|
||||
is_path_exempt,
|
||||
request_matches_token,
|
||||
is_origin_allowed,
|
||||
)
|
||||
init_auth_token()
|
||||
|
||||
|
||||
# CORS: previously wide open (`allow_origins=["*"]`), which combined with
|
||||
# `allow_credentials=True` was a security footgun — any external origin
|
||||
# could CORS-preflight us. Now restricted to Electron renderer origins +
|
||||
# localhost dev servers. The token middleware below provides the
|
||||
# *primary* defense; CORS is defense-in-depth so a misconfigured page
|
||||
# can't even reach us.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
],
|
||||
allow_origin_regex=r"^(file://.*|http://localhost:\d+|http://127\.0\.0\.1:\d+)$",
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Chrome's Private Network Access check: a page on https://api.openswarm.com
|
||||
# POSTing to http://127.0.0.1:8324 triggers a preflight that requires this
|
||||
# header. Without it the request is blocked and the post-checkout activation
|
||||
# flow silently fails. Harmless on every other request — it's only read when
|
||||
# the browser is crossing from a public origin into a private network.
|
||||
@app.middleware("http")
|
||||
async def _allow_private_network(request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["Access-Control-Allow-Private-Network"] = "true"
|
||||
async def _auth_middleware(request: Request, call_next):
|
||||
"""Reject HTTP requests without our per-install bearer token.
|
||||
|
||||
Exemptions (see `auth.is_path_exempt`):
|
||||
- `/api/subscriptions/callback` — external OAuth redirects
|
||||
- `/api/health`, `/api/version` — Electron boot handshake
|
||||
- `OPTIONS` preflights — browsers don't send Authorization on them
|
||||
|
||||
Anything else requires `Authorization: Bearer <token>` OR
|
||||
`x-openswarm-token: <token>`. Failure responds with 401 and a short
|
||||
JSON error — no upstream handler sees the request.
|
||||
|
||||
The anthropic-proxy route (`/api/anthropic-proxy/v1/*`) is NOT
|
||||
exempt. Its caller (the Claude Code CLI we spawn) is configured
|
||||
with `ANTHROPIC_API_KEY=<our_token>` so the CLI's `x-api-key`
|
||||
header carries our token — which `request_matches_token` accepts
|
||||
via its auth-header branches.
|
||||
"""
|
||||
# Preflights never carry Authorization.
|
||||
if request.method == "OPTIONS":
|
||||
response = await call_next(request)
|
||||
elif is_path_exempt(request.url.path):
|
||||
response = await call_next(request)
|
||||
else:
|
||||
# Accept Authorization Bearer, x-openswarm-token, OR x-api-key
|
||||
# (CLI path — CLI sends x-api-key with our token as value).
|
||||
headers = dict(request.headers)
|
||||
x_api_key = headers.get("x-api-key") or headers.get("X-API-Key")
|
||||
auth_ok = request_matches_token(headers)
|
||||
if not auth_ok and x_api_key:
|
||||
import secrets as _s
|
||||
from backend.auth import get_auth_token as _gt
|
||||
auth_ok = _s.compare_digest(x_api_key, _gt() or "\x00")
|
||||
if not auth_ok:
|
||||
logger.warning(
|
||||
f"auth: rejecting {request.method} {request.url.path} "
|
||||
f"(origin={headers.get('origin', '-')}, no valid token)"
|
||||
)
|
||||
return JSONResponse(
|
||||
{"error": "unauthorized", "detail": "missing or invalid token"},
|
||||
status_code=401,
|
||||
)
|
||||
response = await call_next(request)
|
||||
|
||||
# Private-Network-Access header for the one remaining public-origin
|
||||
# path (OAuth callback). Harmless on other requests.
|
||||
response.headers.setdefault("Access-Control-Allow-Private-Network", "true")
|
||||
return response
|
||||
|
||||
@app.websocket("/ws/agents/{session_id}")
|
||||
async def websocket_session(websocket: WebSocket, session_id: str):
|
||||
if not _ws_auth_ok(websocket):
|
||||
return
|
||||
await ws_manager.connect_session(session_id, websocket)
|
||||
try:
|
||||
while True:
|
||||
@@ -108,8 +170,32 @@ async def websocket_session(websocket: WebSocket, session_id: str):
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.disconnect_session(session_id, websocket)
|
||||
|
||||
def _ws_auth_ok(websocket: WebSocket) -> bool:
|
||||
"""Validate token + origin before accepting a WS. Returns True if OK.
|
||||
|
||||
On failure closes with 4401 (custom app-level code) and returns False
|
||||
— the caller must NOT call `websocket.accept()` or read any data.
|
||||
"""
|
||||
headers = dict(websocket.headers)
|
||||
qp = dict(websocket.query_params)
|
||||
origin = headers.get("origin") or headers.get("Origin")
|
||||
token_ok = request_matches_token(headers, query_params=qp)
|
||||
origin_ok = is_origin_allowed(origin)
|
||||
if not (token_ok and origin_ok):
|
||||
reason = "bad token" if not token_ok else f"bad origin ({origin})"
|
||||
logger.warning(f"ws: rejecting connection to {websocket.url.path} — {reason}")
|
||||
# Can't `await websocket.close()` before accept(), so schedule the
|
||||
# close in a task. The client receives a 403 on handshake.
|
||||
import asyncio as _asyncio
|
||||
_asyncio.create_task(websocket.close(code=4401))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@app.websocket("/ws/dashboard")
|
||||
async def websocket_dashboard(websocket: WebSocket):
|
||||
if not _ws_auth_ok(websocket):
|
||||
return
|
||||
await ws_manager.connect_global(websocket)
|
||||
try:
|
||||
while True:
|
||||
|
||||
@@ -271,6 +271,58 @@ async function startBackend() {
|
||||
|
||||
await waitForBackend(backendPort);
|
||||
console.log(`Backend ready on port ${backendPort}`);
|
||||
|
||||
// Backend writes a per-install auth token file at startup. Read it
|
||||
// here so the renderer can include it in WS URLs (`?token=...`) and
|
||||
// HTTP Authorization headers. Without this, any webpage loaded in
|
||||
// any browser on the machine could hit our localhost API and
|
||||
// impersonate the user. See backend/auth.py.
|
||||
await loadAuthToken();
|
||||
}
|
||||
|
||||
// Per-install auth token read from <data-root>/auth.token (backend
|
||||
// generates this at startup). Cached here so `get-auth-token` IPC
|
||||
// calls are fast. If reads fail initially (race with backend) we
|
||||
// retry a few times.
|
||||
let authToken = '';
|
||||
|
||||
function getAuthTokenFilePath() {
|
||||
// Mirrors backend/config/paths.py. On macOS the file lives at
|
||||
// ~/Library/Application Support/OpenSwarm/data/auth.token; on
|
||||
// Windows under %APPDATA%/OpenSwarm/data/; on Linux under
|
||||
// ~/.local/share/OpenSwarm/data/. In dev the backend writes it to
|
||||
// backend/data/auth.token instead.
|
||||
if (isPackaged) {
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(os.homedir(), 'Library', 'Application Support', 'OpenSwarm', 'data', 'auth.token');
|
||||
} else if (process.platform === 'win32') {
|
||||
return path.join(process.env.APPDATA || os.homedir(), 'OpenSwarm', 'data', 'auth.token');
|
||||
} else {
|
||||
const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(xdg, 'OpenSwarm', 'data', 'auth.token');
|
||||
}
|
||||
}
|
||||
// Dev: backend/data/auth.token relative to repo root.
|
||||
return path.join(__dirname, '..', 'backend', 'data', 'auth.token');
|
||||
}
|
||||
|
||||
async function loadAuthToken() {
|
||||
const tokenPath = getAuthTokenFilePath();
|
||||
// Retry up to 20 × 100ms = 2s in case backend is still writing the
|
||||
// file. Backend writes BEFORE binding HTTP port though, so this
|
||||
// usually returns on the first attempt.
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
try {
|
||||
const contents = fs.readFileSync(tokenPath, 'utf8').trim();
|
||||
if (contents) {
|
||||
authToken = contents;
|
||||
console.log(`[auth] loaded token from ${tokenPath}`);
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
console.warn(`[auth] FAILED to load auth token from ${tokenPath} after 2s — WS/HTTP will be rejected`);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
@@ -754,6 +806,18 @@ app.on('activate', () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('get-backend-port', () => backendPort);
|
||||
ipcMain.handle('get-auth-token', () => {
|
||||
// Re-read the file every time. The backend rotates the token on each
|
||||
// start, and during dev hot-reload the cached value could go stale
|
||||
// while the renderer stays alive. Re-reading is cheap (small file,
|
||||
// OS caches it) and guarantees the renderer never holds a dead token.
|
||||
try {
|
||||
const p = getAuthTokenFilePath();
|
||||
const current = fs.readFileSync(p, 'utf8').trim();
|
||||
if (current) authToken = current;
|
||||
} catch (_) {}
|
||||
return authToken;
|
||||
});
|
||||
ipcMain.handle('get-app-version', () => app.getVersion());
|
||||
ipcMain.handle('get-webview-preload-path', () => {
|
||||
return `file://${path.join(__dirname, 'webview-preload.js')}`;
|
||||
|
||||
@@ -10,6 +10,16 @@ const { contextBridge, ipcRenderer } = require('electron');
|
||||
getBackendPort: () => port,
|
||||
getWebviewPreloadPath: () => webviewPreloadPath,
|
||||
|
||||
// Per-install auth token required for WS + HTTP calls to the
|
||||
// localhost backend. Returns a Promise<string>. The renderer should
|
||||
// await this on startup and include the token on every WS URL
|
||||
// (`?token=...`) and HTTP request (`Authorization: Bearer ...`).
|
||||
// We deliberately do NOT expose the token as a plain window global
|
||||
// or a sync getter — contextBridge + IPC keeps it off the
|
||||
// renderer's global object so third-party scripts (including any
|
||||
// code that leaks through <webview>) can't scrape it.
|
||||
getAuthToken: () => ipcRenderer.invoke('get-auth-token'),
|
||||
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
connectSlack: () => ipcRenderer.invoke('connect-slack'),
|
||||
|
||||
+20
-2
@@ -1,6 +1,24 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import Main from './app/Main';
|
||||
import { ensureAuthToken } from './shared/config';
|
||||
|
||||
const root = document.getElementById('root')!;
|
||||
createRoot(root).render(<Main />);
|
||||
// Resolve the per-install auth token from Electron BEFORE first render
|
||||
// so the very first fetch/WS carries the Authorization header. The
|
||||
// token IPC is fast (synchronous file read in main process). We bound
|
||||
// the wait at 3s so a missing Electron bridge (e.g. running the React
|
||||
// app in a plain browser) doesn't hang forever — in that case
|
||||
// `getAuthToken()` returns '' and backend calls will 401, which is
|
||||
// the desired behavior (plain browsers can't be allowed to impersonate
|
||||
// the user).
|
||||
async function bootstrap() {
|
||||
try {
|
||||
await Promise.race([
|
||||
ensureAuthToken(),
|
||||
new Promise(resolve => setTimeout(resolve, 3000)),
|
||||
]);
|
||||
} catch {}
|
||||
const root = document.getElementById('root')!;
|
||||
createRoot(root).render(<Main />);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -4,3 +4,79 @@ const host = window.location.hostname || 'localhost';
|
||||
export const API_BASE = `http://${host}:${port}/api`;
|
||||
export const WS_BASE = `ws://${host}:${port}`;
|
||||
export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.ai';
|
||||
|
||||
// Per-install auth token. Fetched from Electron's main process via the
|
||||
// preload contextBridge. We cache it after first resolution so every
|
||||
// API/WS call is synchronous. On Electron hot-reload the token rotates;
|
||||
// call `refreshAuthToken()` from a 4401 WS handler to pick up a new
|
||||
// one without a full page reload.
|
||||
let _authTokenCache: string = '';
|
||||
let _authTokenPromise: Promise<string> | null = null;
|
||||
|
||||
export function getAuthToken(): string {
|
||||
return _authTokenCache;
|
||||
}
|
||||
|
||||
export async function refreshAuthToken(): Promise<string> {
|
||||
const ow = (window as any).openswarm;
|
||||
if (ow && typeof ow.getAuthToken === 'function') {
|
||||
try {
|
||||
const tok = await ow.getAuthToken();
|
||||
_authTokenCache = typeof tok === 'string' ? tok : '';
|
||||
} catch {
|
||||
_authTokenCache = '';
|
||||
}
|
||||
}
|
||||
return _authTokenCache;
|
||||
}
|
||||
|
||||
// Resolve-once helper: the first call kicks off the IPC request; any
|
||||
// concurrent calls reuse the same promise. Frontend bootstrap awaits
|
||||
// this before the first API call so the token is ready.
|
||||
export function ensureAuthToken(): Promise<string> {
|
||||
if (_authTokenPromise) return _authTokenPromise;
|
||||
_authTokenPromise = refreshAuthToken();
|
||||
return _authTokenPromise;
|
||||
}
|
||||
|
||||
// Install a global fetch interceptor so every fetch(API_BASE + ...)
|
||||
// call site gets the Authorization header without touching each site.
|
||||
// Covers the analytics, settings, agents, dashboards, etc. fetches.
|
||||
// Only applies to requests that target our own API_BASE — pass-through
|
||||
// for every other URL (3rd-party APIs, asset CDNs, etc.).
|
||||
function _installAuthFetchInterceptor() {
|
||||
if ((window as any).__OPENSWARM_FETCH_PATCHED__) return;
|
||||
(window as any).__OPENSWARM_FETCH_PATCHED__ = true;
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
try {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
// Only attach token for our own API. Everything else flows through.
|
||||
const isOurApi = url.startsWith(API_BASE) || url.startsWith(`http://${host}:${port}/`);
|
||||
if (!isOurApi) return originalFetch(input, init);
|
||||
|
||||
// Don't override an explicit Authorization the caller already set.
|
||||
const existingHeaders = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
||||
if (existingHeaders.has('Authorization') || existingHeaders.has('authorization')) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
const token = _authTokenCache || (await ensureAuthToken());
|
||||
if (!token) return originalFetch(input, init);
|
||||
|
||||
existingHeaders.set('Authorization', `Bearer ${token}`);
|
||||
const newInit: RequestInit = { ...(init ?? {}), headers: existingHeaders };
|
||||
return originalFetch(input, newInit);
|
||||
} catch {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Call immediately on module load — config.ts is imported by the main
|
||||
// entry point, so this runs before any component-level fetch.
|
||||
_installAuthFetchInterceptor();
|
||||
// Kick off token resolution in the background so it's warm by the
|
||||
// time the first request goes out.
|
||||
ensureAuthToken();
|
||||
|
||||
@@ -17,6 +17,15 @@ import {
|
||||
trackAgentNotification,
|
||||
} from '../state/agentsSlice';
|
||||
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
|
||||
import { getAuthToken } from '../config';
|
||||
|
||||
// Thin wrapper around getAuthToken so the connect() call site stays
|
||||
// synchronous. If the token isn't cached yet, returns '' and the WS
|
||||
// handshake will 4401 — onclose catches that and refreshes the token
|
||||
// before the next reconnect.
|
||||
const _getAuthTokenSafe = (): string => {
|
||||
try { return getAuthToken() || ''; } catch { return ''; }
|
||||
};
|
||||
|
||||
type WSEvent = {
|
||||
event: string;
|
||||
@@ -104,7 +113,19 @@ class WebSocketManager {
|
||||
connect() {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
// Append our per-install auth token to the URL. The backend's WS
|
||||
// handshake validates this before accepting; without it, any
|
||||
// webpage loaded on the same machine could open a WS and read
|
||||
// agent traffic. See backend/auth.py + main.py:_ws_auth_ok.
|
||||
// Token is fetched async from Electron's preload, but we cache it
|
||||
// after first resolution. If it isn't cached yet, `getAuthToken()`
|
||||
// returns '' and the connection will be rejected — the
|
||||
// onclose handler below retries, by which time the token is
|
||||
// usually loaded.
|
||||
const token = _getAuthTokenSafe();
|
||||
const sep = this.url.includes('?') ? '&' : '?';
|
||||
const urlWithToken = token ? `${this.url}${sep}token=${encodeURIComponent(token)}` : this.url;
|
||||
this.ws = new WebSocket(urlWithToken);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectDelay = 1000;
|
||||
@@ -119,7 +140,13 @@ class WebSocketManager {
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws.onclose = (ev) => {
|
||||
// 4401 = our backend's auth-failure code. Happens on stale token
|
||||
// after backend restart (dev hot-reload). Re-fetch from Electron
|
||||
// IPC before retrying.
|
||||
if (ev && ev.code === 4401) {
|
||||
import('@/shared/config').then(mod => mod.refreshAuthToken().catch(() => {}));
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user