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