a73x

internal/server/web/csp.go

Ref:   Size: 1.8 KiB   History

package web

import (
	"crypto/sha256"
	"encoding/base64"
	"io/fs"
	"regexp"
	"strings"
	"sync"
)

// inlineScript matches a <script> element that carries its code inline. One with
// a src= attribute is skipped: it loads from 'self' and needs no hash.
var inlineScript = regexp.MustCompile(`(?s)<script([^>]*)>(.*?)</script>`)

var (
	hashOnce sync.Once
	hashes   []string
)

// InlineScriptHashes returns CSP source tokens ("sha256-…") covering every
// inline script in the built index.html.
//
// The SPA's entry point is one inline block SvelteKit writes at build time, and
// its contents change on every build (it names a fresh global). Hashing the
// bytes actually embedded — rather than pinning a literal or giving up and
// allowing 'unsafe-inline' — keeps script-src strict across rebuilds with
// nothing for an operator to remember to update.
//
// A hash present in script-src is also what makes CSP ignore 'unsafe-inline',
// so this is the difference between a policy that blocks injected script and
// one that only looks like it does.
func InlineScriptHashes() []string {
	hashOnce.Do(func() { hashes = scanInlineScripts(dist, "dist/index.html") })
	return hashes
}

// scanInlineScripts is the testable half: it takes the filesystem and path so a
// test can hash a document it wrote itself. A missing or unreadable index.html
// yields no hashes — the UI is not built, and there is nothing to allow.
func scanInlineScripts(fsys fs.FS, path string) []string {
	raw, err := fs.ReadFile(fsys, path)
	if err != nil {
		return nil
	}
	var out []string
	for _, m := range inlineScript.FindAllSubmatch(raw, -1) {
		if strings.Contains(strings.ToLower(string(m[1])), "src=") {
			continue
		}
		if len(m[2]) == 0 {
			continue
		}
		sum := sha256.Sum256(m[2])
		out = append(out, "sha256-"+base64.StdEncoding.EncodeToString(sum[:]))
	}
	return out
}