Files
Loki/internal/loki/web_auth.go
T
MichaelandClaude Opus 5.5 b893d1a81f API : un site tiers ne peut plus piloter Loki en douce
Repris d'AJEAN 0.15.5. Sans clé de pilotage (le défaut), l'API /api était
ouverte à tout ce qui savait joindre le port — y compris une page web
quelconque ouverte dans un navigateur du réseau local. Un POST « simple »
(text/plain) ne déclenche aucune pré-vérification CORS : la page pouvait
changer des réglages et, mode agent actif, faire exécuter des commandes.

requireWebAuth passe désormais par crossSiteReject, AVANT le test de clé :
- Sec-Fetch-Site: cross-site → 403 ;
- Origin présent et différent de l'hôte appelé (ou « null ») → 403. curl,
  les scripts et les apps n'envoient pas d'Origin : non concernés ;
- sans clé seulement, l'hôte appelé doit être local (IP, localhost, nom sans
  point, .local/.lan/.home…, nom du conteneur) : c'est ce qui coupe le DNS
  rebinding, où un domaine malveillant se fait résoudre en IP locale.

DEUX ÉCARTS AVEC L'AMONT, DUS AU CONTENEUR
- LOKI_TRUSTED_HOSTS : derrière un reverse proxy, le nom public n'est ni
  local ni celui du conteneur. Plutôt qu'imposer une clé, on peut lister ce
  nom. Documenté dans le README.
- Le trafic du tunnel (marqué par withLocalAuth, authentifié par le relais)
  est dispensé du contrôle d'hôte : son Host est celui du relais.

À SAVOIR : un accès existant par nom de domaine SANS clé ni
LOKI_TRUSTED_HOSTS est désormais refusé (403, message explicite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:05 +02:00

244 lines
9.9 KiB
Go

package loki
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
)
// web_auth.go protège l'API de pilotage (loki web) quand elle est exposée sur
// internet — c.-à-d. l'API que tout client (navigateur, app mobile, script…)
// utilise pour switcher de preset, redémarrer le service, lire le status, etc.
//
// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
// protège llama-server / les complétions). On veut pouvoir donner à un client
// un accès aux complétions sans lui donner le droit de redémarrer la machine,
// et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un
// changement de clé prenne effet sans redémarrer le serveur web.
// readWebKeyErr renvoie la clé de pilotage en distinguant « aucune clé » d'une
// LECTURE RATÉE. La nuance est tout sauf cosmétique : sans clé, l'API est
// ouverte. Confondre les deux, c'est ouvrir l'API parce que la base était
// momentanément verrouillée par une commande CLI — une panne d'E/S qui désarme
// l'authentification. requireWebAuth refuse donc plutôt que d'ouvrir.
func readWebKeyErr() (string, error) {
b, err := getBytesErr(bkState, "web_key")
return string(b), err
}
// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie (ou
// illisible). Réservé à l'affichage ; toute décision d'accès passe par
// readWebKeyErr.
func readWebKey() string { k, _ := readWebKeyErr(); return k }
// requireWebAuth wraps an HTTP handler, rejecting requests that don't present
// the configured Bearer token. When no key is configured the handler is left
// open (pratique en local) — cmdWeb avertit alors bruyamment au démarrage.
func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key, err := readWebKeyErr()
if err != nil {
// On ne sait pas si une clé protège cette API : on ferme.
sendJSON(w, http.StatusServiceUnavailable,
map[string]any{"error": "configuration illisible — réessaie dans un instant"})
return
}
if msg := crossSiteReject(r, key != ""); msg != "" {
sendJSON(w, http.StatusForbidden, map[string]any{"error": msg})
return
}
if key == "" {
next(w, r)
return
}
if !checkBearer(r, key) {
w.Header().Set("WWW-Authenticate", `Bearer realm="loki"`)
sendJSON(w, http.StatusUnauthorized, map[string]any{"error": "non autorisé"})
return
}
next(w, r)
}
}
// requireCompletionKey protège la surface compatible OpenAI (/v1) avec la clé
// des COMPLÉTIONS, et non avec la clé de pilotage. Trois raisons, dans l'ordre
// d'importance :
//
// 1. Sémantique : c'est exactement la séparation décrite en tête de ce fichier
// — on veut pouvoir donner à une app tierce l'accès aux complétions sans lui
// donner le droit de redémarrer la machine.
// 2. Technique : un client OpenAI n'a qu'UN en-tête Authorization. S'il y met la
// clé des complétions, une garde de pilotage le refuse ; s'il y met celle de
// pilotage, llama-server (qui reçoit l'en-tête tel quel) la refuse. Les deux
// gardes ne peuvent pas être satisfaites en même temps.
// 3. Format : les SDK OpenAI lisent body.error.message ; un {"error":"…"} plat
// leur fait afficher un message vide.
func requireCompletionKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Préflight CORS : un OPTIONS ne porte JAMAIS d'Authorization. Le refuser
// casse tout client tiers qui tourne dans un navigateur (playground web,
// interface de chat hébergée ailleurs). llama-server répond lui-même le
// préflight, ses en-têtes CORS traversent le proxy tels quels.
if r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
// Trafic venu du tunnel du relais : la surface OpenAI n'y est ouverte que
// si le drapeau historique l'autorise. Sans lui, le tunnel ne servait
// cette surface que par son front TLS dédié ; la monter sur le mux ne
// doit pas l'ouvrir en douce par le canal HTTP.
if r.Header.Get(viaTunnelHeader) == "tunnel" && !oaiPublicEnabled() {
sendOAIError(w, http.StatusNotFound,
"chemin inconnu — l'endpoint compatible OpenAI est /v1/*", "invalid_request_error", "not_found")
return
}
key, err := effectiveAPIKeyErr()
if err != nil {
// On ne sait pas si une clé protège cet endpoint : on ferme.
sendOAIError(w, http.StatusServiceUnavailable,
"configuration illisible — réessaie dans un instant", "api_error", "service_unavailable")
return
}
if key == "" {
// Aucune clé : endpoint ouvert, comme llama-server sans --api-key. Le
// panneau Accès OpenAI l'affiche en rouge, et cmdWeb le crie au
// démarrage — c'est le prix d'un réglage local sans friction.
next.ServeHTTP(w, r)
return
}
if !checkBearer(r, key) {
w.Header().Set("WWW-Authenticate", `Bearer realm="loki"`)
sendOAIError(w, http.StatusUnauthorized,
"Clé API invalide ou absente — envoie l'en-tête Authorization: Bearer <clé>.",
"invalid_request_error", "invalid_api_key")
return
}
next.ServeHTTP(w, r)
})
}
// checkBearer reports whether the request carries the expected key as an
// "Authorization: Bearer <clé>" header. La comparaison est à temps constant.
// PAS de repli ?key=<clé> en query string : une clé dans l'URL finit dans les
// logs des proxys (Caddy, Cloudflare), l'historique navigateur et les Referer.
func checkBearer(r *http.Request, key string) bool {
want := []byte(key)
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
got := []byte(strings.TrimSpace(h[len("Bearer "):]))
if subtle.ConstantTimeCompare(got, want) == 1 {
return true
}
}
return false
}
// cmdSetWebKey sets (or clears) the control-API key in $LOKI_HOME/.web_key.
//
// loki set-web-key <clé> définit la clé
// loki set-web-key génère une clé aléatoire
// loki set-web-key "" supprime la protection (API ouverte)
//
// Contrairement à set-api-key, aucun redémarrage n'est nécessaire : le serveur
// web relit la clé à chaque requête.
func cmdSetWebKey(args []string) error {
var key string
switch {
case len(args) == 0:
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return err
}
key = "loki-web-" + hex.EncodeToString(buf)
fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key))
case args[0] == "" || args[0] == "off" || args[0] == "none":
key = ""
default:
key = strings.TrimSpace(args[0])
}
if err := putStr(bkState, "web_key", key); err != nil {
return err
}
if key == "" {
fmt.Printf("%s clé de pilotage supprimée — l'API web n'est plus protégée\n", yellow("[info]"))
return nil
}
fmt.Printf("%s clé de pilotage enregistrée\n", green("[ok]"))
fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
fmt.Printf(" (relance 'loki web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
return nil
}
// crossSiteReject refuse les requêtes qu'un SITE TIERS ouvert dans le navigateur
// (de la machine ou du réseau local) ferait en douce vers l'API. Sans clé (le
// défaut), l'API est ouverte : une page malveillante pouvait envoyer un POST
// « simple » (text/plain, sans pré-vérification CORS) à <serveur>:8090 et
// piloter Loki, jusqu'à faire exécuter des commandes à l'agent. Renvoie "" si
// la requête est acceptable, sinon la raison du refus. Repris d'AJEAN 0.15.5.
//
// - Sec-Fetch-Site: cross-site → refus (navigateurs récents, couvre aussi les
// GET déclenchés par une balise <img>/<form>).
// - Origin présent et différent de l'hôte appelé → refus (tous les navigateurs
// envoient Origin sur un POST cross-origin). Les clients hors navigateur
// (scripts, curl, apps) n'envoient pas d'Origin : non concernés.
// - Sans clé seulement : l'hôte appelé doit être local (IP, localhost, nom de
// la machine, nom sans point, .local/.lan…) ou listé dans
// LOKI_TRUSTED_HOSTS. Bloque le « DNS rebinding », où un domaine malveillant
// se fait résoudre en IP locale pour paraître même-origine. Avec une clé,
// inutile : le navigateur n'envoie jamais le Bearer tout seul. Le trafic du
// tunnel (authentifié par le relais) est lui aussi dispensé de ce contrôle.
func crossSiteReject(r *http.Request, keyed bool) string {
if strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") {
return "requête d'un site tiers refusée"
}
if o := r.Header.Get("Origin"); o != "" && o != "null" {
u, err := url.Parse(o)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
return "origine non autorisée : " + o
}
} else if o == "null" {
return "origine non autorisée"
}
if !keyed && r.Header.Get(viaTunnelHeader) != "tunnel" && !localHostName(r.Host) {
return "hôte « " + r.Host + " » non autorisé sans clé de pilotage — définis-en une (loki set-web-key) ou ajoute ce nom à LOKI_TRUSTED_HOSTS"
}
return ""
}
// localHostName : l'hôte désigne-t-il la machine ou le réseau local (et non un
// domaine public, seul utilisable pour un DNS rebinding) ?
func localHostName(hostport string) bool {
h := hostport
if hh, _, err := net.SplitHostPort(hostport); err == nil {
h = hh
}
h = strings.ToLower(strings.Trim(h, "[]."))
if h == "" || net.ParseIP(h) != nil || !strings.Contains(h, ".") {
return true
}
for _, suf := range []string{".localhost", ".local", ".lan", ".home", ".internal", ".home.arpa", ".localdomain"} {
if strings.HasSuffix(h, suf) {
return true
}
}
if me, err := os.Hostname(); err == nil && me != "" {
me = strings.ToLower(me)
if h == me || strings.HasPrefix(h, me+".") {
return true
}
}
// Échappatoire du conteneur : derrière un reverse proxy (loki.mondomaine.fr),
// le nom public n'est ni local ni celui du conteneur.
for _, t := range strings.Split(os.Getenv("LOKI_TRUSTED_HOSTS"), ",") {
if t = strings.ToLower(strings.TrimSpace(t)); t != "" && h == t {
return true
}
}
return false
}