feat: Betrieb hinter einem Anmelde-Proxy, Schlüssel für die App-Liste

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-24 22:09:37 +02:00
1 parent 905b136516
commit 1c3218939b
8 files changed
+382 -45

No files matched your search

+37 -17
View File
@@ -24,23 +24,43 @@ HONEYOS_SESSION_SECRET=
HONEYOS_DATA_DIR=/srv/honeyos HONEYOS_DATA_DIR=/srv/honeyos
# ── Anmeldung ──────────────────────────────────────────────────────────────── # ── Anmeldung ────────────────────────────────────────────────────────────────
# Leerer HONEYOS_OIDC_ISSUER = KEINE Anmeldung, jeder kommt rein. Das ist zum # Genau einer der beiden Wege:
# Entwickeln gedacht und gehört nicht ins offene Netz.
# #
# In PocketID einen Client anlegen und dort als Rückrufadresse exakt # A) Ein Proxy davor meldet an (Pangolin, Authelia, …) — HoneyOS fragt nicht
# https://os.honeyy.duckdns.org/api/auth/callback # noch einmal. Dann NUR das hier setzen:
# eintragen. HoneyOS schickt PKCE mit, ein Client-Geheimnis braucht es HONEYOS_PROXY_AUTH=true
# trotzdem — also keinen „public client" anlegen. # Optional: Kopfzeile, in die der Proxy den Nutzernamen schreibt. Ohne sie
HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org # teilen sich alle einen Satz Einstellungen — bei einer Person völlig ok.
HONEYOS_OIDC_CLIENT_ID=honeyos # HONEYOS_USER_HEADER=Remote-User
HONEYOS_OIDC_CLIENT_SECRET= #
# Nur nötig, wenn die Rückrufadresse von PUBLIC_URL abweicht: # ⚠ In diesem Betrieb prüft HoneyOS selbst nichts mehr. Der Container darf
# HONEYOS_OIDC_REDIRECT_URL= # 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. # ── Schlüssel für die App-Liste ──────────────────────────────────────────────
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID # 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 # Derselbe Wert gehört in das <script>-Tag der App:
# (Benutzername oder sub, kommagetrennt). Leer = jeder, den der Anbieter # <script defer src="https://honeyos.duckdns.org/honeybar/honeybar.js"
# durchlässt. # data-app="aether" data-key="…"></script>
# HONEYOS_ALLOWED_USERS=marc #
# 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.
+58 -18
View File
@@ -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 außer dass `X-Forwarded-Proto: https` durchkommt — sonst setzt HoneyOS das Sitzungscookie
ohne `Secure`. ohne `Secure`.
## Anmeldung ## Anmeldung — zwei Wege, einer davon reicht
In PocketID einen Client anlegen und dort als Rückrufadresse **exakt** eintragen: **A · Ein Proxy davor macht die Tür** (Pangolin, Authelia, …). Dann in der `.env`:
```
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`:
```dotenv ```dotenv
HONEYOS_PUBLIC_URL=https://os.honeyy.duckdns.org HONEYOS_PROXY_AUTH=true
HONEYOS_SESSION_SECRET=… # openssl rand -hex 32 # HONEYOS_USER_HEADER=Remote-User # optional, trennt die Einstellungen je Nutzer
HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org ```
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**
`<PUBLIC_URL>/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_ID=honeyos
HONEYOS_OIDC_CLIENT_SECRET=… HONEYOS_OIDC_CLIENT_SECRET=…
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID 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. Wer nicht angemeldet ist, sieht nur den Anmeldeschirm — keine App-Liste, keine
Die Sitzung ist ein signiertes Cookie ohne Gegenstück auf dem Server: ein Neustart wirft Namen. Die Sitzung ist ein signiertes Cookie ohne Gegenstück im Server: ein
niemanden hinaus, und es gibt nichts aufzuräumen. Sie hält 30 Tage. Neustart wirft niemanden hinaus. Sie hält 30 Tage.
**Ohne `HONEYOS_OIDC_ISSUER` läuft HoneyOS ganz ohne Anmeldung.** Das ist zum Entwickeln **Weder A noch B** heißt: offen für jeden. Sagt beim Start deutlich, dass das
gedacht und sagt beim Start deutlich, dass es so nicht ins offene Netz gehört. 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
<script defer src="https://honeyos.duckdns.org/honeybar/honeybar.js"
data-app="aether" data-key="…"></script>
```
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 ## Apps anlegen
+10 -1
View File
@@ -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. // 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) { func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
if !a.Enabled() { 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) c, err := r.Cookie(sessionCookie)
if err != nil { if err != nil {
+154
View File
@@ -386,3 +386,157 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
} }
return sess, csrf 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)
}
}
+3
View File
@@ -30,3 +30,6 @@ services:
- HONEYOS_OIDC_REDIRECT_URL=${HONEYOS_OIDC_REDIRECT_URL:-} - HONEYOS_OIDC_REDIRECT_URL=${HONEYOS_OIDC_REDIRECT_URL:-}
- HONEYOS_BUTTON_LABEL=${HONEYOS_BUTTON_LABEL:-Anmelden} - HONEYOS_BUTTON_LABEL=${HONEYOS_BUTTON_LABEL:-Anmelden}
- HONEYOS_ALLOWED_USERS=${HONEYOS_ALLOWED_USERS:-} - HONEYOS_ALLOWED_USERS=${HONEYOS_ALLOWED_USERS:-}
- HONEYOS_PROXY_AUTH=${HONEYOS_PROXY_AUTH:-}
- HONEYOS_USER_HEADER=${HONEYOS_USER_HEADER:-}
- HONEYOS_APPS_KEY=${HONEYOS_APPS_KEY:-}
+105 -8
View File
@@ -6,6 +6,8 @@
package main package main
import ( import (
"bytes"
"crypto/subtle"
"embed" "embed"
"encoding/json" "encoding/json"
"io/fs" "io/fs"
@@ -23,16 +25,34 @@ var webFS embed.FS
const localSub = "local" const localSub = "local"
type Config struct { type Config struct {
Addr string Addr string
PublicURL string PublicURL string
DataDir string DataDir string
SessionSecret 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 OIDCIssuer string
OIDCClientID string OIDCClientID string
OIDCClientSecret string OIDCClientSecret string
OIDCRedirect string OIDCRedirect string
ButtonLabel string ButtonLabel string
AllowedUsers []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 { func (c Config) RedirectURL() string {
@@ -62,6 +82,9 @@ func main() {
OIDCRedirect: env("HONEYOS_OIDC_REDIRECT_URL", ""), OIDCRedirect: env("HONEYOS_OIDC_REDIRECT_URL", ""),
ButtonLabel: env("HONEYOS_BUTTON_LABEL", "Anmelden"), ButtonLabel: env("HONEYOS_BUTTON_LABEL", "Anmelden"),
AllowedUsers: splitList(env("HONEYOS_ALLOWED_USERS", "")), 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 != "" { if cfg.OIDCIssuer != "" {
@@ -75,11 +98,23 @@ func main() {
case cfg.PublicURL == "" && cfg.OIDCRedirect == "": case cfg.PublicURL == "" && cfg.OIDCRedirect == "":
log.Fatal("HONEYOS_PUBLIC_URL fehlt — daraus entsteht die Rückrufadresse") 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 { } 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.") 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 { if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err) 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("GET /api/me", s.requireAuth(s.handleMe))
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings)) 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)) mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
web, err := fs.Sub(webFS, "web") web, err := fs.Sub(webFS, "web")
@@ -127,11 +162,43 @@ func (s *Server) routes() http.Handler {
log.Fatal(err) log.Fatal(err)
} }
files := http.FileServer(http.FS(web)) 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) 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 := `<meta name="honeyos-key" content="` + htmlAttr(s.cfg.AppsKey) + `" />`
return bytes.Replace(raw, []byte("</head>"), []byte(" "+meta+"\n </head>"), 1)
}
// htmlAttr entschärft den Wert für ein Attribut in doppelten Anführungszeichen.
func htmlAttr(v string) string {
r := strings.NewReplacer(`&`, "&amp;", `"`, "&quot;", `<`, "&lt;", `>`, "&gt;")
return r.Replace(v)
}
/* ── Zwischenschichten ───────────────────────────────────────────────────── */ /* ── Zwischenschichten ───────────────────────────────────────────────────── */
// cors öffnet die Leiste für die eingetragenen Apps — und nur für die. Die // 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 := w.Header()
h.Set("Access-Control-Allow-Origin", origin) h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Credentials", "true") 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.Set("Access-Control-Allow-Methods", "GET, PUT, POST")
h.Add("Vary", "Origin") h.Add("Vary", "Origin")
if r.Method == http.MethodOptions { 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 { func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
sub, ok := s.auth.SubFromRequest(r) sub, ok := s.auth.SubFromRequest(r)
+10 -1
View File
@@ -13,6 +13,10 @@
* data-Attribute am <script>: * data-Attribute am <script>:
* data-app (Pflicht) id der App, in der die Leiste steckt * data-app (Pflicht) id der App, in der die Leiste steckt
* data-apps URL der App-Liste; ohne das /api/apps neben dem Skript * data-apps URL der App-Liste; ohne das /api/apps neben dem Skript
* data-key Schlüssel für die App-Liste. Nötig, wenn HoneyOS auf einer
* anderen Site liegt als die App: dann schickt der Browser die
* HoneyOS-Sitzung nicht mit, und ohne zweiten Ausweis bliebe die
* Leiste ohne Liste.
* data-home URL des HoneyOS-Desktops * data-home URL des HoneyOS-Desktops
* data-push "body" (Standard) schiebt den Seiteninhalt nach unten, * data-push "body" (Standard) schiebt den Seiteninhalt nach unten,
* "none" legt die Leiste darüber * "none" legt die Leiste darüber
@@ -46,6 +50,7 @@
// geladen wurde. Er gibt die Liste nur an angemeldete Browser heraus und // geladen wurde. Er gibt die Liste nur an angemeldete Browser heraus und
// nur an Ursprünge, die selbst als App eingetragen sind. // nur an Ursprünge, die selbst als App eingetragen sind.
const appsUrl = d("apps", script ? new URL("../api/apps", script.src).href : ""); const appsUrl = d("apps", script ? new URL("../api/apps", script.src).href : "");
const appsKey = d("key", "");
let apps = DEFAULT_APPS; let apps = DEFAULT_APPS;
@@ -270,7 +275,11 @@
try { try {
// credentials: die Sitzung liegt auf der HoneyOS-Domain, und die ist // credentials: die Sitzung liegt auf der HoneyOS-Domain, und die ist
// dieselbe Site wie diese hier — sonst käme das Cookie nicht mit. // dieselbe Site wie diese hier — sonst käme das Cookie nicht mit.
const res = await fetch(appsUrl, { cache: "no-store", credentials: "include" }); const res = await fetch(appsUrl, {
cache: "no-store",
credentials: "include",
headers: appsKey ? { "X-HoneyOS-Key": appsKey } : {},
});
if (res.ok) { if (res.ok) {
const list = await res.json(); const list = await res.json();
if (Array.isArray(list) && list.length) { if (Array.isArray(list) && list.length) {
+5
View File
@@ -185,8 +185,13 @@ function glyphSvg(name) {
* ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer * ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer
* die Seite wirklich geöffnet hat. * die Seite wirklich geöffnet hat.
*/ */
/** Der Schlüssel für die App-Liste steht als Meta-Angabe in der Seite — der
* Server legt ihn dort hinein, wenn einer eingerichtet ist. */
const APPS_KEY = document.querySelector('meta[name="honeyos-key"]')?.content || "";
async function api(path, opts = {}) { async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) }; const headers = { ...(opts.headers || {}) };
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
if (opts.method && opts.method !== "GET") { if (opts.method && opts.method !== "GET") {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
const tok = cookie("honeyos_csrf"); const tok = cookie("honeyos_csrf");