server: remove the legacy invite path (#99)
Legacy invites (the pre-key-invite `invites` table) stored their tokens in plaintext and were still accepted by tokenOK, so a stolen DB yielded usable member credentials. The current client no longer creates or claims them — key-invites superseded them — so remove the whole path: - Delete the createInvite / listInvites / revokeInvite admin endpoints and the getInvite / claimInvite legacy-join endpoints, plus their handlers and DB funcs. - tokenOK now accepts device tokens only (no legacy invite tokens). - A migration DROPs the invites table so existing DBs retain no plaintext. BREAKING: any member still authenticating with an old #k= legacy invite token is logged out and must be re-invited (accepted decision — the current flow issues device tokens). Tests migrate from createInvite to a mintDeviceToken helper.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+95
−280
authz_hardening_test.go+5 −5db.go+4 −93db_test.go+5 −37handlers_test.go+10 −37invite_claim_test.go+57 −1main.go+0 −86push_ssrf_test.go+2 −5push_token_hash_test.go+9 −9unfurl_test.go+3 −7
diff --git a/authz_hardening_test.go b/authz_hardening_test.go| index bc69347..2a925bb 100644 |
| --- a/authz_hardening_test.go |
| +++ b/authz_hardening_test.go |
| @@ -11,9 +11,9 @@ import ( |
| // happens to know the endpoint. |
| func TestUnsubscribePushIsOwnerScoped(t *testing.T) { |
| ts, srv := newTestServer(t) |
| - a, _ := srv.db.createInvite("A") |
| - b, _ := srv.db.createInvite("B") |
| - if err := srv.db.addSubscription(PushSub{Endpoint: "https://e/1", Token: a.Token, P256dh: "p", Auth: "a"}); err != nil { |
| + aTok := mintDeviceToken(t, srv.db, "A") |
| + bTok := mintDeviceToken(t, srv.db, "B") |
| + if err := srv.db.addSubscription(PushSub{Endpoint: "https://e/1", Token: aTok, P256dh: "p", Auth: "a"}); err != nil { |
| t.Fatalf("addSubscription: %v", err) |
| } |
| @@ -35,14 +35,14 @@ func TestUnsubscribePushIsOwnerScoped(t *testing.T) { |
| t.Fatalf("no-token request deleted a sub") |
| } |
| // Valid but non-owning token → accepted, but the other user's sub survives. |
| - if got := post(fmt.Sprintf(`{"token":%q,"endpoint":"https://e/1"}`, b.Token)); got != http.StatusOK { |
| + if got := post(fmt.Sprintf(`{"token":%q,"endpoint":"https://e/1"}`, bTok)); got != http.StatusOK { |
| t.Fatalf("non-owner: want 200, got %d", got) |
| } |
| if subCount() != 1 { |
| t.Fatalf("non-owner deleted someone else's subscription") |
| } |
| // Owner token → removed. |
| - if got := post(fmt.Sprintf(`{"token":%q,"endpoint":"https://e/1"}`, a.Token)); got != http.StatusOK { |
| + if got := post(fmt.Sprintf(`{"token":%q,"endpoint":"https://e/1"}`, aTok)); got != http.StatusOK { |
| t.Fatalf("owner: want 200, got %d", got) |
| } |
| if subCount() != 0 { |
diff --git a/db.go b/db.go| index 2183212..03a02ef 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -81,13 +81,6 @@ CREATE TABLE IF NOT EXISTS config ( |
| key TEXT PRIMARY KEY, |
| value TEXT NOT NULL |
| ); |
| -CREATE TABLE IF NOT EXISTS invites ( |
| - token TEXT PRIMARY KEY, |
| - label TEXT NOT NULL DEFAULT '', |
| - created_at INTEGER NOT NULL, |
| - claimed_at INTEGER, |
| - revoked INTEGER NOT NULL DEFAULT 0 |
| -); |
| CREATE TABLE IF NOT EXISTS messages ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| created_at INTEGER NOT NULL, |
| @@ -227,6 +220,10 @@ CREATE TABLE IF NOT EXISTS bot_configs ( |
| // preimage, so reading the DB (which stores only the id + this hash) is not |
| // enough to claim a pending invite. See deriveInvite in crypto.js. |
| "ALTER TABLE key_invites ADD COLUMN claim_hash TEXT", |
| + // Legacy invites are removed (superseded by key-invites). Their tokens were |
| + // stored in clear and are no longer accepted; drop the table so a stolen DB |
| + // retains no plaintext credentials. Members re-authenticate via device tokens. |
| + "DROP TABLE IF EXISTS invites", |
| // Which user a push subscription belongs to, so removing a user can drop their |
| // subscriptions (sign-out clears them by token; removal has only the user id). |
| "ALTER TABLE push_subscriptions ADD COLUMN user_id TEXT", |
| @@ -420,92 +417,6 @@ func (db *DB) checkAdmin(token string) bool { |
| return subtle.ConstantTimeCompare([]byte(hashToken(token)), []byte(want)) == 1 |
| } |
| -// ---- invites ------------------------------------------------------------ |
| - |
| -type Invite struct { |
| - Token string `json:"token"` |
| - Label string `json:"label"` |
| - CreatedAt int64 `json:"created_at"` |
| - ClaimedAt *int64 `json:"claimed_at"` |
| - Revoked bool `json:"revoked"` |
| -} |
| - |
| -func (db *DB) createInvite(label string) (Invite, error) { |
| - tok, err := randToken() |
| - if err != nil { |
| - return Invite{}, err |
| - } |
| - now := time.Now().Unix() |
| - _, err = db.sql.Exec( |
| - "INSERT INTO invites(token, label, created_at) VALUES(?, ?, ?)", |
| - tok, label, now, |
| - ) |
| - if err != nil { |
| - return Invite{}, err |
| - } |
| - return Invite{Token: tok, Label: label, CreatedAt: now}, nil |
| -} |
| - |
| -func (db *DB) listInvites() ([]Invite, error) { |
| - rows, err := db.sql.Query( |
| - "SELECT token, label, created_at, claimed_at, revoked FROM invites ORDER BY created_at DESC", |
| - ) |
| - if err != nil { |
| - return nil, err |
| - } |
| - defer rows.Close() |
| - out := []Invite{} |
| - for rows.Next() { |
| - var inv Invite |
| - var revoked int |
| - if err := rows.Scan(&inv.Token, &inv.Label, &inv.CreatedAt, &inv.ClaimedAt, &revoked); err != nil { |
| - return nil, err |
| - } |
| - inv.Revoked = revoked != 0 |
| - out = append(out, inv) |
| - } |
| - return out, rows.Err() |
| -} |
| - |
| -// inviteStatus reports whether a token exists and whether it is revoked. |
| -func (db *DB) inviteStatus(token string) (exists, revoked bool, err error) { |
| - var r int |
| - err = db.sql.QueryRow("SELECT revoked FROM invites WHERE token = ?", token).Scan(&r) |
| - if err == sql.ErrNoRows { |
| - return false, false, nil |
| - } |
| - if err != nil { |
| - return false, false, err |
| - } |
| - return true, r != 0, nil |
| -} |
| - |
| -// inviteLabel returns the admin's label for an invite (used to pre-fill the |
| -// screen name). Empty string if the invite doesn't exist or has no label. |
| -func (db *DB) inviteLabel(token string) (string, error) { |
| - var label string |
| - err := db.sql.QueryRow("SELECT label FROM invites WHERE token = ?", token).Scan(&label) |
| - if err == sql.ErrNoRows { |
| - return "", nil |
| - } |
| - return label, err |
| -} |
| - |
| -// claimInvite marks the first use of an invite. Reusable afterwards (so a kid |
| -// can reopen the link on another device) unless revoked. |
| -func (db *DB) claimInvite(token string) error { |
| - _, err := db.sql.Exec( |
| - "UPDATE invites SET claimed_at = COALESCE(claimed_at, ?) WHERE token = ? AND revoked = 0", |
| - time.Now().Unix(), token, |
| - ) |
| - return err |
| -} |
| - |
| -func (db *DB) revokeInvite(token string) error { |
| - _, err := db.sql.Exec("UPDATE invites SET revoked = 1 WHERE token = ?", token) |
| - return err |
| -} |
| - |
| // ---- users, key-invites, device tokens (invite redesign) ---------------- |
| // |
| // The server stores a client-WRAPPED group key per invite, keyed by an id the |
diff --git a/db_test.go b/db_test.go| index 57643c0..ae7712f 100644 |
| --- a/db_test.go |
| +++ b/db_test.go |
| @@ -15,38 +15,6 @@ func newTestDB(t *testing.T) *DB { |
| return db |
| } |
| -func TestInviteLifecycle(t *testing.T) { |
| - db := newTestDB(t) |
| - |
| - inv, err := db.createInvite("Sophie") |
| - if err != nil || inv.Token == "" { |
| - t.Fatalf("createInvite: %v token=%q", err, inv.Token) |
| - } |
| - |
| - exists, revoked, err := db.inviteStatus(inv.Token) |
| - if err != nil || !exists || revoked { |
| - t.Fatalf("fresh invite: exists=%v revoked=%v err=%v", exists, revoked, err) |
| - } |
| - |
| - if label, _ := db.inviteLabel(inv.Token); label != "Sophie" { |
| - t.Fatalf("inviteLabel = %q, want Sophie", label) |
| - } |
| - |
| - if err := db.claimInvite(inv.Token); err != nil { |
| - t.Fatalf("claimInvite: %v", err) |
| - } |
| - |
| - if err := db.revokeInvite(inv.Token); err != nil { |
| - t.Fatalf("revokeInvite: %v", err) |
| - } |
| - if _, revoked, _ := db.inviteStatus(inv.Token); !revoked { |
| - t.Fatal("invite should be revoked") |
| - } |
| - |
| - if exists, _, _ := db.inviteStatus("does-not-exist"); exists { |
| - t.Fatal("unknown token should not exist") |
| - } |
| -} |
| func TestAdminToken(t *testing.T) { |
| db := newTestDB(t) |
| @@ -165,16 +133,16 @@ func TestEditMessage(t *testing.T) { |
| func TestPushSubscriptions(t *testing.T) { |
| db := newTestDB(t) |
| - inv, _ := db.createInvite("x") |
| + tok := mintDeviceToken(t, db, "x") |
| - db.addSubscription(PushSub{Endpoint: "https://e/1", Token: inv.Token, P256dh: "p", Auth: "a"}) |
| - db.addSubscription(PushSub{Endpoint: "https://e/2", Token: inv.Token, P256dh: "p", Auth: "a"}) |
| + db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p", Auth: "a"}) |
| + db.addSubscription(PushSub{Endpoint: "https://e/2", Token: tok, P256dh: "p", Auth: "a"}) |
| if subs, _ := db.listSubscriptions(); len(subs) != 2 { |
| t.Fatalf("expected 2 subs, got %d", len(subs)) |
| } |
| // Same endpoint upserts, not duplicates. |
| - db.addSubscription(PushSub{Endpoint: "https://e/1", Token: inv.Token, P256dh: "p2", Auth: "a2"}) |
| + db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p2", Auth: "a2"}) |
| if subs, _ := db.listSubscriptions(); len(subs) != 2 { |
| t.Fatalf("upsert duplicated: %d", len(subs)) |
| } |
| @@ -183,7 +151,7 @@ func TestPushSubscriptions(t *testing.T) { |
| if subs, _ := db.listSubscriptions(); len(subs) != 1 { |
| t.Fatalf("after delete: %d", len(subs)) |
| } |
| - db.deleteSubscriptionsForToken(inv.Token) |
| + db.deleteSubscriptionsForToken(tok) |
| if subs, _ := db.listSubscriptions(); len(subs) != 0 { |
| t.Fatalf("after token delete: %d", len(subs)) |
| } |
diff --git a/handlers_test.go b/handlers_test.go| index b7dad75..44de3f0 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -47,12 +47,12 @@ func adminPost(t *testing.T, url, body string) *http.Response { |
| func TestAdminAuthGating(t *testing.T) { |
| ts, _ := newTestServer(t) |
| - resp, _ := http.Post(ts.URL+"/api/admin/invites", "application/json", strings.NewReader(`{"label":"x"}`)) |
| + resp, _ := http.Post(ts.URL+"/api/admin/key-invites", "application/json", strings.NewReader(`{"label":"x"}`)) |
| if resp.StatusCode != http.StatusUnauthorized { |
| t.Fatalf("no auth: want 401, got %d", resp.StatusCode) |
| } |
| - req, _ := http.NewRequest("POST", ts.URL+"/api/admin/invites", strings.NewReader(`{}`)) |
| + req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", strings.NewReader(`{}`)) |
| req.Header.Set("Authorization", "Bearer wrong") |
| resp, _ = http.DefaultClient.Do(req) |
| if resp.StatusCode != http.StatusUnauthorized { |
| @@ -85,35 +85,12 @@ func TestRenameUser(t *testing.T) { |
| } |
| } |
| -func TestInviteAndMessagesFlow(t *testing.T) { |
| +func TestMessagesThreadScoping(t *testing.T) { |
| ts, srv := newTestServer(t) |
| - resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"Sophie"}`) |
| - if resp.StatusCode != http.StatusOK { |
| - t.Fatalf("create invite: %d", resp.StatusCode) |
| - } |
| - var inv struct { |
| - Token string `json:"token"` |
| - } |
| - json.NewDecoder(resp.Body).Decode(&inv) |
| - if inv.Token == "" { |
| - t.Fatal("no token returned") |
| - } |
| - |
| - // Invite info exposes the label (for screen-name pre-fill). |
| - resp, _ = http.Get(ts.URL + "/api/invite/" + inv.Token) |
| - var info struct { |
| - Ok bool `json:"ok"` |
| - Label string `json:"label"` |
| - } |
| - json.NewDecoder(resp.Body).Decode(&info) |
| - if !info.Ok || info.Label != "Sophie" { |
| - t.Fatalf("invite info: %+v", info) |
| - } |
| - |
| // Messages are thread-scoped: a bad token is rejected; a real member reading |
| // their own thread is allowed; a member reading a thread they're not in is not. |
| - resp, _ = http.Get(ts.URL + "/api/messages?token=bogus&thread=x") |
| + resp, _ := http.Get(ts.URL + "/api/messages?token=bogus&thread=x") |
| if resp.StatusCode != http.StatusForbidden { |
| t.Fatalf("messages bad token: want 403, got %d", resp.StatusCode) |
| } |
| @@ -401,22 +378,18 @@ func TestPostMessageAPI(t *testing.T) { |
| } |
| func TestBlobUploadDownload(t *testing.T) { |
| - ts, _ := newTestServer(t) |
| - resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`) |
| - var inv struct { |
| - Token string `json:"token"` |
| - } |
| - json.NewDecoder(resp.Body).Decode(&inv) |
| + ts, srv := newTestServer(t) |
| + tok := mintDeviceToken(t, srv.db, "x") |
| payload := []byte("pretend-this-is-encrypted-bytes\x00\x01\x02") |
| // Upload requires a valid token. |
| - resp, _ = http.Post(ts.URL+"/api/upload?token=bogus", "application/octet-stream", strings.NewReader("x")) |
| + resp, _ := http.Post(ts.URL+"/api/upload?token=bogus", "application/octet-stream", strings.NewReader("x")) |
| if resp.StatusCode != http.StatusForbidden { |
| t.Fatalf("upload bad token: want 403, got %d", resp.StatusCode) |
| } |
| - resp, err := http.Post(ts.URL+"/api/upload?token="+inv.Token, "application/octet-stream", strings.NewReader(string(payload))) |
| + resp, err := http.Post(ts.URL+"/api/upload?token="+tok, "application/octet-stream", strings.NewReader(string(payload))) |
| if err != nil || resp.StatusCode != http.StatusOK { |
| t.Fatalf("upload: err=%v status=%d", err, resp.StatusCode) |
| } |
| @@ -435,7 +408,7 @@ func TestBlobUploadDownload(t *testing.T) { |
| } |
| // Round-trips exactly. |
| - resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + inv.Token) |
| + resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok) |
| if resp.StatusCode != http.StatusOK { |
| t.Fatalf("download: %d", resp.StatusCode) |
| } |
| @@ -445,7 +418,7 @@ func TestBlobUploadDownload(t *testing.T) { |
| } |
| // Unknown id is a 404. |
| - resp, _ = http.Get(ts.URL + "/api/blob/nope?token=" + inv.Token) |
| + resp, _ = http.Get(ts.URL + "/api/blob/nope?token=" + tok) |
| if resp.StatusCode != http.StatusNotFound { |
| t.Fatalf("missing blob: want 404, got %d", resp.StatusCode) |
| } |
diff --git a/invite_claim_test.go b/invite_claim_test.go| index 890a5cc..4d497ff 100644 |
| --- a/invite_claim_test.go |
| +++ b/invite_claim_test.go |
| @@ -1,6 +1,62 @@ |
| package main |
| -import "testing" |
| +import ( |
| + "database/sql" |
| + "fmt" |
| + "sync/atomic" |
| + "testing" |
| +) |
| + |
| +// A pre-existing DB with the old plaintext invites table has it dropped on open, |
| +// and a token that used to authenticate via it is no longer accepted. |
| +func TestLegacyInvitesRemovedOnOpen(t *testing.T) { |
| + path := t.TempDir() + "/legacy.db" |
| + raw, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatalf("open raw: %v", err) |
| + } |
| + if _, err := raw.Exec(`CREATE TABLE invites (token TEXT PRIMARY KEY, label TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, claimed_at INTEGER, revoked INTEGER NOT NULL DEFAULT 0)`); err != nil { |
| + t.Fatalf("create legacy table: %v", err) |
| + } |
| + if _, err := raw.Exec(`INSERT INTO invites(token, label, created_at) VALUES('legacytok', 'x', 0)`); err != nil { |
| + t.Fatalf("seed legacy token: %v", err) |
| + } |
| + raw.Close() |
| + |
| + db, err := openDB(path) |
| + if err != nil { |
| + t.Fatalf("openDB: %v", err) |
| + } |
| + if _, err := db.sql.Exec("SELECT 1 FROM invites LIMIT 1"); err == nil { |
| + t.Fatal("legacy invites table was not dropped on open") |
| + } |
| + srv := &Server{db: db} |
| + if srv.tokenOK("legacytok") { |
| + t.Fatal("a legacy invite token is still accepted for auth") |
| + } |
| +} |
| + |
| +var mintSeq atomic.Int64 |
| + |
| +// mintDeviceToken creates a joined member and returns a valid device (session) |
| +// token via the key-invite claim flow — the standard way to get an authenticated |
| +// caller in tests now that legacy invites are gone. |
| +func mintDeviceToken(t *testing.T, db *DB, label string) string { |
| + t.Helper() |
| + uid, err := db.createUser(label) |
| + if err != nil { |
| + t.Fatalf("createUser: %v", err) |
| + } |
| + id := fmt.Sprintf("mint-%s-%d", label, mintSeq.Add(1)) |
| + if err := db.createKeyInvite(id, uid, "", testClaimHash, 0); err != nil { |
| + t.Fatalf("createKeyInvite: %v", err) |
| + } |
| + tok, ok, err := db.claimKeyInvite(id, label, "", testClaimSecret) |
| + if err != nil || !ok { |
| + t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err) |
| + } |
| + return tok |
| +} |
| // Shared across the invite tests: a claim secret and its stored hash. Creation |
| // stores testClaimHash; claiming presents testClaimSecret. |
diff --git a/main.go b/main.go| index 36cb0f4..886002f 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -162,10 +162,6 @@ func (s *Server) handler() http.Handler { |
| // caches it, and serves cached results immediately (refreshing stale ones). |
| mux.HandleFunc("GET /api/unfurl", s.unfurl) |
| - // Invite lifecycle (legacy join page) |
| - mux.HandleFunc("GET /api/invite/{token}", s.getInvite) |
| - mux.HandleFunc("POST /api/invite/{token}/claim", s.claimInvite) |
| - |
| // Key-invites (invite redesign): server stores a client-wrapped key it can't |
| // open; claiming mints a device token. See docs/invite-redesign.md. |
| mux.HandleFunc("GET /api/key-invite/{id}", s.getKeyInvite) |
| @@ -191,9 +187,6 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/admin/group", s.setGroup) |
| // Admin API (requires admin token) |
| - mux.HandleFunc("POST /api/admin/invites", s.createInvite) |
| - mux.HandleFunc("GET /api/admin/invites", s.listInvites) |
| - mux.HandleFunc("POST /api/admin/invites/{token}/revoke", s.revokeInvite) |
| mux.HandleFunc("GET /api/admin/settings", s.getSettings) |
| mux.HandleFunc("POST /api/admin/settings", s.setSettings) |
| @@ -461,9 +454,6 @@ func (s *Server) tokenOK(token string) bool { |
| if token == "" { |
| return false |
| } |
| - if exists, revoked, err := s.db.inviteStatus(token); err == nil && exists && !revoked { |
| - return true |
| - } |
| return s.db.deviceTokenValid(token) |
| } |
| @@ -1140,40 +1130,6 @@ func (s *Server) fetchAndCachePreview(norm string) LinkPreview { |
| return pv |
| } |
| -// ---- invite (join flow) ------------------------------------------------- |
| - |
| -func (s *Server) getInvite(w http.ResponseWriter, r *http.Request) { |
| - token := r.PathValue("token") |
| - exists, revoked, err := s.db.inviteStatus(token) |
| - if err != nil { |
| - httpError(w, http.StatusInternalServerError, "db error") |
| - return |
| - } |
| - label := "" |
| - if exists { |
| - label, _ = s.db.inviteLabel(token) |
| - } |
| - writeJSON(w, http.StatusOK, map[string]any{"ok": exists && !revoked, "revoked": revoked, "label": label}) |
| -} |
| - |
| -func (s *Server) claimInvite(w http.ResponseWriter, r *http.Request) { |
| - token := r.PathValue("token") |
| - exists, revoked, err := s.db.inviteStatus(token) |
| - if err != nil { |
| - httpError(w, http.StatusInternalServerError, "db error") |
| - return |
| - } |
| - if !exists || revoked { |
| - httpError(w, http.StatusForbidden, "invalid invite") |
| - return |
| - } |
| - if err := s.db.claimInvite(token); err != nil { |
| - httpError(w, http.StatusInternalServerError, "db error") |
| - return |
| - } |
| - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| -} |
| - |
| // ---- key-invites (invite redesign) -------------------------------------- |
| // createKeyInvite stores a client-wrapped group key (the admin's browser did the |
| @@ -1638,48 +1594,6 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { |
| return false |
| } |
| -func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) { |
| - if !s.requireAdmin(w, r) { |
| - return |
| - } |
| - var body struct { |
| - Label string `json:"label"` |
| - } |
| - json.NewDecoder(r.Body).Decode(&body) |
| - inv, err := s.db.createInvite(strings.TrimSpace(body.Label)) |
| - if err != nil { |
| - httpError(w, http.StatusInternalServerError, "db error") |
| - return |
| - } |
| - writeJSON(w, http.StatusOK, inv) |
| -} |
| - |
| -func (s *Server) listInvites(w http.ResponseWriter, r *http.Request) { |
| - if !s.requireAdmin(w, r) { |
| - return |
| - } |
| - invites, err := s.db.listInvites() |
| - if err != nil { |
| - httpError(w, http.StatusInternalServerError, "db error") |
| - return |
| - } |
| - writeJSON(w, http.StatusOK, map[string]any{"invites": invites}) |
| -} |
| - |
| -func (s *Server) revokeInvite(w http.ResponseWriter, r *http.Request) { |
| - if !s.requireAdmin(w, r) { |
| - return |
| - } |
| - token := r.PathValue("token") |
| - if err := s.db.revokeInvite(token); err != nil { |
| - httpError(w, http.StatusInternalServerError, "db error") |
| - return |
| - } |
| - // A revoked participant should also stop receiving push notifications. |
| - _ = s.db.deleteSubscriptionsForToken(token) |
| - _ = s.db.deleteAPNsTokensForToken(token) |
| - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| -} |
| // ---- push notifications ------------------------------------------------- |
diff --git a/push_ssrf_test.go b/push_ssrf_test.go| index d7b072a..5700805 100644 |
| --- a/push_ssrf_test.go |
| +++ b/push_ssrf_test.go |
| @@ -66,12 +66,9 @@ func TestGuardDialControlBlocksNonPublic(t *testing.T) { |
| // register an internal/non-https push endpoint (proves the validator is wired in). |
| func TestSubscribePushRejectsBadEndpoint(t *testing.T) { |
| ts, srv := newTestServer(t) |
| - inv, err := srv.db.createInvite("push-test") |
| - if err != nil { |
| - t.Fatalf("createInvite: %v", err) |
| - } |
| + tok := mintDeviceToken(t, srv.db, "push-test") |
| post := func(endpoint string) int { |
| - body := fmt.Sprintf(`{"token":%q,"subscription":{"endpoint":%q,"keys":{"p256dh":"p","auth":"a"}}}`, inv.Token, endpoint) |
| + body := fmt.Sprintf(`{"token":%q,"subscription":{"endpoint":%q,"keys":{"p256dh":"p","auth":"a"}}}`, tok, endpoint) |
| resp, err := http.Post(ts.URL+"/api/push/subscribe", "application/json", strings.NewReader(body)) |
| if err != nil { |
| t.Fatalf("post: %v", err) |
diff --git a/push_token_hash_test.go b/push_token_hash_test.go| index 6bb6c10..6d63ae5 100644 |
| --- a/push_token_hash_test.go |
| +++ b/push_token_hash_test.go |
| @@ -5,26 +5,26 @@ import "testing" |
| // The push/APNs tables must never retain a usable session token — only its hash. |
| func TestPushTokensHashedAtRest(t *testing.T) { |
| db := newTestDB(t) |
| - inv, _ := db.createInvite("x") |
| + tok := mintDeviceToken(t, db, "x") |
| - if err := db.addSubscription(PushSub{Endpoint: "https://e/1", Token: inv.Token, P256dh: "p", Auth: "a"}); err != nil { |
| + if err := db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p", Auth: "a"}); err != nil { |
| t.Fatalf("addSubscription: %v", err) |
| } |
| - if err := db.addAPNsToken(APNsToken{APNsToken: "apns-1", Token: inv.Token}); err != nil { |
| + if err := db.addAPNsToken(APNsToken{APNsToken: "apns-1", Token: tok}); err != nil { |
| t.Fatalf("addAPNsToken: %v", err) |
| } |
| check := func(table, keyCol, keyVal string) { |
| - var tok, th string |
| + var storedTok, th string |
| if err := db.sql.QueryRow( |
| "SELECT token, COALESCE(token_hash, '') FROM "+table+" WHERE "+keyCol+" = ?", keyVal, |
| - ).Scan(&tok, &th); err != nil { |
| + ).Scan(&storedTok, &th); err != nil { |
| t.Fatalf("%s row: %v", table, err) |
| } |
| - if tok != "" { |
| - t.Errorf("%s retains plaintext token %q, want empty", table, tok) |
| + if storedTok != "" { |
| + t.Errorf("%s retains plaintext token %q, want empty", table, storedTok) |
| } |
| - if th != hashToken(inv.Token) { |
| + if th != hashToken(tok) { |
| t.Errorf("%s token_hash = %q, want hash of the token", table, th) |
| } |
| } |
| @@ -32,7 +32,7 @@ func TestPushTokensHashedAtRest(t *testing.T) { |
| check("apns_tokens", "apns_token", "apns-1") |
| // Delete-by-raw-token still resolves via the stored hash. |
| - if err := db.deleteSubscriptionsForToken(inv.Token); err != nil { |
| + if err := db.deleteSubscriptionsForToken(tok); err != nil { |
| t.Fatalf("deleteSubscriptionsForToken: %v", err) |
| } |
| if subs, _ := db.listSubscriptions(); len(subs) != 0 { |
diff --git a/unfurl_test.go b/unfurl_test.go| index 432c58d..eaa43d4 100644 |
| --- a/unfurl_test.go |
| +++ b/unfurl_test.go |
| @@ -65,11 +65,7 @@ func TestUnfurlEndpoint(t *testing.T) { |
| })) |
| t.Cleanup(page.Close) |
| - resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`) |
| - var inv struct { |
| - Token string `json:"token"` |
| - } |
| - json.NewDecoder(resp.Body).Decode(&inv) |
| + tok := mintDeviceToken(t, srv.db, "x") |
| // No token → 403. |
| r, _ := http.Get(ts.URL + "/api/unfurl?url=" + page.URL) |
| @@ -78,13 +74,13 @@ func TestUnfurlEndpoint(t *testing.T) { |
| } |
| // Bad URL → 400. |
| - r, _ = http.Get(ts.URL + "/api/unfurl?token=" + inv.Token + "&url=notaurl") |
| + r, _ = http.Get(ts.URL + "/api/unfurl?token=" + tok + "&url=notaurl") |
| if r.StatusCode != http.StatusBadRequest { |
| t.Fatalf("unfurl bad url: want 400, got %d", r.StatusCode) |
| } |
| // Good fetch → preview returned and cached. |
| - r, _ = http.Get(ts.URL + "/api/unfurl?token=" + inv.Token + "&url=" + page.URL) |
| + r, _ = http.Get(ts.URL + "/api/unfurl?token=" + tok + "&url=" + page.URL) |
| if r.StatusCode != http.StatusOK { |
| t.Fatalf("unfurl: %d", r.StatusCode) |
| } |