H-116: close visitor payload trust boundaries (#19)

* H-116: close visitor payload trust boundaries

* H-129: unblock SQLite backend tests

* H-129: remove stale last-seen response assertions

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-15 01:15:04 +08:00
committed by GitHub
co-authored by rogee
parent 55fac96700
commit 18ecee3e46
9 changed files with 211 additions and 16 deletions
@@ -11,6 +11,7 @@ import (
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/internal/webhookutil"
wspkg "github.com/gochat/gochat/internal/ws"
)
@@ -1323,7 +1324,7 @@ func widgetMessagePayload(message model.Message, conversationID uint) gin.H {
"conversation_id": conversationID,
"message_type": message.MessageType,
"content_type": message.ContentType,
"content_attributes": message.ContentAttributes,
"content_attributes": webhookutil.SanitizeOutboundJSON(message.ContentAttributes),
"created_at": message.CreatedAt.Unix(),
"private": message.Private,
"source_id": message.SourceID,
@@ -1513,13 +1514,10 @@ func publicMessagePayload(message model.Message, conversation model.Conversation
"content": message.Content,
"message_type": message.MessageType,
"content_type": message.ContentType,
"content_attributes": message.ContentAttributes,
"content_attributes": webhookutil.SanitizeOutboundJSON(message.ContentAttributes),
"created_at": message.CreatedAt.Unix(),
"conversation_id": publicDisplayID(conversation),
}
if message.SenderID != nil {
payload["sender"] = gin.H{"id": *message.SenderID, "type": message.SenderType}
}
return payload
}
@@ -50,6 +50,29 @@ type recordingWidgetEventPublisher struct {
payload interface{}
}
func TestPublicMessagePayloadSanitizesIdentity(t *testing.T) {
senderID := uint(9)
message := model.Message{
SenderID: &senderID,
SenderType: string(model.SenderTypeAgentBot),
Content: "same reply",
ContentAttributes: datatypes.JSON(`[{"business":"keep","nested":{"sender_name":"Captain","ai_takeover_active":true}}]`),
}
publicPayload := publicMessagePayload(message, model.Conversation{Base: model.Base{ID: 1}})
assert.NotContains(t, publicPayload, "sender")
assert.Equal(t, "same reply", publicPayload["content"])
assert.Equal(t, []any{map[string]any{"business": "keep", "nested": map[string]any{}}}, publicPayload["content_attributes"])
message.SenderType = string(model.SenderTypeUser)
assert.Equal(t, publicPayload, publicMessagePayload(message, model.Conversation{Base: model.Base{ID: 1}}))
widgetPayload := widgetMessagePayload(message, 1)
assert.Equal(t, publicPayload["content_attributes"], widgetPayload["content_attributes"])
message.ContentAttributes = datatypes.JSON(`{"sender_name":`)
assert.Empty(t, publicMessagePayload(message, model.Conversation{})["content_attributes"])
}
func (p *recordingWidgetEventPublisher) PublishWidgetEvent(accountID uint, pubsubToken string, eventType string, payload interface{}) {
p.accountID = accountID
p.pubsubToken = pubsubToken
+17 -5
View File
@@ -14,6 +14,7 @@ import (
"github.com/gorilla/websocket"
"github.com/gochat/gochat/internal/webhookutil"
wspkg "github.com/gochat/gochat/internal/ws"
"github.com/gochat/gochat/pkg/logger"
)
@@ -282,7 +283,7 @@ func (h *Hub) SendToAccount(accountID uint, data []byte) {
if clientIDs, ok := h.rooms[roomName]; ok {
for clientID := range clientIDs {
if client, ok := h.clients[clientID]; ok {
msg := wrapActionCableMessage(client.Identifier, data)
msg := wrapActionCableMessage(client.Identifier, eventDataForClient(client, data))
if msg == nil {
continue
}
@@ -306,7 +307,7 @@ func (h *Hub) SendToAccountConversation(accountID uint, conversationID uint, dat
if clientIDs, ok := h.rooms[roomName]; ok {
for clientID := range clientIDs {
if client, ok := h.clients[clientID]; ok {
msg := wrapActionCableMessage(client.Identifier, data)
msg := wrapActionCableMessage(client.Identifier, eventDataForClient(client, data))
if msg == nil {
continue
}
@@ -320,6 +321,17 @@ func (h *Hub) SendToAccountConversation(accountID uint, conversationID uint, dat
}
}
func eventDataForClient(client *Client, data []byte) []byte {
if !client.IsContact {
return data
}
payload, err := json.Marshal(webhookutil.SanitizeOutboundJSON(data))
if err != nil {
return []byte(`{}`)
}
return payload
}
// SendToRoom sends a message to all clients in a named room.
func (h *Hub) SendToRoom(room string, data []byte) {
h.mu.RLock()
@@ -328,7 +340,7 @@ func (h *Hub) SendToRoom(room string, data []byte) {
if clientIDs, ok := h.rooms[room]; ok {
for clientID := range clientIDs {
if client, ok := h.clients[clientID]; ok {
msg := wrapActionCableMessage(client.Identifier, data)
msg := wrapActionCableMessage(client.Identifier, eventDataForClient(client, data))
if msg == nil {
continue
}
@@ -348,7 +360,7 @@ func (h *Hub) SendToClient(clientID string, data []byte) {
defer h.mu.RUnlock()
if client, ok := h.clients[clientID]; ok {
msg := wrapActionCableMessage(client.Identifier, data)
msg := wrapActionCableMessage(client.Identifier, eventDataForClient(client, data))
if msg == nil {
return
}
@@ -624,4 +636,4 @@ func conversationRoomName(accountID uint, conversationID uint) string {
func pubsubTokenRoomName(token string) string {
return fmt.Sprintf("pubsub_token_%s", token)
}
}
+55
View File
@@ -255,6 +255,61 @@ func TestHub_SendToAccount(t *testing.T) {
}
}
func TestHub_SendToAccountSanitizesVisitorIdentity(t *testing.T) {
hub := NewHubSimple()
agent := NewClient(1, 10, nil, hub)
agent.Identifier = `{"channel":"AccountChannel","account_id":10}`
hub.Register(agent)
visitor := NewClient(2, 10, nil, hub)
visitor.IsContact = true
visitor.Identifier = `{"channel":"AccountChannel","account_id":10}`
hub.Register(visitor)
hub.SendToAccount(10, []byte(`{"event":"message.created","data":{"content":"same reply","sender_type":"AgentBot","sender_id":7,"ai_takeover_active":true,"additional_attributes":{"agent_name":"Captain"}}}`))
var agentMessage, visitorMessage []byte
select {
case agentMessage = <-agent.Send:
default:
t.Fatal("expected agent event")
}
select {
case visitorMessage = <-visitor.Send:
default:
t.Fatal("expected visitor event")
}
assert.Contains(t, string(agentMessage), "AgentBot")
assert.NotContains(t, string(visitorMessage), "AgentBot")
assert.NotContains(t, string(visitorMessage), "sender_id")
assert.NotContains(t, string(visitorMessage), "sender_type")
assert.NotContains(t, string(visitorMessage), "ai_takeover_active")
assert.NotContains(t, string(visitorMessage), "agent_name")
assert.NotContains(t, string(visitorMessage), "Captain")
}
func TestHub_VisitorRoomAndClientDeliverySanitizeIdentity(t *testing.T) {
hub := NewHubSimple()
visitor := NewClient(2, 10, nil, hub)
visitor.IsContact = true
visitor.Identifier = `{"channel":"RoomChannel"}`
hub.Register(visitor)
hub.subscribeClient(visitor.ID, "visitor-room")
visitor.SubscribedRooms["visitor-room"] = true
data := []byte(`{"event":"message.created","data":{"sender_type":"Captain::Assistant","sender_name":"Captain"}}`)
hub.SendToRoom("visitor-room", data)
hub.SendToClient(visitor.ID, data)
for range 2 {
select {
case message := <-visitor.Send:
assert.NotContains(t, string(message), "Captain")
assert.NotContains(t, string(message), "sender_type")
default:
t.Fatal("expected sanitized visitor event")
}
}
}
func TestHub_SendToAccount_NoClients(t *testing.T) {
hub := NewHubSimple()
hub.SendToAccount(10, []byte(`{"event":"test"}`)) // should not panic