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:
1 parent
b5a83e4057
commit
9b8e82cdb2
6 files changed
+164
-17
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user