From 1c3218939b60d37ce305fcdb8202cbbdace84dd4 Mon Sep 17 00:00:00 2001 From: marc Date: Mon, 24 Aug 2026 22:09:37 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Betrieb=20hinter=20einem=20Anmelde-Prox?= =?UTF-8?q?y,=20Schl=C3=BCssel=20f=C3=BCr=20die=20App-Liste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marc stellt Pangolin davor und will keinen zweiten Anmeldeschirm. - HONEYOS_PROXY_AUTH=true: HoneyOS prüft nicht mehr selbst, der Start sagt deutlich, dass der Container dann nur über den Proxy erreichbar sein darf. Mit HONEYOS_USER_HEADER bekommt jeder Name seine eigenen Einstellungen. - HONEYOS_APPS_KEY schützt /api/apps. Nötig, weil HoneyOS auf einer anderen Site liegt als die Apps (duckdns.org steht auf der Public-Suffix-Liste) – der Browser schickt die Sitzung dort nicht mit, und der Pfad muss im Proxy von der Anmeldung befreit sein. Beim Durchspielen mit zwei echten Hostnamen fiel ein Loch auf: Ohne eigene Anmeldung gilt jede Anfrage als angemeldet, also gab der Rückfall auf diese Prüfung die Liste an jeden heraus – auch ohne oder mit falschem Schlüssel. Der Rückfall greift jetzt nur noch, wo es wirklich eine Sitzung geben kann. Co-Authored-By: Claude Opus 5 --- .env.example | 54 +++++++++----- README.md | 76 ++++++++++++++----- auth.go | 11 ++- auth_test.go | 154 +++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 3 + main.go | 113 ++++++++++++++++++++++++++-- web/honeybar/honeybar.js | 11 ++- web/os.js | 5 ++ 8 files changed, 382 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index 7ac8211..c00e6fa 100644 --- a/.env.example +++ b/.env.example @@ -24,23 +24,43 @@ HONEYOS_SESSION_SECRET= HONEYOS_DATA_DIR=/srv/honeyos # ── Anmeldung ──────────────────────────────────────────────────────────────── -# Leerer HONEYOS_OIDC_ISSUER = KEINE Anmeldung, jeder kommt rein. Das ist zum -# Entwickeln gedacht und gehört nicht ins offene Netz. +# Genau einer der beiden Wege: # -# In PocketID einen Client anlegen und dort als Rückrufadresse exakt -# https://os.honeyy.duckdns.org/api/auth/callback -# eintragen. HoneyOS schickt PKCE mit, ein Client-Geheimnis braucht es -# trotzdem — also keinen „public client" anlegen. -HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org -HONEYOS_OIDC_CLIENT_ID=honeyos -HONEYOS_OIDC_CLIENT_SECRET= -# Nur nötig, wenn die Rückrufadresse von PUBLIC_URL abweicht: -# HONEYOS_OIDC_REDIRECT_URL= +# A) Ein Proxy davor meldet an (Pangolin, Authelia, …) — HoneyOS fragt nicht +# noch einmal. Dann NUR das hier setzen: +HONEYOS_PROXY_AUTH=true +# Optional: Kopfzeile, in die der Proxy den Nutzernamen schreibt. Ohne sie +# teilen sich alle einen Satz Einstellungen — bei einer Person völlig ok. +# HONEYOS_USER_HEADER=Remote-User +# +# ⚠ In diesem Betrieb prüft HoneyOS selbst nichts mehr. Der Container darf +# dann NUR über den Proxy erreichbar sein — nicht zusätzlich auf einem +# offenen Port im Netz hängen. +# +# B) HoneyOS meldet selbst an, mit eigenem Anmeldeschirm. Dann PROXY_AUTH +# weglassen und stattdessen: +# HONEYOS_SESSION_SECRET= # openssl rand -hex 32 +# HONEYOS_OIDC_ISSUER=https://id.example.com +# HONEYOS_OIDC_CLIENT_ID=honeyos +# HONEYOS_OIDC_CLIENT_SECRET= +# HONEYOS_OIDC_REDIRECT_URL= # nur wenn abweichend von PUBLIC_URL +# HONEYOS_BUTTON_LABEL=Anmelden mit PocketID +# HONEYOS_ALLOWED_USERS=marc # zweiter Riegel neben den IdP-Gruppen -# Beschriftung des einen Knopfes auf dem Anmeldeschirm. -HONEYOS_BUTTON_LABEL=Anmelden mit PocketID +# ── Schlüssel für die App-Liste ────────────────────────────────────────────── +# Nötig, sobald die Leiste in einer App läuft, die auf einer ANDEREN Site liegt +# als HoneyOS — etwa honeyos.duckdns.org gegenüber cloud.honeyy.duckdns.org. +# Der Browser schickt die Sitzung dann nicht mit, und ohne zweiten Ausweis +# bekäme die Leiste nie eine Liste. +# +# Ist der Pfad /api/apps im Proxy von der Anmeldung befreit, ist dieser +# Schlüssel das Einzige, was davorsteht. Dann setzen. +# openssl rand -hex 16 +HONEYOS_APPS_KEY= -# Zweiter Riegel neben den Gruppen in PocketID: nur diese Konten dürfen rein -# (Benutzername oder sub, kommagetrennt). Leer = jeder, den der Anbieter -# durchlässt. -# HONEYOS_ALLOWED_USERS=marc +# Derselbe Wert gehört in das +# +# Zusätzlich lässt HoneyOS die Liste nur an Ursprünge heraus, die selbst als App +# eingetragen sind. Der Schlüssel allein nützt einer fremden Seite also nichts. diff --git a/README.md b/README.md index 75b29d2..8d36457 100644 --- a/README.md +++ b/README.md @@ -29,33 +29,73 @@ und die Einstellungen je Nutzer. Sichere sie zusammen mit der `.env` — ohne außer dass `X-Forwarded-Proto: https` durchkommt — sonst setzt HoneyOS das Sitzungscookie ohne `Secure`. -## Anmeldung +## Anmeldung — zwei Wege, einer davon reicht -In PocketID einen Client anlegen und dort als Rückrufadresse **exakt** eintragen: - -``` -https://os.honeyy.duckdns.org/api/auth/callback -``` - -HoneyOS schickt PKCE mit, braucht aber trotzdem ein Client-Geheimnis — also **keinen -„public client"** anlegen. Danach in die `.env`: +**A · Ein Proxy davor macht die Tür** (Pangolin, Authelia, …). Dann in der `.env`: ```dotenv -HONEYOS_PUBLIC_URL=https://os.honeyy.duckdns.org -HONEYOS_SESSION_SECRET=… # openssl rand -hex 32 -HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org +HONEYOS_PROXY_AUTH=true +# HONEYOS_USER_HEADER=Remote-User # optional, trennt die Einstellungen je Nutzer +``` + +HoneyOS fragt dann nicht noch einmal — kein zweiter Anmeldeschirm. **Der +Container darf in diesem Betrieb nur über den Proxy erreichbar sein**, denn er +prüft selbst nichts mehr. + +**B · HoneyOS meldet selbst an**, mit eigenem Anmeldeschirm über deinen +OIDC-Anbieter. In PocketID einen Client anlegen und als Rückrufadresse **exakt** +`/api/auth/callback` eintragen. HoneyOS schickt PKCE mit, braucht +aber trotzdem ein Client-Geheimnis — also **keinen „public client"**. + +```dotenv +HONEYOS_SESSION_SECRET=… # openssl rand -hex 32 +HONEYOS_OIDC_ISSUER=https://id.example.com HONEYOS_OIDC_CLIENT_ID=honeyos HONEYOS_OIDC_CLIENT_SECRET=… HONEYOS_BUTTON_LABEL=Anmelden mit PocketID -# HONEYOS_ALLOWED_USERS=marc # zweiter Riegel neben den PocketID-Gruppen ``` -Wer nicht angemeldet ist, sieht **nur** den Anmeldeschirm — keine App-Liste, keine Namen. -Die Sitzung ist ein signiertes Cookie ohne Gegenstück auf dem Server: ein Neustart wirft -niemanden hinaus, und es gibt nichts aufzuräumen. Sie hält 30 Tage. +Wer nicht angemeldet ist, sieht nur den Anmeldeschirm — keine App-Liste, keine +Namen. Die Sitzung ist ein signiertes Cookie ohne Gegenstück im Server: ein +Neustart wirft niemanden hinaus. Sie hält 30 Tage. -**Ohne `HONEYOS_OIDC_ISSUER` läuft HoneyOS ganz ohne Anmeldung.** Das ist zum Entwickeln -gedacht und sagt beim Start deutlich, dass es so nicht ins offene Netz gehört. +**Weder A noch B** heißt: offen für jeden. Sagt beim Start deutlich, dass das +nur zum Entwickeln taugt. + +## Wenn HoneyOS auf einer anderen Site liegt als die Apps + +Der Browser behandelt `honeyos.duckdns.org` und `cloud.honeyy.duckdns.org` als +**fremde Sites** — `duckdns.org` steht auf der Public-Suffix-Liste, die +gemeinsame Endung zählt also nicht. Folge: Bei der Anfrage aus einer App heraus +schickt der Browser die HoneyOS-Sitzung **nicht** mit. + +Damit die Leiste trotzdem eine App-Liste bekommt: + +```dotenv +HONEYOS_APPS_KEY=… # openssl rand -hex 16 +``` + +und derselbe Wert im Skript-Tag der App: + +```html + +``` + +Der Schlüssel steht damit in der App, die ohnehin nur Berechtigte sehen. Ohne +ihn antwortet `/api/apps` mit 401 — auch im Proxy-Betrieb, gerade weil dieser +Pfad im Proxy von der Anmeldung befreit sein muss. + +Zweite Schranke, unabhängig davon: HoneyOS gibt die Liste nur an Ursprünge +heraus, die **selbst als App eingetragen** sind. Eine fremde Seite scheitert +also an CORS, selbst mit richtigem Schlüssel. + +**Was auf zwei Sites nicht geht:** der Anmeldestand auf den Kacheln des +Schreibtischs (fragt über die Domaingrenze). Der Name *in der Leiste* geht +weiterhin — den holt sie bei der App selbst, gleicher Ursprung. + +Liegen alle Adressen unter derselben Domain (`os.honeyy…`, `cloud.honeyy…`), +entfällt der Schlüssel und alles funktioniert ohne Einschränkung. ## Apps anlegen diff --git a/auth.go b/auth.go index dfeddef..e7fa27d 100644 --- a/auth.go +++ b/auth.go @@ -109,9 +109,18 @@ func (a *Auth) clearSession(w http.ResponseWriter, r *http.Request) { } // SubFromRequest liest die Sitzung. Zweiter Rückgabewert sagt, ob sie gilt. +// +// Ohne eigene Anmeldung (Proxy davor oder offener Betrieb) gilt jede Anfrage +// als angemeldet — die Prüfung hat dann jemand anders gemacht, oder es gibt +// keine. Nennt der Proxy einen Namen, bekommt der seine eigenen Einstellungen. func (a *Auth) SubFromRequest(r *http.Request) (string, bool) { if !a.Enabled() { - return localSub, true // offener Betrieb, siehe Config.Open + if a.cfg.UserHeader != "" { + if u := strings.TrimSpace(r.Header.Get(a.cfg.UserHeader)); u != "" { + return "proxy:" + u, true + } + } + return localSub, true } c, err := r.Cookie(sessionCookie) if err != nil { diff --git a/auth_test.go b/auth_test.go index 860da75..fb09f05 100644 --- a/auth_test.go +++ b/auth_test.go @@ -386,3 +386,157 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) { } return sess, csrf } + +// ── Betrieb hinter einem Anmelde-Proxy ───────────────────────────────────── +// Kein eigener Anmeldeschirm: Pangolin o. ä. hat die Tür schon zugemacht. + +func proxyServer(t *testing.T, cfg Config) *Server { + t.Helper() + store, err := OpenStore(dataPath(t.TempDir())) + if err != nil { + t.Fatal(err) + } + cfg.SessionSecret = strings.Repeat("k", 40) + return &Server{cfg: cfg, auth: NewAuth(cfg), store: store} +} + +func TestProxyBetriebLässtDurch(t *testing.T) { + h := proxyServer(t, Config{ProxyAuth: true}).routes() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil)) + if rec.Code != 200 { + t.Fatalf("/api/me hinter dem Proxy: %d", rec.Code) + } + // Kein Anmeldeschirm: /api/public darf nicht behaupten, es gäbe OIDC. + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/public", nil)) + var pub struct{ OIDC, SignedIn bool } + _ = json.Unmarshal(rec.Body.Bytes(), &pub) + if pub.OIDC || !pub.SignedIn { + t.Errorf("die Oberfläche würde einen Anmeldeschirm zeigen: %+v", pub) + } +} + +// Nennt der Proxy einen Namen, bekommt jeder Name seine eigenen Einstellungen. +func TestProxyKopfzeileTrenntEinstellungen(t *testing.T) { + s := proxyServer(t, Config{ProxyAuth: true, UserHeader: "Remote-User"}) + h := s.routes() + + put := func(user, body string) { + req := httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body)) + if user != "" { + req.Header.Set("Remote-User", user) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("PUT für %q: %d", user, rec.Code) + } + } + put("marc", `{"phosphor":"green"}`) + put("gast", `{"phosphor":"ice"}`) + + if got := s.store.User("proxy:marc").Settings["phosphor"]; got != "green" { + t.Errorf("marc: %q", got) + } + if got := s.store.User("proxy:gast").Settings["phosphor"]; got != "ice" { + t.Errorf("gast: %q", got) + } +} + +// ── Schlüssel für die App-Liste ──────────────────────────────────────────── + +func TestAppListeBrauchtDenSchlüssel(t *testing.T) { + idp := newFakeIdP(t, "honeyos") + s := newTestServer(t, idp.URL) + s.cfg.AppsKey = "geheim123" + s.auth = NewAuth(s.cfg) + h := s.routes() + + get := func(mod func(*http.Request)) int { + req := httptest.NewRequest("GET", "/api/apps", nil) + if mod != nil { + mod(req) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code + } + + if code := get(nil); code != http.StatusUnauthorized { + t.Errorf("ohne Schlüssel: %d, erwartet 401", code) + } + if code := get(func(r *http.Request) { r.Header.Set("X-HoneyOS-Key", "falsch") }); code != http.StatusUnauthorized { + t.Errorf("falscher Schlüssel: %d, erwartet 401", code) + } + if code := get(func(r *http.Request) { r.Header.Set("X-HoneyOS-Key", "geheim123") }); code != 200 { + t.Errorf("richtiger Schlüssel: %d, erwartet 200", code) + } + if code := get(func(r *http.Request) { r.URL.RawQuery = "key=geheim123" }); code != 200 { + t.Errorf("Schlüssel als Parameter: %d, erwartet 200", code) + } +} + +// Der Schlüssel öffnet nur das Lesen der Liste, nicht das Schreiben. +func TestSchlüsselDarfNichtSchreiben(t *testing.T) { + idp := newFakeIdP(t, "honeyos") + s := newTestServer(t, idp.URL) + s.cfg.AppsKey = "geheim123" + s.auth = NewAuth(s.cfg) + h := s.routes() + + req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(`[{"id":"x","url":"https://a.test"}]`)) + req.Header.Set("X-HoneyOS-Key", "geheim123") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("Schreiben mit Schlüssel: %d, erwartet 401", rec.Code) + } +} + +// Die Oberfläche muss den Schlüssel mitbekommen, sonst fragt sie sich selbst aus. +func TestStartseiteTrägtDenSchlüssel(t *testing.T) { + s := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"}) + rec := httptest.NewRecorder() + s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + + if !strings.Contains(rec.Body.String(), `name="honeyos-key" content="geheim123"`) { + t.Fatal("Schlüssel steht nicht in der Startseite") + } + + // Ohne Schlüssel darf auch nichts drinstehen. + s2 := proxyServer(t, Config{ProxyAuth: true}) + rec = httptest.NewRecorder() + s2.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + if strings.Contains(rec.Body.String(), "honeyos-key") { + t.Error("ohne Schlüssel steht trotzdem eine Meta-Angabe in der Seite") + } +} + +// Der Fall, der beim Durchspielen aufgefallen ist: Ohne eigene Anmeldung gilt +// jede Anfrage als angemeldet. Der Schlüssel darf dann NICHT auf diese Prüfung +// zurückfallen, sonst gibt der freigegebene Pfad die Liste an jeden heraus. +func TestSchlüsselGiltAuchHinterDemProxy(t *testing.T) { + h := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"}).routes() + + get := func(key string) int { + req := httptest.NewRequest("GET", "/api/apps", nil) + if key != "" { + req.Header.Set("X-HoneyOS-Key", key) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code + } + + if code := get(""); code != http.StatusUnauthorized { + t.Errorf("ohne Schlüssel hinter dem Proxy: %d, erwartet 401", code) + } + if code := get("falsch"); code != http.StatusUnauthorized { + t.Errorf("falscher Schlüssel hinter dem Proxy: %d, erwartet 401", code) + } + if code := get("geheim123"); code != 200 { + t.Errorf("richtiger Schlüssel: %d, erwartet 200", code) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 7bc90b9..5cb9ac7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,3 +30,6 @@ services: - HONEYOS_OIDC_REDIRECT_URL=${HONEYOS_OIDC_REDIRECT_URL:-} - HONEYOS_BUTTON_LABEL=${HONEYOS_BUTTON_LABEL:-Anmelden} - HONEYOS_ALLOWED_USERS=${HONEYOS_ALLOWED_USERS:-} + - HONEYOS_PROXY_AUTH=${HONEYOS_PROXY_AUTH:-} + - HONEYOS_USER_HEADER=${HONEYOS_USER_HEADER:-} + - HONEYOS_APPS_KEY=${HONEYOS_APPS_KEY:-} diff --git a/main.go b/main.go index 3dfa2df..91382b4 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ package main import ( + "bytes" + "crypto/subtle" "embed" "encoding/json" "io/fs" @@ -23,16 +25,34 @@ var webFS embed.FS const localSub = "local" type Config struct { - Addr string - PublicURL string - DataDir string - SessionSecret string + Addr string + PublicURL string + DataDir string + SessionSecret string + + // Anmeldung — genau einer der beiden Wege: + // + // OIDCIssuer gesetzt → HoneyOS meldet selbst an, eigener Anmeldeschirm. + // ProxyAuth gesetzt → ein vorgelagerter Proxy (Pangolin o. ä.) hat das + // schon erledigt. HoneyOS fragt dann nicht nach. + // + // Keines von beiden heißt: offen. Das ist zum Entwickeln gedacht und sagt + // beim Start deutlich, dass es so nicht ins Netz gehört. OIDCIssuer string OIDCClientID string OIDCClientSecret string OIDCRedirect string ButtonLabel string AllowedUsers []string + + ProxyAuth bool + // UserHeader nennt die Kopfzeile, in die der Proxy den Nutzernamen schreibt + // (z. B. Remote-User). Leer heißt: ein einziger Nutzer für alle. + UserHeader string + + // AppsKey schützt die App-Liste, wenn ihr Pfad im Proxy von der Anmeldung + // befreit ist. Ohne Schlüssel liest sie jeder, der die Adresse kennt. + AppsKey string } func (c Config) RedirectURL() string { @@ -62,6 +82,9 @@ func main() { OIDCRedirect: env("HONEYOS_OIDC_REDIRECT_URL", ""), ButtonLabel: env("HONEYOS_BUTTON_LABEL", "Anmelden"), AllowedUsers: splitList(env("HONEYOS_ALLOWED_USERS", "")), + ProxyAuth: env("HONEYOS_PROXY_AUTH", "") == "true", + UserHeader: env("HONEYOS_USER_HEADER", ""), + AppsKey: env("HONEYOS_APPS_KEY", ""), } if cfg.OIDCIssuer != "" { @@ -75,11 +98,23 @@ func main() { case cfg.PublicURL == "" && cfg.OIDCRedirect == "": log.Fatal("HONEYOS_PUBLIC_URL fehlt — daraus entsteht die Rückrufadresse") } + } else if cfg.ProxyAuth { + log.Println("Anmeldung: übernimmt der vorgelagerte Proxy (HONEYOS_PROXY_AUTH=true).") + if cfg.UserHeader != "" { + log.Printf("Nutzername aus der Kopfzeile %s", cfg.UserHeader) + } else { + log.Println("Kein HONEYOS_USER_HEADER: alle teilen einen Satz Einstellungen.") + } + log.Println("⚠ HoneyOS prüft dann selbst nichts mehr — es darf nur über den Proxy erreichbar sein.") } else { - log.Println("⚠ kein HONEYOS_OIDC_ISSUER: HoneyOS läuft OHNE Anmeldung.") + log.Println("⚠ weder HONEYOS_OIDC_ISSUER noch HONEYOS_PROXY_AUTH: HoneyOS läuft OHNE Anmeldung.") log.Println("⚠ Das ist für die Entwicklung gedacht. Nicht ins offene Netz stellen.") } + if cfg.AppsKey == "" && (cfg.ProxyAuth || cfg.OIDCIssuer == "") { + log.Println("Hinweis: ohne HONEYOS_APPS_KEY liest die App-Liste jeder, der ihren Pfad kennt.") + } + if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err) } @@ -119,7 +154,7 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe)) mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings)) - mux.HandleFunc("GET /api/apps", s.requireAuth(s.handleApps)) + mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps)) mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps)) web, err := fs.Sub(webFS, "web") @@ -127,11 +162,43 @@ func (s *Server) routes() http.Handler { log.Fatal(err) } files := http.FileServer(http.FS(web)) - mux.Handle("GET /", noCache(files)) + index := s.indexHTML(web) + + // Die Startseite kommt aus dem Speicher, weil der Schlüssel hineingehört. + // Alles andere liefert der Dateiserver unverändert aus. + mux.Handle("GET /", noCache(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(index)) + return + } + files.ServeHTTP(w, r) + }))) return s.cors(mux) } +// indexHTML holt die Startseite aus dem Binary und legt den Schlüssel als +// Meta-Angabe hinein. Die Seite selbst ist im Proxy geschützt; wer sie sieht, +// darf die Liste ohnehin lesen. +func (s *Server) indexHTML(web fs.FS) []byte { + raw, err := fs.ReadFile(web, "index.html") + if err != nil { + log.Fatalf("index.html fehlt im Binary: %v", err) + } + if s.cfg.AppsKey == "" { + return raw + } + meta := `` + return bytes.Replace(raw, []byte(""), []byte(" "+meta+"\n "), 1) +} + +// htmlAttr entschärft den Wert für ein Attribut in doppelten Anführungszeichen. +func htmlAttr(v string) string { + r := strings.NewReplacer(`&`, "&", `"`, """, `<`, "<", `>`, ">") + return r.Replace(v) +} + /* ── Zwischenschichten ───────────────────────────────────────────────────── */ // cors öffnet die Leiste für die eingetragenen Apps — und nur für die. Die @@ -144,7 +211,7 @@ func (s *Server) cors(next http.Handler) http.Handler { h := w.Header() h.Set("Access-Control-Allow-Origin", origin) h.Set("Access-Control-Allow-Credentials", "true") - h.Set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token") + h.Set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token, X-HoneyOS-Key") h.Set("Access-Control-Allow-Methods", "GET, PUT, POST") h.Add("Vary", "Origin") if r.Method == http.MethodOptions { @@ -163,6 +230,36 @@ func noCache(next http.Handler) http.Handler { }) } +// appsReadable lässt die App-Liste zusätzlich mit dem Schlüssel durch. +// +// Der Grund ist eine Browserregel, keine Bequemlichkeit: Liegt HoneyOS auf +// einer anderen Site als die Apps, schickt der Browser die Sitzung bei der +// Anfrage aus einer App heraus nicht mit. Ohne einen zweiten Ausweis bekäme die +// Leiste dort nie eine Liste. Der Schlüssel steht im Skript-Tag der App und ist +// damit nur für den lesbar, der ohnehin an die App herankommt. +func (s *Server) appsReadable(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if key := s.cfg.AppsKey; key != "" { + given := r.Header.Get("X-HoneyOS-Key") + if given == "" { + given = r.URL.Query().Get("key") + } + if subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1 { + next(w, r, localSub) + return + } + // Ohne eigene Anmeldung gibt es keine Sitzung, die den Schlüssel + // ersetzen könnte: dort gilt jede Anfrage als angemeldet, und der + // Rückfall würde die Liste an jeden herausgeben. Hier ist Schluss. + if !s.auth.Enabled() { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Schlüssel fehlt oder passt nicht"}) + return + } + } + s.requireAuth(next)(w, r) + } +} + func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sub, ok := s.auth.SubFromRequest(r) diff --git a/web/honeybar/honeybar.js b/web/honeybar/honeybar.js index e97855e..dbdf695 100644 --- a/web/honeybar/honeybar.js +++ b/web/honeybar/honeybar.js @@ -13,6 +13,10 @@ * data-Attribute am