mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""Per-install bearer token gating the localhost API and WS streams."""
|
|
|
|
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 p_write_atomic(path: str, data: str, mode: int = 0o600) -> None:
|
|
"""Atomic write to `path` at the given file mode; never world-readable or half-written."""
|
|
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:
|
|
"""Load the per-install token from disk, or mint one if missing; reused across restarts so Electron's cached copy stays valid."""
|
|
global TOKEN
|
|
try:
|
|
if os.path.exists(AUTH_TOKEN_FILE):
|
|
with open(AUTH_TOKEN_FILE, "r", encoding="utf-8") as f:
|
|
existing = f.read().strip()
|
|
if existing and 16 <= len(existing) <= 512:
|
|
TOKEN = existing
|
|
logger.info(
|
|
f"auth: reusing existing token from {AUTH_TOKEN_FILE}"
|
|
)
|
|
return TOKEN
|
|
except Exception as e:
|
|
logger.warning(f"auth: failed to read existing token, generating new: {e}")
|
|
|
|
TOKEN = secrets.token_urlsafe(32)
|
|
try:
|
|
p_write_atomic(AUTH_TOKEN_FILE, TOKEN, mode=0o600)
|
|
logger.info(f"auth: wrote token to {AUTH_TOKEN_FILE} (mode 0600)")
|
|
except Exception as e:
|
|
# If we can't write the file, Electron can't read it; log loudly but don't crash.
|
|
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
|
|
|
|
|
|
class p_TokenScrubFilter(logging.Filter):
|
|
"""Logging filter that redacts the install token from log records (defense in depth)."""
|
|
|
|
P_PLACEHOLDER = "<REDACTED:openswarm-token>"
|
|
|
|
@staticmethod
|
|
def p_args_might_contain_token(args) -> bool:
|
|
"""Cheap pre-check; avoids eager %-formatting on the >99% of records that don't mention the token."""
|
|
if not args:
|
|
return False
|
|
items = args if isinstance(args, (tuple, list)) else (args,)
|
|
for a in items:
|
|
if isinstance(a, str) and TOKEN in a:
|
|
return True
|
|
if isinstance(a, dict):
|
|
for v in a.values():
|
|
if isinstance(v, str) and TOKEN in v:
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def p_scrub_args(cls, args):
|
|
"""Scrub token from args while preserving tuple/dict shape; uvicorn's AccessFormatter unpacks args as a 5-tuple and explodes on None."""
|
|
if args is None:
|
|
return args
|
|
if isinstance(args, dict):
|
|
new_dict = None
|
|
for k, v in args.items():
|
|
if isinstance(v, str) and TOKEN in v:
|
|
if new_dict is None:
|
|
new_dict = dict(args)
|
|
new_dict[k] = v.replace(TOKEN, cls.P_PLACEHOLDER)
|
|
return new_dict if new_dict is not None else args
|
|
if isinstance(args, tuple):
|
|
new_list = None
|
|
for i, v in enumerate(args):
|
|
if isinstance(v, str) and TOKEN in v:
|
|
if new_list is None:
|
|
new_list = list(args)
|
|
new_list[i] = v.replace(TOKEN, cls.P_PLACEHOLDER)
|
|
return tuple(new_list) if new_list is not None else args
|
|
if isinstance(args, str) and TOKEN in args:
|
|
return args.replace(TOKEN, cls.P_PLACEHOLDER)
|
|
return args
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover (defensive)
|
|
if not TOKEN:
|
|
return True
|
|
# Fast path: skip eager %-formatting on records that don't mention the token.
|
|
raw_msg = record.msg if isinstance(record.msg, str) else ""
|
|
if TOKEN not in raw_msg and not self.p_args_might_contain_token(record.args):
|
|
return True
|
|
# Slow path: in-place args rewrite (preserves shape for AccessFormatter), then re-render to catch tokens buried in custom reprs.
|
|
try:
|
|
if isinstance(record.msg, str) and TOKEN in record.msg:
|
|
record.msg = record.msg.replace(TOKEN, self.P_PLACEHOLDER)
|
|
scrubbed = self.p_scrub_args(record.args)
|
|
if scrubbed is not record.args:
|
|
record.args = scrubbed
|
|
try:
|
|
rendered = record.getMessage()
|
|
if TOKEN in rendered:
|
|
record.msg = rendered.replace(TOKEN, self.P_PLACEHOLDER)
|
|
record.args = None
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
# Never let the scrubber suppress a log line; worst case the token leaks for that one record.
|
|
pass
|
|
return True
|
|
|
|
|
|
p_scrubber_installed = False
|
|
|
|
|
|
def install_token_scrubber() -> None:
|
|
"""Attach the scrubbing filter to every existing AND future log handler; logger-level filters miss propagated child records."""
|
|
global p_scrubber_installed
|
|
if p_scrubber_installed:
|
|
return
|
|
|
|
scrubber = p_TokenScrubFilter()
|
|
|
|
def p_attach(handler: logging.Handler) -> None:
|
|
if not any(isinstance(f, p_TokenScrubFilter) for f in handler.filters):
|
|
handler.addFilter(scrubber)
|
|
|
|
loggers: list[logging.Logger] = [logging.getLogger()]
|
|
for logger in logging.root.manager.loggerDict.values():
|
|
if isinstance(logger, logging.Logger):
|
|
loggers.append(logger)
|
|
for logger in loggers:
|
|
for h in list(logger.handlers):
|
|
p_attach(h)
|
|
|
|
# Patch addHandler so handlers attached later (uvicorn finishes log config after main.py imports) get the scrubber too.
|
|
p_original_addHandler = logging.Logger.addHandler
|
|
|
|
def p_patched_addHandler(self: logging.Logger, hdlr: logging.Handler) -> None:
|
|
p_attach(hdlr)
|
|
return p_original_addHandler(self, hdlr)
|
|
|
|
logging.Logger.addHandler = p_patched_addHandler # type: ignore[assignment]
|
|
|
|
root = logging.getLogger()
|
|
if not any(isinstance(f, p_TokenScrubFilter) for f in root.filters):
|
|
root.addFilter(scrubber)
|
|
|
|
p_scrubber_installed = True
|
|
|
|
|
|
# Auth-exempt paths: external redirects with their own nonce/state validation, plus the bootstrap health probe.
|
|
P_AUTH_EXEMPT_EXACT = {
|
|
"/api/subscriptions/callback",
|
|
"/api/tools/oauth/callback",
|
|
"/api/tools/oauth/cloud-claim",
|
|
"/api/subscription/activate",
|
|
"/api/auth/signin-activate",
|
|
"/api/version",
|
|
# Local Google OAuth token-endpoint proxy: hit by the
|
|
# google-workspace-mcp subprocess we spawn. It doesn't (and can't
|
|
# easily) carry the install bearer in google-auth's refresh post.
|
|
# Localhost binding is the gate, and the route does nothing the
|
|
# public api.openswarm.com/api/oauth/google/refresh doesn't already
|
|
# do for any internet caller, so no new attack surface.
|
|
"/api/tools/google-oauth-token",
|
|
# Dev-only token handoff for the split-port frontend (no Electron preload
|
|
# to read the token from). The route itself 404s in packaged builds.
|
|
"/api/dev/token",
|
|
}
|
|
|
|
P_AUTH_EXEMPT_PREFIX = (
|
|
# Electron polls /api/health/check before loading the token.
|
|
"/api/health",
|
|
# 9Router proxies OpenAI requests with the user's sk-... bearer, not our local token; localhost-only is the gate.
|
|
"/api/openai-passthrough",
|
|
"/docs",
|
|
"/openapi",
|
|
"/redoc",
|
|
"/favicon",
|
|
)
|
|
|
|
|
|
def is_path_exempt(path: str) -> bool:
|
|
"""True if this request path bypasses token auth."""
|
|
if path in P_AUTH_EXEMPT_EXACT:
|
|
return True
|
|
for p in P_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 HTTP/WS request carries our token (Bearer, x-openswarm-token, or ?token=); constant-time compare."""
|
|
if not TOKEN:
|
|
# Backend not initialized: fail closed. Only test fixtures that bypass main hit this.
|
|
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
|
|
|
|
|
|
# WS Origin allowlist: Electron packaged is file://, dev is localhost:3000, some Electron contexts send bare "null".
|
|
P_ORIGIN_ALLOWLIST_DEV = {
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"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:
|
|
# Native WS client / curl / MCP subprocess: token check still required, so allow.
|
|
return True
|
|
if origin in P_ORIGIN_ALLOWLIST_DEV:
|
|
return True
|
|
# Packaged Electron file:// includes paths like file:///Applications/OpenSwarm.app/...; match by prefix.
|
|
if origin.startswith("file://"):
|
|
return True
|
|
if origin.startswith("http://localhost:") or origin.startswith("http://127.0.0.1:"):
|
|
return True
|
|
return False
|