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:
@@ -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
|
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`
|
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).
|
(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
|
- 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
|
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
|
the air and from a USB stick, the emulator, the release relay. OTA.md: updates are checked from
|
||||||
|
|||||||
+2
-2
@@ -29,8 +29,8 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: server
|
||||||
# 2.6.0, pinned by digest so a re-pushed tag cannot change what runs.
|
# 2.6.1, pinned by digest so a re-pushed tag cannot change what runs.
|
||||||
image: git.stinnesbeck.com/nils/mg4-server@sha256:35ff5973836620c1e69f06b00163cf95183f938cb9830355452d4161c6c46e33
|
image: git.stinnesbeck.com/nils/mg4-server@sha256:df1d40e423b7ee365e8379e2eaefb4c0db3a599e6429728a37d46827f83d82cd
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
|
|||||||
<form method="post" action="/settings" autocomplete="off">
|
<form method="post" action="/settings" autocomplete="off">
|
||||||
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
|
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
|
||||||
{{else}}<label for="code">Code shown on the car</label>
|
{{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>
|
<label for="abrpApiKey">ABRP API key</label>
|
||||||
<input id="abrpApiKey" name="abrpApiKey" type="password" autocomplete="off" spellcheck="false">
|
<input id="abrpApiKey" name="abrpApiKey" type="password" autocomplete="off" spellcheck="false">
|
||||||
<label for="abrpToken">ABRP user token</label>
|
<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) {
|
func renderSettingsPage(w http.ResponseWriter, status int, data settingsPageData) {
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
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("X-Content-Type-Options", "nosniff")
|
||||||
h.Set("Referrer-Policy", "no-referrer")
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
h.Set("Cache-Control", "no-store")
|
h.Set("Cache-Control", "no-store")
|
||||||
|
|||||||
+26
-4
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
@@ -147,7 +149,7 @@ type pageData struct {
|
|||||||
Done bool
|
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 = `
|
const pageStyle = `
|
||||||
:root{color-scheme:dark}
|
: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}
|
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}
|
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}
|
.approve{background:#E0424C;color:#1A0B0C}.deny{background:#2A1416;color:#FF9A9E;border:1px solid #3A1215}
|
||||||
.error{color:#FFB4AB}.ok{color:#A4DCC1}
|
.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>
|
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">
|
<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>
|
<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">
|
<form method="post" action="/pair" autocomplete="on">
|
||||||
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
|
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
|
||||||
{{else}}<label for="code">Code shown on the car</label>
|
{{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>
|
<input type="text" name="username" value="mg4-approval" autocomplete="username" hidden readonly>
|
||||||
<label for="password">Approval password</label>
|
<label for="password">Approval password</label>
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" required {{if .Code}}autofocus{{end}}>
|
<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) {
|
func renderPage(w http.ResponseWriter, status int, data pageData) {
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
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", pageCSP)
|
||||||
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
|
||||||
h.Set("X-Content-Type-Options", "nosniff")
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
h.Set("Referrer-Policy", "no-referrer")
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
h.Set("Cache-Control", "no-store")
|
h.Set("Cache-Control", "no-store")
|
||||||
|
|||||||
@@ -329,3 +329,30 @@ func TestClientAddressTakesTheProxysEntry(t *testing.T) {
|
|||||||
t.Fatalf("without a proxy: %q", got)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user