fix(security): stream tickets instead of session tokens in URLs (FLAW-22)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
WebSocket and EventSource put the 30-day session token into the URL, where proxies log it. The app now gets a one-time ticket right before connecting (POST /auth/stream-ticket, 60 s, bound to the token version); ?token= is no longer accepted for streams, non-browser clients may use the Authorization header. With BASE_URL set, CORS allows only that origin and WebSocket upgrades only it or the requested host; credentials are off (no cookies exist). Logout, ending all sessions, a new password and deactivation close all live streams of the account; clients with a valid session reconnect with a fresh ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c711c2cf7
commit
f57a9884d1
17 files changed
+335
-41
No files matched your search
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
// WebSocket and EventSource cannot send an Authorization header, so the browser used to put its
|
||||
// 30-day session token into the URL, where it ends up in proxy and access logs (FLAW-22). Instead the
|
||||
// app now asks for a stream ticket (POST /auth/stream-ticket, with the normal header) right before it
|
||||
// connects and passes that: random, valid for 60 seconds, usable once, bound to the session's token
|
||||
// version. A ticket in a log is worthless.
|
||||
|
||||
const streamTicketTTL = 60 * time.Second
|
||||
|
||||
type streamTicket struct {
|
||||
userID string
|
||||
tokenVersion int
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type streamTickets struct {
|
||||
mu sync.Mutex
|
||||
tickets map[string]streamTicket
|
||||
}
|
||||
|
||||
func (t *streamTickets) issue(userID string, tokenVersion int) (string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := hex.EncodeToString(buf)
|
||||
now := time.Now()
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.tickets == nil {
|
||||
t.tickets = map[string]streamTicket{}
|
||||
}
|
||||
for k, v := range t.tickets {
|
||||
if now.After(v.expires) {
|
||||
delete(t.tickets, k)
|
||||
}
|
||||
}
|
||||
t.tickets[id] = streamTicket{userID: userID, tokenVersion: tokenVersion, expires: now.Add(streamTicketTTL)}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// consume returns the ticket and removes it; ok is false for unknown or expired tickets.
|
||||
func (t *streamTickets) consume(id string) (streamTicket, bool) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
tk, ok := t.tickets[id]
|
||||
delete(t.tickets, id)
|
||||
if !ok || time.Now().After(tk.expires) {
|
||||
return streamTicket{}, false
|
||||
}
|
||||
return tk, true
|
||||
}
|
||||
|
||||
func (s *Server) handleStreamTicket(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.store.GetUserByID(auth.GetUserID(r.Context()))
|
||||
if err != nil || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||
return
|
||||
}
|
||||
ticket, err := s.streamTickets.issue(user.ID, user.TokenVersion)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Ticket konnte nicht erstellt werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ticket": ticket, "expiresIn": int(streamTicketTTL.Seconds())})
|
||||
}
|
||||
|
||||
// streamOriginAllowed: with BASE_URL set, browsers may open WebSockets only from that address or the
|
||||
// requested host itself. Requests without Origin (non-browser clients) pass; they need a ticket anyway.
|
||||
func (s *Server) streamOriginAllowed(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" || !s.cfg.BaseURLSet {
|
||||
return true
|
||||
}
|
||||
o, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(o.Host, r.Host) {
|
||||
return true
|
||||
}
|
||||
base, err := url.Parse(s.cfg.BaseURL)
|
||||
return err == nil && strings.EqualFold(o.Host, base.Host)
|
||||
}
|
||||
Reference in new issue
Block a user