Verdande og Urd er to af nornerne i Völuspá, og digtet har dem skære
mærker i træ: *skáru á skíði*. Verdande er det der bliver; Urd er det der
blev, og blev gemt. En instans startet uden opgaveruterne er et andet
produkt for den der bruger den, så den siger et andet navn.
Navnet læses fra udgaven i ÉT sted — Config.ProductName/ProductSlug —
frem for at stå skrevet i teksterne. Frontend-bygget er byte-identisk i
begge udgaver; serveren omdøber de to filer, browseren læser før noget
JavaScript kører.
Der er to titler, og det blev først klart, da e2e-prøven sagde det:
skallens <title>, som serveren omdøber, og rutens egen i <svelte:head>,
som appen sætter bagefter og som overskriver den første. Go-prøven var
grøn over de bytes, serveren sendte, mens fanen sagde "Noter · verdande".
Begge prøver spørger nu om begge.
Omdøbningen er ankret på hele strenge frem for på ordet "verdande", fordi
app.html også indeholder localStorage.getItem('verdande:theme'). Omdøbes
præfikset der og ikke i resten af appen, bliver temaet sat fra én nøgle og
læst fra en anden, og det eneste symptom er det hvide blink, det
indlejrede script findes for at undgå. Tre plantede fejl bekræfter, at det
er de rigtige prøver, der fanger det: et flyttet anker i app.html, en bred
søg-erstat, og en applyRebrands uden fejlretur.
Mailen var det vigtigste sted. appName var et felt med en værdi og ingen
læser, og kommentaren ved siden af sagde, at det stod i hver besked — de
tre steder havde ordet skrevet ud i stedet. En invitation fra en
urd-instans går til et andet menneskes indbakke.
Også omdøbt: web-manifestet (og sendt som application/manifest+json — Go
kender ikke .webmanifest og sniffer den til text/plain, hvorefter
browseren ignorerer manifestet uden at sige noget, og installationsknappen
bare ikke er der), mærket i menuen (ᚢ uruz frem for ᚹ wunjo),
opstartsloggen, passkey-promptens visningsnavn, ICS-kalendernavnet og
udseendet, der er opkaldt efter programmet.
Ikonet er IKKE med: icon.svg er stadig ᚹ, og de tre PNG'er genereres fra
den. Det rører binære, genererede filer og hører i sin egen ændring.
Co-Authored-By: Claude Opus 5 <[email protected]>
98 lines
3.5 KiB
Go
98 lines
3.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"io/fs"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// inlineScript matches a <script> block that has no src attribute — that is, one
|
|
// whose body is executed inline and therefore needs to be named in the CSP.
|
|
var inlineScript = regexp.MustCompile(`(?is)<script(?:\s[^>]*)?>(.*?)</script>`)
|
|
|
|
// hasSrc distinguishes <script src="..."> from an inline block. Only the latter
|
|
// needs a hash; the former is already covered by 'self'.
|
|
var hasSrc = regexp.MustCompile(`(?is)^<script[^>]*\ssrc\s*=`)
|
|
|
|
// scriptHashes returns the CSP source expressions for every inline script in the
|
|
// built index.html.
|
|
//
|
|
// This is computed at startup from the shipped file rather than written down as a
|
|
// constant, because both inline scripts change: SvelteKit's bootstrap is rebuilt on
|
|
// every frontend build, and the theme script in app.html changes whenever it is
|
|
// edited. A hard-coded hash would go stale silently and the symptom would be a
|
|
// blank page in production only — the CSP is not enforced when the page is opened
|
|
// from a file, and a stale hash never fails a test.
|
|
//
|
|
// The alternative, 'unsafe-inline', would drop the one protection that makes an
|
|
// injected <script> in a task title inert. Two hashes are cheaper than that.
|
|
func scriptHashes(web fs.FS) []string {
|
|
if web == nil {
|
|
return nil
|
|
}
|
|
raw, err := fs.ReadFile(web, "index.html")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return scriptHashesIn(raw)
|
|
}
|
|
|
|
// scriptHashesIn is the same thing over bytes that are already in hand.
|
|
//
|
|
// It exists because the notes edition does not serve the index.html that is on
|
|
// disk — it serves a renamed copy of it (see shell.go) — and the policy has to
|
|
// describe the bytes that actually leave the server. Hashing the file while
|
|
// serving something else is the shape of mistake that cannot be noticed locally:
|
|
// the policy is not enforced for a page opened from a file, so the page is blank
|
|
// in production and nowhere else.
|
|
func scriptHashesIn(raw []byte) []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
|
|
tag := string(raw[match[0]:match[1]])
|
|
if hasSrc.MatchString(tag) {
|
|
continue
|
|
}
|
|
body := string(raw[match[2]:match[3]])
|
|
if strings.TrimSpace(body) == "" {
|
|
continue
|
|
}
|
|
// The hash is over the script's exact contents, byte for byte — including
|
|
// leading and trailing whitespace, which is why the body is not trimmed.
|
|
sum := sha256.Sum256([]byte(body))
|
|
expr := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'"
|
|
if !seen[expr] {
|
|
seen[expr] = true
|
|
out = append(out, expr)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// contentSecurityPolicy assembles the policy served with every response.
|
|
//
|
|
// It is strict because verdande loads nothing from anywhere else: no CDN, no
|
|
// analytics, no webfont. The one concession is style-src 'unsafe-inline', which
|
|
// Svelte's scoped styles and the inline `style=` on an avatar colour both need;
|
|
// inline CSS cannot execute, so it is a far smaller allowance than the script
|
|
// equivalent.
|
|
func contentSecurityPolicy(hashes []string) string {
|
|
scriptSrc := append([]string{"'self'"}, hashes...)
|
|
|
|
return strings.Join([]string{
|
|
"default-src 'self'",
|
|
"script-src " + strings.Join(scriptSrc, " "),
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data: blob:",
|
|
"font-src 'self'",
|
|
// The WebSocket is same-origin, so 'self' covers ws: and wss: to this host.
|
|
"connect-src 'self'",
|
|
"form-action 'self'",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'none'",
|
|
"object-src 'none'",
|
|
}, "; ")
|
|
}
|