Просмотр исходного кода

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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Kirill Rudenko 7 часов назад
Родитель
Сommit
8979072bd9

+ 3 - 3
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.                            |
 | **Jc**       | Number of junk packets sent before the handshake.                            |
 | **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`.     |
 | **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`.                                    |
 | **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. `<r 148>`. Generated sets fill `I1` only, matching Amnezia's own generator. |
 | **I1-I5**    | Optional signature packets — random bytes prepended before the handshake, e.g. `<r 148>`. 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. |
 | **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. |
 | **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. |

+ 3 - 3
frontend/src/pages/inbounds/form/protocols/amneziawg.tsx

@@ -103,13 +103,13 @@ export default function AmneziawgFields({
         <InputNumber min={0} style={{ width: '100%' }} />
         <InputNumber min={0} style={{ width: '100%' }} />
       </FormField>
       </FormField>
       <FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
       <FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
-        <InputNumber min={0} style={{ width: '100%' }} />
+        <InputNumber min={0} max={1552} style={{ width: '100%' }} />
       </FormField>
       </FormField>
       <FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
       <FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
-        <InputNumber min={0} style={{ width: '100%' }} />
+        <InputNumber min={0} max={1608} style={{ width: '100%' }} />
       </FormField>
       </FormField>
       <FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
       <FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
-        <InputNumber min={0} max={64} style={{ width: '100%' }} />
+        <InputNumber min={0} max={1636} style={{ width: '100%' }} />
       </FormField>
       </FormField>
       <FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
       <FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
         <InputNumber min={0} max={32} style={{ width: '100%' }} />
         <InputNumber min={0} max={32} style={{ width: '100%' }} />

+ 3 - 3
frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx

@@ -70,9 +70,9 @@ export default function AmneziawgFields() {
       <ObfNumber name="jc" label={t('pages.xray.amneziawg.jc')} min={0} />
       <ObfNumber name="jc" label={t('pages.xray.amneziawg.jc')} min={0} />
       <ObfNumber name="jmin" label={t('pages.xray.amneziawg.jmin')} min={0} />
       <ObfNumber name="jmin" label={t('pages.xray.amneziawg.jmin')} min={0} />
       <ObfNumber name="jmax" label={t('pages.xray.amneziawg.jmax')} min={0} />
       <ObfNumber name="jmax" label={t('pages.xray.amneziawg.jmax')} min={0} />
-      <ObfNumber name="s1" label={t('pages.xray.amneziawg.s1')} min={0} />
-      <ObfNumber name="s2" label={t('pages.xray.amneziawg.s2')} min={0} />
-      <ObfNumber name="s3" label={t('pages.xray.amneziawg.s3')} min={0} max={64} />
+      <ObfNumber name="s1" label={t('pages.xray.amneziawg.s1')} min={0} max={65535} />
+      <ObfNumber name="s2" label={t('pages.xray.amneziawg.s2')} min={0} max={65535} />
+      <ObfNumber name="s3" label={t('pages.xray.amneziawg.s3')} min={0} max={65535} />
       <ObfNumber name="s4" label={t('pages.xray.amneziawg.s4')} min={0} max={32} />
       <ObfNumber name="s4" label={t('pages.xray.amneziawg.s4')} min={0} max={32} />
       <ObfText name="h1" label={t('pages.xray.amneziawg.h1')} placeholder="100-800" />
       <ObfText name="h1" label={t('pages.xray.amneziawg.h1')} placeholder="100-800" />
       <ObfText name="h2" label={t('pages.xray.amneziawg.h2')} placeholder="900-1600" />
       <ObfText name="h2" label={t('pages.xray.amneziawg.h2')} placeholder="900-1600" />

+ 3 - 3
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)),
   jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)),
   jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)),
   jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)),
   jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)),
   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)),
   s4: clearedToDefault(z.number().int().min(0).max(32).default(5)),
   h1: z.string().default(''),
   h1: z.string().default(''),
   h2: z.string().default(''),
   h2: z.string().default(''),

+ 4 - 3
frontend/src/schemas/protocols/outbound/amneziawg.ts

@@ -22,9 +22,10 @@ export const AmneziaWGOutboundSettingsSchema = z.object({
   jc: z.number().int().min(0).default(0),
   jc: z.number().int().min(0).default(0),
   jmin: z.number().int().min(0).default(40),
   jmin: z.number().int().min(0).default(40),
   jmax: z.number().int().min(0).default(100),
   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),
   s4: z.number().int().min(0).max(32).default(12),
   h1: z.string().default(''),
   h1: z.string().default(''),
   h2: z.string().default(''),
   h2: z.string().default(''),

+ 22 - 9
frontend/src/test/amneziawg-schema-cleared.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest';
 import { describe, expect, it } from 'vitest';
 
 
 import { AmneziawgServerSchema } from '@/schemas/protocols/inbound/amneziawg';
 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
 // AntD InputNumber emits null when cleared; a cleared numeric field must
 // refill its schema default instead of failing validation and blocking the save.
 // 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', () => {
 describe('AmneziawgServerSchema obfuscation bounds', () => {
   const overWidth: Array<[string, number]> = [
   const overWidth: Array<[string, number]> = [
-    ['s1', 65536],
-    ['s2', 70000],
-    ['s3', 65],
+    ['s1', 1553],
+    ['s2', 1609],
+    ['s3', 1637],
     ['s4', 33],
     ['s4', 33],
     ['jc', 4294967296],
     ['jc', 4294967296],
     ['jmin', 4294967296],
     ['jmin', 4294967296],
     ['jmax', 5000000000],
     ['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);
     expect(AmneziawgServerSchema.safeParse({ [field]: value }).success).toBe(false);
   });
   });
 
 
   const atLimit: Array<[string, number]> = [
   const atLimit: Array<[string, number]> = [
-    ['s1', 65535],
-    ['s2', 65535],
-    ['s3', 64],
+    ['s1', 1552],
+    ['s2', 1608],
+    ['s3', 1636],
     ['s4', 32],
     ['s4', 32],
     ['jc', 4294967295],
     ['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);
+  });
+});

+ 22 - 0
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")
+	}
+}

+ 53 - 6
internal/amneziawg/params.go

@@ -64,7 +64,7 @@ func GenerateObfuscation31() Obfuscation31 {
 	}
 	}
 	// Floored at 12: HeaderProtectionKey is always generated below, and IpcSet
 	// Floored at 12: HeaderProtectionKey is always generated below, and IpcSet
 	// rejects header protection unless every S1-S4 is >= 12.
 	// 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)
 	o.S4 = randInt(12, 27) // transport padding (max 32)
 
 
 	h := generateHValues()
 	h := generateHValues()
@@ -134,6 +134,31 @@ func generateHValues() [4]string {
 	return out
 	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
 // ValidateObfuscation rejects malformed parameters before they are saved, so
 // a bad manual entry can't break the embedded amneziawg-go device's own
 // a bad manual entry can't break the embedded amneziawg-go device's own
 // UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or
 // UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or
@@ -144,8 +169,8 @@ func ValidateObfuscation(o Obfuscation31) error {
 	if o.Jmin > o.Jmax {
 	if o.Jmin > o.Jmax {
 		return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", 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 {
 	for _, f := range []struct {
 		name string
 		name string
 		v    int
 		v    int
@@ -156,6 +181,7 @@ func ValidateObfuscation(o Obfuscation31) error {
 		{"Jmax", o.Jmax, math.MaxUint32},
 		{"Jmax", o.Jmax, math.MaxUint32},
 		{"S1", o.S1, math.MaxUint16},
 		{"S1", o.S1, math.MaxUint16},
 		{"S2", o.S2, math.MaxUint16},
 		{"S2", o.S2, math.MaxUint16},
+		{"S3", o.S3, math.MaxUint16},
 	} {
 	} {
 		if int64(f.v) < 0 || int64(f.v) > f.max {
 		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)
 			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)
 			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 {
 	if o.S4 < 0 || o.S4 > 32 {
 		return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4)
 		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)
 			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 {
 	if err := validateHeaderProtectionKey(o.HeaderProtectionKey); err != nil {
 		return err
 		return err
 	}
 	}
@@ -378,6 +404,27 @@ func validateUintRange(v string, minAllowed int64) error {
 	return nil
 	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
 // parseUintRange parses "N" (lo == hi) or "low-high"; ok is false when blank
 // or non-numeric. Bounds are NOT checked here.
 // or non-numeric. Bounds are NOT checked here.
 func parseUintRange(v string) (lo, hi int64, ok bool) {
 func parseUintRange(v string) (lo, hi int64, ok bool) {

+ 63 - 4
internal/amneziawg/params_test.go

@@ -142,9 +142,9 @@ func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) {
 
 
 func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
 func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
 	o := validObfuscation()
 	o := validObfuscation()
-	o.S3 = 65
+	o.S3 = 65536
 	if err := ValidateObfuscation(o); err == nil {
 	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 = validObfuscation()
 	o.S4 = 33
 	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) {
 func TestValidateObfuscationRejectsLowSWithHeaderProtection(t *testing.T) {
 	for field, set := range map[string]func(o *Obfuscation31){
 	for field, set := range map[string]func(o *Obfuscation31){
 		"S1": func(o *Obfuscation31) { o.S1 = 11 },
 		"S1": func(o *Obfuscation31) { o.S1 = 11 },
@@ -415,7 +426,7 @@ func TestEffectiveMTUPrefersTheAdminsValue(t *testing.T) {
 }
 }
 
 
 // TestValidateObfuscationRejectsOutOfRangeJunkAndPadding pins the widths
 // 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) {
 func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) {
 	base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
 	base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
 	tests := []struct {
 	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 {
 	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)
+		}
 	}
 	}
 }
 }
 
 

+ 11 - 0
internal/amneziawgnet/device_test.go

@@ -584,6 +584,17 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
 		{"I1 chained tags", func(o *amneziawg.Obfuscation31) { o.I1 = "<b ff00><r 10>" }},
 		{"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 valueless tag", func(o *amneziawg.Obfuscation31) { o.I1 = "<t><rc 5>" }},
 		{"I1 no tags at all", func(o *amneziawg.Obfuscation31) { o.I1 = "plain text" }},
 		{"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 {
 	for i, tc := range cases {

+ 1 - 1
internal/web/service/inbound_amneziawg.go

@@ -238,7 +238,7 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS
 	} {
 	} {
 		*f = amneziawg.CanonicalizeUintRange(*f)
 		*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)
 		return fmt.Errorf("amneziawg: %w", err)
 	}
 	}
 	if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil {
 	if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil {

+ 1 - 0
internal/web/service/inbound_amneziawg_test.go

@@ -188,6 +188,7 @@ func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) {
 	}{
 	}{
 		{"bad headerProtectionKey", `"headerProtectionKey":"short"`},
 		{"bad headerProtectionKey", `"headerProtectionKey":"short"`},
 		{"zero rekeyTimeout", `"rekeyTimeout":"0"`},
 		{"zero rekeyTimeout", `"rekeyTimeout":"0"`},
+		{"S1 past what an iOS client can receive", `"s1":1553`},
 		{"rekey overlapping reject", `"rekeyAfterTime":"100-200","rejectAfterTime":"150-300"`},
 		{"rekey overlapping reject", `"rekeyAfterTime":"100-200","rejectAfterTime":"150-300"`},
 		{"control chars in i2", `"i2":"<r 64>\nPostUp = evil"`},
 		{"control chars in i2", `"i2":"<r 64>\nPostUp = evil"`},
 		{"line-wrapped headerProtectionKey", `"headerProtectionKey":"MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="`},
 		{"line-wrapped headerProtectionKey", `"headerProtectionKey":"MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="`},