feat(oauth): ARIA bekommt Zugangstokens nicht mehr zu sehen

oauth_get_token hat den access_token im Klartext als Tool-Ergebnis
zurueckgegeben. Tool-Ergebnisse sind Teil der Unterhaltung — der Token ging
damit an Anthropic und lag je nach Konto-Einstellung 30 Tage oder 5 Jahre dort.

Neu: das Brain fuehrt den API-Aufruf SELBST aus.

- `oauth_call(service, url, method, body)` haengt die Authorization an,
  schickt den Request und gibt nur die Antwort zurueck. Der Token verlaesst
  den Brain-Prozess nicht.
- `oauth_get_token` sagt nur noch, OB authentifiziert ist und wie lange noch.
  Refresh laeuft weiter automatisch.
- Skills bleiben unveraendert: die holen den Token serverseitig ueber
  {BRAIN_INTERNAL_URL}/oauth/<service>/token — der laeuft nie durch ein
  Gespraech.

Host-Sperre als Sicherheitskern: die URL kommt vom Modell, also koennte ein
Aufruf den Token sonst an einen beliebigen Server schicken. Erlaubt sind nur
Hosts, die zum Provider gehoeren (aus auth_url/token_url, plus optionale
api_hosts). Verglichen wird auf den letzten zwei Labels, NICHT per endswith —
"boesespotify.com" endet sonst auch auf "spotify.com". Dazu: nur https (sonst
ginge der Token im Klartext ueber die Leitung) und nur die ueblichen Methoden.

19 Pruefungen, darunter die Angriffsvarianten: boesespotify.com,
spotify.com.angreifer.de, accounts.spotify.com.evil.io, spotify.co, leerer
Host — alle blockiert; api.spotify.com und abschliessender Punkt erlaubt.

System-Prompt und seed_rules nachgezogen, sonst erklaert ARIA sich das alte
Verhalten selbst. Beide sagen jetzt zusaetzlich ausdruecklich: Secrets gehoeren
nie in den Chat, und ein Secret nie in einer Antwort wiederholen. Genau so ist
hier naemlich ein client_secret abgeflossen — nicht ueber ein Tool, sondern
weil es eingetippt und dann zurueckzitiert wurde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:16:41 +02:00
co-authored by Claude Opus 5
parent 752e2abc5f
commit 2c60a19ed6
4 changed files with 219 additions and 22 deletions
+111 -1
View File
@@ -28,6 +28,7 @@ import logging
import os
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
@@ -129,7 +130,8 @@ def register_provider(service: str, auth_url: str, token_url: str,
scopes: Optional[list[str]] = None,
client_auth: str = "body",
extra_auth_params: Optional[dict] = None,
accept_header: Optional[str] = None) -> dict:
accept_header: Optional[str] = None,
api_hosts: Optional[list[str]] = None) -> dict:
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
vom Brain-Tool `oauth_register_provider` gerufen.
@@ -158,6 +160,12 @@ def register_provider(service: str, auth_url: str, token_url: str,
entry["extra_auth_params"] = extra_auth_params
if accept_header is not None:
entry["accept_header"] = accept_header
if api_hosts is not None:
# Zusaetzliche API-Hosts, an die oauth_call den Token schicken darf.
# Noetig, wenn die API auf einer anderen Domain liegt als der Login
# (z.B. github.com -> api.github.com waere noch dieselbe Basis, aber
# manche Provider nutzen voellig andere Namen).
entry["api_hosts"] = [str(h).strip().lower() for h in api_hosts if str(h).strip()]
apps[svc] = entry
_save_json(APPS_FILE, apps)
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
@@ -416,6 +424,108 @@ def revoke(service: str) -> bool:
return True
def allowed_hosts(service: str) -> list[str]:
"""Hosts, an die ein Token dieses Services geschickt werden darf.
SICHERHEITSKERN von `call()`: ohne diese Liste koennte ein Aufruf den
Zugangstoken an einen beliebigen Server schicken — die Anfrage-URL kommt
schliesslich vom Modell. Darum wird nur zugelassen, was zum Provider selbst
gehoert: die Hosts aus auth_url/token_url plus optional eingetragene
api_hosts. Subdomains desselben registrierbaren Namens zaehlen mit
(accounts.spotify.com -> api.spotify.com), fremde Domains nie.
"""
cfg = _provider_config(service)
hosts: set[str] = set()
for key in ("auth_url", "token_url"):
u = cfg.get(key) or ""
try:
h = urllib.parse.urlparse(u).hostname or ""
except Exception:
h = ""
if h:
hosts.add(h.lower())
for h in (cfg.get("api_hosts") or []):
h = str(h).strip().lower()
if h:
hosts.add(h)
return sorted(hosts)
def _host_allowed(host: str, allowed: list[str]) -> bool:
"""True, wenn host zu einem der erlaubten Hosts gehoert.
Verglichen wird auf den letzten ZWEI Labels (spotify.com). Ein reiner
endswith-Vergleich waere angreifbar: 'boesespotify.com' endet auch auf
'spotify.com'.
"""
h = (host or "").lower().strip(".")
if not h:
return False
def base(x: str) -> str:
parts = x.lower().strip(".").split(".")
return ".".join(parts[-2:]) if len(parts) >= 2 else x.lower()
hb = base(h)
return any(h == a or hb == base(a) for a in allowed)
def call(service: str, url: str, method: str = "GET",
body: Optional[str] = None, content_type: str = "application/json",
timeout: float = 20.0, max_bytes: int = 200_000) -> tuple[int, str]:
"""Fuehrt einen HTTP-Aufruf MIT dem Zugangstoken aus — ohne ihn herauszugeben.
Das ist der Ersatz fuer "gib mir den Token, ich rufe selbst": der Token
verlaesst den Brain-Prozess nicht und kann damit auch nicht in einem
Gespraechsprotokoll landen.
Returns (status_code, text). Wirft ValueError bei unerlaubter URL.
"""
svc = (service or "").strip()
if not svc:
raise ValueError("service ist Pflicht")
u = (url or "").strip()
if not u.startswith("https://"):
# Kein http:// — der Token ginge im Klartext ueber die Leitung.
raise ValueError("url muss mit https:// beginnen")
host = urllib.parse.urlparse(u).hostname or ""
allowed = allowed_hosts(svc)
if not allowed:
raise ValueError(
f"Fuer '{svc}' sind keine Hosts hinterlegt — erst oauth_register_provider aufrufen.")
if not _host_allowed(host, allowed):
raise ValueError(
f"Host '{host}' gehoert nicht zu '{svc}'. Erlaubt: {', '.join(allowed)}")
m = (method or "GET").upper()
if m not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
raise ValueError(f"Methode nicht erlaubt: {method}")
record = get_token(svc) # refresht bei Bedarf
token = record.get("access_token", "")
ttype = record.get("token_type") or "Bearer"
data = body.encode("utf-8") if body else None
req = urllib.request.Request(u, data=data, method=m)
req.add_header("Authorization", f"{ttype} {token}")
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read(max_bytes + 1)
status = resp.status
except urllib.error.HTTPError as exc:
raw = exc.read(max_bytes + 1) if hasattr(exc, "read") else b""
status = exc.code
truncated = len(raw) > max_bytes
text = raw[:max_bytes].decode("utf-8", "replace")
if truncated:
text += "\n…(gekuerzt)"
logger.info("[oauth] call %s %s %s -> %s (%d Byte)", svc, m, host, status, len(raw))
return status, text
def list_services() -> list[dict]:
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
+ Ablaufzeit. Wird von Diagnostic genutzt."""