diff --git a/frontend/src/schemas/protocols/inbound/amneziawg.ts b/frontend/src/schemas/protocols/inbound/amneziawg.ts index 30dd90739..5bc8312d4 100644 --- a/frontend/src/schemas/protocols/inbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/inbound/amneziawg.ts @@ -66,11 +66,13 @@ export const AmneziawgServerSchema = z.object({ // z.object's default unknown-key stripping doesn't silently drop it from // an existing stored settings blob on the next save. routeThroughXray: z.boolean().default(false).optional(), - jc: clearedToDefault(z.number().int().min(0).default(5)), - jmin: clearedToDefault(z.number().int().min(0).default(10)), - jmax: clearedToDefault(z.number().int().min(0).default(50)), - s1: clearedToDefault(z.number().int().min(0).default(30)), - s2: clearedToDefault(z.number().int().min(0).default(45)), + // Upper bounds match amneziawg-go's own UAPI parsers (device/uapi.go): + // jc/jmin/jmax are uint32, s1-s4 uint16. Wider values make IpcSet fail. + 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)), s4: clearedToDefault(z.number().int().min(0).max(32).default(5)), h1: z.string().default(''), diff --git a/frontend/src/test/amneziawg-schema-cleared.test.ts b/frontend/src/test/amneziawg-schema-cleared.test.ts index 519112c40..dcfb48fd8 100644 --- a/frontend/src/test/amneziawg-schema-cleared.test.ts +++ b/frontend/src/test/amneziawg-schema-cleared.test.ts @@ -32,3 +32,40 @@ describe('AmneziawgServerSchema cleared numeric fields', () => { expect(parsed.jc).toBe(5); }); }); + +// 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. +describe('AmneziawgServerSchema obfuscation bounds', () => { + const overWidth: Array<[string, number]> = [ + ['s1', 65536], + ['s2', 70000], + ['s3', 65], + ['s4', 33], + ['jc', 4294967296], + ['jmin', 4294967296], + ['jmax', 5000000000], + ]; + + it.each(overWidth)('rejects %s above the width amneziawg-go parses', (field, value) => { + expect(AmneziawgServerSchema.safeParse({ [field]: value }).success).toBe(false); + }); + + const atLimit: Array<[string, number]> = [ + ['s1', 65535], + ['s2', 65535], + ['s3', 64], + ['s4', 32], + ['jc', 4294967295], + ]; + + it.each(atLimit)('accepts %s exactly at its limit', (field, value) => { + const parsed = AmneziawgServerSchema.safeParse({ [field]: value }); + expect(parsed.success).toBe(true); + }); + + it('still rejects negatives on every junk and padding field', () => { + for (const field of ['jc', 'jmin', 'jmax', 's1', 's2', 's3', 's4']) { + expect(AmneziawgServerSchema.safeParse({ [field]: -1 }).success).toBe(false); + } + }); +}); diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go index 54842f086..8e09fecd1 100644 --- a/internal/amneziawg/params.go +++ b/internal/amneziawg/params.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/base64" "fmt" + "math" "math/big" "net/netip" "regexp" @@ -130,6 +131,28 @@ 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. + for _, f := range []struct { + name string + v int + max int64 + }{ + {"Jc", o.Jc, math.MaxUint32}, + {"Jmin", o.Jmin, math.MaxUint32}, + {"Jmax", o.Jmax, math.MaxUint32}, + {"S1", o.S1, math.MaxUint16}, + {"S2", o.S2, 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) + } + } + for i, spec := range []string{o.I1, o.I2, o.I3, o.I4, o.I5} { + if err := validateObfChain(spec); err != nil { + 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) } @@ -187,6 +210,40 @@ func ValidateObfuscation(o Obfuscation31) error { return nil } +// obfChainTags mirrors amneziawg-go's own obfBuilders map (device/obf.go): an +// unknown tag makes newObfChain fail, and IpcSet then rejects the whole device. +var obfChainTags = map[string]bool{ + "b": true, "t": true, "r": true, "rc": true, + "rd": true, "d": true, "ds": true, "dz": true, +} + +// validateObfChain checks an I1-I5 signature-packet spec's "" +// structure. Each tag's own value grammar stays amneziawg-go's to enforce. +func validateObfChain(spec string) error { + if strings.TrimSpace(spec) == "" { + return nil + } + remaining := spec + for { + start := strings.IndexByte(remaining, '<') + if start == -1 { + return nil + } + end := strings.IndexByte(remaining[start:], '>') + if end == -1 { + return fmt.Errorf("spec %q is missing an enclosing '>'", spec) + } + fields := strings.Fields(remaining[start+1 : start+end]) + if len(fields) == 0 { + return fmt.Errorf("spec %q has an empty <> tag", spec) + } + if !obfChainTags[fields[0]] { + return fmt.Errorf("spec %q uses unknown tag <%s>", spec, fields[0]) + } + remaining = remaining[start+end+1:] + } +} + // CanonicalizeUintRange stores a pasted "110 - 140" as "110-140", and // collapses a whitespace-only value back to "feature off". func CanonicalizeUintRange(v string) string { diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go index 7ba57b5c9..be7d830b6 100644 --- a/internal/amneziawg/params_test.go +++ b/internal/amneziawg/params_test.go @@ -377,3 +377,60 @@ func TestValidateConfigValueRejectsControlCharacters(t *testing.T) { } } } + +// TestValidateObfuscationRejectsOutOfRangeJunkAndPadding pins the widths +// amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s4. +func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) { + base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} + tests := []struct { + name string + mut func(*Obfuscation31) + }{ + {"S1 over uint16", func(o *Obfuscation31) { o.S1 = 65536 }}, + {"S2 over uint16", func(o *Obfuscation31) { o.S2 = 70000 }}, + {"negative Jc", func(o *Obfuscation31) { o.Jc = -1 }}, + {"negative Jmin and Jmax", func(o *Obfuscation31) { o.Jmin, o.Jmax = -5, -1 }}, + {"Jc over uint32", func(o *Obfuscation31) { o.Jc = 5000000000 }}, + {"negative S1", func(o *Obfuscation31) { o.S1 = -1 }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := base + tt.mut(&o) + if err := ValidateObfuscation(o); err == nil { + t.Fatal("ValidateObfuscation accepted a value amneziawg-go's UAPI parser rejects, so the inbound would save and then fail to apply") + } + }) + } + 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) + } +} + +// TestValidateObfuscationRejectsMalformedSignaturePackets covers I1-I5, whose +// "" chain amneziawg-go parses with newObfChain (device/obf.go). +func TestValidateObfuscationRejectsMalformedSignaturePackets(t *testing.T) { + base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} + + bad := []string{"", "", "< >", ""} + for _, spec := range bad { + t.Run("reject "+spec, func(t *testing.T) { + o := base + o.I1 = spec + if err := ValidateObfuscation(o); err == nil { + t.Fatalf("ValidateObfuscation accepted I1=%q, which newObfChain rejects", spec) + } + }) + } + + good := []string{"", "", "", "", "no tags at all"} + for _, spec := range good { + t.Run("accept "+spec, func(t *testing.T) { + o := base + o.I5 = spec + if err := ValidateObfuscation(o); err != nil { + t.Fatalf("ValidateObfuscation rejected valid I5=%q: %v", spec, err) + } + }) + } +} diff --git a/internal/amneziawgnet/device_test.go b/internal/amneziawgnet/device_test.go index 4fe5bf5aa..c5b2231b1 100644 --- a/internal/amneziawgnet/device_test.go +++ b/internal/amneziawgnet/device_test.go @@ -548,3 +548,79 @@ func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) { t.Fatal("timed out waiting for the server side to finish") } } + +// TestValidatedObfuscationAlwaysApplies pins the contract ValidateObfuscation +// exists for: whatever it accepts, amneziawg-go's own IpcSet must accept too. +func TestValidatedObfuscationAlwaysApplies(t *testing.T) { + priv, pub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("server keypair: %v", err) + } + _, peerPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("peer keypair: %v", err) + } + base := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} + + cases := []struct { + name string + mut func(*amneziawg.Obfuscation31) + }{ + {"generated defaults", func(o *amneziawg.Obfuscation31) { *o = amneziawg.GenerateObfuscation31() }}, + {"S1 over uint16", func(o *amneziawg.Obfuscation31) { o.S1 = 70000 }}, + {"S2 over uint16", func(o *amneziawg.Obfuscation31) { o.S2 = 70000 }}, + {"negative Jc", func(o *amneziawg.Obfuscation31) { o.Jc = -1 }}, + {"negative Jmin and Jmax", func(o *amneziawg.Obfuscation31) { o.Jmin, o.Jmax = -5, -1 }}, + {"Jc over uint32", func(o *amneziawg.Obfuscation31) { o.Jc = 5000000000 }}, + {"I1 unknown tag", func(o *amneziawg.Obfuscation31) { o.I1 = "" }}, + {"I1 missing close", func(o *amneziawg.Obfuscation31) { o.I1 = "