fix(realtime): scope events to authorized users, authorize rooms, keep SSE alive
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s
Phase 0 of the collaboration plan: - Remove BroadcastToAll; task, task list and workspace folder events now only reach users who can see the resource. Task list events carry only the list ID, clients reload with their own permissions. - Reject unauthenticated WebSocket/SSE connections (401). Every room join (URL room, JOIN_ROOM, SSE rooms) is checked via a room authorizer; /events/rooms requires login and only accepts the caller's own sseId. - Reorder rejects tasks the caller cannot access or edit (IDOR). - Lift the server WriteTimeout for SSE streams so they are not cut after 30s. - Fix folder share removal sending DOC_DELETED to the share ID. - Tests for all of the above; docs and flaw list updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74797e3f44
commit
bcc06da3ee
10 files changed
+526
-86
No files matched your search
+59
-37
@@ -93,6 +93,27 @@ type Hub struct {
|
||||
sseUserRooms map[string]map[*SSEClient]bool
|
||||
sseRooms map[string]map[*SSEClient]bool
|
||||
closed bool
|
||||
|
||||
// authorizeRoom decides whether a user may receive events of a room; without it every join is denied
|
||||
authorizeRoom func(userID, roomID string) bool
|
||||
}
|
||||
|
||||
// SetRoomAuthorizer installs the permission check used for every room join (URL room, JOIN_ROOM, SSE rooms).
|
||||
func (h *Hub) SetRoomAuthorizer(f func(userID, roomID string) bool) {
|
||||
h.mu.Lock()
|
||||
h.authorizeRoom = f
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// canJoin must be called without holding h.mu, since the authorizer queries the database.
|
||||
func (h *Hub) canJoin(userID, roomID string) bool {
|
||||
if roomID == "" || userID == "" || userID == "anonymous" {
|
||||
return false
|
||||
}
|
||||
h.mu.RLock()
|
||||
f := h.authorizeRoom
|
||||
h.mu.RUnlock()
|
||||
return f != nil && f(userID, roomID)
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
@@ -107,6 +128,9 @@ func NewHub() *Hub {
|
||||
}
|
||||
|
||||
func (h *Hub) Register(c *Client) {
|
||||
if c.roomID != "" && !h.canJoin(c.userID, c.roomID) {
|
||||
c.roomID = ""
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.allClients[c] = true
|
||||
if c.roomID != "" {
|
||||
@@ -167,6 +191,14 @@ func (h *Hub) Unregister(c *Client) {
|
||||
}
|
||||
|
||||
func (h *Hub) RegisterSSE(c *SSEClient) {
|
||||
if c.roomID != "" && !h.canJoin(c.userID, c.roomID) {
|
||||
c.roomID = ""
|
||||
}
|
||||
for rID := range c.subscribedRooms {
|
||||
if !h.canJoin(c.userID, rID) {
|
||||
delete(c.subscribedRooms, rID)
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.sseClients[c] = true
|
||||
if c.roomID != "" {
|
||||
@@ -232,11 +264,15 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
||||
// JoinSSERoomByID adds the SSE connection sseID to a room; it only succeeds for the user owning that connection.
|
||||
func (h *Hub) JoinSSERoomByID(sseID, userID, roomID string) bool {
|
||||
if !h.canJoin(userID, roomID) {
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for client := range h.sseClients {
|
||||
if client.id == sseID {
|
||||
if client.id == sseID && client.userID == userID {
|
||||
if h.sseRooms[roomID] == nil {
|
||||
h.sseRooms[roomID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
@@ -251,11 +287,11 @@ func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
|
||||
func (h *Hub) LeaveSSERoomByID(sseID, userID, roomID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for client := range h.sseClients {
|
||||
if client.id == sseID {
|
||||
if client.id == sseID && client.userID == userID {
|
||||
if room, ok := h.sseRooms[roomID]; ok {
|
||||
delete(room, client)
|
||||
if len(room) == 0 {
|
||||
@@ -305,32 +341,6 @@ func (h *Hub) Close() {
|
||||
|
||||
// Broadcasting Methods
|
||||
|
||||
func (h *Hub) BroadcastToAll(event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for client := range h.allClients {
|
||||
client.trySend(data)
|
||||
}
|
||||
for sseClient := range h.sseClients {
|
||||
sseClient.trySend(data)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
@@ -361,15 +371,21 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
|
||||
h.BroadcastToUser(userID, event, payload)
|
||||
// BroadcastToUsers sends one event to every connection of the given users (duplicates are ignored).
|
||||
func (h *Hub) BroadcastToUsers(userIDs []string, event string, payload interface{}) {
|
||||
seen := make(map[string]bool, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
if uid == "" || seen[uid] {
|
||||
continue
|
||||
}
|
||||
seen[uid] = true
|
||||
h.BroadcastToUser(uid, event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||
if docID != "" {
|
||||
h.BroadcastRoom("doc:"+docID, event, payload, nil)
|
||||
} else {
|
||||
h.BroadcastToAll(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +429,10 @@ func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) {
|
||||
h.Broadcast("", listID, event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) JoinRoom(c *Client, roomID string) {
|
||||
func (h *Hub) JoinRoom(c *Client, roomID string) bool {
|
||||
if !h.canJoin(c.userID, roomID) {
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.rooms[roomID] == nil {
|
||||
@@ -424,6 +443,7 @@ func (h *Hub) JoinRoom(c *Client, roomID string) {
|
||||
c.subscribedRooms = make(map[string]bool)
|
||||
}
|
||||
c.subscribedRooms[roomID] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Hub) LeaveRoom(c *Client, roomID string) {
|
||||
@@ -512,11 +532,13 @@ func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID st
|
||||
return
|
||||
}
|
||||
|
||||
// The server's WriteTimeout would cut long-lived streams; lift it for this response only
|
||||
_ = http.NewResponseController(w).SetWriteDeadline(time.Time{})
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
client := &SSEClient{
|
||||
id: domain.NewID("sse"),
|
||||
@@ -591,7 +613,7 @@ func (c *Client) readPump() {
|
||||
break
|
||||
}
|
||||
|
||||
// Handle client-initiated lock, CRDT, room subscription, and presence messages
|
||||
// Handle client-initiated room subscription messages
|
||||
var incoming struct {
|
||||
Event string `json:"event"`
|
||||
DocID string `json:"docId"`
|
||||
|
||||
+36
-2
@@ -22,7 +22,7 @@ func TestHubCloseAndGracefulShutdown(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify broadcast works
|
||||
hub.BroadcastToAll("TEST_EVENT", map[string]string{"msg": "hello"})
|
||||
hub.BroadcastToUser("user-1", "TEST_EVENT", map[string]string{"msg": "hello"})
|
||||
|
||||
// Concurrently invoke Close() and Broadcast() to verify no panics or deadlocks
|
||||
var wg sync.WaitGroup
|
||||
@@ -31,7 +31,7 @@ func TestHubCloseAndGracefulShutdown(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
hub.BroadcastToAll("SPAM", map[string]int{"i": i})
|
||||
hub.BroadcastToUser("user-1", "SPAM", map[string]int{"i": i})
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
@@ -56,3 +56,37 @@ func TestHubCloseAndGracefulShutdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomJoinsRequireAuthorization(t *testing.T) {
|
||||
hub := NewHub()
|
||||
c := &Client{hub: hub, userID: "user-1", send: make(chan []byte, 10)}
|
||||
|
||||
if hub.JoinRoom(c, "lst_1") {
|
||||
t.Fatal("join must be denied without an authorizer")
|
||||
}
|
||||
|
||||
hub.SetRoomAuthorizer(func(userID, roomID string) bool {
|
||||
return userID == "user-1" && roomID == "lst_allowed"
|
||||
})
|
||||
if hub.JoinRoom(c, "lst_other") {
|
||||
t.Fatal("join to an unauthorized room must be denied")
|
||||
}
|
||||
if !hub.JoinRoom(c, "lst_allowed") {
|
||||
t.Fatal("join to an authorized room must succeed")
|
||||
}
|
||||
|
||||
anon := &Client{hub: hub, userID: "anonymous", send: make(chan []byte, 10)}
|
||||
if hub.JoinRoom(anon, "lst_allowed") {
|
||||
t.Fatal("anonymous clients must never join rooms")
|
||||
}
|
||||
|
||||
sse := &SSEClient{id: "sse_1", userID: "user-1", send: make(chan []byte, 10),
|
||||
subscribedRooms: map[string]bool{"lst_allowed": true, "doc:secret": true}}
|
||||
hub.RegisterSSE(sse)
|
||||
if sse.subscribedRooms["doc:secret"] {
|
||||
t.Fatal("unauthorized initial SSE rooms must be dropped")
|
||||
}
|
||||
if hub.JoinSSERoomByID("sse_1", "user-2", "lst_allowed") {
|
||||
t.Fatal("joining with another user's sseId must fail")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user