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
@@ -2,10 +2,6 @@ package service
import "testing"
// Xray access-log lines carry attacker-influenced content (a client's requested
// destination is logged verbatim) and can be truncated. parseAccessLogFields
// must never panic on a short or malformed line, and must still parse a
// well-formed line correctly.
func TestParseAccessLogFields(t *testing.T) {
malformed := []string{
"",
@@ -88,9 +88,6 @@ func TestResetClientExpiryTimeByEmail_MultiInbound(t *testing.T) {
}
}
// A disable-by-email (Telegram bot / LDAP sync) must flip enable on every
// inbound the client is attached to. Patching only the first inbound left the
// client connecting through its siblings' running Xray.
func TestSetClientEnableByEmail_MultiInbound(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
@@ -5,9 +5,6 @@ import (
"time"
)
// lockInbound must not hold the global registry mutex while it waits on a busy
// inbound's own mutex, otherwise one slow client operation on a single inbound
// freezes client mutations on every other inbound panel-wide.
func TestLockInboundReleasesRegistryMutexWhileWaiting(t *testing.T) {
const id = 990006
held := lockInbound(id)
+4 -1
View File
@@ -210,7 +210,10 @@ func (s *ClientService) ResetAllTraffics() (bool, error) {
return res.Error
}
affected = res.RowsAffected
return tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error
if err := tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
return err
}
return tx.Where("1 = 1").Delete(&model.NodeClientTraffic{}).Error
})
})
if err != nil {
@@ -6,10 +6,6 @@ import (
"time"
)
// A server that accepts the TCP connection but then never speaks must not block
// the sender goroutine indefinitely: sendPlain arms a connection deadline so the
// SMTP greeting read fails instead of hanging until the OS TCP timeout, long
// after the caller's own 30s budget has passed.
func TestSendPlainReturnsOnStalledServer(t *testing.T) {
orig := smtpDeadline
smtpDeadline = 300 * time.Millisecond
@@ -91,10 +91,6 @@ func TestAddInbound_RejectsFinalMaskRealityCombo(t *testing.T) {
}
}
// AddInbound must always create a new row. The add controller binds the model's
// `id` form field and never clears it, so a client that reuses an existing id
// (e.g. duplicating an inbound fetched from /get) must not silently overwrite
// that stored row via GORM Save's upsert-on-primary-key behavior.
func TestAddInbound_IgnoresBoundIdAndCreatesNewRow(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
@@ -127,10 +123,6 @@ func TestAddInbound_IgnoresBoundIdAndCreatesNewRow(t *testing.T) {
}
}
// A WireGuard inbound carrying clients (an imported config, or a node-reconcile
// re-add) must be accepted: WG clients are keyed by their public key and have no
// `id`, so the generic default validation branch wrongly rejected them with
// "empty client ID".
func TestAddInbound_AcceptsWireguardClientWithKey(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
@@ -11,11 +11,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
// A local MTProto inbound edit must not push to the managed sidecar from inside
// the serialized write transaction: that blocks the single traffic-writer
// goroutine on process/network I/O, and a later step failing the transaction
// would leave the sidecar ahead of the rolled-back database. The push belongs in
// the post-commit hook, exactly as the xray branch already does it.
func TestUpdateInboundLocalMtprotoDefersPushUntilCommit(t *testing.T) {
setupConflictDB(t)
@@ -52,9 +47,6 @@ func TestUpdateInboundLocalMtprotoDefersPushUntilCommit(t *testing.T) {
}
}
// Re-enabling a routed MTProto inbound must request an xray restart: the egress
// SOCKS bridge is only injected for enabled inbounds, so the running config
// needs regenerating or the sidecar dials a bridge that is not there.
func TestSetInboundEnableRoutedMtprotoRequestsRestart(t *testing.T) {
setupConflictDB(t)
+5 -3
View File
@@ -37,9 +37,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
if rbErr := tx.Rollback().Error; rbErr != nil {
logger.Warning("Error rolling back traffic tx:", rbErr)
}
} else if cErr := tx.Commit().Error; cErr != nil {
logger.Warning("Error committing traffic tx:", cErr)
}
}()
err = s.addInboundTraffic(tx, inboundTraffics)
@@ -10,9 +10,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
// A hostile egress proxy (or a MITM on the WARP endpoint) could stream an
// arbitrarily large body; doWarpRequest must cap the read at maxResponseSize so
// the panel cannot be forced into an unbounded allocation.
func TestDoWarpRequestCapsResponseBody(t *testing.T) {
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
@@ -2,10 +2,6 @@ package service
import "testing"
// The x25519 / mldsa65 / mlkem768 key generators parse xray's stdout by fixed
// slice index. A short or reformatted output (a future xray release, or a
// binary that failed silently) must yield an error, never an out-of-range
// panic that 500s the handler.
func TestParseXrayKeyPairOutput(t *testing.T) {
a, b, err := parseXrayKeyPairOutput("Private key: abc123\nPublic key: def456\n")
if err != nil {
@@ -277,7 +277,6 @@ func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
return refreshed, nil
}
// fetchAndStore does the actual network + parse + stability + persist work.
// subscriptionFetchClient builds the HTTP client used to fetch a subscription.
// A configured panel egress proxy dials the loopback SOCKS bridge (xray handles
// the real egress), so its localhost dial must not be SSRF-blocked. A direct
@@ -295,6 +294,7 @@ func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Durat
}
}
// fetchAndStore does the actual network + parse + stability + persist work.
func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) {
// Re-sanitize on every fetch (handles legacy rows + defense in depth against
// any direct DB tampering). Private targets are blocked unless this
@@ -10,9 +10,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
)
// The direct subscription-fetch client must dial through the SSRF guard so a
// subscription host that resolves to a private/internal address (including a
// DNS-rebinding flip after validation) is blocked at dial time, not connected to.
func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) {
setupSettingTestDB(t)
client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5 * time.Second)
@@ -21,9 +21,6 @@ func (b *blockingResetRuntime) ResetAllTraffics(context.Context) error {
return nil
}
// A "Reset All Traffics" that propagates to nodes must not hold the single serial
// traffic writer across the remote HTTP calls: each can block for seconds, and
// stalling the writer drops every concurrent poll's traffic deltas.
func TestResetAllTrafficsDoesNotBlockWriterOnNodeCall(t *testing.T) {
db := initTrafficTestDB(t)
resetTrafficWriterForTest(t)
@@ -6,12 +6,6 @@ import (
"github.com/mymmrac/telego"
)
// A non-admin callback must never reach a privileged handler. The second
// callback switch runs outside the isAdmin guard, so without the default-deny
// check a non-admin who can tap an admin's inline button (e.g. in a group) could
// export the database backup or reset all traffic. Here the privileged handler
// would panic on the nil bot/services of a bare Tgbot; the guard must return
// first, so no panic occurs.
func TestAnswerCallbackDeniesPrivilegedActionToNonAdmin(t *testing.T) {
defer func() {
if r := recover(); r != nil {
@@ -2,10 +2,6 @@ package tgbot
import "testing"
// A transient "delete after N seconds" message must not reset the conversation
// state when its timer fires: the user may have advanced to the next wizard step
// (setting a fresh state) within that window, and clearing it would silently
// drop their next input.
func TestDeleteMessageAfterDelayKeepsUserState(t *testing.T) {
userStateMgr.reset()
t.Cleanup(userStateMgr.reset)
@@ -1128,11 +1128,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
}
}
// The callbacks below sit outside the isAdmin block above, so a non-admin who
// can see an admin's inline keyboard (for example when the bot runs in a
// group) could otherwise trigger a database backup export, a mass traffic
// reset or client creation. Default-deny: a non-admin may only run the
// per-user client_* callbacks that key off their own Telegram id.
if !isAdmin && !isClientSelfCallback(callbackQuery.Data) {
return
}
+24 -8
View File
@@ -11,11 +11,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// The traffic tick stages inbound and client deltas, then runs three best-effort
// maintenance helpers (renew, disable-depleted-clients, disable-depleted-inbounds)
// that are meant to log and continue. A failure in one of them must not roll back
// the already-staged traffic — xray has already advanced its baseline, so a
// rolled-back tick loses that traffic permanently.
func TestAddTrafficCommitsDespiteDisableHelperError(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
@@ -53,9 +48,6 @@ func TestAddTrafficCommitsDespiteDisableHelperError(t *testing.T) {
}
}
// Reset All Client Traffic must re-enable clients that were auto-disabled for
// exceeding their quota, matching every other reset path; otherwise a reset
// leaves depleted clients cut with zero usage.
func TestResetAllTrafficsReenablesDepletedClients(t *testing.T) {
db := initTrafficTestDB(t)
svc := &ClientService{}
@@ -76,3 +68,27 @@ func TestResetAllTrafficsReenablesDepletedClients(t *testing.T) {
t.Fatal("a depleted client must be re-enabled after Reset All Client Traffic, matching every other reset path")
}
}
func TestResetAllTrafficsClearsNodeBaselines(t *testing.T) {
db := initTrafficTestDB(t)
svc := &ClientService{}
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "spent@x", Enable: true, Up: 60, Down: 60, Total: 100}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: "spent@x", Up: 60, Down: 60}).Error; err != nil {
t.Fatalf("seed node baseline: %v", err)
}
if _, err := svc.ResetAllTraffics(); err != nil {
t.Fatalf("ResetAllTraffics: %v", err)
}
var cnt int64
if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", "spent@x").Count(&cnt).Error; err != nil {
t.Fatalf("count baselines: %v", err)
}
if cnt != 0 {
t.Fatalf("Reset All Client Traffic must clear node baselines like its sibling reset paths, found %d", cnt)
}
}
-3
View File
@@ -986,9 +986,6 @@ func (s *XrayService) RestartXray(isForce bool) error {
lock.Lock()
defer lock.Unlock()
logger.Debug("restart Xray, force:", isForce)
// A background reconcile (a pending config-change flag, warp/ldap/outbound
// jobs) must not revive an Xray the admin deliberately stopped; only an
// explicit forced restart clears the manual-stop state.
if !isForce && isManuallyStopped.Load() {
return nil
}
@@ -4,9 +4,6 @@ import (
"testing"
)
// A background (non-forced) restart — the pending-config-change cron, warp/ldap/
// outbound reconcile jobs — must not revive an Xray the admin deliberately
// stopped. Only an explicit forced restart clears the manual-stop state.
func TestRestartXrayRespectsManualStop(t *testing.T) {
setupSettingTestDB(t)
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", "{ not valid json"); err != nil {
@@ -22,9 +19,6 @@ func TestRestartXrayRespectsManualStop(t *testing.T) {
}
}
// When the pending-restart reconcile consumes the need-restart flag but the
// restart itself fails, the flag must be re-armed so the config change is
// retried rather than silently dropped.
func TestApplyPendingRestartReArmsFlagOnFailure(t *testing.T) {
setupSettingTestDB(t)
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", "{ not valid json"); err != nil {