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

+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)
}
}