fix(wireguard): check every line of generated client configs

The WireGuard and AmneziaWG .conf builders (subscription service, clients
page, inbound modal and subscription page) checked only a few of the values
they write. Each builder now assembles the file line by line and renders
nothing when a line holds a control character, so every value is covered
whatever stored it. A decoded vpn:// payload is refused when it carries a
wg-quick hook directive, which no panel builder writes, and the subscription
page hides a config block that rendered nothing.

The inbound form now validates a WireGuard/AmneziaWG client's allowedIPs on
create and on update, including the stored clients a protocol switch keeps,
as the client endpoints already did.
This commit is contained in:
MHSanaei
2026-10-24 01:10:09 +02:00
parent 57fd61790c
commit 0faad4f4f3
12 changed files with 285 additions and 222 deletions
+7
View File
@@ -1409,6 +1409,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if client.PublicKey == "" {
return inbound, false, common.NewError("wireguard client requires a key")
}
if _, err := normalizeWireguardAllowedIPs(client.AllowedIPs); err != nil {
return inbound, false, err
}
case "mtproto":
if client.Secret == "" {
return inbound, false, common.NewError("mtproto client requires a secret")
@@ -1923,6 +1926,10 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e
if client.Password == "" {
return common.NewError("empty client ID")
}
case model.WireGuard, model.AmneziaWG:
if _, err := normalizeWireguardAllowedIPs(client.AllowedIPs); err != nil {
return err
}
case model.TUIC:
if client.ID == "" {
return common.NewError("empty client ID")
@@ -265,6 +265,30 @@ func TestNormalizeAmneziaWGSettings_RejectsInjectedClientAllowedIPs(t *testing.T
}
}
// A protocol switch keeps the stored clients, re-injected after the AmneziaWG
// normalize ran, so a WireGuard row's bad allowedIPs skipped every check.
func TestUpdateInbound_ProtocolSwitchRejectsStoredClientAllowedIPs(t *testing.T) {
setupConflictDB(t)
stored := mkInbound(t, 51830, model.WireGuard, `{"secretKey":"`+awgTestPrivateKey+`","clients":[{"email":"sw@x","enable":true,`+
`"privateKey":"`+awgTestPrivateKey+`","publicKey":"`+awgTestPublicKey+`","allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`)
update := *stored
update.Protocol = model.AmneziaWG
update.Settings = `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`
_, _, err := (&InboundService{}).UpdateInbound(&update)
if err == nil || !strings.Contains(err.Error(), "invalid allowedIPs entry") {
t.Fatalf("UpdateInbound err = %v, want an invalid allowedIPs entry error", err)
}
var after model.Inbound
if err := database.GetDB().First(&after, stored.Id).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if after.Protocol != model.WireGuard {
t.Fatalf("rejected switch was persisted: protocol = %q", after.Protocol)
}
}
func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -149,6 +150,29 @@ func TestAddInbound_AcceptsWireguardClientWithKey(t *testing.T) {
}
}
// The client endpoints already refuse a non-prefix allowedIPs entry; the inbound
// form stored it verbatim, and a line break in it reaches every rendered .conf.
func TestAddInbound_RejectsWireguardClientAllowedIPsThatIsNotAPrefix(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
settings := `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420,"clients":[{"email":"wgbad@x","enable":true,"privateKey":"keep-priv","publicKey":"keep-pub",` +
`"allowedIPs":["10.0.0.51/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`
in := &model.Inbound{Tag: "in-45201-wg", Enable: true, Listen: "0.0.0.0", Port: 45201, Protocol: model.WireGuard, Settings: settings}
_, _, err := svc.AddInbound(in)
if err == nil || !strings.Contains(err.Error(), "invalid allowedIPs entry") {
t.Fatalf("AddInbound err = %v, want an invalid allowedIPs entry error", err)
}
var count int64
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "in-45201-wg").Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 0 {
t.Fatalf("rejected WireGuard inbound was persisted anyway, row count = %d", count)
}
}
// end-to-end: same guard on the update path, on a row that was valid before
// the edit — the rejected StreamSettings must not overwrite the stored row.
func TestUpdateInbound_RejectsFinalMaskRealityCombo(t *testing.T) {