Files
viettranx 79cae648e4 fix(security): pairing expiry race, sender-ID validation, Slack senderID cleanup
- Add expires_at check to ApprovePairing SELECT (PG + SQLite) to close
  race between prune DELETE and code lookup
- Add isValidSenderID regex validation to handleRequest, handleRevoke,
  and handleBrowserPairingStatus (prevents log injection / bus poisoning)
- Add slog.Warn on decrypt fallback paths for downgrade detection
- Remove Slack compound senderID|displayName format; all channels now
  pass plain senderID with displayName in metadata
2026-04-02 20:29:44 +07:00

275 lines
9.0 KiB
Go

package methods
import (
"context"
"encoding/json"
"log/slog"
"regexp"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
var validSenderIDRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:@-]*$`)
// isValidSenderID checks that a sender ID contains only safe characters.
// Prevents log injection and bus event poisoning.
func isValidSenderID(id string) bool {
return len(id) <= 128 && validSenderIDRe.MatchString(id)
}
// PairingApproveCallback is called after a pairing is approved.
// channel is the channel name (e.g., "telegram"), chatID is the chat to notify,
// senderID identifies the paired entity (e.g., "group:XXXX" for group pairings).
type PairingApproveCallback func(ctx context.Context, channel, chatID, senderID string)
// PairingMethods handles device.pair.request, device.pair.approve, device.pair.list, device.pair.revoke.
type PairingMethods struct {
service store.PairingStore
msgBus *bus.MessageBus
onApprove PairingApproveCallback
broadcaster func(protocol.EventFrame)
rateLimiter *gateway.RateLimiter
}
func NewPairingMethods(service store.PairingStore, msgBus *bus.MessageBus, rateLimiter *gateway.RateLimiter) *PairingMethods {
return &PairingMethods{service: service, msgBus: msgBus, rateLimiter: rateLimiter}
}
// SetOnApprove sets a callback that fires after a pairing is approved.
func (m *PairingMethods) SetOnApprove(cb PairingApproveCallback) {
m.onApprove = cb
}
// SetBroadcaster sets a function to broadcast events to all WS clients.
func (m *PairingMethods) SetBroadcaster(fn func(protocol.EventFrame)) {
m.broadcaster = fn
}
func (m *PairingMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodPairingRequest, m.handleRequest)
router.Register(protocol.MethodPairingApprove, m.handleApprove)
router.Register(protocol.MethodPairingDeny, m.handleDeny)
router.Register(protocol.MethodPairingList, m.handleList)
router.Register(protocol.MethodPairingRevoke, m.handleRevoke)
router.Register(protocol.MethodBrowserPairingStatus, m.handleBrowserPairingStatus)
}
func (m *PairingMethods) handleRequest(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
SenderID string `json:"senderId"`
Channel string `json:"channel"`
ChatID string `json:"chatId"`
AccountID string `json:"accountId"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.SenderID == "" || params.Channel == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired)))
return
}
if !isValidSenderID(params.SenderID) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid sender_id format"))
return
}
if params.AccountID == "" {
params.AccountID = "default"
}
code, err := m.service.RequestPairing(ctx, params.SenderID, params.Channel, params.ChatID, params.AccountID, nil)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"code": code,
}))
}
func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Code string `json:"code"`
ApprovedBy string `json:"approvedBy"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.Code == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCodeRequired)))
return
}
if params.ApprovedBy == "" {
params.ApprovedBy = "operator"
}
paired, err := m.service.ApprovePairing(ctx, params.Code, params.ApprovedBy)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
// Notify the user via channel (matching TS notifyPairingApproved).
// Use Background context: the CLI client may disconnect before the notification is sent.
if m.onApprove != nil && paired != nil {
go m.onApprove(context.Background(), paired.Channel, paired.ChatID, paired.SenderID)
}
if m.broadcaster != nil {
m.broadcaster(*protocol.NewEvent(protocol.EventDevicePairRes, map[string]any{"action": "approved"}))
}
emitAudit(m.msgBus, client, "pairing.approved", "pairing", params.Code)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"paired": paired,
}))
}
func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Code string `json:"code"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.Code == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCodeRequired)))
return
}
if err := m.service.DenyPairing(ctx, params.Code); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
if m.broadcaster != nil {
m.broadcaster(*protocol.NewEvent(protocol.EventDevicePairRes, map[string]any{"action": "denied"}))
}
emitAudit(m.msgBus, client, "pairing.denied", "pairing", params.Code)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"denied": true,
}))
}
func (m *PairingMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
pending := m.service.ListPending(ctx)
paired := m.service.ListPaired(ctx)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"pending": pending,
"paired": paired,
}))
}
func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
SenderID string `json:"senderId"`
Channel string `json:"channel"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.SenderID == "" || params.Channel == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired)))
return
}
if !isValidSenderID(params.SenderID) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid sender_id format"))
return
}
if err := m.service.RevokePairing(ctx, params.SenderID, params.Channel); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
if m.broadcaster != nil {
m.broadcaster(*protocol.NewEvent(protocol.EventDevicePairRes, map[string]any{"action": "revoked"}))
}
// Broadcast revocation so the server can force-disconnect the active session.
if m.msgBus != nil {
m.msgBus.Broadcast(bus.Event{
Name: bus.EventPairingRevoked,
Payload: bus.PairingRevokedPayload{
SenderID: params.SenderID,
Channel: params.Channel,
},
})
}
emitAudit(m.msgBus, client, "pairing.revoked", "pairing", params.SenderID)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"revoked": true,
}))
}
// handleBrowserPairingStatus lets a pending browser client check if its pairing code has been approved.
// Called by unauthenticated clients during the browser pairing flow.
func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
// Rate-limit unauthenticated polling to prevent sender_id enumeration.
if m.rateLimiter != nil && m.rateLimiter.Enabled() && !m.rateLimiter.Allow("pairing:"+client.RemoteAddr()) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrResourceExhausted, "rate limited"))
return
}
locale := store.LocaleFromContext(ctx)
var params struct {
SenderID string `json:"sender_id"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.SenderID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderIDRequired)))
return
}
if !isValidSenderID(params.SenderID) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid sender_id format"))
return
}
paired, pairErr := m.service.IsPaired(ctx, params.SenderID, "browser")
if pairErr != nil {
slog.Warn("security.pairing_check_failed", "sender_id", params.SenderID, "error", pairErr)
}
if paired {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"status": "approved",
}))
return
}
// Check if the pairing request still exists (not expired)
pending := m.service.ListPending(ctx)
for _, p := range pending {
if p.SenderID == params.SenderID && p.Channel == "browser" {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"status": "pending",
}))
return
}
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"status": "expired",
}))
}