package web import ( "crypto/sha256" "encoding/base64" "regexp" "strings" ) // inlineScriptRe matches each `) 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'", }, "; ") }