diff --git a/README.md b/README.md index a81ac8d..d3eef1d 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,15 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — | | `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — | | `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — | +| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — | + +**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les +hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse +toute requête venue d'un autre site : une page web ouverte dans un navigateur +du réseau ne peut plus piloter Loki en douce (ni par DNS rebinding). Derrière +un reverse proxy (`loki.mondomaine.fr`), définir une clé +(`docker exec -it loki loki set-web-key`) ou lister le nom dans +`LOKI_TRUSTED_HOSTS`. En CLI dans le conteneur : `docker exec -it loki loki status` (aussi : `logs`, `restart`, `config`, `bench`, `test`…). diff --git a/internal/loki/web_auth.go b/internal/loki/web_auth.go index cda98b0..5a959cf 100644 --- a/internal/loki/web_auth.go +++ b/internal/loki/web_auth.go @@ -5,7 +5,10 @@ import ( "crypto/subtle" "encoding/hex" "fmt" + "net" "net/http" + "net/url" + "os" "strings" ) @@ -46,6 +49,10 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc { 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 @@ -166,3 +173,71 @@ func cmdSetWebKey(args []string) error { 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) à :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 /
). +// - 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 +} diff --git a/internal/loki/web_auth_crosssite_test.go b/internal/loki/web_auth_crosssite_test.go new file mode 100644 index 0000000..d3d44bf --- /dev/null +++ b/internal/loki/web_auth_crosssite_test.go @@ -0,0 +1,46 @@ +package loki + +import ( + "net/http/httptest" + "testing" +) + +func TestCrossSiteReject(t *testing.T) { + t.Setenv("LOKI_TRUSTED_HOSTS", "loki.exemple.fr") + cas := []struct { + nom string + host, origin string + fetchSite string + tunnel bool + keyed bool + refuse bool + }{ + {nom: "curl sur IP", host: "192.168.1.10:8090"}, + {nom: "localhost", host: "localhost:8090", origin: "http://localhost:8090"}, + {nom: "nom sans point", host: "tower:8090", origin: "http://tower:8090"}, + {nom: "nom .local", host: "tower.local:8090"}, + {nom: "origine tierce", host: "192.168.1.10:8090", origin: "http://evil.example", refuse: true}, + {nom: "origine null", host: "localhost:8090", origin: "null", refuse: true}, + {nom: "sec-fetch cross-site", host: "localhost:8090", fetchSite: "cross-site", refuse: true}, + {nom: "rebinding sans clé", host: "evil.example:8090", origin: "http://evil.example:8090", refuse: true}, + {nom: "domaine avec clé", host: "evil.example:8090", keyed: true}, + {nom: "domaine de confiance", host: "loki.exemple.fr", origin: "https://loki.exemple.fr"}, + {nom: "tunnel", host: "relais.exemple.net", tunnel: true}, + } + for _, c := range cas { + r := httptest.NewRequest("POST", "/api/status", nil) + r.Host = c.host + if c.origin != "" { + r.Header.Set("Origin", c.origin) + } + if c.fetchSite != "" { + r.Header.Set("Sec-Fetch-Site", c.fetchSite) + } + if c.tunnel { + r.Header.Set(viaTunnelHeader, "tunnel") + } + if got := crossSiteReject(r, c.keyed) != ""; got != c.refuse { + t.Errorf("%s : refus=%v, attendu %v", c.nom, got, c.refuse) + } + } +}