From bae56bec2f44053e77fa247db838587d9785d66f Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Mon, 19 Oct 2026 08:19:00 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 6 ++++++ k8s/server.yaml | 4 ++-- server/handoff_http.go | 4 ++-- server/pairing_http.go | 30 ++++++++++++++++++++++++++---- server/pairing_test.go | 27 +++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/SPECS.md b/SPECS.md index a2c0d45..be5872c 100644 --- a/SPECS.md +++ b/SPECS.md @@ -118,6 +118,12 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-19 — Server 2.6.1: the code field on the phone pages (`/pair`, `/settings`, when opened + without the code in the link) upper-cases what is typed and puts the dash in after the third + character. That takes the pages' first script: one inline script, allowed by its SHA-256 hash in + the CSP (`script-src 'sha256-…'`, still `default-src 'none'`); a test checks the served script + against the header. Without it the server still accepts the code in any case, with or without + the dash. Image pinned by digest in `k8s/server.yaml` (not applied). - 2026-10-18 — README brought up to date: screenshots of all seven tabs from 2.18.1 at the car's 1778×640 window (the upstream and 2.8.2 screenshots are gone), tab descriptions, updating over the air and from a USB stick, the emulator, the release relay. OTA.md: updates are checked from diff --git a/k8s/server.yaml b/k8s/server.yaml index e9912a6..8c495c3 100644 --- a/k8s/server.yaml +++ b/k8s/server.yaml @@ -29,8 +29,8 @@ spec: type: RuntimeDefault containers: - name: server - # 2.6.0, pinned by digest so a re-pushed tag cannot change what runs. - image: git.stinnesbeck.com/nils/mg4-server@sha256:35ff5973836620c1e69f06b00163cf95183f938cb9830355452d4161c6c46e33 + # 2.6.1, pinned by digest so a re-pushed tag cannot change what runs. + image: git.stinnesbeck.com/nils/mg4-server@sha256:df1d40e423b7ee365e8379e2eaefb4c0db3a599e6429728a37d46827f83d82cd ports: - name: http containerPort: 8080 diff --git a/server/handoff_http.go b/server/handoff_http.go index cfc8a76..0a4cf88 100644 --- a/server/handoff_http.go +++ b/server/handoff_http.go @@ -111,7 +111,7 @@ var settingsTemplate = template.Must(template.New("settings").Parse(` {{if .Code}}
{{.Code}}
{{else}} -{{end}} +` + codeInput + `{{end}} @@ -133,7 +133,7 @@ var settingsTemplate = template.Must(template.New("settings").Parse(` +` + +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(` Pair a car — Nils' MG4 @@ -174,7 +197,7 @@ var pairTemplate = template.Must(template.New("pair").Parse(`
{{if .Code}}
{{.Code}}
{{else}} -{{end}} +` + codeInput + `{{end}} @@ -188,8 +211,7 @@ var pairTemplate = template.Must(template.New("pair").Parse(` 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") diff --git a/server/pairing_test.go b/server/pairing_test.go index 2be5f00..68c7afc 100644 --- a/server/pairing_test.go +++ b/server/pairing_test.go @@ -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)`) + 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, "