fix(amneziawg): reject obfuscation values amneziawg-go's own UAPI rejects

ValidateObfuscation exists, by its own doc comment, so that a bad manual
entry cannot break the embedded device's IpcSet. It was not covering enough
to do that. Auditing the panel against amneziawg-go v3.1.20260828's full
UAPI surface turned up two holes, both confirmed by driving the values
through a real IpcSet:

  S1 = 70000        upstream parses s1-s4 as uint16
  S2 = 70000        (device/uapi.go)
  Jc = -1           jc/jmin/jmax are uint32, so no negatives
  Jmin/Jmax = -5/-1
  Jc = 5000000000   and nothing wider than uint32
  I1 = <rand 100>   newObfChain hard-fails on an unknown tag
  I1 = <r 100       ... and on a missing '>'
  I1 = <>           ... and on an empty one

All eight passed validation and were then rejected by the device. Only S3
and S4 were bounded, which is why the asymmetry went unnoticed. The inbound
saves, the reconcile fails on every tick, and the interface never comes up
with a single log line to say so.

Bound the five numeric fields to the widths upstream actually parses, and
check the I1-I5 chain's <tag value> structure against a tag set mirroring
upstream's own obfBuilders map. Each tag's value grammar stays amneziawg-go's
to enforce -- that is eight builders across several files, and duplicating
them here would drift. So <r abc> still reaches IpcSet, now as the only
remaining class rather than one of four.

Mirror the same bounds in the Zod schema, next to the max() that s3 and s4
already carried, so the form rejects the value instead of the save doing it.

TestValidatedObfuscationAlwaysApplies pins the contract itself: whatever
ValidateObfuscation accepts, a real amneziawg-go device must accept too. It
covers the specs the new grammar check deliberately allows, not just the ones
it rejects, so the allowlist cannot quietly become stricter than upstream.

The rest of the audit found no gaps: all 17 settable device keys reach
buildUAPIConfig, ServerSettings, the Zod schema and all three .conf
emitters. fwmark and persistent_keepalive_interval remain unemitted, both
deliberately -- the panel models no fwmark anywhere, and keepAlive is carried
client-side where WireGuard puts it.
This commit is contained in:
Sanaei
2026-09-04 14:57:29 +02:00
parent be5ee3e0e1
commit 3b5273b1d6
5 changed files with 234 additions and 5 deletions
@@ -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(''),
@@ -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);
}
});
});