Commit d8dfc35992

d8dfc35992ffb900c7e130898f2c5fd12b771340

parent: dbed1606f9

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 11:10 UTC

web: mask a short device token on the settings page

prefix8 returns anything under nine characters unchanged, and device
add enforces no minimum token length, so a short token was rendered
whole. shortToken already handled this in the control layer two
commits before the page was written; export it and render the column
through it, so the two surfaces cannot drift again. prefix8 itself is
unchanged: it also derives the typed-confirmation strings for key,
PGP and email removal.

Ref #89
internal/httpd/account.go +9 −7
@@ -32,14 +32,16 @@ type accountPGP struct {
3232}
3333
3434// accountDevice is one registered APNs device as the settings page shows
35// it. Token is truncated to its first 8 characters: the full token is
36// device-identifying and never reaches the page.
35// it. The full token is device-identifying and never reaches the page.
3736type accountDevice struct {
38 ID int64
39 Label string
37 ID int64
38 Label string
39 // Token is rendered by control.ShortToken, the same renderer
40 // notifications device list uses: prefix8 returns anything under nine
41 // characters unchanged, and device add enforces no minimum length.
4042 Token string
4143 LastSeenAt string
42 Confirm string // the truncated token, typed back to confirm removal
44 Confirm string // the token's first 8 characters, typed back to confirm removal
4345}
4446
4547// accountForm renders the account's own settings: keys, addresses, and the
@@ -81,8 +83,8 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
8183 var devices []accountDevice
8284 if list, err := s.st.PushDevices(u.ID); err == nil {
8385 for _, d := range list {
84 token := prefix8(d.Token)
85 devices = append(devices, accountDevice{ID: d.ID, Label: d.Label, Token: token, LastSeenAt: d.LastSeenAt, Confirm: token})
86 devices = append(devices, accountDevice{ID: d.ID, Label: d.Label,
87 Token: control.ShortToken(d.Token), LastSeenAt: d.LastSeenAt, Confirm: prefix8(d.Token)})
8688 }
8789 }
8890
internal/httpd/account_test.go +30
@@ -135,3 +135,33 @@ func TestAccountSubmitDeviceRemove(t *testing.T) {
135135 t.Fatalf("device not removed: %v", devices)
136136 }
137137}
138
139// A token at or under the truncation length is masked rather than shown
140// whole, as notifications device list masks it. prefix8 returns anything
141// shorter than nine characters unchanged, and device add enforces no
142// minimum length, so the short token is a value that reaches the page.
143func TestAccountPageMasksAShortDeviceToken(t *testing.T) {
144 st, err := store.Open(":memory:")
145 if err != nil {
146 t.Fatal(err)
147 }
148 defer st.Close()
149 if err := st.MigrateUp(); err != nil {
150 t.Fatal(err)
151 }
152 uid, err := st.CreateUser("alice", false)
153 if err != nil {
154 t.Fatal(err)
155 }
156 if _, err := st.AddPushDevice(uid, "abc123", "iphone"); err != nil {
157 t.Fatal(err)
158 }
159
160 s := New(config.Default(), st)
161 rr := httptest.NewRecorder()
162 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), store.User{ID: uid, Username: "alice"})
163
164 if strings.Contains(rr.Body.String(), `class="mono">abc123<`) {
165 t.Fatalf("the page printed the short token verbatim:\n%s", rr.Body.String())
166 }
167}