df07085ecb
The initial scaffold left three holes the review caught. CI only checked gofmt and go vet, so golangci-lint and the pre-commit hooks were advisory rather than enforced. The HTTP server bounded only the header read, so a slow or stalled peer could hold a connection indefinitely. And the browser got no content-security policy at all, leaving the SPA's same-origin assumption unenforced. Add golangci-lint and pre-commit hook steps to the existing pre-commit workflow, mirroring the estate's images so the required context name stays ci/woodpecker/pr/pre-commit. Bound the server with ReadTimeout, WriteTimeout, and IdleTimeout, keeping the write budget generous enough for the poster proxy's streamed responses. Stamp Content-Security-Policy, X-Content-Type-Options, and Referrer-Policy onto every response from a single middleware wrapping the root handler. Assert the headers across the API, UI, assets, probes, and rejections. Guard the CSP's no-unsafe-inline assumption with a ui test that fails if a shipped asset grows an inline script, style block, or event handler. Extend the make pre-commit target to match the widened CI checks.
67 lines
1.9 KiB
Go
67 lines
1.9 KiB
Go
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 <style>, or a
|
|
// style= attribute would silently stop rendering in the browser, so guard the
|
|
// invariant here rather than discovering it in production.
|
|
func TestShippedAssetsCarryNothingInline(t *testing.T) {
|
|
banned := []struct {
|
|
what string
|
|
re *regexp.Regexp
|
|
}{
|
|
{"inline <style> block", regexp.MustCompile(`(?is)<style[\s>]`)},
|
|
{"style= attribute", regexp.MustCompile(`(?i)\sstyle\s*=`)},
|
|
{"inline event handler", regexp.MustCompile(`(?i)\son(?:click|load|error|submit|change|input|keydown)\s*=`)},
|
|
}
|
|
scriptBody := regexp.MustCompile(`(?is)<script[^>]*>(.*?)</script>`)
|
|
|
|
assets := Assets()
|
|
err := fs.WalkDir(assets, ".", func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() || !strings.HasSuffix(p, ".html") {
|
|
return err
|
|
}
|
|
b, err := fs.ReadFile(assets, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, ban := range banned {
|
|
if ban.re.Match(b) {
|
|
t.Errorf("%s contains an %s, which the CSP forbids", p, ban.what)
|
|
}
|
|
}
|
|
for _, m := range scriptBody.FindAllSubmatch(b, -1) {
|
|
if len(strings.TrimSpace(string(m[1]))) > 0 {
|
|
t.Errorf("%s contains an inline <script> body, which the CSP forbids", p)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// The favicon is a data: URI, which is why img-src carries data:. If it ever
|
|
// stops being one, img-src should tighten back to 'self'.
|
|
func TestFaviconIsTheOnlyDataURI(t *testing.T) {
|
|
b, err := fs.ReadFile(Assets(), "index.html")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
if !strings.Contains(line, "data:") {
|
|
continue
|
|
}
|
|
if !strings.Contains(line, `rel="icon"`) {
|
|
t.Errorf("unexpected data: URI outside the favicon: %s", strings.TrimSpace(line))
|
|
}
|
|
}
|
|
}
|