fix: close panics and races the audit's own fixes left nearby

Second-pass review of the 54-commit self-correcting audit. Each item below
was confirmed by reading the surrounding source (and, where practical, the
pre-fix code) before being changed; regression tests are included for every
behavioral fix.

Concurrency:
- eventbus: Bus.Subscribe called wg.Add with no synchronization against a
  concurrent Bus.Stop's wg.Wait, a real "WaitGroup misuse" panic risk (e.g. a
  Telegram-bot settings save racing panel shutdown/restart). Stop now flips a
  mu-guarded `stopped` flag before waiting, and Subscribe checks it under the
  same lock, so Add and Wait can no longer race.

Security:
- login_limiter: evictForRoom's fallback eviction picked an arbitrary map
  key, including ones still under an active cooldown - an attacker flooding
  /login with fresh usernames could evict their own (or anyone's) blocked
  record and reset the lockout. The fallback now skips actively-blocked
  records, only falling back to an unconditional evict if the map is
  somehow entirely full of active blocks (preserves the hard memory cap).

Subscription-endpoint panics (reachable by any client hitting /sub):
- internal/sub/service.go: applyPathAndHostParams/Obj (ws/httpupgrade/xhttp
  with no path settings object) and the TLS alpn readers in three places
  used unchecked type assertions - exactly the bug class abab7cd0 patched
  elsewhere in the same switch statements, just not these call sites.
- internal/sub/json_service.go, clash_service.go: the externalProxy loops
  in the JSON and Clash generators used unchecked assertions on a
  legacy/admin-supplied field (missing "port", non-object entry, etc.).
- internal/sub/json_service.go: realityData's shortId/serverName selection
  could assert a non-string array element.

Other correctness:
- client_traffic.go: ResetAllTraffics (touched by 3eb214d0) still skipped
  clearing NodeClientTraffic node-sync baselines, unlike its sibling reset
  paths in the same file - a node's next sync would re-add pre-reset delta
  on top of the freshly-zeroed counter.
- inbound_traffic.go: the traffic-tick tx's Commit/Rollback errors were
  silently discarded; now logged so a backend-level commit failure (e.g. an
  aborted Postgres tx from a best-effort helper) doesn't masquerade as a
  successful tick.
- outbound_subscription.go: the new subscriptionFetchClient doc comment was
  wedged between fetchAndStore's existing comment and fetchAndStore itself,
  leaving fetchAndStore undocumented and the comment describing the wrong
  function.

Convention cleanup:
- Removed narrative // comments added by the audit that violate this repo's
  no-inline-comment rule (mostly narrating the specific bug/fix rather than
  a lasting contract, and mostly on new Test functions, which this repo's
  existing tests never comment) - calibrated against this exact codebase's
  own pre-existing comment style so legitimate godoc-style doc comments
  were left alone.
This commit is contained in:
claude[bot]
2026-07-15 11:59:30 +00:00
parent a862680645
commit fbad71d620
35 changed files with 179 additions and 147 deletions
+10 -4
View File
@@ -168,16 +168,22 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
proxies := make([]map[string]any, 0, len(externalProxies))
for _, ep := range externalProxies {
extPrxy := ep.(map[string]any)
extPrxy, ok := ep.(map[string]any)
if !ok {
continue
}
// Expand the host's {{VAR}} remark template for this client (no-op for
// the synthetic/legacy entry) before it becomes the proxy name.
subReq.renderHostRemark(inbound, client, extPrxy, network)
workingInbound := *inbound
workingInbound.Listen = extPrxy["dest"].(string)
workingInbound.Port = int(extPrxy["port"].(float64))
workingInbound.Listen, _ = extPrxy["dest"].(string)
if port, ok := extPrxy["port"].(float64); ok {
workingInbound.Port = int(port)
}
workingStream := cloneStreamForExternalProxy(stream)
switch extPrxy["forceTls"].(string) {
forceTls, _ := extPrxy["forceTls"].(string)
switch forceTls {
case "tls":
if workingStream["security"] != "tls" {
workingStream["security"] = "tls"
+12 -6
View File
@@ -177,14 +177,20 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
network, _ := stream["network"].(string)
for _, ep := range externalProxies {
extPrxy := ep.(map[string]any)
extPrxy, ok := ep.(map[string]any)
if !ok {
continue
}
// Expand the host's {{VAR}} remark template for this client (no-op for
// the synthetic/legacy entry) before it's used as the config remark.
subReq.renderHostRemark(inbound, client, extPrxy, network)
inbound.Listen = extPrxy["dest"].(string)
inbound.Port = int(extPrxy["port"].(float64))
inbound.Listen, _ = extPrxy["dest"].(string)
if port, ok := extPrxy["port"].(float64); ok {
inbound.Port = int(port)
}
newStream := cloneStreamForExternalProxy(stream)
switch extPrxy["forceTls"].(string) {
forceTls, _ := extPrxy["forceTls"].(string)
switch forceTls {
case "tls":
if newStream["security"] != "tls" {
newStream["security"] = "tls"
@@ -351,13 +357,13 @@ func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map
rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
shortIds, ok := rData["shortIds"].([]any)
if ok && len(shortIds) > 0 {
rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)
rltyData["shortId"], _ = shortIds[random.Num(len(shortIds))].(string)
} else {
rltyData["shortId"] = ""
}
serverNames, ok := rData["serverNames"].([]any)
if ok && len(serverNames) > 0 {
rltyData["serverName"] = serverNames[random.Num(len(serverNames))].(string)
rltyData["serverName"], _ = serverNames[random.Num(len(serverNames))].(string)
} else {
rltyData["serverName"] = ""
}
-4
View File
@@ -131,15 +131,11 @@ func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
func TestExpandRemarkVars_DropEmptySegments(t *testing.T) {
inbound := &model.Inbound{Remark: "host"}
// A client with no comment: the {{COMMENT}} segment resolves to empty and
// must be dropped, not left as a trailing "|".
noComment := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
if got := expandRemarkVars("{{INBOUND}}|{{COMMENT}}", noComment); got != "host" {
t.Errorf("empty comment segment = %q, want %q (no trailing pipe)", got, "host")
}
// A decorated empty segment (emoji + empty token) drops whole, not leaving
// a dangling emoji.
if got := expandRemarkVars("{{INBOUND}}|📅{{EXPIRE_DATE}}", noComment); got != "host" {
t.Errorf("decorated empty segment = %q, want %q", got, "host")
}
+11 -5
View File
@@ -993,7 +993,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
alpns, _ := tlsSetting["alpn"].([]any)
var alpn []string
for _, a := range alpns {
alpn = append(alpn, a.(string))
if s, ok := a.(string); ok {
alpn = append(alpn, s)
}
}
if len(alpn) > 0 {
params["alpn"] = strings.Join(alpn, ",")
@@ -1180,7 +1182,7 @@ func unmarshalStreamSettings(streamSettings string) map[string]any {
}
func applyPathAndHostParams(settings map[string]any, params map[string]string) {
params["path"] = settings["path"].(string)
params["path"], _ = settings["path"].(string)
if host, ok := settings["host"].(string); ok && len(host) > 0 {
params["host"] = host
} else {
@@ -1190,7 +1192,7 @@ func applyPathAndHostParams(settings map[string]any, params map[string]string) {
}
func applyPathAndHostObj(settings map[string]any, obj map[string]any) {
obj["path"] = settings["path"].(string)
obj["path"], _ = settings["path"].(string)
if host, ok := settings["host"].(string); ok && len(host) > 0 {
obj["host"] = host
} else {
@@ -1312,7 +1314,9 @@ func applyShareTLSParams(stream map[string]any, params map[string]string) {
alpns, _ := tlsSetting["alpn"].([]any)
var alpn []string
for _, a := range alpns {
alpn = append(alpn, a.(string))
if s, ok := a.(string); ok {
alpn = append(alpn, s)
}
}
if len(alpn) > 0 {
params["alpn"] = strings.Join(alpn, ",")
@@ -1346,7 +1350,9 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
if len(alpns) > 0 {
var alpn []string
for _, a := range alpns {
alpn = append(alpn, a.(string))
if s, ok := a.(string); ok {
alpn = append(alpn, s)
}
}
obj["alpn"] = strings.Join(alpn, ",")
}
+41 -11
View File
@@ -11,11 +11,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// A subscription is built by iterating every client's share link with no
// recover(), so any panic in the link generators 500s the whole subscription
// for every client. Valid-but-unusual stream settings (an empty Reality
// shortIds/serverNames array, a tcp-http header with no request, a grpc block
// missing its keys) must therefore produce a link, not a panic.
func TestGetSubsToleratesUnusualStreamSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
@@ -27,6 +22,9 @@ func TestGetSubsToleratesUnusualStreamSettings(t *testing.T) {
{"tcp http empty path", `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":[]}}}}`},
{"grpc missing keys", `{"network":"grpc","security":"none","grpcSettings":{}}`},
{"empty stream settings", `{}`},
{"ws missing wsSettings", `{"network":"ws","security":"none"}`},
{"httpupgrade missing settings", `{"network":"httpupgrade","security":"none"}`},
{"tls alpn non-string element", `{"network":"tcp","security":"tls","tlsSettings":{"alpn":[123]}}`},
}
for i, tc := range cases {
@@ -46,9 +44,6 @@ func TestGetSubsToleratesUnusualStreamSettings(t *testing.T) {
}
}
// The JSON subscription generator for a Hysteria inbound whose StreamSettings
// omit the hysteriaSettings key must not panic (which would 500 the whole JSON
// subscription); the raw generator already tolerates this shape.
func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
seedSubDB(t)
db := database.GetDB()
@@ -83,9 +78,21 @@ func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
}
}
// A Clash subscription must carry the pinned peer certificate SHA-256 when the
// inbound configures one, matching the JSON subscription; dropping it silently
// downgrades certificate pinning for Clash subscribers.
func TestGetJsonToleratesNonStringRealityShortId(t *testing.T) {
seedSubDB(t)
stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}`
seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
jsonService := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if out == "" {
t.Fatal("GetJson returned empty for a reality inbound with a non-string shortId element")
}
}
func TestGetClashEmitsPinnedCertSha256(t *testing.T) {
seedSubDB(t)
const pin = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
@@ -100,3 +107,26 @@ func TestGetClashEmitsPinnedCertSha256(t *testing.T) {
t.Fatalf("Clash proxy dropped the pinned cert sha256:\n%s", out)
}
}
func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
seedSubDB(t)
stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}`
seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
jsonService := NewSubJsonService("", "", "", NewSubService(""))
jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if jsonOut == "" {
t.Fatal("GetJson returned empty for an externalProxy entry missing port")
}
clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if clashOut == "" {
t.Fatal("GetClash returned empty for an externalProxy entry missing port")
}
}