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>
58 lines
2.1 KiB
Go
58 lines
2.1 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// inlineScriptRe matches each <script> element and captures its attributes
|
|
// and body. index.html is our own build output, so a regexp is enough: there
|
|
// is no hostile markup to out-parse.
|
|
var inlineScriptRe = regexp.MustCompile(`(?is)<script\b([^>]*)>(.*?)</script>`)
|
|
|
|
var srcAttrRe = regexp.MustCompile(`(?i)\bsrc\s*=`)
|
|
|
|
// contentSecurityPolicy builds the policy served with index.html.
|
|
//
|
|
// Scripts are allowed from our own origin plus the exact inline scripts the
|
|
// page carries: the theme bootstrap in app.html, the branding global the Vite
|
|
// plugin injects, and SvelteKit's start-up block. Their hashes are taken from
|
|
// the page AFTER the branding template has run, so they match the bytes the
|
|
// browser actually receives, whatever the operator's app name. Any script
|
|
// that differs, including one injected through an XSS, is refused.
|
|
//
|
|
// The rest:
|
|
// - style-src allows inline styles: Svelte transitions and style:
|
|
// directives write them, and inline style is not a script vector.
|
|
// - img-src admits https:/http: because Lidarr suggestion art is a remote
|
|
// poster URL. Images cannot run code.
|
|
// - media-src/connect-src stay on our own origin: streams, the API and the
|
|
// SSE stream are all same-origin. blob: covers Web Audio and object URLs.
|
|
func contentSecurityPolicy(indexHTML []byte) string {
|
|
scriptSrc := []string{"'self'"}
|
|
for _, m := range inlineScriptRe.FindAllSubmatch(indexHTML, -1) {
|
|
if srcAttrRe.Match(m[1]) {
|
|
continue
|
|
}
|
|
sum := sha256.Sum256(m[2])
|
|
scriptSrc = append(scriptSrc, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'")
|
|
}
|
|
return strings.Join([]string{
|
|
"default-src 'self'",
|
|
"base-uri 'self'",
|
|
"object-src 'none'",
|
|
"frame-ancestors 'none'",
|
|
"form-action 'self'",
|
|
"script-src " + strings.Join(scriptSrc, " "),
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data: blob: https: http:",
|
|
"font-src 'self' data:",
|
|
"media-src 'self' blob:",
|
|
"connect-src 'self'",
|
|
"worker-src 'self' blob:",
|
|
"manifest-src 'self'",
|
|
}, "; ")
|
|
}
|