Files
3x-ui/internal/web/service/tgbot/tgbot_client_links_authz_test.go
T
BlindMaster24 7ac5277c4f fix(tgbot): require client ownership for non-admin link callbacks (#6489)
A non-admin tapping a subscription, individual-links or QR-links button was
served whatever email that button carried. Those keyboards outlive the chat
they were sent to (group chats, forwarded cards, a client whose tgId was
later revoked), so the email in the callback data cannot authorise itself.
The lookup the self-service usage command already performs now decides
whether the callback is served.

The gate also has to read the email at all: encodeQuery replaces any
callback payload past 64 chars with a hash, so for a client whose email is
long enough the non-admin path saw a bare hash and dropped the tap without a
word. The raw data is now decoded before the gate, which is the same decode
the admin path already performs, and an email the caller cannot prove is
answered with the generic error instead of silence.
2026-09-13 11:46:14 +02:00

126 lines
3.9 KiB
Go

package tgbot
import (
"path/filepath"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mymmrac/telego"
)
const (
ownerTgID = int64(4242)
ownerMail = "owner@x"
)
// newLinksCallbackTgbot seeds one inbound whose settings bind email to
// ownerTgID, the traffic row the ownership lookup joins on, and a mocked API.
func newLinksCallbackTgbot(t *testing.T, email string) (*Tgbot, func(string) int) {
t.Helper()
mock, calls := staleButtonServer(t, map[string]any{
"answerCallbackQuery": map[string]any{"ok": true, "result": true},
"sendMessage": map[string]any{"ok": true, "result": map[string]any{
"message_id": 1,
"date": 0,
"chat": map[string]any{"id": ownerTgID, "type": "private"},
}},
})
swapTestBot(t, mock.URL)
t.Cleanup(mock.Close)
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
inbound := &model.Inbound{
UserId: 1,
Remark: "in",
Port: 443,
Protocol: model.VLESS,
Enable: true,
Settings: `{"clients":[{"email":"` + email + `","tgId":4242,"subId":"sub-owned"}]}`,
}
if err := database.GetDB().Create(inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
if err := database.GetDB().Create(&xray.ClientTraffic{
InboundId: inbound.Id,
Email: email,
Enable: true,
}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
origRunning := isRunning
t.Cleanup(func() { isRunning = origRunning })
isRunning = true
return &Tgbot{}, calls
}
func tapClientLinks(t *testing.T, tb *Tgbot, tgUserID int64, data string) {
t.Helper()
tb.answerCallback(&telego.CallbackQuery{
ID: "q1",
From: telego.User{ID: tgUserID},
Data: data,
Message: &telego.Message{Chat: telego.Chat{ID: tgUserID}},
}, false)
}
// Regression test: a non-admin tapping a link callback carrying another
// client's email must be refused; without the ownership check it is served.
func TestClientLinkCallbackRefusesForeignClient(t *testing.T) {
tb, calls := newLinksCallbackTgbot(t, ownerMail)
tapClientLinks(t, tb, ownerTgID, "client_sub_links someone-else@x")
if n := calls("sendMessage"); n != 0 {
t.Errorf("sendMessage calls = %d, want 0: a non-admin received a foreign client's links", n)
}
if n := calls("answerCallbackQuery"); n != 1 {
t.Errorf("answerCallbackQuery calls = %d, want 1: the refused tap must be answered", n)
}
}
// The same guard must not lock the owner out of their own links.
func TestClientLinkCallbackServesOwnClient(t *testing.T) {
tb, calls := newLinksCallbackTgbot(t, ownerMail)
tapClientLinks(t, tb, ownerTgID, "client_sub_links "+ownerMail)
if n := calls("sendMessage"); n != 1 {
t.Errorf("sendMessage calls = %d, want 1: the owner must still get its links", n)
}
if n := calls("answerCallbackQuery"); n != 0 {
t.Errorf("answerCallbackQuery calls = %d, want 0: an allowed tap is not refused", n)
}
}
// Regression test: a payload past 64 chars arrives as its hash, so an email long
// enough to be hashed must still be decoded and served to its owner.
func TestHashedLinkCallbackServesOwnClient(t *testing.T) {
const longMail = "very-long-owner-address-for-hashed-buttons@example.com"
tb, calls := newLinksCallbackTgbot(t, longMail)
origStorage := hashStorage
hashStorage = global.NewHashStorage(20 * time.Minute)
t.Cleanup(func() { hashStorage = origStorage })
data := tb.encodeQuery("client_sub_links " + longMail)
if data == "client_sub_links "+longMail {
t.Fatalf("encodeQuery left %q unhashed; the test needs a hashed payload", data)
}
tapClientLinks(t, tb, ownerTgID, data)
if n := calls("sendMessage"); n != 1 {
t.Errorf("sendMessage calls = %d, want 1: the owner's hashed button must still be served", n)
}
}