From 8979072bd9b9132f5b8e346c77ba0bae0dff5626 Mon Sep 17 00:00:00 2001 From: Kirill Rudenko Date: Sat, 26 Sep 2026 23:02:49 +0300 Subject: [PATCH] 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 * 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 Co-authored-by: Claude Opus 5.5 Co-authored-by: Sanaei --- docs/content/docs/en/config/amneziawg.mdx | 6 +- .../inbounds/form/protocols/amneziawg.tsx | 6 +- .../xray/outbounds/protocols/amneziawg.tsx | 6 +- .../schemas/protocols/inbound/amneziawg.ts | 6 +- .../schemas/protocols/outbound/amneziawg.ts | 7 +- .../src/test/amneziawg-schema-cleared.test.ts | 31 ++++++--- internal/amneziawg/outbound_test.go | 22 ++++++ internal/amneziawg/params.go | 59 ++++++++++++++-- internal/amneziawg/params_test.go | 67 +++++++++++++++++-- internal/amneziawgnet/device_test.go | 11 +++ internal/web/service/inbound_amneziawg.go | 2 +- .../web/service/inbound_amneziawg_test.go | 1 + 12 files changed, 189 insertions(+), 35 deletions(-) diff --git a/docs/content/docs/en/config/amneziawg.mdx b/docs/content/docs/en/config/amneziawg.mdx index 805ab2689..161352e03 100644 --- a/docs/content/docs/en/config/amneziawg.mdx +++ b/docs/content/docs/en/config/amneziawg.mdx @@ -43,10 +43,10 @@ value defeats the point, since DPI can fingerprint it over time. | ------------ | ---------------------------------------------------------------------------- | | **Jc** | Number of junk packets sent before the handshake. | | **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`. | -| **S1/S2** | Padding added to the handshake init/response packets. `S1 + 56` must not equal `S2` — amneziawg-go rejects a value that would make both packets the same size. | -| **S3** | Cookie-reply padding, `0`-`64`. | +| **S1/S2** | Padding added to the handshake init/response packets, `0`-`1552` / `0`-`1608`: the packets are `148 + S1` and `92 + S2` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. The panel also rejects `S1 + 56 = S2`, which would give both packets the same size on the wire (amneziawg-go itself accepts it). An AmneziaWG outbound takes the remote server's values as they are, up to `65535`. | +| **S3** | Cookie-reply padding, `0`-`1636`: the reply is `64 + S3` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. An outbound, as with S1/S2, takes the remote server's value up to `65535`. | | **S4** | Transport (data) packet padding, `0`-`32`. | -| **H1-H4** | Magic header values that replace WireGuard's standard message-type bytes. Each is a single integer or a `low-high` range; `1`-`4` are reserved (real WireGuard message types) and must not be used. | +| **H1-H4** | Header values that replace WireGuard's message-type field. Each is a single integer or a `low-high` range, and the four must not overlap — amneziawg-go and the kernel module refuse the whole device otherwise. `1`-`4` are WireGuard's own types and the engine default for a blank field: valid, but without a HeaderProtectionKey the type field then reads like plain WireGuard. | | **I1-I5** | Optional signature packets — random bytes prepended before the handshake, e.g. ``. Generated sets fill `I1` only, matching Amnezia's own generator. | | **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. | | **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. | diff --git a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx index 5a6ac758c..39c13d065 100644 --- a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx +++ b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx @@ -103,13 +103,13 @@ export default function AmneziawgFields({ - + - + - + diff --git a/frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx b/frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx index 14e2345dd..4fb1726db 100644 --- a/frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx +++ b/frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx @@ -70,9 +70,9 @@ export default function AmneziawgFields() { - - - + + + diff --git a/frontend/src/schemas/protocols/inbound/amneziawg.ts b/frontend/src/schemas/protocols/inbound/amneziawg.ts index 5bc8312d4..5c00ce0ef 100644 --- a/frontend/src/schemas/protocols/inbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/inbound/amneziawg.ts @@ -71,9 +71,9 @@ export const AmneziawgServerSchema = z.object({ jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)), jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)), jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)), - s1: clearedToDefault(z.number().int().min(0).max(65535).default(30)), - s2: clearedToDefault(z.number().int().min(0).max(65535).default(45)), - s3: clearedToDefault(z.number().int().min(0).max(64).default(10)), + s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)), + s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)), + s3: clearedToDefault(z.number().int().min(0).max(1636).default(10)), s4: clearedToDefault(z.number().int().min(0).max(32).default(5)), h1: z.string().default(''), h2: z.string().default(''), diff --git a/frontend/src/schemas/protocols/outbound/amneziawg.ts b/frontend/src/schemas/protocols/outbound/amneziawg.ts index 8db3c8c9c..924208027 100644 --- a/frontend/src/schemas/protocols/outbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/outbound/amneziawg.ts @@ -22,9 +22,10 @@ export const AmneziaWGOutboundSettingsSchema = z.object({ jc: z.number().int().min(0).default(0), jmin: z.number().int().min(0).default(40), jmax: z.number().int().min(0).default(100), - s1: z.number().int().min(0).default(15), - s2: z.number().int().min(0).default(80), - s3: z.number().int().min(0).max(64).default(12), + // The remote server sets S1-S3; only amneziawg-go's uint16 UAPI width bounds them here. + s1: z.number().int().min(0).max(65535).default(15), + s2: z.number().int().min(0).max(65535).default(80), + s3: z.number().int().min(0).max(65535).default(12), s4: z.number().int().min(0).max(32).default(12), h1: z.string().default(''), h2: z.string().default(''), diff --git a/frontend/src/test/amneziawg-schema-cleared.test.ts b/frontend/src/test/amneziawg-schema-cleared.test.ts index dcfb48fd8..7a70d007a 100644 --- a/frontend/src/test/amneziawg-schema-cleared.test.ts +++ b/frontend/src/test/amneziawg-schema-cleared.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { AmneziawgServerSchema } from '@/schemas/protocols/inbound/amneziawg'; +import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound/amneziawg'; // AntD InputNumber emits null when cleared; a cleared numeric field must // refill its schema default instead of failing validation and blocking the save. @@ -33,27 +34,27 @@ describe('AmneziawgServerSchema cleared numeric fields', () => { }); }); -// The form must reject what amneziawg-go's UAPI parsers reject (device/uapi.go: -// jc/jmin/jmax uint32, s1-s4 uint16), or the save silently outlives the apply. +// The form must reject what cannot apply or be received: jc/jmin/jmax past uint32 (device/uapi.go), +// S1-S3 past amneziawg-go's 1700-byte iOS receive buffer, S4 past 32 (MTU headroom). describe('AmneziawgServerSchema obfuscation bounds', () => { const overWidth: Array<[string, number]> = [ - ['s1', 65536], - ['s2', 70000], - ['s3', 65], + ['s1', 1553], + ['s2', 1609], + ['s3', 1637], ['s4', 33], ['jc', 4294967296], ['jmin', 4294967296], ['jmax', 5000000000], ]; - it.each(overWidth)('rejects %s above the width amneziawg-go parses', (field, value) => { + it.each(overWidth)('rejects %s past what amneziawg-go can apply or receive', (field, value) => { expect(AmneziawgServerSchema.safeParse({ [field]: value }).success).toBe(false); }); const atLimit: Array<[string, number]> = [ - ['s1', 65535], - ['s2', 65535], - ['s3', 64], + ['s1', 1552], + ['s2', 1608], + ['s3', 1636], ['s4', 32], ['jc', 4294967295], ]; @@ -69,3 +70,15 @@ describe('AmneziawgServerSchema obfuscation bounds', () => { } }); }); + +// An outbound's S values come from the remote server and are received on Linux, +// so only amneziawg-go's uint16 UAPI width bounds them, not the iOS buffer. +describe('AmneziaWGOutboundSettingsSchema padding bounds', () => { + it.each(['s1', 's2', 's3'])('accepts %s past the inbound iOS cap', (field) => { + expect(AmneziaWGOutboundSettingsSchema.safeParse({ [field]: 2000 }).success).toBe(true); + }); + + it.each(['s1', 's2', 's3'])('rejects %s past uint16', (field) => { + expect(AmneziaWGOutboundSettingsSchema.safeParse({ [field]: 65536 }).success).toBe(false); + }); +}); diff --git a/internal/amneziawg/outbound_test.go b/internal/amneziawg/outbound_test.go index b24bce6af..9c796cd80 100644 --- a/internal/amneziawg/outbound_test.go +++ b/internal/amneziawg/outbound_test.go @@ -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") + } +} diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go index 94dc27f7e..9fc912a73 100644 --- a/internal/amneziawg/params.go +++ b/internal/amneziawg/params.go @@ -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) { diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go index 6327ed340..1b800b001 100644 --- a/internal/amneziawg/params_test.go +++ b/internal/amneziawg/params_test.go @@ -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) + } } } diff --git a/internal/amneziawgnet/device_test.go b/internal/amneziawgnet/device_test.go index 1eda9c71c..0cecc488e 100644 --- a/internal/amneziawgnet/device_test.go +++ b/internal/amneziawgnet/device_test.go @@ -584,6 +584,17 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) { {"I1 chained tags", func(o *amneziawg.Obfuscation31) { o.I1 = "" }}, {"I1 valueless tag", func(o *amneziawg.Obfuscation31) { o.I1 = "" }}, {"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 { diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go index 7e00d414d..6c2de6907 100644 --- a/internal/web/service/inbound_amneziawg.go +++ b/internal/web/service/inbound_amneziawg.go @@ -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 { diff --git a/internal/web/service/inbound_amneziawg_test.go b/internal/web/service/inbound_amneziawg_test.go index 9be7ca6b3..4b0435912 100644 --- a/internal/web/service/inbound_amneziawg_test.go +++ b/internal/web/service/inbound_amneziawg_test.go @@ -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":"\nPostUp = evil"`}, {"line-wrapped headerProtectionKey", `"headerProtectionKey":"MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="`},