mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
* fix(security): stop API tokens reaching privileged agent tools A bearer API token resolves to the human who minted it, and minting is admin-only, so every owner-keyed privilege check in the agent path answers "admin". A token issued for a narrow integration therefore reached bash and python with the authority of the account that created it. Three independent routes to that sink, each closed here. The token could answer its own tool-approval prompt. An approval records that a person authorized one dangerous action, and a token cannot make that statement, so /api/chat_stream now refuses an approval resume from a bearer caller. The chat-session grant was reconstructable from caller-supplied message metadata. Two routes persist a metadata blob on the caller's behalf, so the shape of a resolved approval card could be written straight into a transcript and was then read back as authority. The server now signs the grant when it resolves an approval and verifies that signature when reading it back, binding it to the chat and the approval it was issued for. Both routes also drop server-owned keys from an inbound blob. A run driven by a token inherited its owner's tool set. Such a run is now capped at the non-admin policy regardless of who minted the credential, which holds even where no approval is raised at all. The human path is unchanged: a browser session still receives the prompt, still approves, and a granted chat-session scope still carries to later turns in that chat. Scope enforcement across the wider route surface is a separate gap and is not addressed here. * fix scoped chat delegation boundaries * fix(auth): reject malformed chat approval signatures --------- Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
200 lines
8.4 KiB
Python
200 lines
8.4 KiB
Python
"""Shared auth helpers used by all route files."""
|
|
|
|
import os
|
|
from typing import Optional
|
|
from fastapi import Request, HTTPException
|
|
|
|
from src.owner_identity import auth_disabled, effective_storage_owner
|
|
|
|
|
|
def get_current_user(request: Request) -> Optional[str]:
|
|
"""Get current username from request state (set by auth middleware)."""
|
|
return getattr(request.state, 'current_user', None)
|
|
|
|
|
|
def effective_user(request: Request) -> Optional[str]:
|
|
"""The real human behind the request, for ownership/attribution.
|
|
|
|
Cookie sessions resolve to the logged-in username. Bearer ``ody_`` callers
|
|
come through as the sandboxed pseudo-user "api" so they can't wander into
|
|
cookie/user routes by default, but their token was minted by, and belongs
|
|
to, a real owner stamped on ``request.state.api_token_owner``. Routes that
|
|
should attribute a token's actions to that owner (sessions, chat history)
|
|
call this instead of :func:`get_current_user`, so a paired client sees and
|
|
creates the SAME data as the owner's desktop UI rather than a separate
|
|
"api"-owned silo.
|
|
|
|
For cookie sessions this is identical to :func:`get_current_user`, so
|
|
swapping a route over is a no-op for browser users. A bearer token with no
|
|
owner falls back to :func:`get_current_user` (the "api" pseudo-user), so it
|
|
never escalates.
|
|
"""
|
|
if getattr(request.state, "api_token", False):
|
|
owner = getattr(request.state, "api_token_owner", None)
|
|
if owner:
|
|
return owner
|
|
return get_current_user(request)
|
|
|
|
|
|
def _is_api_token_request(request: Request) -> bool:
|
|
"""Return True when middleware authenticated a bearer API token."""
|
|
return bool(getattr(request.state, "api_token", False))
|
|
|
|
|
|
def is_delegated_credential(request: Request) -> bool:
|
|
"""Whether this request arrived on a credential acting FOR a human.
|
|
|
|
A bearer API token is minted by a person and then handed to something
|
|
else: an integration, a script, a third party. :func:`effective_user`
|
|
resolves it back to that person for ownership and attribution, which is
|
|
correct for data but wrong for authority. Only admins can mint tokens, so
|
|
every token resolves to an admin, and any gate that asks "is the owner an
|
|
admin?" answers yes for a credential the owner has given away.
|
|
|
|
Security decisions about what the AGENT may do should ask this instead, so
|
|
a token cannot inherit the shell merely because its owner could use one.
|
|
"""
|
|
return _is_api_token_request(request)
|
|
|
|
|
|
def require_api_token_scope(request: Request, scope: str) -> Optional[str]:
|
|
"""Require ``scope`` when the request is authenticated by an API token.
|
|
|
|
Browser sessions are unaffected. Scoped bearer routes use this before
|
|
touching owner data so resolving the token back to its owner never also
|
|
grants the owner's interactive-session authority.
|
|
"""
|
|
if not _is_api_token_request(request):
|
|
return get_current_user(request)
|
|
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
|
if scope not in scopes:
|
|
raise HTTPException(403, f"API token missing required scope: {scope}")
|
|
owner = getattr(request.state, "api_token_owner", None)
|
|
if not owner:
|
|
raise HTTPException(403, "API token has no owner")
|
|
return owner
|
|
|
|
|
|
def require_chat_api_token_scope(request: Request) -> Optional[str]:
|
|
"""FastAPI dependency for chat/session/history bearer surfaces."""
|
|
return require_api_token_scope(request, "chat")
|
|
|
|
|
|
def require_authenticated_request(request: Request) -> str:
|
|
"""Allow either a browser session or a valid bearer API token.
|
|
|
|
This is intentionally narrower than :func:`require_user`: use it only for
|
|
routes that need authentication but do not read or mutate owner-scoped
|
|
user data. Owner-scoped routes should use ``require_user`` for browser
|
|
sessions or their own API-token scope/owner gate.
|
|
"""
|
|
if _is_api_token_request(request):
|
|
return effective_user(request) or ""
|
|
return require_user(request)
|
|
|
|
|
|
def _auth_disabled() -> bool:
|
|
"""True when the operator has explicitly turned off auth via .env.
|
|
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the
|
|
three call sites agree on what "off" means."""
|
|
return auth_disabled()
|
|
|
|
|
|
def storage_owner_for_request(request: Request) -> Optional[str]:
|
|
"""Resolve the storage owner for code paths that need an owner bucket.
|
|
|
|
This does not replace route authentication. It only gives auth-disabled
|
|
no-login mode a stable storage identity instead of writing new data as
|
|
legacy NULL/ownerless state.
|
|
"""
|
|
return effective_storage_owner(effective_user(request))
|
|
|
|
|
|
def require_user(request: Request) -> str:
|
|
"""FastAPI dependency: reject unauthenticated callers when the upstream
|
|
auth middleware was bypassed unexpectedly (e.g. SSRF from a sibling
|
|
service). Returns the resolved username, or "" in single-user / anonymous
|
|
modes where no username is available.
|
|
|
|
The three "" cases are:
|
|
1. AUTH_ENABLED=false — the operator explicitly turned auth off.
|
|
The full /login flow is skipped (issue #622), so route-level
|
|
require_user must let the request through too instead of 401-ing
|
|
and forcing the browser to /login.
|
|
2. Unconfigured first-run + loopback caller — pre-setup access from
|
|
localhost so the operator can hit the SPA before creating the
|
|
first admin.
|
|
3. LOCALHOST_BYPASS=true + loopback caller — documented dev bypass.
|
|
|
|
Use this on routes that touch user data so middleware misconfig can't
|
|
open them up.
|
|
"""
|
|
if _is_api_token_request(request):
|
|
raise HTTPException(403, "API tokens must use a scope-aware API route")
|
|
|
|
u = get_current_user(request)
|
|
if u:
|
|
return u
|
|
# Operator-disabled auth: honor it at the route layer too. Without this,
|
|
# routes that depend on require_user 401, the front-end fetch wrapper
|
|
# redirects to /login, and the user sees a login page despite
|
|
# AUTH_ENABLED=false (issue #622). Docker / reverse-proxy deployments
|
|
# hit this because requests arrive from a non-loopback client.host, so
|
|
# the loopback fall-through below never fires.
|
|
if _auth_disabled():
|
|
return ""
|
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
|
client = getattr(request, "client", None)
|
|
host = (client.host if client else "") or ""
|
|
is_loopback = host in ("127.0.0.1", "::1", "localhost")
|
|
# LOCALHOST_BYPASS=true is the dev-only "I'm on loopback, skip auth"
|
|
# switch. Mirror the middleware so routes don't 401 the same caller
|
|
# the middleware just let through.
|
|
if is_loopback and os.getenv("LOCALHOST_BYPASS", "false").lower() == "true":
|
|
return ""
|
|
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
|
raise HTTPException(401, "Not authenticated")
|
|
# Unconfigured / first-run mode: only allow loopback callers.
|
|
if is_loopback:
|
|
return ""
|
|
raise HTTPException(401, "Not authenticated")
|
|
|
|
|
|
def require_privilege(request: Request, key: str) -> str:
|
|
"""Reject callers whose `auth.json` privilege flag for `key` is False.
|
|
Returns the username so the route handler can keep using it.
|
|
|
|
Admins always have every privilege via `auth_manager.get_privileges`
|
|
(which returns ADMIN_PRIVILEGES wholesale), so this is a no-op for
|
|
them. In unauthenticated single-user mode (`require_user` returns ""),
|
|
privileges aren't enforced.
|
|
"""
|
|
user = require_user(request)
|
|
if not user:
|
|
return user
|
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
|
if auth_mgr is None:
|
|
return user
|
|
try:
|
|
privs = auth_mgr.get_privileges(user) or {}
|
|
except Exception:
|
|
return user
|
|
if not isinstance(privs, dict):
|
|
privs = {}
|
|
# True = permitted; missing key defaults to permitted (unknown privileges
|
|
# fail open — the UI gates display-side).
|
|
if not privs.get(key, True):
|
|
raise HTTPException(403, f"Your account is not allowed to {key.replace('_', ' ')}.")
|
|
return user
|
|
|
|
|
|
def owner_filter(query, model_cls, user: str, *, include_shared: bool = True):
|
|
"""Filter `query` so only rows owned by `user` (and optionally null-owner
|
|
'shared' rows) come through. No-op when `user` is empty (single-user
|
|
mode). Returns the modified query."""
|
|
if not user:
|
|
return query
|
|
if include_shared:
|
|
return query.filter((model_cls.owner == user) | (model_cls.owner == None)) # noqa: E711
|
|
return query.filter(model_cls.owner == user)
|