Files
goclaw/internal/gateway/methods/pairing.go
T
viettranx 0b5124a8f1 fix(security): harden pairing auth — fail-closed, rate-limit, TTL
- Change WS pairing check from fail-open to fail-closed on DB error
  (router.go: previously granted RoleOperator on any IsPaired() error)
- Add "browser" to InternalChannels so it's properly excluded from
  outbound dispatch without ad-hoc helpers
- Rate-limit browser.pairing.status endpoint to prevent sender_id
  enumeration (reuses server RateLimiter via PairingMethods injection)
- Add expires_at column to paired_devices with 30-day TTL for
  defense-in-depth; IsPaired() now checks expiry, ListPaired() prunes
- Add confidence_score column to team_tasks, team_messages,
  team_task_comments
- Bump RequiredSchemaVersion to 21
2026-03-16 19:55:08 +07:00

251 lines
8.2 KiB
Go

package methods
import (
"context"
"encoding/json"
"log/slog"
"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"
)
// 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 params.AccountID == "" {
params.AccountID = "default"
}
code, err := m.service.RequestPairing(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(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(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(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
pending := m.service.ListPending()
paired := m.service.ListPaired()
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 err := m.service.RevokePairing(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
}
paired, pairErr := m.service.IsPaired(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()
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",
}))
}