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,73 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
)
|
||||
|
||||
func TestStreamTicketsReplaceTokensInURLs(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
ticket := func(tok string) string {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/stream-ticket", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out struct{ Ticket string }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return out.Ticket
|
||||
}
|
||||
stream := func(query string) string {
|
||||
return srv.authenticateUserFromReq(httptest.NewRequest(http.MethodGet, "/api/v1/events?"+query, nil))
|
||||
}
|
||||
|
||||
tk := ticket(token)
|
||||
if tk == "" {
|
||||
t.Fatal("no ticket issued")
|
||||
}
|
||||
if stream("ticket="+tk) == "" {
|
||||
t.Fatal("a fresh ticket must open the stream")
|
||||
}
|
||||
if stream("ticket="+tk) != "" {
|
||||
t.Fatal("a ticket works only once")
|
||||
}
|
||||
if stream("token="+token) != "" {
|
||||
t.Fatal("session tokens in the URL are no longer accepted")
|
||||
}
|
||||
|
||||
// Ending all sessions makes unused tickets worthless as well
|
||||
tk = ticket(token)
|
||||
uid := stream("ticket=" + ticket(token))
|
||||
_ = store.RevokeAllUserTokens(uid)
|
||||
if stream("ticket="+tk) != "" {
|
||||
t.Fatal("ticket from before 'all sessions ended' must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamOriginCheck(t *testing.T) {
|
||||
srv := &Server{cfg: &config.Config{BaseURL: "https://notes.example.com", BaseURLSet: true}}
|
||||
check := func(host, origin string) bool {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/ws", nil)
|
||||
r.Host = host
|
||||
if origin != "" {
|
||||
r.Header.Set("Origin", origin)
|
||||
}
|
||||
return srv.streamOriginAllowed(r)
|
||||
}
|
||||
if !check("mindsift:8080", "https://notes.example.com") {
|
||||
t.Error("BASE_URL origin must pass even if the proxy rewrote Host")
|
||||
}
|
||||
if !check("notes.example.com", "https://notes.example.com") {
|
||||
t.Error("same host must pass")
|
||||
}
|
||||
if check("notes.example.com", "https://evil.example.org") {
|
||||
t.Error("foreign origin must be refused")
|
||||
}
|
||||
if !check("notes.example.com", "") {
|
||||
t.Error("non-browser clients without Origin pass (they still need a ticket)")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user