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": { "function": {
"name": "oauth_get_token", "name": "oauth_get_token",
"description": ( "description": (
"Liefert das aktuelle access_token fuer einen Service. " "Prueft, ob ein Service authentifiziert ist, und wie lange der "
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) " "Token noch gilt. Refresht dabei automatisch, wenn noetig.\n\n"
"und der Provider einen refresh_token mitgegeben hat.\n\n" "GIBT DEN TOKEN NICHT HERAUS — und Du brauchst ihn auch nicht: "
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — " "fuer API-Aufrufe nimm `oauth_call`, das Brain haengt die "
"der token kommt als Bearer-Header in Deinen HTTP-Request, " "Authorization selbst an. In einem Skill holst Du ihn zur "
"z.B. `Authorization: Bearer <token>`.\n\n" "Laufzeit ueber {BRAIN_INTERNAL_URL}/oauth/<service>/token.\n\n"
"Wirft wenn Service noch nicht authentifiziert ist oder der " "Wirft, wenn der Service noch nicht authentifiziert ist oder der "
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen." "Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
), ),
"parameters": { "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", "type": "function",
"function": { "function": {
@@ -3723,15 +3752,47 @@ class Agent:
record = oauth_mod.get_token(svc) record = oauth_mod.get_token(svc)
except RuntimeError as exc: except RuntimeError as exc:
return f"FEHLER: {exc}" return f"FEHLER: {exc}"
tok = record.get("access_token", "")
ttype = record.get("token_type", "Bearer")
exp = record.get("expires_at", 0) exp = record.get("expires_at", 0)
remain = max(0, int(exp) - int(__import__("time").time())) 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 ( return (
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n" f"OK — {svc} ist authentifiziert (Token gueltig noch {remain}s).\n"
f"access_token: {tok}\n" f"Der Token wird NICHT herausgegeben und ist dafuer auch nicht noetig:\n"
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}" 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": if name == "oauth_revoke":
svc = (arguments.get("service") or "").strip() svc = (arguments.get("service") or "").strip()
if not svc: if not svc:
+111 -1
View File
@@ -28,6 +28,7 @@ import logging
import os import os
import secrets import secrets
import time import time
import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -129,7 +130,8 @@ def register_provider(service: str, auth_url: str, token_url: str,
scopes: Optional[list[str]] = None, scopes: Optional[list[str]] = None,
client_auth: str = "body", client_auth: str = "body",
extra_auth_params: Optional[dict] = None, 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 """Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
vom Brain-Tool `oauth_register_provider` gerufen. 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 entry["extra_auth_params"] = extra_auth_params
if accept_header is not None: if accept_header is not None:
entry["accept_header"] = accept_header 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 apps[svc] = entry
_save_json(APPS_FILE, apps) _save_json(APPS_FILE, apps)
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)", logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
@@ -416,6 +424,108 @@ def revoke(service: str) -> bool:
return True 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]: def list_services() -> list[dict]:
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist """Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
+ Ablaufzeit. Wird von Diagnostic genutzt.""" + 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) " "Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
"Services via OAuth2 ansprechen. Workflow ist IMMER:", "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`.", "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}`", f"**Callback-URL (fest, NICHT raten):** `{base}`",
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige " "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", "category": "skills",
"content": ( "content": (
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder " "Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
"client_secret direkt in den Skill-Code. Fuer OAuth-Services " "client_secret direkt in den Skill-Code — und schreibe sie auch "
"(Spotify, Google, GitHub etc.) nutze das Brain-Tool " "NIE in eine Chat-Antwort. Alles was im Gespraech steht, wird "
"`oauth_get_token('<service>')` — das macht Auto-Refresh und " "uebertragen und gespeichert.\n"
"haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten " "\n"
"manuell neu einloggen, das nervt." "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": ( "content": (
"Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / " "Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / "
"'token expired' zurueckgibt: RUFE ZUERST " "'token expired' zurueckgibt: RUFE ZUERST "
"`oauth_get_token('<service>')`. Brain holt entweder den noch " "`oauth_get_token('<service>')`. Das refresht automatisch ueber den "
"gueltigen Token oder refresht ihn automatisch ueber den " "gespeicherten refresh_token und sagt Dir, ob wieder alles gueltig "
"gespeicherten refresh_token. In 99% der Faelle reicht das.\n" "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" "\n"
"Nur wenn `oauth_get_token` selbst einen Fehler wirft " "Nur wenn `oauth_get_token` selbst einen Fehler wirft "
"('refresh failed', 'no refresh_token', 'service nicht " "('refresh failed', 'no refresh_token', 'service nicht "