Server 2.6.1: code field upper-cases and inserts the dash

The phone pages' code field gets one inline script, allowed by its
SHA-256 hash in the CSP; a test checks the served script against the
header. Image pinned by digest in k8s/server.yaml (not applied).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-19 08:19:00 +02:00
co-authored by Claude Opus 5.5
parent 31ea4e7d78
commit bae56bec2f
5 changed files with 63 additions and 8 deletions
+2 -2
View File
@@ -111,7 +111,7 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
<form method="post" action="/settings" autocomplete="off">
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
{{else}}<label for="code">Code shown on the car</label>
<input id="code" name="code" autocapitalize="characters" placeholder="XXX-XXX" required>{{end}}
` + codeInput + `{{end}}
<label for="abrpApiKey">ABRP API key</label>
<input id="abrpApiKey" name="abrpApiKey" type="password" autocomplete="off" spellcheck="false">
<label for="abrpToken">ABRP user token</label>
@@ -133,7 +133,7 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
func renderSettingsPage(w http.ResponseWriter, status int, data settingsPageData) {
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
h.Set("Content-Security-Policy", pageCSP)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Cache-Control", "no-store")
+26 -4
View File
@@ -1,6 +1,8 @@
package main
import (
"crypto/sha256"
"encoding/base64"
"errors"
"html/template"
"io"
@@ -147,7 +149,7 @@ type pageData struct {
Done bool
}
// pageStyle is shared by the phone pages (pairing, settings): dark, MG red, no scripts.
// pageStyle is shared by the phone pages (pairing, settings): dark, MG red.
const pageStyle = `
:root{color-scheme:dark}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#000;color:#F4F3EF;font:16px/1.5 system-ui,sans-serif}
@@ -160,8 +162,29 @@ input{box-sizing:border-box;width:100%;padding:14px;border-radius:10px;border:1p
button{flex:1;padding:14px;border-radius:10px;border:0;font-size:17px;font-weight:600;cursor:pointer}
.approve{background:#E0424C;color:#1A0B0C}.deny{background:#2A1416;color:#FF9A9E;border:1px solid #3A1215}
.error{color:#FFB4AB}.ok{color:#A4DCC1}
#code{text-transform:uppercase;letter-spacing:2px;font-family:ui-monospace,monospace}
`
// codeInput is the typed-code field of both phone pages. Its script upper-cases what is typed and
// puts the dash in after the third character; without the script the server still accepts the
// code in any case, with or without the dash (normalizeUserCode).
const codeInput = `<input id="code" name="code" autocapitalize="characters" autocomplete="off" autocorrect="off" spellcheck="false" maxlength="7" placeholder="XXX-XXX" required>
<script>` + codeScript + `</script>`
const codeScript = `document.getElementById('code').addEventListener('input',function(e){` +
`var v=this.value.toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,6);` +
`var del=e.inputType&&e.inputType.indexOf('delete')===0;` +
`this.value=v.length>3||(v.length===3&&!del)?v.slice(0,3)+'-'+v.slice(3):v;});`
// pageCSP allows no script but codeScript, pinned by its hash, and forms only back to this server.
var pageCSP = "default-src 'none'; script-src 'sha256-" + scriptHash(codeScript) +
"'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
func scriptHash(script string) string {
sum := sha256.Sum256([]byte(script))
return base64.StdEncoding.EncodeToString(sum[:])
}
var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pair a car — Nils' MG4</title>
@@ -174,7 +197,7 @@ var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
<form method="post" action="/pair" autocomplete="on">
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
{{else}}<label for="code">Code shown on the car</label>
<input id="code" name="code" autocapitalize="characters" autocomplete="off" placeholder="XXX-XXX" required>{{end}}
` + codeInput + `{{end}}
<input type="text" name="username" value="mg4-approval" autocomplete="username" hidden readonly>
<label for="password">Approval password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required {{if .Code}}autofocus{{end}}>
@@ -188,8 +211,7 @@ var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
func renderPage(w http.ResponseWriter, status int, data pageData) {
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
// No scripts at all, no framing, forms only back to this page.
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
h.Set("Content-Security-Policy", pageCSP)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Cache-Control", "no-store")
+27
View File
@@ -329,3 +329,30 @@ func TestClientAddressTakesTheProxysEntry(t *testing.T) {
t.Fatalf("without a proxy: %q", got)
}
}
// The typed-code field's script is the only one the pages may run: the CSP pins it by hash, so
// the script served must hash to exactly what the header allows.
func TestCodeScriptMatchesTheCSP(t *testing.T) {
api, _ := newTestAPI()
h := api.Routes()
script := regexp.MustCompile(`(?s)<script>(.*?)</script>`)
for _, path := range []string{"/pair", "/settings"} {
page := do(t, h, "GET", path, "", "")
if page.Code != 200 {
t.Fatalf("%s: %d", path, page.Code)
}
m := script.FindAllStringSubmatch(page.Body.String(), -1)
if len(m) != 1 || m[0][1] != codeScript {
t.Fatalf("%s: scripts %q", path, m)
}
csp := page.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "script-src 'sha256-"+scriptHash(m[0][1])+"'") || !strings.Contains(csp, "default-src 'none'") {
t.Fatalf("%s: CSP %q", path, csp)
}
}
// With the code in the link there is nothing to type, and no script.
_, userCode, _ := startPairing(t, h)
if body := do(t, h, "GET", "/pair?code="+userCode, "", "").Body.String(); strings.Contains(body, "<script") {
t.Fatal("script on the page with the code filled in")
}
}