mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-13 09:25:14 +03:00
fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H (#6642)
* fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H The native AmneziaWG validator, both Zod schemas, both forms and the docs now follow the rules amneziawg-go actually enforces. S1-S3. A padded handshake message is 148+S1, 92+S2 or 64+S3 bytes (device/send.go). The peer reads each datagram into a [MaxMessageSize]byte buffer, where MaxMessageSize = MaxSegmentSize (device/pools.go, constants.go). MaxSegmentSize is 65535 on Linux/Android, 2016 on Windows and 1700 on iOS (device/queueconstants_*.go). The limits are therefore S1 <= 1552, S2 <= 1608 and S3 <= 1636. Before, S1/S2 allowed 65535, which iOS peers silently drop, and S3 was capped at 64, a number inherited from the coinman-dev/3ax-ui port in #6105 with no stated reason. That cap blocked real configs such as Amnezia Premium's S3=1045. RandomTrailers only tops a packet up to 500 bytes (DefaultUdpWindow), so it never pushes a message past these limits. H1-H4. amneziawg-go refuses the whole device when the header ranges overlap ("headers must not overlap", device/uapi.go mergeWithDevice), and so does the kernel module (src/netlink.c). The panel did not check this, so an inbound with overlapping ranges saved and then failed to apply. A blank H is never sent, so the engine keeps its default, WireGuard's own type 1-4; the check treats blank fields that way. The docs said 1-4 "must not be used". They are valid and are the engine default, only unobfuscated without a HeaderProtectionKey. The docs also said amneziawg-go rejects S1+56 == S2. It does not (IpcSet accepts it). The panel keeps that rule as a fingerprint guard, and the docs now say so. Tests: the new params_test cases and the Zod bounds fail on the old code. TestValidatedObfuscationAlwaysApplies runs every accepted set through a real amneziawg-go IpcSet and now covers overlap, blank-H defaults, H=1-4, the exact S bounds and the full Amnezia Premium set. Before this fix it failed with "headers must not overlap". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(amneziawg): bound only inbound padding by the iOS receive buffer The 1700-byte iOS buffer limits what an inbound's clients can receive, but ValidateObfuscation also runs for outbounds, and the Xray template save re-validates every AmneziaWG outbound. An outbound whose remote server uses S1 above 1552 would have blocked every Xray settings save, though its values come from that server and are received on Linux. ValidateObfuscation keeps amneziawg-go's uint16 UAPI width for S1-S3; ValidateServerObfuscation adds the receive-buffer bounds and is what inbounds call. The outbound schema and form follow the same split. --------- Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -310,3 +310,25 @@ func TestValidateAmneziaWGOutbound_DNSField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An outbound's S values are dictated by the remote server and received here on
|
||||
// Linux, so the iOS receive-buffer cap on inbounds must not refuse them.
|
||||
func TestValidateAmneziaWGOutbound_AcceptsRemotePaddingPastTheIOSCap(t *testing.T) {
|
||||
m := validOutboundMapT(t)
|
||||
m["s1"], m["s2"], m["s3"] = 2000, 3000, 4000
|
||||
bs, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err != nil {
|
||||
t.Fatalf("remote server padding S1=2000 S2=3000 S3=4000 refused: %v", err)
|
||||
}
|
||||
|
||||
m["s1"] = 65536
|
||||
if bs, err = json.Marshal(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
|
||||
t.Fatal("S1=65536 is past amneziawg-go's uint16 UAPI width and must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func GenerateObfuscation31() Obfuscation31 {
|
||||
}
|
||||
// Floored at 12: HeaderProtectionKey is always generated below, and IpcSet
|
||||
// rejects header protection unless every S1-S4 is >= 12.
|
||||
o.S3 = randInt(12, 55) // cookie padding (max 64)
|
||||
o.S3 = randInt(12, 55) // cookie padding
|
||||
o.S4 = randInt(12, 27) // transport padding (max 32)
|
||||
|
||||
h := generateHValues()
|
||||
@@ -134,6 +134,31 @@ func generateHValues() [4]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Padded handshake messages (148+S1, 92+S2, 64+S3 bytes) must fit the smallest receive
|
||||
// buffer amneziawg-go has: MaxSegmentSize 1700 on iOS (device/queueconstants_ios.go).
|
||||
const (
|
||||
maxServerS1 = 1700 - 148
|
||||
maxServerS2 = 1700 - 92
|
||||
maxServerS3 = 1700 - 64
|
||||
)
|
||||
|
||||
// ValidateServerObfuscation adds the receive-buffer bounds to ValidateObfuscation:
|
||||
// an inbound's peers may be iOS clients, which cannot receive a larger handshake.
|
||||
func ValidateServerObfuscation(o Obfuscation31) error {
|
||||
if err := ValidateObfuscation(o); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
v, max int
|
||||
}{{"S1", o.S1, maxServerS1}, {"S2", o.S2, maxServerS2}, {"S3", o.S3, maxServerS3}} {
|
||||
if f.v > f.max {
|
||||
return fmt.Errorf("invalid %s value %d (must be 0..%d so every client can receive it)", f.name, f.v, f.max)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateObfuscation rejects malformed parameters before they are saved, so
|
||||
// a bad manual entry can't break the embedded amneziawg-go device's own
|
||||
// UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or
|
||||
@@ -144,8 +169,8 @@ func ValidateObfuscation(o Obfuscation31) error {
|
||||
if o.Jmin > o.Jmax {
|
||||
return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax)
|
||||
}
|
||||
// amneziawg-go parses jc/jmin/jmax as uint32 and s1-s4 as uint16
|
||||
// (device/uapi.go); a wider value makes IpcSet reject the whole device.
|
||||
// amneziawg-go parses jc/jmin/jmax as uint32 and s1-s3 as uint16 (device/uapi.go);
|
||||
// a wider value makes IpcSet reject the whole device.
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
v int
|
||||
@@ -156,6 +181,7 @@ func ValidateObfuscation(o Obfuscation31) error {
|
||||
{"Jmax", o.Jmax, math.MaxUint32},
|
||||
{"S1", o.S1, math.MaxUint16},
|
||||
{"S2", o.S2, math.MaxUint16},
|
||||
{"S3", o.S3, math.MaxUint16},
|
||||
} {
|
||||
if int64(f.v) < 0 || int64(f.v) > f.max {
|
||||
return fmt.Errorf("invalid %s value %d (must be 0..%d)", f.name, f.v, f.max)
|
||||
@@ -166,9 +192,6 @@ func ValidateObfuscation(o Obfuscation31) error {
|
||||
return fmt.Errorf("invalid I%d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
if o.S3 < 0 || o.S3 > 64 {
|
||||
return fmt.Errorf("invalid S3 value %d (must be 0..64)", o.S3)
|
||||
}
|
||||
if o.S4 < 0 || o.S4 > 32 {
|
||||
return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4)
|
||||
}
|
||||
@@ -180,6 +203,9 @@ func ValidateObfuscation(o Obfuscation31) error {
|
||||
return fmt.Errorf("invalid H%d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
if err := validateHNoOverlap([4]string{o.H1, o.H2, o.H3, o.H4}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateHeaderProtectionKey(o.HeaderProtectionKey); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -378,6 +404,27 @@ func validateUintRange(v string, minAllowed int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHNoOverlap mirrors amneziawg-go's "headers must not overlap" (device/uapi.go).
|
||||
// A blank Hn is never sent, so the engine keeps its default: WireGuard's own type n.
|
||||
func validateHNoOverlap(hs [4]string) error {
|
||||
var lo, hi [4]int64
|
||||
for i, h := range hs {
|
||||
l, u, ok := parseUintRange(h)
|
||||
if !ok {
|
||||
l, u = int64(i+1), int64(i+1)
|
||||
}
|
||||
lo[i], hi[i] = l, u
|
||||
}
|
||||
for i := range 4 {
|
||||
for j := i + 1; j < 4; j++ {
|
||||
if lo[i] <= hi[j] && lo[j] <= hi[i] {
|
||||
return fmt.Errorf("invalid H%d/H%d: %d-%d and %d-%d overlap", i+1, j+1, lo[i], hi[i], lo[j], hi[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseUintRange parses "N" (lo == hi) or "low-high"; ok is false when blank
|
||||
// or non-numeric. Bounds are NOT checked here.
|
||||
func parseUintRange(v string) (lo, hi int64, ok bool) {
|
||||
|
||||
@@ -142,9 +142,9 @@ func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) {
|
||||
|
||||
func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
|
||||
o := validObfuscation()
|
||||
o.S3 = 65
|
||||
o.S3 = 65536
|
||||
if err := ValidateObfuscation(o); err == nil {
|
||||
t.Fatal("S3 > 64 must be rejected")
|
||||
t.Fatal("S3 past uint16 must be rejected: amneziawg-go's UAPI parser refuses it")
|
||||
}
|
||||
o = validObfuscation()
|
||||
o.S4 = 33
|
||||
@@ -158,6 +158,17 @@ func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Amnezia Premium ships S3=1045; 1636 is the largest cookie padding every platform can receive.
|
||||
func TestValidateServerObfuscationAcceptsLargeS3(t *testing.T) {
|
||||
for _, s3 := range []int{1045, 1636} {
|
||||
o := validObfuscation()
|
||||
o.S3 = s3
|
||||
if err := ValidateServerObfuscation(o); err != nil {
|
||||
t.Fatalf("S3=%d must be accepted: %v", s3, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateObfuscationRejectsLowSWithHeaderProtection(t *testing.T) {
|
||||
for field, set := range map[string]func(o *Obfuscation31){
|
||||
"S1": func(o *Obfuscation31) { o.S1 = 11 },
|
||||
@@ -415,7 +426,7 @@ func TestEffectiveMTUPrefersTheAdminsValue(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestValidateObfuscationRejectsOutOfRangeJunkAndPadding pins the widths
|
||||
// amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s4.
|
||||
// amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s3.
|
||||
func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) {
|
||||
base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
|
||||
tests := []struct {
|
||||
@@ -439,7 +450,55 @@ func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) {
|
||||
})
|
||||
}
|
||||
if err := ValidateObfuscation(Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 65535, S2: 30, S3: 20, S4: 20}); err != nil {
|
||||
t.Fatalf("S1 at the uint16 maximum must stay valid: %v", err)
|
||||
t.Fatalf("S1 at the uint16 maximum must stay valid for an outbound: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An inbound's handshakes must fit iOS's 1700-byte buffer: 148+S1, 92+S2 and 64+S3.
|
||||
func TestValidateServerObfuscationBoundsHandshakesByTheIOSBuffer(t *testing.T) {
|
||||
base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
|
||||
for name, mut := range map[string]func(*Obfuscation31){
|
||||
"S1 init over 1700 bytes": func(o *Obfuscation31) { o.S1 = 1553 },
|
||||
"S2 response over 1700 bytes": func(o *Obfuscation31) { o.S2 = 1609 },
|
||||
"S3 cookie over 1700 bytes": func(o *Obfuscation31) { o.S3 = 1637 },
|
||||
} {
|
||||
o := base
|
||||
mut(&o)
|
||||
if err := ValidateServerObfuscation(o); err == nil {
|
||||
t.Fatalf("%s: an iOS client could never receive it, so the inbound must not save", name)
|
||||
}
|
||||
}
|
||||
// 148+1552 and 92+1608 are exactly 1700; Amnezia Premium ships S1=284 S2=659.
|
||||
for _, s := range [][2]int{{1552, 30}, {20, 1608}, {284, 659}} {
|
||||
if err := ValidateServerObfuscation(Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: s[0], S2: s[1], S3: 20, S4: 20}); err != nil {
|
||||
t.Fatalf("S1=%d S2=%d must stay valid: %v", s[0], s[1], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// amneziawg-go refuses the whole device when H1-H4 overlap ("headers must not overlap",
|
||||
// device/uapi.go); 1-4 alone are legal and the engine's own default.
|
||||
func TestValidateObfuscationHOverlap(t *testing.T) {
|
||||
base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
|
||||
reject := [][4]string{
|
||||
{"100-200", "150-300", "400", "500"},
|
||||
{"7", "7", "8", "9"},
|
||||
{"3", "", "", ""}, // blank H3 keeps the engine default 3
|
||||
}
|
||||
for _, h := range reject {
|
||||
o := base
|
||||
o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
|
||||
if err := ValidateObfuscation(o); err == nil {
|
||||
t.Fatalf("H=%v overlaps, amneziawg-go rejects it, so the inbound must not save", h)
|
||||
}
|
||||
}
|
||||
accept := [][4]string{{"1", "2", "3", "4"}, {"", "", "", ""}, {"5-10", "11-20", "21", "22-30"}}
|
||||
for _, h := range accept {
|
||||
o := base
|
||||
o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
|
||||
if err := ValidateObfuscation(o); err != nil {
|
||||
t.Fatalf("H=%v must be accepted: %v", h, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -584,6 +584,17 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
|
||||
{"I1 chained tags", func(o *amneziawg.Obfuscation31) { o.I1 = "<b ff00><r 10>" }},
|
||||
{"I1 valueless tag", func(o *amneziawg.Obfuscation31) { o.I1 = "<t><rc 5>" }},
|
||||
{"I1 no tags at all", func(o *amneziawg.Obfuscation31) { o.I1 = "plain text" }},
|
||||
{"H ranges overlap", func(o *amneziawg.Obfuscation31) { o.H1, o.H2 = "100-200", "150-300" }},
|
||||
{"H1 equals the blank H3 default", func(o *amneziawg.Obfuscation31) { o.H1 = "3" }},
|
||||
{"H1-H4 = WireGuard's 1-4", func(o *amneziawg.Obfuscation31) { o.H1, o.H2, o.H3, o.H4 = "1", "2", "3", "4" }},
|
||||
// Separate cases: 1552+56 == 1608, so both maxima together trip the S1/S2 size rule.
|
||||
{"S1 and S3 at the 1700-byte bound", func(o *amneziawg.Obfuscation31) { o.S1, o.S3 = 1552, 1636 }},
|
||||
{"S2 at the 1700-byte bound", func(o *amneziawg.Obfuscation31) { o.S2 = 1608 }},
|
||||
{"Amnezia Premium set", func(o *amneziawg.Obfuscation31) {
|
||||
o.S1, o.S2, o.S3, o.S4 = 284, 659, 1045, 12
|
||||
o.H1, o.H2, o.H3, o.H4 = "1", "2", "3", "4"
|
||||
o.HeaderProtectionKey = "A2lG0Jm3m8u1WJt0qg3d7V6Qx8cFvH9pL1nR4sT6yZ0="
|
||||
}},
|
||||
}
|
||||
|
||||
for i, tc := range cases {
|
||||
|
||||
@@ -238,7 +238,7 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS
|
||||
} {
|
||||
*f = amneziawg.CanonicalizeUintRange(*f)
|
||||
}
|
||||
if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation()); err != nil {
|
||||
if err := amneziawg.ValidateServerObfuscation(parsed.Server.Obfuscation()); err != nil {
|
||||
return fmt.Errorf("amneziawg: %w", err)
|
||||
}
|
||||
if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil {
|
||||
|
||||
@@ -188,6 +188,7 @@ func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) {
|
||||
}{
|
||||
{"bad headerProtectionKey", `"headerProtectionKey":"short"`},
|
||||
{"zero rekeyTimeout", `"rekeyTimeout":"0"`},
|
||||
{"S1 past what an iOS client can receive", `"s1":1553`},
|
||||
{"rekey overlapping reject", `"rekeyAfterTime":"100-200","rejectAfterTime":"150-300"`},
|
||||
{"control chars in i2", `"i2":"<r 64>\nPostUp = evil"`},
|
||||
{"line-wrapped headerProtectionKey", `"headerProtectionKey":"MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="`},
|
||||
|
||||
Reference in New Issue
Block a user