diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml index 99a6341..8793790 100644 --- a/.woodpecker/pre-commit.yaml +++ b/.woodpecker/pre-commit.yaml @@ -17,3 +17,33 @@ steps: limits: memory: 2Gi cpu: 2 + + - name: lint + image: golangci/golangci-lint:latest + commands: + - golangci-lint run ./... + backend_options: + kubernetes: + serviceAccountName: mediamark-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 + + - name: hooks + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: mediamark-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Makefile b/Makefile index 5b3365d..069339d 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,8 @@ all: build pre-commit: test -z "$$(gofmt -l .)" go vet ./... + golangci-lint run ./... + uvx pre-commit run --all-files build: @for b in $(BINARIES); do \ diff --git a/cmd/mediamark/main.go b/cmd/mediamark/main.go index 716bca2..086b851 100644 --- a/cmd/mediamark/main.go +++ b/cmd/mediamark/main.go @@ -42,6 +42,11 @@ func main() { Addr: cfg.Listen, Handler: server.New(cfg, lib, arrs, ui.Assets(), log).Handler(), ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + // The art proxy streams posters upstream-to-browser, so the write budget + // is the generous one. + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/internal/server/server.go b/internal/server/server.go index 57d656d..52a2344 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -62,7 +62,24 @@ func (s *Server) Handler() http.Handler { gated.HandleFunc("/", s.handleUI) mux.Handle("/", s.gate.Wrap(gated)) - return mux + return secureHeaders(mux) +} + +// cspPolicy locks the page to same-origin code. The SPA carries no inline +// script or style, so no unsafe-inline escape hatch is needed; data: is in +// img-src solely for the inline SVG favicon. +const cspPolicy = "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'" + +// secureHeaders stamps the browser-facing hardening headers onto every +// response — API, UI and probes alike — before the handler writes. +func secureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", cspPolicy) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) } func (s *Server) readyz(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1e94bc4..f4b9aad 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -375,3 +375,33 @@ func TestSPAFallbackAndAssets(t *testing.T) { t.Errorf("unknown api route = %d %q", rec.Code, rec.Body) } } + +// The hardening headers must ride on every response: the page shell, static +// assets, the JSON API, the ungated probes, and rejections alike. +func TestSecurityHeadersOnEveryResponse(t *testing.T) { + e := newEnv(t, nil) + want := map[string]string{ + "Content-Security-Policy": "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + } + check := func(what string, rec *httptest.ResponseRecorder) { + t.Helper() + for k, v := range want { + if got := rec.Header().Get(k); got != v { + t.Errorf("%s: %s = %q, want %q", what, k, got, v) + } + } + } + for _, p := range []string{"/", "/movies", "/app.js", "/api/library/movies", "/livez", "/readyz"} { + check(p, e.do(t, http.MethodGet, p)) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("unauthorized = %d, want 403", rec.Code) + } + check("403", rec) +} diff --git a/ui/embed_test.go b/ui/embed_test.go new file mode 100644 index 0000000..de099be --- /dev/null +++ b/ui/embed_test.go @@ -0,0 +1,66 @@ +package ui + +import ( + "io/fs" + "regexp" + "strings" + "testing" +) + +// The server ships a CSP of script-src 'self'; style-src 'self' with no +// unsafe-inline. Assets that grow an inline script, an inline