mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 23:18:13 +02:00
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>
This commit is contained in:
1 parent
513aca1c7b
commit
b893d1a81f
3 files changed
+130
No files matched your search
@@ -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) | — |
|
| `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_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_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 :
|
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,6 +49,10 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
map[string]any{"error": "configuration illisible — réessaie dans un instant"})
|
map[string]any{"error": "configuration illisible — réessaie dans un instant"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := crossSiteReject(r, key != ""); msg != "" {
|
||||||
|
sendJSON(w, http.StatusForbidden, map[string]any{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
if key == "" {
|
if key == "" {
|
||||||
next(w, r)
|
next(w, r)
|
||||||
return
|
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")
|
fmt.Printf(" (relance 'loki web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user