feat(honeybar): HoneyOS-Leiste einbinden, CSP-Ausnahme dafür

Die Leiste kommt als Skript von der HoneyOS-Domain und holt sich von dort
ihre App-Liste. Beides blockte die CSP bisher (script-src / connect-src
fielen auf 'self' zurueck) – ohne Ausnahme erschien sie gar nicht.

- HONEYBAR_ORIGIN nimmt genau diesen einen Ursprung in script-src und
  connect-src auf. Leer heisst: keine Leiste, und die Regel bleibt so eng
  wie bisher. Frame-Freigabe erbt sie ausdruecklich nicht.
- SecurityHeaders nimmt die Ausnahmen jetzt als CSP-Struct statt als
  variadische frameSrc-Liste – mit drei Richtungen wurde das sonst unklar.
- Das <script>-Tag steht in der index.html und nicht serverseitig
  eingespritzt: der Service Worker legt die index.html in seinen Vorrat
  (navigateFallback), was zur Bauzeit fehlt, kaeme im Browser nie an.
- defer, damit ein langsames oder abgeschaltetes HoneyOS den Start von
  Aether nicht ausbremst.

Tests fuer die neue Regel: eng ohne Ausnahme, beide Richtungen mit,
Pfad wird auf den Ursprung gekuerzt, Adresse ohne Schema faellt raus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-24 21:03:01 +02:00
1 parent b5a83e4057
commit 9b8e82cdb2
6 files changed
+164 -17

No files matched your search

+7 -1
View File
@@ -314,7 +314,13 @@ func main() {
r.Use(chimiddleware.RequestID)
r.Use(middleware.TrustedProxy(cfg))
r.Use(middleware.Logger)
r.Use(middleware.SecurityHeaders(officeFrameSrc))
r.Use(middleware.SecurityHeaders(middleware.CSP{
FrameSrc: []string{officeFrameSrc},
// Die HoneyOS-Leiste kommt als Skript von dort und holt sich von dort
// auch ihre App-Liste. Leer = keine Leiste und die alte, engere Regel.
ScriptSrc: []string{cfg.HoneybarOrigin},
ConnectSrc: []string{cfg.HoneybarOrigin},
}))
r.Use(chimiddleware.Recoverer)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
+9
View File
@@ -78,6 +78,14 @@ type Config struct {
// eigenen internen Namen – die Adresse muss also umgeschrieben werden.
// Leer heißt: dieselbe wie OfficeURL.
OfficePublicURL string
// HoneybarOrigin ist der Ursprung, von dem die HoneyOS-Leiste geladen wird
// (z. B. https://os.example.com). Er wird in die CSP aufgenommen — ohne das
// blockt der Browser das Skript und dessen Anfragen.
//
// Muss zur Adresse im <script>-Tag der index.html passen. Leer heißt: keine
// Leiste, und die CSP bleibt so eng wie bisher.
HoneybarOrigin string
// OfficeWopiURL ist die Adresse, unter der der **Office-Server** Aether
// erreicht.
//
@@ -180,6 +188,7 @@ func Load() (*Config, error) {
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"),
OfficeAllowedHosts: splitList(getEnv("OFFICE_ALLOWED_HOSTS", "")),
OfficeInsecureTLS: getEnv("OFFICE_INSECURE_TLS", "false") == "true",
+42 -16
View File
@@ -6,26 +6,52 @@ import (
"strings"
)
// CSP nennt die Ursprünge, die zusätzlich zu 'self' erlaubt sind.
//
// Jedes Feld ist eine bewusst enge Ausnahme mit einem konkreten Anlass. Leer
// heißt: keine Ausnahme — dann gilt für diese Richtung allein 'self'.
type CSP struct {
// FrameSrc: Seiten, die in einem <iframe> erscheinen dürfen.
// Gebraucht für den Office-Server; `default-src 'self'` deckt auch
// `frame-src` ab, ohne Ausnahme blockt der Browser ihn also, noch bevor
// eine Anfrage hinausgeht. Gemessen als „Framing 'http://…:9980/'
// violates … default-src 'self'".
FrameSrc []string
// ScriptSrc: fremde Ursprünge, von denen Skripte geladen werden dürfen.
// ConnectSrc: fremde Ursprünge, die per fetch/XHR erreichbar sein sollen.
// Beide gebraucht für eine eingebettete Leiste, die von woanders kommt und
// sich ihre Daten selbst holt — das Skript über das eine, die Anfrage über
// das andere. Nur eines von beiden zu setzen ergibt selten Sinn.
ScriptSrc []string
ConnectSrc []string
}
// SecurityHeaders adds security headers that the app controls.
// HSTS is intentionally omitted — it belongs on the edge reverse proxy.
//
// frameSrc nennt zusätzliche Ursprünge, die in einem Rahmen erscheinen dürfen.
// Gebraucht für den Office-Server: Seine Bedienoberfläche läuft in einem
// <iframe>, und `default-src 'self'` deckt auch `frame-src` ab – ohne
// Ausnahme blockt der Browser sie, noch bevor eine Anfrage hinausgeht.
// Gemessen als „Framing 'http://…:9980/' violates … default-src 'self'".
//
// Bewusst **nur der eine konfigurierte Ursprung** und kein Platzhalter: Die
// Ausnahme soll so schmal sein wie der Anlass.
func SecurityHeaders(frameSrc ...string) func(http.Handler) http.Handler {
frame := "frame-src 'self'"
for _, raw := range frameSrc {
if origin := originOf(raw); origin != "" {
frame += " " + origin
// Ausnahmen werden **einzeln benannt** und nie als Platzhalter aufgenommen:
// Sie sollen so schmal sein wie ihr Anlass. Ein Eintrag, der sich nicht als
// Adresse lesen lässt, wird verworfen statt durchgereicht.
func SecurityHeaders(csp CSP) func(http.Handler) http.Handler {
directive := func(name string, extra []string) string {
out := name + " 'self'"
for _, raw := range extra {
if origin := originOf(raw); origin != "" {
out += " " + origin
}
}
return out
}
csp := "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; " + frame
policy := strings.Join([]string{
"default-src 'self'",
directive("script-src", csp.ScriptSrc),
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
directive("connect-src", csp.ConnectSrc),
directive("frame-src", csp.FrameSrc),
}, "; ")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -33,7 +59,7 @@ func SecurityHeaders(frameSrc ...string) func(http.Handler) http.Handler {
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Content-Security-Policy", csp)
h.Set("Content-Security-Policy", policy)
next.ServeHTTP(w, r)
})
}
+83
View File
@@ -0,0 +1,83 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func policyFor(t *testing.T, csp CSP) string {
t.Helper()
h := SecurityHeaders(csp)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
return rec.Header().Get("Content-Security-Policy")
}
// Ohne Ausnahmen darf nichts Fremdes erlaubt sein — das ist der Normalfall
// und der Zustand, in dem eine Instanz ohne Zusatzkonfiguration läuft.
func TestCSPOhneAusnahmenBleibtEng(t *testing.T) {
got := policyFor(t, CSP{})
for _, want := range []string{
"default-src 'self'",
"script-src 'self';",
"connect-src 'self';",
"frame-src 'self'",
} {
if !strings.Contains(got+";", want) {
t.Errorf("Regel %q fehlt in %q", want, got)
}
}
if strings.Contains(got, "*") {
t.Errorf("Platzhalter in der Regel: %q", got)
}
}
// Die eingebettete Leiste braucht zwei Richtungen: das Skript selbst und die
// Anfrage, mit der es sich seine Daten holt.
func TestCSPLeisteBekommtSkriptUndVerbindung(t *testing.T) {
const bar = "https://os.example.com"
got := policyFor(t, CSP{ScriptSrc: []string{bar}, ConnectSrc: []string{bar}})
if !strings.Contains(got, "script-src 'self' "+bar) {
t.Errorf("script-src erlaubt die Leiste nicht: %q", got)
}
if !strings.Contains(got, "connect-src 'self' "+bar) {
t.Errorf("connect-src erlaubt die Anfrage nicht: %q", got)
}
// Die Ausnahme gilt nur, wofür sie gedacht ist.
if strings.Contains(got, "frame-src 'self' "+bar) {
t.Errorf("die Leiste darf keine Rahmen-Freigabe erben: %q", got)
}
}
// Ein Pfad in der Konfiguration wäre enger als gemeint; ein Tippfehler ohne
// Schema soll gar nicht erst in die Regel geraten.
func TestCSPKürztAufDenUrsprungUndVerwirftUnsinn(t *testing.T) {
got := policyFor(t, CSP{ScriptSrc: []string{"https://os.example.com/honeybar/honeybar.js"}})
if !strings.Contains(got, "script-src 'self' https://os.example.com;") {
t.Errorf("Pfad nicht abgeschnitten: %q", got)
}
got = policyFor(t, CSP{ScriptSrc: []string{"os.example.com", "", " "}})
if !strings.Contains(got+";", "script-src 'self';") {
t.Errorf("Adresse ohne Schema wurde übernommen: %q", got)
}
}
func TestSicherheitsköpfeStehen(t *testing.T) {
h := SecurityHeaders(CSP{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
for k, want := range map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
} {
if got := rec.Header().Get(k); got != want {
t.Errorf("%s = %q, want %q", k, got, want)
}
}
}
+8
View File
@@ -64,6 +64,14 @@ ADMIN_PASSWORD=changeme_strong_admin_password
# Beschriftung des Knopfes auf der Anmeldeseite.
# OIDC_BUTTON_LABEL=Anmelden mit PocketID
# ── HoneyOS-Leiste ────────────────────────────────────────────────────────────
# Ursprung, von dem die Leiste geladen wird. Nimmt ihn in die CSP auf
# (script-src + connect-src) — ohne das blockt der Browser das Skript.
#
# Muss exakt zur Adresse im <script>-Tag der frontend/index.html passen.
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
HONEYBAR_ORIGIN=https://os.example.com
# ── Volltextsuche (OCR) ───────────────────────────────────────────────────────
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
# OCR_ENABLED=false
+15
View File
@@ -18,5 +18,20 @@
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- HoneyOS-Leiste: der Umschalter zwischen den eigenen Apps. Sie rendert
in einem Shadow DOM, Aethers Stile berührt sie nicht.
Sie steht hier statt serverseitig eingespritzt, weil der Service Worker
die index.html in seinen Vorrat legt (navigateFallback) — was zur
Bauzeit nicht drinsteht, käme beim Browser nie an.
`defer`: ohne wartet der Seitenaufbau auf das Skript. Ist HoneyOS
langsam oder aus, darf das Aether nicht ausbremsen.
Damit der Browser sie überhaupt lädt, muss HONEYBAR_ORIGIN in der .env
auf denselben Ursprung zeigen — sonst blockt die CSP. -->
<script defer src="https://os.honeyy.duckdns.org/honeybar/honeybar.js"
data-app="aether"></script>
</body>
</html>