test-web / test (push) Successful in 55s
test-go / test (push) Successful in 1m14s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s
test-go / integration (push) Canceled after 2m50s
There were no security headers at all. Now: - every response: nosniff, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy (no camera/mic/geolocation), X-Frame-Options DENY; each set only if the handler hasn't. - HSTS only when the trusted proxy reports HTTPS (rule 94); never a redirect. - index.html carries a Content-Security-Policy whose script-src is 'self' plus the sha256 of each inline script in the page as served, computed after the branding template runs. No 'unsafe-inline' or 'unsafe-eval' for scripts. img-src admits remote https/http because Lidarr suggestion art is a remote poster URL. Hashing in Go rather than via SvelteKit's kit.csp covers the inline scripts SvelteKit doesn't know about (app.html's theme bootstrap and the branding global injected at build) and stays correct whatever the app name is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
4.5 KiB
Go
129 lines
4.5 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
)
|
|
|
|
func okHandler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Body != nil {
|
|
if _, err := io.ReadAll(r.Body); err != nil {
|
|
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
}
|
|
|
|
func TestLimitRequestBody_RejectsOversizedBody(t *testing.T) {
|
|
h := limitRequestBody(okHandler())
|
|
|
|
big := strings.NewReader(strings.Repeat("x", maxRequestBody+1))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", big))
|
|
if w.Code != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("oversized body: status = %d, want the handler's read to fail", w.Code)
|
|
}
|
|
|
|
small := strings.NewReader(`{"name":"ok"}`)
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", small))
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("small body: status = %d, want 204", w.Code)
|
|
}
|
|
}
|
|
|
|
// The cases a cross-site form or no-preflight fetch could produce must be
|
|
// refused when the session cookie rides along; everything a real client
|
|
// sends must pass.
|
|
func TestRequireJSONForCookieWrites(t *testing.T) {
|
|
cookie := &http.Cookie{Name: auth.SessionCookieName, Value: "tok"}
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
contentType string
|
|
body string
|
|
withCookie bool
|
|
want int
|
|
}{
|
|
{"form post with cookie", http.MethodPost, "/api/me/password", "application/x-www-form-urlencoded", "a=b", true, http.StatusUnsupportedMediaType},
|
|
{"text/plain post with cookie", http.MethodPost, "/api/me/password", "text/plain", `{"a":1}`, true, http.StatusUnsupportedMediaType},
|
|
{"multipart with cookie", http.MethodPut, "/api/me/profile", "multipart/form-data; boundary=x", "--x--", true, http.StatusUnsupportedMediaType},
|
|
{"body without content type", http.MethodPost, "/api/me/profile", "", `{"a":1}`, true, http.StatusUnsupportedMediaType},
|
|
|
|
{"json with cookie", http.MethodPost, "/api/me/password", "application/json", `{}`, true, http.StatusNoContent},
|
|
{"json with charset", http.MethodPost, "/api/me/password", "application/json; charset=utf-8", `{}`, true, http.StatusNoContent},
|
|
{"bodiless delete with cookie", http.MethodDelete, "/api/me/sessions/1", "", "", true, http.StatusNoContent},
|
|
{"form post without cookie (bearer client)", http.MethodPost, "/api/me/password", "text/plain", "x", false, http.StatusNoContent},
|
|
{"subsonic post is not /api", http.MethodPost, "/rest/scrobble", "application/x-www-form-urlencoded", "id=1", true, http.StatusNoContent},
|
|
{"GET is never checked", http.MethodGet, "/api/me", "text/plain", "", true, http.StatusNoContent},
|
|
}
|
|
h := requireJSONForCookieWrites(okHandler())
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var body io.Reader
|
|
if tc.body != "" {
|
|
body = strings.NewReader(tc.body)
|
|
}
|
|
req := httptest.NewRequest(tc.method, tc.path, body)
|
|
if tc.contentType != "" {
|
|
req.Header.Set("Content-Type", tc.contentType)
|
|
}
|
|
if tc.withCookie {
|
|
req.AddCookie(cookie)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Code != tc.want {
|
|
t.Errorf("status = %d, want %d", w.Code, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityHeaders(t *testing.T) {
|
|
serve := func(hops int, proto string) http.Header {
|
|
h := securityHeaders(func() int { return hops })(okHandler())
|
|
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
|
if proto != "" {
|
|
req.Header.Set("X-Forwarded-Proto", proto)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
return w.Header()
|
|
}
|
|
|
|
base := serve(0, "")
|
|
for key, want := range map[string]string{
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
"X-Frame-Options": "DENY",
|
|
} {
|
|
if got := base.Get(key); got != want {
|
|
t.Errorf("%s = %q, want %q", key, got, want)
|
|
}
|
|
}
|
|
if !strings.Contains(base.Get("Permissions-Policy"), "microphone=()") {
|
|
t.Errorf("Permissions-Policy = %q", base.Get("Permissions-Policy"))
|
|
}
|
|
|
|
// Rule 94's three cases for HSTS.
|
|
if got := serve(0, "https").Get("Strict-Transport-Security"); got != "" {
|
|
t.Errorf("hops 0 + forwarded https: HSTS = %q, want none", got)
|
|
}
|
|
if got := serve(1, "https").Get("Strict-Transport-Security"); got == "" {
|
|
t.Error("hops 1 + forwarded https: want HSTS")
|
|
}
|
|
if got := serve(1, "").Get("Strict-Transport-Security"); got != "" {
|
|
t.Errorf("plain request: HSTS = %q, want none", got)
|
|
}
|
|
}
|