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
+73 -12
View File
@@ -820,13 +820,13 @@ META_TOOLS = [
"function": {
"name": "oauth_get_token",
"description": (
"Liefert das aktuelle access_token fuer einen Service. "
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
"und der Provider einen refresh_token mitgegeben hat.\n\n"
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
"z.B. `Authorization: Bearer <token>`.\n\n"
"Wirft wenn Service noch nicht authentifiziert ist oder der "
"Prueft, ob ein Service authentifiziert ist, und wie lange der "
"Token noch gilt. Refresht dabei automatisch, wenn noetig.\n\n"
"GIBT DEN TOKEN NICHT HERAUS — und Du brauchst ihn auch nicht: "
"fuer API-Aufrufe nimm `oauth_call`, das Brain haengt die "
"Authorization selbst an. In einem Skill holst Du ihn zur "
"Laufzeit ueber {BRAIN_INTERNAL_URL}/oauth/<service>/token.\n\n"
"Wirft, wenn der Service noch nicht authentifiziert ist oder der "
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
),
"parameters": {
@@ -838,6 +838,35 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "oauth_call",
"description": (
"Ruft eine Provider-API MIT dem hinterlegten Zugangstoken auf, "
"ohne dass Du den Token siehst. Das Brain haengt den "
"Authorization-Header an, fuehrt den Request aus und gibt Dir nur "
"die Antwort.\n\n"
"Nimm das fuer einzelne Aufrufe (z.B. Spotify-Wiedergabe steuern, "
"GitHub-Issue anlegen). Fuer etwas, das Du oefter brauchst, bau "
"lieber einen Skill.\n\n"
"Nur https, und nur Hosts, die zum Service gehoeren — eine fremde "
"Domain wird abgelehnt (sonst koennte der Token irgendwohin "
"geschickt werden). Antwort wird bei 200 KB gekuerzt."
),
"parameters": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "z.B. spotify, github."},
"url": {"type": "string", "description": "Vollstaendige https-URL der API."},
"method": {"type": "string", "description": "GET (Default), POST, PUT, PATCH, DELETE."},
"body": {"type": "string", "description": "Request-Body, meist JSON als String."},
"content_type": {"type": "string", "description": "Default application/json."},
},
"required": ["service", "url"],
},
},
},
{
"type": "function",
"function": {
@@ -3723,15 +3752,47 @@ class Agent:
record = oauth_mod.get_token(svc)
except RuntimeError as exc:
return f"FEHLER: {exc}"
tok = record.get("access_token", "")
ttype = record.get("token_type", "Bearer")
exp = record.get("expires_at", 0)
remain = max(0, int(exp) - int(__import__("time").time()))
hosts = ", ".join(oauth_mod.allowed_hosts(svc)) or "(keine hinterlegt)"
# Der Token wird hier BEWUSST nicht mehr herausgegeben. Frueher
# stand er im Klartext in dieser Tool-Antwort und damit im
# Gespraechsprotokoll — also auch bei Anthropic. Fuer API-Aufrufe
# gibt es oauth_call: das Brain haengt den Header selbst an.
return (
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
f"access_token: {tok}\n"
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
f"OK — {svc} ist authentifiziert (Token gueltig noch {remain}s).\n"
f"Der Token wird NICHT herausgegeben und ist dafuer auch nicht noetig:\n"
f"- Einzelne API-Aufrufe: `oauth_call(service='{svc}', url=...)` — "
f"das Brain haengt die Authorization an.\n"
f"- In einem Skill: GET {{BRAIN_INTERNAL_URL}}/oauth/{svc}/token "
f"zur Laufzeit holen, nie hartkodieren.\n"
f"Erlaubte Hosts: {hosts}"
)
if name == "oauth_call":
svc = (arguments.get("service") or "").strip()
url = (arguments.get("url") or "").strip()
if not svc or not url:
return "FEHLER: service und url sind Pflicht."
body = arguments.get("body")
if body is not None and not isinstance(body, str):
body = json.dumps(body, ensure_ascii=False)
try:
status, text = oauth_mod.call(
svc, url,
method=(arguments.get("method") or "GET"),
body=body,
content_type=(arguments.get("content_type") or "application/json"),
)
except ValueError as exc:
return f"FEHLER: {exc}"
except RuntimeError as exc:
return f"FEHLER: {exc}"
except Exception as exc:
logger.exception("oauth_call fehlgeschlagen")
return f"FEHLER: Aufruf fehlgeschlagen: {exc}"
ok = 200 <= status < 300
head = f"HTTP {status}" + ("" if ok else " (Fehler)")
return f"{head}\n{text}" if text else head
if name == "oauth_revoke":
svc = (arguments.get("service") or "").strip()
if not svc:
+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."""
+15 -1
View File
@@ -457,8 +457,22 @@ def build_oauth_section(oauth_services: list[dict] | None,
"",
"Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
"Services via OAuth2 ansprechen. Workflow ist IMMER:",
"1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.",
"1. `oauth_get_token(service)` fragen — sagt nur OB authentifiziert ist.",
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
"3. API ansprechen mit `oauth_call(service, url, ...)` — das Brain haengt "
"die Authorization selbst an.",
"",
"**Du bekommst den Zugangstoken NICHT zu sehen — und brauchst ihn nicht.** "
"Das ist Absicht: alles was Du siehst, steht im Gespraechsprotokoll. Frag "
"also nicht danach und versuche nicht, ihn aus Dateien oder Umgebungs"
"variablen zu lesen. Fuer etwas, das oefter gebraucht wird, bau einen Skill "
"— der holt sich den Token zur Laufzeit ueber "
"`{BRAIN_INTERNAL_URL}/oauth/<service>/token`, serverseitig.",
"",
"**Secrets gehoeren nie in den Chat.** Wenn Stefan Dir ein Passwort, einen "
"API-Key oder ein client_secret schicken will: NICHT hier abtippen lassen. "
"Verweise ihn auf Diagnostic → OAuth-Apps bzw. die Skill-Konfiguration. "
"Und wiederhole ein Secret niemals in Deiner Antwort.",
"",
f"**Callback-URL (fest, NICHT raten):** `{base}`",
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
+20 -8
View File
@@ -185,11 +185,21 @@ SEED_RULES: List[dict] = [
"category": "skills",
"content": (
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
"client_secret direkt in den Skill-Code. Fuer OAuth-Services "
"(Spotify, Google, GitHub etc.) nutze das Brain-Tool "
"`oauth_get_token('<service>')` — das macht Auto-Refresh und "
"haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten "
"manuell neu einloggen, das nervt."
"client_secret direkt in den Skill-Code — und schreibe sie auch "
"NIE in eine Chat-Antwort. Alles was im Gespraech steht, wird "
"uebertragen und gespeichert.\n"
"\n"
"Fuer OAuth-Services (Spotify, Google, GitHub etc.):\n"
"- Im Gespraech: `oauth_call('<service>', url, ...)` — das Brain "
"haengt die Authorization an, Du siehst den Token nie.\n"
"- Im Skill-Code: den Token zur Laufzeit holen ueber "
"GET {BRAIN_INTERNAL_URL}/oauth/<service>/token. Der Aufruf macht "
"Auto-Refresh; Stefan muss sich sonst alle 60 Minuten neu "
"einloggen, das nervt.\n"
"- Zugangsdaten, die kein OAuth sind (Benutzername/Passwort, "
"API-Key): in die SKILL-KONFIGURATION (`skill_set_config`), nicht "
"in den Code und nicht in den Chat. Der Skill bekommt sie zur "
"Laufzeit als CFG_<NAME>-Umgebungsvariable."
),
},
{
@@ -235,9 +245,11 @@ SEED_RULES: List[dict] = [
"content": (
"Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / "
"'token expired' zurueckgibt: RUFE ZUERST "
"`oauth_get_token('<service>')`. Brain holt entweder den noch "
"gueltigen Token oder refresht ihn automatisch ueber den "
"gespeicherten refresh_token. In 99% der Faelle reicht das.\n"
"`oauth_get_token('<service>')`. Das refresht automatisch ueber den "
"gespeicherten refresh_token und sagt Dir, ob wieder alles gueltig "
"ist (den Token selbst bekommst Du bewusst nicht — "
"`oauth_call` nutzt ihn fuer Dich). In 99% der Faelle reicht das: "
"danach denselben Aufruf einfach nochmal.\n"
"\n"
"Nur wenn `oauth_get_token` selbst einen Fehler wirft "
"('refresh failed', 'no refresh_token', 'service nicht "