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
# ── 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 <script>-Tag der App:
# <script defer src="https://honeyos.duckdns.org/honeybar/honeybar.js"
# data-app="aether" data-key="…"></script>
#
# 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
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**
`<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_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
<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
+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.
//
// 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 {
+154
View File
@@ -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)
}
}
+3
View File
@@ -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:-}
+105 -8
View File
@@ -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 := `<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 ───────────────────────────────────────────────────── */
// 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)
+10 -1
View File
@@ -13,6 +13,10 @@
* data-Attribute am <script>:
* 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-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-push "body" (Standard) schiebt den Seiteninhalt nach unten,
* "none" legt die Leiste darüber
@@ -46,6 +50,7 @@
// geladen wurde. Er gibt die Liste nur an angemeldete Browser heraus und
// nur an Ursprünge, die selbst als App eingetragen sind.
const appsUrl = d("apps", script ? new URL("../api/apps", script.src).href : "");
const appsKey = d("key", "");
let apps = DEFAULT_APPS;
@@ -270,7 +275,11 @@
try {
// credentials: die Sitzung liegt auf der HoneyOS-Domain, und die ist
// 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) {
const list = await res.json();
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
* 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 = {}) {
const headers = { ...(opts.headers || {}) };
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
if (opts.method && opts.method !== "GET") {
headers["Content-Type"] = "application/json";
const tok = cookie("honeyos_csrf");