ADR 0515 §6: /static/app.js and /static/app.css are built in and served with their types and nosniff; the policy allows script and style from the origin alone and connect-src 'self'; no page carries an inline script, a style or a handler. The script posts the page's forms without a reload and says a failure on the page.
78 lines
3.4 KiB
Go
78 lines
3.4 KiB
Go
package web
|
|
|
|
// Issue 1859/AC4 (novox/hq ADR 0515 §6): one script and one stylesheet, from the page's own origin; the
|
|
// content security policy allows script and style from it alone, and connect-src 'self'; no inline script
|
|
// and no style attribute on any page.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
inlineScript = regexp.MustCompile(`(?i)<script(\s[^>]*)?>`)
|
|
styleAttr = regexp.MustCompile(`(?i)\sstyle\s*=|<style[\s>]|\son[a-z]+\s*=`)
|
|
)
|
|
|
|
// 1859/AC4.
|
|
func TestEveryPageLoadsScriptAndStyleFromItsOwnOriginAlone(t *testing.T) {
|
|
b := (&readingStandIn{list: fullList()}).bundle(time.Date(2026, 10, 22, 7, 45, 0, 0, time.UTC))
|
|
accountsRaw, _ := accountsAnswerJSON(map[string]any{"account": "work", "kind": "imap", "address": "op@work.example", "state": "current"})
|
|
b.Accounts = func(context.Context) (json.RawMessage, error) { return accountsRaw, nil }
|
|
b.AccountBucket = (&fakeAccountsBucket{}).get
|
|
b.MailLessons = seatAskJSON(t, map[string]any{"lessons": []any{}})
|
|
b.MailGroups = seatAskJSON(t, map[string]any{"groups": []any{}})
|
|
b.Search = seatAskJSON(t, map[string]any{"found": []any{}})
|
|
b.Read = seatAskJSON(t, map[string]any{"account": "work", "folder": "INBOX", "uid": 101, "subject": "s", "text": "t"})
|
|
s := NewServer(testHost, nil, b)
|
|
for _, path := range []string{"/", "/all", "/folder?account=work&folder=INBOX", "/search?q=x", "/mail?account=work&folder=INBOX&uid=101", "/write"} {
|
|
code, body, h := get(t, s, path)
|
|
csp := h.Get("Content-Security-Policy")
|
|
for _, want := range []string{"script-src 'self'", "style-src 'self'", "connect-src 'self'", "default-src 'self'"} {
|
|
if !strings.Contains(csp, want) {
|
|
t.Fatalf("%s: the policy %q does not hold %q", path, csp, want)
|
|
}
|
|
}
|
|
for _, tag := range inlineScript.FindAllString(body, -1) {
|
|
if !strings.Contains(tag, `src="/static/app.js"`) {
|
|
t.Fatalf("%s: a script that is not /static/app.js: %s", path, tag)
|
|
}
|
|
}
|
|
if m := styleAttr.FindString(body); m != "" {
|
|
t.Fatalf("%s (status %d): an inline style or handler %q:\n%s", path, code, m, body)
|
|
}
|
|
}
|
|
_, body, _ := get(t, s, "/")
|
|
if !strings.Contains(body, `<link rel="stylesheet" href="/static/app.css">`) || !strings.Contains(body, `<script src="/static/app.js" defer></script>`) {
|
|
t.Fatalf("the list page does not load the one stylesheet and the one script:\n%s", body)
|
|
}
|
|
for path, typ := range map[string]string{"/static/app.js": "text/javascript; charset=utf-8", "/static/app.css": "text/css; charset=utf-8"} {
|
|
code, body, h := get(t, s, path)
|
|
if code != http.StatusOK || h.Get("Content-Type") != typ || h.Get("X-Content-Type-Options") != "nosniff" || len(body) < 100 {
|
|
t.Fatalf("%s: status %d, type %q, nosniff %q, %d bytes; want 200, %s, nosniff, the file", path, code,
|
|
h.Get("Content-Type"), h.Get("X-Content-Type-Options"), len(body), typ)
|
|
}
|
|
}
|
|
if code, _, _ := get(t, s, "/static/other.js"); code != http.StatusNotFound {
|
|
t.Fatalf("/static/other.js: status %d, want 404 — the two files alone are served", code)
|
|
}
|
|
}
|
|
|
|
// 1859/AC4: the script talks only to its own origin: no address of another in it.
|
|
func TestTheScriptNamesNoOtherOrigin(t *testing.T) {
|
|
js, err := staticFiles.ReadFile("static/app.js")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, word := range []string{"http://", "https://", "//cdn", "eval(", "innerHTML ="} {
|
|
if strings.Contains(string(js), word) {
|
|
t.Fatalf("app.js holds %q", word)
|
|
}
|
|
}
|
|
}
|