fix(sub): resolve a blank subId to no subscription

A subId is the only selector of a subscription's inbounds and external
links, so a blank one matched every client stored without a subId, as
clients embedded through inbounds/add or inbounds/import are. The
Telegram bot authorised a client by email and then looked its links up
by that blank subId, handing a bound user the working links of every
other subId-less client.

A blank subId now resolves to nothing in the subscription service, and
the bot builds a subId-less client's own links by email, as the panel's
client page does.
This commit is contained in:
MHSanaei
2026-11-01 00:57:32 +01:00
parent 9a95153f0e
commit 1c748adadf
5 changed files with 70 additions and 1 deletions
+3
View File
@@ -36,6 +36,9 @@ type expandedLink struct {
// getClientExternalLinksBySubId returns active rows with owner state attached.
// Consumers keep inactive owners as metadata but omit their link values.
func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
if strings.TrimSpace(subId) == "" {
return nil, nil
}
db := database.GetDB()
var recs []model.ClientRecord
if err := db.Where("sub_id = ?", subId).Find(&recs).Error; err != nil {
@@ -113,3 +113,25 @@ func TestInactiveExternalOnlySubRemainsKnownWithoutExposingLinks(t *testing.T) {
})
}
}
// Clients stored without a subId share no subscription; a blank key must not
// select all of them. The routes never pass one, in-process callers can.
func TestBlankSubIdResolvesToNothing(t *testing.T) {
for _, subId := range []string{"", " "} {
seedSubDB(t)
seedSubInbound(t, subId, "one", 4431, 1, `{"network":"tcp","security":"none"}`)
seedSubInbound(t, subId, "two", 4432, 2, `{"network":"tcp","security":"none"}`)
seedInactiveExternalOnlySub(t, subId, "ext@e", true, time.Now().Add(time.Hour).UnixMilli())
links, emails, _, _, err := NewSubService("").GetSubs(subId, "req.example.com")
if err != nil || len(links) != 0 || len(emails) != 0 {
t.Errorf("GetSubs(%q) = %v, %v, %v; want nothing", subId, links, emails, err)
}
if out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson(subId, "req.example.com", false); err != nil || out != "" {
t.Errorf("GetJson(%q) = %q, %v; want nothing", subId, out, err)
}
if out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash(subId, "req.example.com"); err != nil || out != "" {
t.Errorf("GetClash(%q) = %q, %v; want nothing", subId, out, err)
}
}
}
+5 -1
View File
@@ -737,8 +737,12 @@ func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
return 0
}
// getInboundsBySubId loads the subscriber's inbounds; hosts are read per request, never cached.
// getInboundsBySubId loads the subscriber's inbounds, hosts read per request; a blank subId
// would match every client stored without one, so it names no subscription.
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
if strings.TrimSpace(subId) == "" {
return nil, nil
}
inbounds, err := s.loadInboundsBySubId(subId)
if err != nil {
return nil, err