From a3df3fc2a60c6383e85cb99edc912fada11ca1dc Mon Sep 17 00:00:00 2001 From: Slinetrac Date: Mon, 15 Dec 2025 12:26:38 +0800 Subject: [PATCH] refactor(uri-parser): centralize scheme dispatch and harden parsing --- src/utils/uri-parser.ts | 1753 +++++++++++++++++++++++---------------- 1 file changed, 1049 insertions(+), 704 deletions(-) diff --git a/src/utils/uri-parser.ts b/src/utils/uri-parser.ts index c3a622c5f..6c0abfd26 100644 --- a/src/utils/uri-parser.ts +++ b/src/utils/uri-parser.ts @@ -1,37 +1,67 @@ -export default function parseUri(uri: string): IProxyConfig { - const head = uri.split("://")[0]; - switch (head) { - case "ss": - return URI_SS(uri); - case "ssr": - return URI_SSR(uri); - case "vmess": - return URI_VMESS(uri); - case "vless": - return URI_VLESS(uri); - case "trojan": - return URI_Trojan(uri); - case "hysteria2": - return URI_Hysteria2(uri); - case "hy2": - return URI_Hysteria2(uri); - case "hysteria": - return URI_Hysteria(uri); - case "hy": - return URI_Hysteria(uri); - case "tuic": - return URI_TUIC(uri); - case "wireguard": - return URI_Wireguard(uri); - case "wg": - return URI_Wireguard(uri); - case "http": - return URI_HTTP(uri); - case "socks5": - return URI_SOCKS(uri); - default: - throw Error(`Unknown uri type: ${head}`); +type UriParser = (uri: string) => IProxyConfig; + +const URI_SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//; + +const URI_PARSERS: Record = { + ss: URI_SS, + ssr: URI_SSR, + vmess: URI_VMESS, + vless: URI_VLESS, + trojan: URI_Trojan, + hysteria2: URI_Hysteria2, + hy2: URI_Hysteria2, + hysteria: URI_Hysteria, + hy: URI_Hysteria, + tuic: URI_TUIC, + wireguard: URI_Wireguard, + wg: URI_Wireguard, + http: URI_HTTP, + https: URI_HTTP, + socks5: URI_SOCKS, + socks: URI_SOCKS, +}; + +function normalizeUriAndGetScheme(input: string): { + uri: string; + scheme: string; +} { + const trimmed = input.trim(); + const match = URI_SCHEME_RE.exec(trimmed); + if (!match) { + const schemeGuess = (trimmed.split("://")[0] ?? "").toLowerCase(); + return { uri: trimmed, scheme: schemeGuess }; } + + const scheme = match[1].toLowerCase(); + return { uri: scheme + trimmed.slice(match[1].length), scheme }; +} + +function stripUriScheme( + uri: string, + expectedSchemes: string | readonly string[], + errorMessage: string, +): string { + const match = URI_SCHEME_RE.exec(uri); + if (!match) { + throw new Error(errorMessage); + } + const scheme = match[1].toLowerCase(); + + const expected = + typeof expectedSchemes === "string" ? [expectedSchemes] : expectedSchemes; + if (!expected.includes(scheme)) { + throw new Error(errorMessage); + } + return uri.slice(match[0].length); +} + +export default function parseUri(uri: string): IProxyConfig { + const { uri: normalized, scheme } = normalizeUriAndGetScheme(uri); + const parser = URI_PARSERS[scheme]; + if (!parser) { + throw new Error(`Unknown uri type: ${scheme}`); + } + return parser(normalized); } function getIfNotBlank( @@ -41,8 +71,8 @@ function getIfNotBlank( return value && value.trim() !== "" ? value : dft; } -function getIfPresent(value: any, dft?: any): any { - return value ? value : dft; +function getIfPresent(value: T | null | undefined, dft?: T): T | undefined { + return value !== null && value !== undefined ? value : dft; } function isPresent(value: any): boolean { @@ -53,6 +83,182 @@ function trimStr(str: string | undefined): string | undefined { return str ? str.trim() : str; } +function safeDecodeURIComponent(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function decodeAndTrim(value: string | undefined): string | undefined { + const decoded = safeDecodeURIComponent(value); + const trimmed = decoded?.trim(); + return trimmed ? trimmed : undefined; +} + +function splitOnce(input: string, delimiter: string): [string, string?] { + const idx = input.indexOf(delimiter); + if (idx === -1) return [input]; + return [input.slice(0, idx), input.slice(idx + delimiter.length)]; +} + +function parseQueryString( + query: string | undefined, +): Record { + const out: Record = {}; + if (!query) return out; + for (const part of query.split("&")) { + if (!part) continue; + const [keyRaw, valueRaw] = splitOnce(part, "="); + const key = keyRaw.trim(); + if (!key) continue; + out[key] = + valueRaw === undefined + ? undefined + : (safeDecodeURIComponent(valueRaw) ?? valueRaw); + } + return out; +} + +function normalizeQueryKey(key: string): string { + return key.replace(/_/g, "-"); +} + +function parseQueryStringNormalized( + query: string | undefined, +): Record { + const raw = parseQueryString(query); + const normalized: Record = {}; + for (const [key, value] of Object.entries(raw)) { + normalized[normalizeQueryKey(key)] = value; + } + return normalized; +} + +function parseBool(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + return /^(?:true|1)$/i.test(value); +} + +function parseBoolOrPresence(value: string | undefined): boolean { + if (value === undefined) return true; + const trimmed = value.trim(); + if (trimmed === "") return true; + return /^(?:true|1)$/i.test(trimmed); +} + +function parseVlessFlow(value: string | undefined): string | undefined { + const flow = getIfNotBlank(value); + if (!flow) return undefined; + if (/^none$/i.test(flow)) return undefined; + if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(flow)) return undefined; + return flow; +} + +function parseInteger(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? undefined : parsed; +} + +function parsePortStrict( + value: string | number | null | undefined, +): number | undefined { + if (value === null || value === undefined) return undefined; + const raw = String(value).trim(); + if (!/^\d+$/.test(raw)) return undefined; + const parsed = Number.parseInt(raw, 10); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) { + return undefined; + } + return parsed; +} + +function parseRequiredPort( + value: string | number | null | undefined, + errorMessage: string, +): number { + const parsed = parsePortStrict(value); + if (parsed === undefined) { + throw new Error(errorMessage); + } + return parsed; +} + +function parsePortOrDefault(port: string | undefined, dft: number): number { + return parseInteger(port) ?? dft; +} + +const IP_VERSIONS = [ + "dual", + "ipv4", + "ipv6", + "ipv4-prefer", + "ipv6-prefer", +] as const; + +function parseIpVersion( + value: string | undefined, +): (typeof IP_VERSIONS)[number] { + return value && IP_VERSIONS.includes(value as (typeof IP_VERSIONS)[number]) + ? (value as (typeof IP_VERSIONS)[number]) + : "dual"; +} + +type UrlLikeParts = { + auth?: string; + host: string; + port?: string; + query?: string; + fragment?: string; +}; + +const URLLIKE_RE = + /^(?:(?.*?)@)?(?.*?)(?::(?\d+))?\/?(?:\?(?.*?))?(?:#(?.*?))?$/; + +function parseUrlLike( + input: string, + options: { requireAuth: true; errorMessage: string }, +): UrlLikeParts & { auth: string }; +function parseUrlLike( + input: string, + options: { requireAuth?: false; errorMessage: string }, +): UrlLikeParts; +function parseUrlLike( + input: string, + options: { requireAuth?: boolean; errorMessage: string }, +): UrlLikeParts { + const match = URLLIKE_RE.exec(input); + const groups = (match?.groups ?? {}) as { + auth?: string; + host?: string; + port?: string; + query?: string; + fragment?: string; + }; + if (!match || groups.host === undefined) { + throw new Error(options.errorMessage); + } + + const auth = getIfNotBlank(groups.auth); + if (options.requireAuth && !auth) { + throw new Error(options.errorMessage); + } + + const result: UrlLikeParts = { + auth, + host: groups.host, + port: groups.port, + query: groups.query, + fragment: groups.fragment, + }; + return options.requireAuth + ? ({ ...result, auth } as UrlLikeParts & { auth: string }) + : result; +} + function isIPv4(address: string): boolean { // Check if the address is IPv4 const ipv4Regex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/; @@ -67,188 +273,308 @@ function isIPv6(address: string): boolean { } function decodeBase64OrOriginal(str: string): string { + const normalized = str + .replace(/[\r\n\s]/g, "") + .replace(/-/g, "+") + .replace(/_/g, "/"); + + const padLen = normalized.length % 4; + const padded = + padLen === 0 ? normalized : normalized + "=".repeat(4 - padLen); + try { - return atob(str); + const decoded = atob(padded); + // Heuristic: only accept "text-like" results to avoid accidentally decoding + // non-base64 strings that happen to be decodable. + for (let i = 0; i < decoded.length; i++) { + const code = decoded.charCodeAt(i); + if (code === 9 || code === 10 || code === 13) continue; + if (code < 32 || code === 127) { + return str; + } + } + return decoded; } catch { return str; } } -function getCipher(str: string | undefined) { - switch (str) { - case "none": - return "none"; - case "auto": - return "auto"; - case "dummy": - return "dummy"; - case "aes-128-gcm": - return "aes-128-gcm"; - case "aes-192-gcm": - return "aes-192-gcm"; - case "aes-256-gcm": - return "aes-256-gcm"; - case "lea-128-gcm": - return "lea-128-gcm"; - case "lea-192-gcm": - return "lea-192-gcm"; - case "lea-256-gcm": - return "lea-256-gcm"; - case "aes-128-gcm-siv": - return "aes-128-gcm-siv"; - case "aes-256-gcm-siv": - return "aes-256-gcm-siv"; - case "2022-blake3-aes-128-gcm": - return "2022-blake3-aes-128-gcm"; - case "2022-blake3-aes-256-gcm": - return "2022-blake3-aes-256-gcm"; - case "aes-128-cfb": - return "aes-128-cfb"; - case "aes-192-cfb": - return "aes-192-cfb"; - case "aes-256-cfb": - return "aes-256-cfb"; - case "aes-128-ctr": - return "aes-128-ctr"; - case "aes-192-ctr": - return "aes-192-ctr"; - case "aes-256-ctr": - return "aes-256-ctr"; - case "chacha20": - return "chacha20"; - case "chacha20-poly1305": - return "chacha20-ietf-poly1305"; - case "chacha20-ietf": - return "chacha20-ietf"; - case "chacha20-ietf-poly1305": - return "chacha20-ietf-poly1305"; - case "2022-blake3-chacha20-poly1305": - return "2022-blake3-chacha20-poly1305"; - case "rabbit128-poly1305": - return "rabbit128-poly1305"; - case "xchacha20-ietf-poly1305": - return "xchacha20-ietf-poly1305"; - case "xchacha20": - return "xchacha20"; - case "aegis-128l": - return "aegis-128l"; - case "aegis-256": - return "aegis-256"; - case "aez-384": - return "aez-384"; - case "deoxys-ii-256-128": - return "deoxys-ii-256-128"; - case "rc4-md5": - return "rc4-md5"; - case undefined: - return "none"; - default: - return "auto"; +const CIPHER_ALIASES: Record = { + "chacha20-poly1305": "chacha20-ietf-poly1305", +}; + +const KNOWN_CIPHERS = new Set([ + "none", + "auto", + "dummy", + "aes-128-gcm", + "aes-192-gcm", + "aes-256-gcm", + "lea-128-gcm", + "lea-192-gcm", + "lea-256-gcm", + "aes-128-gcm-siv", + "aes-256-gcm-siv", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "aes-128-cfb", + "aes-192-cfb", + "aes-256-cfb", + "aes-128-ctr", + "aes-192-ctr", + "aes-256-ctr", + "chacha20", + "chacha20-ietf", + "chacha20-ietf-poly1305", + "2022-blake3-chacha20-poly1305", + "rabbit128-poly1305", + "xchacha20-ietf-poly1305", + "xchacha20", + "aegis-128l", + "aegis-256", + "aez-384", + "deoxys-ii-256-128", + "rc4-md5", +]); + +function getCipher(value: unknown): CipherType { + if (value === undefined) return "none"; + if (typeof value !== "string") return "auto"; + const aliased = CIPHER_ALIASES[value] ?? value; + return KNOWN_CIPHERS.has(aliased as CipherType) + ? (aliased as CipherType) + : "auto"; +} + +function firstString(value: any): string | undefined { + if (value === null || value === undefined) return undefined; + if (Array.isArray(value)) { + if (value.length === 0) return undefined; + const first = value[0]; + return first === null || first === undefined ? undefined : String(first); + } + return String(value); +} + +function parseVmessShadowrocketParams(raw: string): Record { + const match = /(^[^?]+?)\/?\?(.*)$/.exec(raw); + if (!match) return {}; + + const [, base64Line, qs] = match; + const content = decodeBase64OrOriginal(base64Line); + const params: Record = {}; + + for (const addon of qs.split("&")) { + if (!addon) continue; + const [keyRaw, valueRaw] = splitOnce(addon, "="); + const key = keyRaw.trim(); + if (!key) continue; + if (valueRaw === undefined) { + params[key] = true; + continue; + } + const value = safeDecodeURIComponent(valueRaw) ?? valueRaw; + params[key] = value.includes(",") ? value.split(",") : value; + } + + const contentMatch = /(^[^:]+?):([^:]+?)@(.*):(\d+)$/.exec(content); + if (!contentMatch) return params; + + const [, cipher, uuid, server, port] = contentMatch; + params.scy = cipher; + params.id = uuid; + params.port = port; + params.add = server; + return params; +} + +function parseVmessParams(decoded: string, raw: string): Record { + try { + // V2rayN URI format + return JSON.parse(decoded); + } catch (e) { + // Shadowrocket URI format + console.warn( + "[URI_VMESS] JSON.parse(content) failed, falling back to Shadowrocket parsing:", + e, + ); + return parseVmessShadowrocketParams(raw); } } +function parseVmessQuantumult(content: string): IProxyVmessConfig { + const partitions = content.split(",").map((p) => p.trim()); + const params: Record = {}; + for (const part of partitions) { + if (part.indexOf("=") !== -1) { + const [key, val] = splitOnce(part, "="); + params[key.trim()] = val?.trim() ?? ""; + } + } + + const proxy: IProxyVmessConfig = { + name: partitions[0].split("=")[0].trim(), + type: "vmess", + server: partitions[1], + port: parseRequiredPort(partitions[2], "Invalid vmess uri: invalid port"), + cipher: getCipher(getIfNotBlank(partitions[3], "auto")), + uuid: partitions[4].match(/^"(.*)"$/)?.[1] || "", + tls: params.obfs === "wss", + udp: parseBool(params["udp-relay"]), + tfo: parseBool(params["fast-open"]), + "skip-cert-verify": + params["tls-verification"] === undefined + ? undefined + : !parseBool(params["tls-verification"]), + }; + + if (isPresent(params.obfs)) { + if (params.obfs === "ws" || params.obfs === "wss") { + proxy.network = "ws"; + proxy["ws-opts"] = { + path: + (getIfNotBlank(params["obfs-path"]) || '"/"').match( + /^"(.*)"$/, + )?.[1] || "/", + headers: { + Host: + params["obfs-header"]?.match(/Host:\s*([a-zA-Z0-9-.]*)/)?.[1] || "", + }, + }; + } else { + throw new Error(`Unsupported obfs: ${params.obfs}`); + } + } + + return proxy; +} + function URI_SS(line: string): IProxyShadowsocksConfig { - // parse url - let content = line.split("ss://")[1]; + const afterScheme = stripUriScheme(line, "ss", "Invalid ss uri"); + if (!afterScheme) { + throw new Error("Invalid ss uri"); + } + + const [withoutHash, hashRaw] = splitOnce(afterScheme, "#"); + const nameFromHash = decodeAndTrim(hashRaw); + + const [mainRaw, queryRaw] = splitOnce(withoutHash, "?"); + const queryParams = parseQueryString(queryRaw); + + const main = mainRaw.includes("@") + ? mainRaw + : decodeBase64OrOriginal(mainRaw); + const atIdx = main.lastIndexOf("@"); + if (atIdx === -1) { + throw new Error("Invalid ss uri: missing '@'"); + } + + const userInfoStr = decodeBase64OrOriginal(main.slice(0, atIdx)); + const serverAndPortWithPath = main.slice(atIdx + 1); + const serverAndPort = serverAndPortWithPath.split("/")[0]; + + const portIdx = serverAndPort.lastIndexOf(":"); + if (portIdx === -1) { + throw new Error("Invalid ss uri: missing port"); + } + const server = serverAndPort.slice(0, portIdx); + const portRaw = serverAndPort.slice(portIdx + 1); + const port = parseRequiredPort(portRaw, "Invalid ss uri: invalid port"); + + const userInfo = userInfoStr.match(/(^.*?):(.*$)/); const proxy: IProxyShadowsocksConfig = { - name: decodeURIComponent(line.split("#")[1]).trim(), + name: nameFromHash ?? `SS ${server}:${port}`, type: "ss", - server: "", - port: 0, + server, + port, + cipher: getCipher(userInfo?.[1]), + password: userInfo?.[2], }; - content = content.split("#")[0]; // strip proxy name - // handle IPV4 and IPV6 - let serverAndPortArray = content.match(/@([^/]*)(\/|$)/); - let userInfoStr = decodeBase64OrOriginal(content.split("@")[0]); - let query = ""; - if (!serverAndPortArray) { - if (content.includes("?")) { - const parsed = content.match(/^(.*)(\?.*)$/); - content = parsed?.[1] ?? ""; - query = parsed?.[2] ?? ""; - } - content = decodeBase64OrOriginal(content); - if (query) { - if (/(&|\?)v2ray-plugin=/.test(query)) { - const parsed = query.match(/(&|\?)v2ray-plugin=(.*?)(&|$)/); - const v2rayPlugin = parsed![2]; - if (v2rayPlugin) { - proxy.plugin = "v2ray-plugin"; - proxy["plugin-opts"] = JSON.parse( - decodeBase64OrOriginal(v2rayPlugin), - ); - } - } - content = `${content}${query}`; - } - userInfoStr = content.split("@")[0]; - serverAndPortArray = content.match(/@([^/]*)(\/|$)/); - } - const serverAndPort = serverAndPortArray?.[1]; - const portIdx = serverAndPort?.lastIndexOf(":") ?? 0; - proxy.server = serverAndPort?.substring(0, portIdx) ?? ""; - proxy.port = parseInt( - `${serverAndPort?.substring(portIdx + 1)}`.match(/\d+/)?.[0] ?? "", - ); - const userInfo = userInfoStr.match(/(^.*?):(.*$)/); - proxy.cipher = getCipher(userInfo?.[1]); - proxy.password = userInfo?.[2]; - // handle obfs - const idx = content.indexOf("?plugin="); - if (idx !== -1) { - const pluginInfo = ( - "plugin=" + decodeURIComponent(content.split("?plugin=")[1].split("&")[0]) - ).split(";"); - const params: Record = {}; - for (const item of pluginInfo) { - const [key, val] = item.split("="); - if (key) params[key] = val || true; // some options like "tls" will not have value + // plugin from `plugin=...` + const pluginParam = queryParams.plugin; + if (pluginParam) { + const pluginParts = pluginParam.split(";"); + const pluginName = pluginParts[0]; + const pluginOptions: Record = { plugin: pluginName }; + for (const raw of pluginParts.slice(1)) { + if (!raw) continue; + const [key, val] = splitOnce(raw, "="); + if (!key) continue; + pluginOptions[key] = val === undefined || val === "" ? true : val; } - switch (params.plugin) { + + switch (pluginOptions.plugin) { case "obfs-local": case "simple-obfs": proxy.plugin = "obfs"; proxy["plugin-opts"] = { - mode: params.obfs, - host: getIfNotBlank(params["obfs-host"]), + mode: pluginOptions.obfs, + host: getIfNotBlank(pluginOptions["obfs-host"]), }; break; case "v2ray-plugin": proxy.plugin = "v2ray-plugin"; proxy["plugin-opts"] = { mode: "websocket", - host: getIfNotBlank(params["obfs-host"]), - path: getIfNotBlank(params.path), - tls: getIfPresent(params.tls), + host: getIfNotBlank(pluginOptions["obfs-host"] ?? pluginOptions.host), + path: getIfNotBlank(pluginOptions.path), + tls: getIfPresent(pluginOptions.tls), }; break; default: - throw new Error(`Unsupported plugin option: ${params.plugin}`); + throw new Error(`Unsupported plugin option: ${pluginOptions.plugin}`); } } - if (/(&|\?)uot=(1|true)/i.test(query)) { + + // plugin from `v2ray-plugin=...` (base64 JSON) + const v2rayPluginParam = queryParams["v2ray-plugin"]; + if (!proxy.plugin && v2rayPluginParam) { + proxy.plugin = "v2ray-plugin"; + proxy["plugin-opts"] = JSON.parse(decodeBase64OrOriginal(v2rayPluginParam)); + } + + if ( + Object.prototype.hasOwnProperty.call(queryParams, "uot") && + parseBoolOrPresence(queryParams.uot) + ) { proxy["udp-over-tcp"] = true; } - if (/(&|\?)tfo=(1|true)/i.test(query)) { + if ( + Object.prototype.hasOwnProperty.call(queryParams, "tfo") && + parseBoolOrPresence(queryParams.tfo) + ) { proxy.tfo = true; } + return proxy; } function URI_SSR(line: string): IProxyshadowsocksRConfig { - line = decodeBase64OrOriginal(line.split("ssr://")[1]); + const afterScheme = stripUriScheme(line, "ssr", "Invalid ssr uri"); + if (!afterScheme) { + throw new Error("Invalid ssr uri"); + } + line = decodeBase64OrOriginal(afterScheme); // handle IPV6 & IPV4 format let splitIdx = line.indexOf(":origin"); if (splitIdx === -1) { splitIdx = line.indexOf(":auth_"); } + if (splitIdx === -1) { + throw new Error("Invalid ssr uri"); + } const serverAndPort = line.substring(0, splitIdx); - const server = serverAndPort.substring(0, serverAndPort.lastIndexOf(":")); - const port = parseInt( - serverAndPort.substring(serverAndPort.lastIndexOf(":") + 1), + const portIdx = serverAndPort.lastIndexOf(":"); + if (portIdx === -1) { + throw new Error("Invalid ssr uri: missing port"); + } + const server = serverAndPort.substring(0, portIdx); + const port = parseRequiredPort( + serverAndPort.substring(portIdx + 1), + "Invalid ssr uri: invalid port", ); const params = line @@ -267,164 +593,97 @@ function URI_SSR(line: string): IProxyshadowsocksRConfig { }; // get other params - const other_params: Record = {}; - const paramsArray = line.split("/?")[1]?.split("&") || []; - for (const item of paramsArray) { - const [key, val] = item.split("="); - if (val?.trim().length > 0) { - other_params[key] = val.trim(); + const otherParams: Record = {}; + const rawOtherParams = parseQueryString(line.split("/?")[1]); + for (const [key, value] of Object.entries(rawOtherParams)) { + const trimmed = value?.trim(); + if (trimmed) { + otherParams[key] = trimmed; } } proxy = { ...proxy, - name: other_params.remarks - ? decodeBase64OrOriginal(other_params.remarks).trim() + name: otherParams.remarks + ? decodeBase64OrOriginal(otherParams.remarks).trim() : (proxy.server ?? ""), "protocol-param": getIfNotBlank( - decodeBase64OrOriginal(other_params.protoparam || "").replace(/\s/g, ""), + decodeBase64OrOriginal(otherParams.protoparam || "").replace(/\s/g, ""), ), "obfs-param": getIfNotBlank( - decodeBase64OrOriginal(other_params.obfsparam || "").replace(/\s/g, ""), + decodeBase64OrOriginal(otherParams.obfsparam || "").replace(/\s/g, ""), ), }; return proxy; } function URI_VMESS(line: string): IProxyVmessConfig { - line = line.split("vmess://")[1]; - let content = decodeBase64OrOriginal(line); + const afterScheme = stripUriScheme(line, "vmess", "Invalid vmess uri"); + if (!afterScheme) { + throw new Error("Invalid vmess uri"); + } + const raw = afterScheme; + const content = decodeBase64OrOriginal(raw); if (/=\s*vmess/.test(content)) { - // Quantumult VMess URI format - const partitions = content.split(",").map((p) => p.trim()); - const params: Record = {}; - for (const part of partitions) { - if (part.indexOf("=") !== -1) { - const [key, val] = part.split("="); - params[key.trim()] = val.trim(); - } - } + return parseVmessQuantumult(content); + } - const proxy: IProxyVmessConfig = { - name: partitions[0].split("=")[0].trim(), - type: "vmess", - server: partitions[1], - port: parseInt(partitions[2], 10), - cipher: getCipher(getIfNotBlank(partitions[3], "auto")), - uuid: partitions[4].match(/^"(.*)"$/)?.[1] || "", - tls: params.obfs === "wss", - udp: getIfPresent(params["udp-relay"]), - tfo: getIfPresent(params["fast-open"]), - "skip-cert-verify": isPresent(params["tls-verification"]) - ? !params["tls-verification"] - : undefined, - }; + const params = parseVmessParams(content, raw); + const server = params.add; + const port = parseRequiredPort( + params.port, + "Invalid vmess uri: invalid port", + ); + const tlsValue = params.tls; + const proxy: IProxyVmessConfig = { + name: + trimStr(params.ps) ?? + trimStr(params.remarks) ?? + trimStr(params.remark) ?? + `VMess ${server}:${port}`, + type: "vmess", + server, + port, + cipher: getCipher(getIfPresent(params.scy, "auto")), + uuid: params.id, + tls: + tlsValue === "tls" || + tlsValue === true || + tlsValue === 1 || + tlsValue === "1" || + tlsValue === "true", + "skip-cert-verify": isPresent(params.verify_cert) + ? !parseBool(params.verify_cert.toString()) + : undefined, + }; - if (isPresent(params.obfs)) { - if (params.obfs === "ws" || params.obfs === "wss") { - proxy.network = "ws"; - proxy["ws-opts"] = { - path: - (getIfNotBlank(params["obfs-path"]) || '"/"').match( - /^"(.*)"$/, - )?.[1] || "/", - headers: { - Host: - params["obfs-header"]?.match(/Host:\s*([a-zA-Z0-9-.]*)/)?.[1] || - "", - }, - }; - } else { - throw new Error(`Unsupported obfs: ${params.obfs}`); - } - } + proxy.alterId = parseInt(getIfPresent(params.aid ?? params.alterId, 0), 10); - return proxy; - } else { - let params: Record = {}; + if (proxy.tls && params.sni) { + proxy.servername = params.sni; + } - try { - // V2rayN URI format - params = JSON.parse(content); - } catch (e) { - // Shadowrocket URI format - console.warn( - "[URI_VMESS] JSON.parse(content) failed, falling back to Shadowrocket parsing:", - e, - ); - const match = /(^[^?]+?)\/?\?(.*)$/.exec(line); - if (match) { - const [_, base64Line, qs] = match; - content = decodeBase64OrOriginal(base64Line); + let httpupgrade = false; + if (params.net === "ws" || params.obfs === "websocket") { + proxy.network = "ws"; + } else if ( + ["http"].includes(params.net) || + ["http"].includes(params.obfs) || + ["http"].includes(params.type) + ) { + proxy.network = "http"; + } else if (["grpc"].includes(params.net)) { + proxy.network = "grpc"; + } else if (params.net === "httpupgrade") { + proxy.network = "ws"; + httpupgrade = true; + } else if (params.net === "h2" || proxy.network === "h2") { + proxy.network = "h2"; + } - for (const addon of qs.split("&")) { - const [key, valueRaw] = addon.split("="); - const value = decodeURIComponent(valueRaw); - if (value.indexOf(",") === -1) { - params[key] = value; - } else { - params[key] = value.split(","); - } - } - - const contentMatch = /(^[^:]+?):([^:]+?)@(.*):(\d+)$/.exec(content); - - if (contentMatch) { - const [__, cipher, uuid, server, port] = contentMatch; - - params.scy = cipher; - params.id = uuid; - params.port = port; - params.add = server; - } - } - } - - const server = params.add; - const port = parseInt(getIfPresent(params.port), 10); - const proxy: IProxyVmessConfig = { - name: - trimStr(params.ps) ?? - trimStr(params.remarks) ?? - trimStr(params.remark) ?? - `VMess ${server}:${port}`, - type: "vmess", - server, - port, - cipher: getCipher(getIfPresent(params.scy, "auto")), - uuid: params.id, - tls: ["tls", true, 1, "1"].includes(params.tls), - "skip-cert-verify": isPresent(params.verify_cert) - ? !params.verify_cert - : undefined, - }; - - proxy.alterId = parseInt(getIfPresent(params.aid ?? params.alterId, 0), 10); - - if (proxy.tls && params.sni) { - proxy.servername = params.sni; - } - - let httpupgrade = false; - if (params.net === "ws" || params.obfs === "websocket") { - proxy.network = "ws"; - } else if ( - ["http"].includes(params.net) || - ["http"].includes(params.obfs) || - ["http"].includes(params.type) - ) { - proxy.network = "http"; - } else if (["grpc"].includes(params.net)) { - proxy.network = "grpc"; - } else if (params.net === "httpupgrade") { - proxy.network = "ws"; - httpupgrade = true; - } else if (params.net === "h2" || proxy.network === "h2") { - proxy.network = "h2"; - } - - if (proxy.network) { - let transportHost = params.host ?? params.obfsParam; + if (proxy.network) { + let transportHost: any = params.host ?? params.obfsParam; + if (typeof transportHost === "string") { try { const parsedObfs = JSON.parse(transportHost); const parsedHost = parsedObfs?.Host; @@ -433,124 +692,186 @@ function URI_VMESS(line: string): IProxyVmessConfig { } } catch (e) { console.warn("[URI_VMESS] transportHost JSON.parse failed:", e); - // ignore JSON parse errors - } - - let transportPath = params.path; - if (proxy.network === "http") { - if (transportHost) { - transportHost = Array.isArray(transportHost) - ? transportHost[0] - : transportHost; - } - if (transportPath) { - transportPath = Array.isArray(transportPath) - ? transportPath[0] - : transportPath; - } else { - transportPath = "/"; - } - } - - if (transportPath || transportHost) { - if (["grpc"].includes(proxy.network)) { - proxy[`grpc-opts`] = { - "grpc-service-name": getIfNotBlank(transportPath), - }; - } else { - const opts: Record = { - path: getIfNotBlank(transportPath), - headers: { Host: getIfNotBlank(transportHost) }, - }; - if (httpupgrade) { - opts["v2ray-http-upgrade"] = true; - opts["v2ray-http-upgrade-fast-open"] = true; - } - switch (proxy.network) { - case "ws": - proxy["ws-opts"] = opts; - break; - case "http": - proxy["http-opts"] = opts; - break; - case "h2": - proxy["h2-opts"] = opts; - break; - default: - break; - } - } - } else { - delete proxy.network; - } - - if (proxy.tls && !proxy.servername && transportHost) { - proxy.servername = transportHost; } } - return proxy; + const transportPath: any = params.path; + const hostFirst = getIfNotBlank(firstString(transportHost)); + const pathFirst = getIfNotBlank(firstString(transportPath)); + + switch (proxy.network) { + case "grpc": { + if (!hostFirst && !pathFirst) { + delete proxy.network; + break; + } + const serviceName = getIfNotBlank(pathFirst); + if (serviceName) { + proxy["grpc-opts"] = { "grpc-service-name": serviceName }; + } + break; + } + case "h2": { + if (!hostFirst && !pathFirst) { + delete proxy.network; + break; + } + const h2Opts: H2Options = {}; + if (hostFirst) h2Opts.host = hostFirst; + if (pathFirst) h2Opts.path = pathFirst; + if (Object.keys(h2Opts).length > 0) { + proxy["h2-opts"] = h2Opts; + } + break; + } + case "http": { + const hosts = Array.isArray(transportHost) + ? transportHost + .map((h: any) => String(h).trim()) + .filter((h: string) => h) + : hostFirst + ? [hostFirst] + : undefined; + + let paths = Array.isArray(transportPath) + ? transportPath + .map((p: any) => String(p).trim()) + .filter((p: string) => p) + : pathFirst + ? [pathFirst] + : []; + + if (paths.length === 0) paths = ["/"]; + + const httpOpts: HttpOptions = { path: paths }; + if (hosts && hosts.length > 0) { + httpOpts.headers = { Host: hosts }; + } + proxy["http-opts"] = httpOpts; + break; + } + case "ws": { + if (!hostFirst && !pathFirst && !httpupgrade) { + delete proxy.network; + break; + } + const wsOpts: WsOptions = { + path: pathFirst, + headers: hostFirst ? { Host: hostFirst } : undefined, + }; + if (httpupgrade) { + wsOpts["v2ray-http-upgrade"] = true; + wsOpts["v2ray-http-upgrade-fast-open"] = true; + } + proxy["ws-opts"] = wsOpts; + break; + } + default: + break; + } + + if (proxy.tls && !proxy.servername && hostFirst) { + proxy.servername = hostFirst; + } } + + return proxy; } /** * VLess URL Decode. */ function URI_VLESS(line: string): IProxyVlessConfig { - line = line.split("vless://")[1]; - let isShadowrocket; - let parsed = /^(.*?)@(.*?):(\d+)\/?(\?(.*?))?(?:#(.*?))?$/.exec(line)!; - if (!parsed) { - const [_, base64, other] = /^(.*?)(\?.*?$)/.exec(line)!; - line = `${atob(base64)}${other}`; - parsed = /^(.*?)@(.*?):(\d+)\/?(\?(.*?))?(?:#(.*?))?$/.exec(line)!; - isShadowrocket = true; - } - const [, uuidRaw, server, portStr, , addons = "", nameRaw] = parsed; - let uuid = uuidRaw; - let name = nameRaw; - if (isShadowrocket) { - uuid = uuidRaw.replace(/^.*?:/g, ""); + const afterScheme = stripUriScheme(line, "vless", "Invalid vless uri"); + if (!afterScheme) { + throw new Error("Invalid vless uri"); } - const port = parseInt(portStr, 10); - uuid = decodeURIComponent(uuid); - name = decodeURIComponent(name); + let rest = afterScheme; + let isShadowrocket = false; + + const parseVlessRest = ( + input: string, + ): { + uuidRaw: string; + server: string; + port: number; + addons?: string; + nameRaw?: string; + } => { + const parsed = parseUrlLike(input, { + requireAuth: true, + errorMessage: "Invalid vless uri", + }); + if (!parsed.port) { + throw new Error("Invalid vless uri: missing port"); + } + const port = parseRequiredPort( + parsed.port, + "Invalid vless uri: invalid port", + ); + return { + uuidRaw: parsed.auth, + server: parsed.host, + port, + addons: parsed.query, + nameRaw: parsed.fragment, + }; + }; + + let parsed: ReturnType; + try { + parsed = parseVlessRest(rest); + } catch { + const shadowMatch = /^(.*?)(\?.*?$)/.exec(rest); + if (!shadowMatch) { + throw new Error("Invalid vless uri"); + } + const [, base64Part, other] = shadowMatch; + rest = `${decodeBase64OrOriginal(base64Part)}${other}`; + parsed = parseVlessRest(rest); + isShadowrocket = true; + } + + const { uuidRaw, server, port, addons = "", nameRaw } = parsed; + + let uuid = uuidRaw; + if (isShadowrocket) { + uuid = uuid.replace(/^.*?:/g, ""); + } + uuid = safeDecodeURIComponent(uuid) ?? uuid; + + const params = parseQueryStringNormalized(addons); + const name = + decodeAndTrim(nameRaw) ?? + trimStr(params.remarks) ?? + trimStr(params.remark) ?? + `VLESS ${server}:${port}`; const proxy: IProxyVlessConfig = { type: "vless", - name: "", + name, server, port, uuid, }; - const params: Record = {}; - for (const addon of addons.split("&")) { - const [key, valueRaw] = addon.split("="); - const value = decodeURIComponent(valueRaw); - params[key] = value; - } - - proxy.name = - trimStr(name) ?? - trimStr(params.remarks) ?? - trimStr(params.remark) ?? - `VLESS ${server}:${port}`; - proxy.tls = (params.security && params.security !== "none") || undefined; - if (isShadowrocket && /TRUE|1/i.test(params.tls)) { + if (isShadowrocket && parseBool(params.tls) === true) { proxy.tls = true; params.security = params.security ?? "reality"; } + proxy.servername = params.sni || params.peer; - proxy.flow = params.flow ? "xtls-rprx-vision" : undefined; + proxy.flow = parseVlessFlow(params.flow); proxy["client-fingerprint"] = params.fp as ClientFingerprint; proxy.alpn = params.alpn ? params.alpn.split(",") : undefined; - proxy["skip-cert-verify"] = /(TRUE)|1/i.test(params.allowInsecure); + if (Object.prototype.hasOwnProperty.call(params, "allowInsecure")) { + proxy["skip-cert-verify"] = parseBoolOrPresence(params.allowInsecure); + } - if (["reality"].includes(params.security)) { + if (params.security === "reality") { const opts: IProxyVlessConfig["reality-opts"] = {}; if (params.pbk) { opts["public-key"] = params.pbk; @@ -564,74 +885,94 @@ function URI_VLESS(line: string): IProxyVlessConfig { } let httpupgrade = false; - proxy["ws-opts"] = { - headers: undefined, - path: undefined, - }; - - proxy["http-opts"] = { - headers: undefined, - path: undefined, - }; - proxy["grpc-opts"] = {}; + let network: NetworkType = "tcp"; if (params.headerType === "http") { - proxy.network = "http"; - } else if (params.type === "ws") { - proxy.network = "ws"; - httpupgrade = true; + network = "http"; } else { - proxy.network = ["tcp", "ws", "http", "grpc", "h2"].includes(params.type) - ? (params.type as NetworkType) - : "tcp"; - } - if (!proxy.network && isShadowrocket && params.obfs) { - switch (params.type) { - case "sw": - proxy.network = "ws"; - break; - case "http": - proxy.network = "http"; - break; - case "h2": - proxy.network = "h2"; - break; - case "grpc": - proxy.network = "grpc"; - break; - default: { - break; - } + let type = params.type; + if (type === "websocket") type = "ws"; + if (isShadowrocket && type === "sw") type = "ws"; + if (type === "httpupgrade") { + network = "ws"; + httpupgrade = true; + } else if (type && ["tcp", "ws", "http", "grpc", "h2"].includes(type)) { + network = type as NetworkType; + } else { + network = "tcp"; + } + + if (params.type === "ws") { + httpupgrade = true; } } - if (["websocket"].includes(proxy.network)) { - proxy.network = "ws"; - } + + proxy.network = network; + if (proxy.network && !["tcp", "none"].includes(proxy.network)) { - const opts: Record = {}; const host = params.host ?? params.obfsParam; - if (host) { - if (params.obfsParam) { - try { - const parsed = JSON.parse(host); - opts.headers = parsed; - } catch (e) { - console.warn("[URI_VLESS] host JSON.parse failed:", e); - opts.headers = { Host: host }; + const path = params.path; + + switch (proxy.network) { + case "grpc": + { + const serviceName = getIfNotBlank(path); + if (serviceName) { + proxy["grpc-opts"] = { "grpc-service-name": serviceName }; + } } - } else { - opts.headers = { Host: host }; + break; + case "h2": { + const h2Opts: H2Options = {}; + const hostVal = getIfNotBlank(host); + const pathVal = getIfNotBlank(path); + if (hostVal) h2Opts.host = hostVal; + if (pathVal) h2Opts.path = pathVal; + if (Object.keys(h2Opts).length > 0) { + proxy["h2-opts"] = h2Opts; + } + break; } - } - if (params.path) { - opts.path = params.path; - } - if (httpupgrade) { - opts["v2ray-http-upgrade"] = true; - opts["v2ray-http-upgrade-fast-open"] = true; - } - if (Object.keys(opts).length > 0) { - proxy[`ws-opts`] = opts; + case "http": { + const httpOpts: HttpOptions = {}; + const hostVal = getIfNotBlank(host); + const pathVal = getIfNotBlank(path); + if (pathVal) httpOpts.path = [pathVal]; + if (hostVal) httpOpts.headers = { Host: [hostVal] }; + if (Object.keys(httpOpts).length > 0) { + proxy["http-opts"] = httpOpts; + } + break; + } + case "ws": { + const wsOpts: WsOptions = {}; + if (host) { + if (params.obfsParam) { + try { + const parsedHeaders = JSON.parse(host); + wsOpts.headers = parsedHeaders; + } catch (e) { + console.warn("[URI_VLESS] host JSON.parse failed:", e); + wsOpts.headers = { Host: host }; + } + } else { + wsOpts.headers = { Host: host }; + } + } + if (path) { + wsOpts.path = path; + } + if (httpupgrade) { + wsOpts["v2ray-http-upgrade"] = true; + wsOpts["v2ray-http-upgrade-fast-open"] = true; + } + if (Object.keys(wsOpts).length > 0) { + proxy["ws-opts"] = wsOpts; + } + break; + } + default: + break; } } @@ -639,30 +980,33 @@ function URI_VLESS(line: string): IProxyVlessConfig { if (proxy.network === "ws") { proxy.servername = proxy["ws-opts"]?.headers?.Host; } else if (proxy.network === "http") { - const httpHost = proxy["http-opts"]?.headers?.Host; - proxy.servername = Array.isArray(httpHost) ? httpHost[0] : httpHost; + proxy.servername = proxy["http-opts"]?.headers?.Host?.[0]; + } else if (proxy.network === "h2") { + proxy.servername = proxy["h2-opts"]?.host; } } + return proxy; } function URI_Trojan(line: string): IProxyTrojanConfig { - line = line.split("trojan://")[1]; - const [, passwordRaw, server, , port, , addons = "", nameRaw] = - /^(.*?)@(.*?)(:(\d+))?\/?(\?(.*?))?(?:#(.*?))?$/.exec(line) || []; - - let portNum = parseInt(`${port}`, 10); - if (isNaN(portNum)) { - portNum = 443; + const afterScheme = stripUriScheme(line, "trojan", "Invalid trojan uri"); + if (!afterScheme) { + throw new Error("Invalid trojan uri"); } - - let password = passwordRaw; - password = decodeURIComponent(password); - - let name = nameRaw; - const decodedName = trimStr(decodeURIComponent(name)); - - name = decodedName ?? `Trojan ${server}:${portNum}`; + const { + auth: passwordRaw, + host: server, + port, + query: addons, + fragment: nameRaw, + } = parseUrlLike(afterScheme, { + requireAuth: true, + errorMessage: "Invalid trojan uri", + }); + const portNum = parsePortOrDefault(port, 443); + const password = safeDecodeURIComponent(passwordRaw) ?? passwordRaw; + const name = decodeAndTrim(nameRaw) ?? `Trojan ${server}:${portNum}`; const proxy: IProxyTrojanConfig = { type: "trojan", name, @@ -670,88 +1014,88 @@ function URI_Trojan(line: string): IProxyTrojanConfig { port: portNum, password, }; - let host = ""; - let path = ""; - for (const addon of addons.split("&")) { - const [key, valueRaw] = addon.split("="); - const value = decodeURIComponent(valueRaw); - switch (key) { - case "type": - if (["ws", "h2"].includes(value)) { - proxy.network = value as NetworkType; - } else { - proxy.network = "tcp"; - } - break; - case "host": - host = value; - break; - case "path": - path = value; - break; - case "alpn": - proxy["alpn"] = value ? value.split(",") : undefined; - break; - case "sni": - proxy["sni"] = value; - break; - case "skip-cert-verify": - proxy["skip-cert-verify"] = /(TRUE)|1/i.test(value); - break; - case "fingerprint": - proxy["fingerprint"] = value; - break; - case "fp": - proxy["fingerprint"] = value; - break; - case "encryption": - { - const encryption = value.split(";"); - if (encryption.length === 3) { - proxy["ss-opts"] = { - enabled: true, - method: encryption[1], - password: encryption[2], - }; - } - } - break; - case "client-fingerprint": - proxy["client-fingerprint"] = value as ClientFingerprint; - break; - default: - break; + const params = parseQueryStringNormalized(addons); + + const network = params.type; + if (network && ["ws", "grpc", "h2", "tcp"].includes(network)) { + proxy.network = network as NetworkType; + } + + const host = getIfNotBlank(params.host); + const path = getIfNotBlank(params.path); + + if (params.alpn) { + proxy.alpn = params.alpn.split(","); + } + if (params.sni) { + proxy.sni = params.sni; + } + if (Object.prototype.hasOwnProperty.call(params, "skip-cert-verify")) { + proxy["skip-cert-verify"] = parseBoolOrPresence(params["skip-cert-verify"]); + } + + proxy.fingerprint = params.fingerprint ?? params.fp; + + if (params.encryption) { + const encryption = params.encryption.split(";"); + if (encryption.length === 3) { + proxy["ss-opts"] = { + enabled: true, + method: encryption[1], + password: encryption[2], + }; } } + + if (params["client-fingerprint"]) { + proxy["client-fingerprint"] = params[ + "client-fingerprint" + ] as ClientFingerprint; + } + if (proxy.network === "ws") { - proxy["ws-opts"] = { - headers: { Host: host }, - path, - } as WsOptions; + const wsOpts: WsOptions = {}; + if (host) wsOpts.headers = { Host: host }; + if (path) wsOpts.path = path; + if (Object.keys(wsOpts).length > 0) { + proxy["ws-opts"] = wsOpts; + } } else if (proxy.network === "grpc") { - proxy["grpc-opts"] = { - "grpc-service-name": path, - } as GrpcOptions; + const serviceName = getIfNotBlank(path); + if (serviceName) { + proxy["grpc-opts"] = { "grpc-service-name": serviceName }; + } } return proxy; } function URI_Hysteria2(line: string): IProxyHysteria2Config { - line = line.split(/(hysteria2|hy2):\/\//)[2]; - - const [, passwordRaw, server, , port, , addons = "", nameRaw] = - /^(.*?)@(.*?)(:(\d+))?\/?(\?(.*?))?(?:#(.*?))?$/.exec(line) || []; - let portNum = parseInt(`${port}`, 10); - if (isNaN(portNum)) { - portNum = 443; + const afterScheme = stripUriScheme( + line, + ["hysteria2", "hy2"], + "Invalid hysteria2 uri", + ); + if (!afterScheme) { + throw new Error("Invalid hysteria2 uri"); } - const password = decodeURIComponent(passwordRaw); + const { + auth: passwordRaw, + host: server, + port, + query: addons, + fragment: nameRaw, + } = parseUrlLike(afterScheme, { + requireAuth: true, + errorMessage: "Invalid hysteria2 uri", + }); + const portNum = parsePortOrDefault(port, 443); + const password = safeDecodeURIComponent(passwordRaw) ?? passwordRaw; - const decodedName = trimStr(decodeURIComponent(nameRaw)); + const decodedName = decodeAndTrim(nameRaw); - const name = decodedName ?? `Hysteria2 ${server}:${port}`; + const name = decodedName ?? `Hysteria2 ${server}:${portNum}`; const proxy: IProxyHysteria2Config = { type: "hysteria2", @@ -761,13 +1105,7 @@ function URI_Hysteria2(line: string): IProxyHysteria2Config { password, }; - const params: Record = {}; - for (const addon of addons.split("&")) { - const [key, valueRaw] = addon.split("="); - let value = valueRaw; - value = decodeURIComponent(valueRaw); - params[key] = value; - } + const params = parseQueryStringNormalized(addons); proxy.sni = params.sni; if (!proxy.sni && params.peer) { @@ -779,24 +1117,34 @@ function URI_Hysteria2(line: string): IProxyHysteria2Config { proxy.ports = params.mport; proxy["obfs-password"] = params["obfs-password"]; - proxy["skip-cert-verify"] = /(TRUE)|1/i.test(params.insecure); - proxy.tfo = /(TRUE)|1/i.test(params.fastopen); + if (Object.prototype.hasOwnProperty.call(params, "insecure")) { + proxy["skip-cert-verify"] = parseBoolOrPresence(params.insecure); + } + if (Object.prototype.hasOwnProperty.call(params, "fastopen")) { + proxy.tfo = parseBoolOrPresence(params.fastopen); + } proxy.fingerprint = params.pinSHA256; return proxy; } function URI_Hysteria(line: string): IProxyHysteriaConfig { - line = line.split(/(hysteria|hy):\/\//)[2]; - const [, server, , port, , addons = "", nameRaw] = - /^(.*?)(:(\d+))?\/?(\?(.*?))?(?:#(.*?))?$/.exec(line)!; - let portNum = parseInt(`${port}`, 10); - if (isNaN(portNum)) { - portNum = 443; + const afterScheme = stripUriScheme( + line, + ["hysteria", "hy"], + "Invalid hysteria uri", + ); + if (!afterScheme) { + throw new Error("Invalid hysteria uri"); } - const decodedName = trimStr(decodeURIComponent(nameRaw)); - - const name = decodedName ?? `Hysteria ${server}:${port}`; + const { + host: server, + port, + query: addons, + fragment: nameRaw, + } = parseUrlLike(afterScheme, { errorMessage: "Invalid hysteria uri" }); + const portNum = parsePortOrDefault(port, 443); + const name = decodeAndTrim(nameRaw) ?? `Hysteria ${server}:${portNum}`; const proxy: IProxyHysteriaConfig = { type: "hysteria", @@ -804,78 +1152,70 @@ function URI_Hysteria(line: string): IProxyHysteriaConfig { server, port: portNum, }; - const params: Record = {}; - for (const addon of addons.split("&")) { - let [key, value] = addon.split("="); - key = key.replace(/_/, "-"); - value = decodeURIComponent(value); + const params = parseQueryStringNormalized(addons); + + for (const [key, value] of Object.entries(params)) { switch (key) { case "alpn": - proxy["alpn"] = value ? value.split(",") : undefined; + proxy.alpn = value ? value.split(",") : undefined; break; case "insecure": - proxy["skip-cert-verify"] = /(TRUE)|1/i.test(value); + proxy["skip-cert-verify"] = parseBoolOrPresence(value); break; case "auth": - proxy["auth-str"] = value; + if (value) proxy["auth-str"] = value; break; case "mport": - proxy["ports"] = value; + if (value) proxy.ports = value; break; case "obfsParam": - proxy["obfs"] = value; + if (value) proxy.obfs = value; break; case "upmbps": - proxy["up"] = value; + if (value) proxy.up = value; break; case "downmbps": - proxy["down"] = value; + if (value) proxy.down = value; break; case "obfs": - proxy["obfs"] = value || ""; + if (value !== undefined) proxy.obfs = value || ""; break; case "fast-open": - proxy["fast-open"] = /(TRUE)|1/i.test(value); + proxy["fast-open"] = parseBoolOrPresence(value); break; case "peer": - proxy["fast-open"] = /(TRUE)|1/i.test(value); + if (!proxy.sni && value) proxy.sni = value; break; case "recv-window-conn": - proxy["recv-window-conn"] = parseInt(value); + proxy["recv-window-conn"] = parseInteger(value); break; case "recv-window": - proxy["recv-window"] = parseInt(value); + proxy["recv-window"] = parseInteger(value); break; case "ca": - proxy["ca"] = value; + if (value) proxy.ca = value; break; case "ca-str": - proxy["ca-str"] = value; + if (value) proxy["ca-str"] = value; break; case "disable-mtu-discovery": - proxy["disable-mtu-discovery"] = /(TRUE)|1/i.test(value); + proxy["disable-mtu-discovery"] = parseBoolOrPresence(value); break; case "fingerprint": - proxy["fingerprint"] = value; + if (value) proxy.fingerprint = value; break; case "protocol": - proxy["protocol"] = value; + if (value) proxy.protocol = value; break; case "sni": - proxy["sni"] = value; + if (value) proxy.sni = value; break; default: break; } } - if (!proxy.sni && params.peer) { - proxy.sni = params.peer; - } - if (!proxy["fast-open"] && params["fast-open"]) { - proxy["fast-open"] = true; - } if (!proxy.protocol) { proxy.protocol = "udp"; } @@ -884,19 +1224,30 @@ function URI_Hysteria(line: string): IProxyHysteriaConfig { } function URI_TUIC(line: string): IProxyTuicConfig { - line = line.split(/tuic:\/\//)[1]; - - const [, uuid, passwordRaw, server, , port, , addons = "", nameRaw] = - /^(.*?):(.*?)@(.*?)(:(\d+))?\/?(\?(.*?))?(?:#(.*?))?$/.exec(line) || []; - - let portNum = parseInt(`${port}`, 10); - if (isNaN(portNum)) { - portNum = 443; + const afterScheme = stripUriScheme(line, "tuic", "Invalid tuic uri"); + if (!afterScheme) { + throw new Error("Invalid tuic uri"); + } + const { + auth, + host: server, + port, + query: addons, + fragment: nameRaw, + } = parseUrlLike(afterScheme, { + requireAuth: true, + errorMessage: "Invalid tuic uri", + }); + const [uuid, passwordRaw] = splitOnce(auth, ":"); + if (passwordRaw === undefined) { + throw new Error("Invalid tuic uri"); } - const password = decodeURIComponent(passwordRaw); - const decodedName = trimStr(decodeURIComponent(nameRaw)); - const name = decodedName ?? `TUIC ${server}:${port}`; + const portNum = parsePortOrDefault(port, 443); + const password = safeDecodeURIComponent(passwordRaw) ?? passwordRaw; + const decodedName = decodeAndTrim(nameRaw); + + const name = decodedName ?? `TUIC ${server}:${portNum}`; const proxy: IProxyTuicConfig = { type: "tuic", @@ -907,31 +1258,29 @@ function URI_TUIC(line: string): IProxyTuicConfig { uuid, }; - for (const addon of addons.split("&")) { - let [key, value] = addon.split("="); - key = key.replace(/_/, "-"); - value = decodeURIComponent(value); + const params = parseQueryStringNormalized(addons); + for (const [key, value] of Object.entries(params)) { switch (key) { case "token": - proxy["token"] = value; + proxy.token = value; break; case "ip": - proxy["ip"] = value; + proxy.ip = value; break; case "heartbeat-interval": - proxy["heartbeat-interval"] = parseInt(value); + proxy["heartbeat-interval"] = parseInteger(value); break; case "alpn": - proxy["alpn"] = value ? value.split(",") : undefined; + proxy.alpn = value ? value.split(",") : undefined; break; case "disable-sni": - proxy["disable-sni"] = /(TRUE)|1/i.test(value); + proxy["disable-sni"] = parseBoolOrPresence(value); break; case "reduce-rtt": - proxy["reduce-rtt"] = /(TRUE)|1/i.test(value); + proxy["reduce-rtt"] = parseBoolOrPresence(value); break; case "request-timeout": - proxy["request-timeout"] = parseInt(value); + proxy["request-timeout"] = parseInteger(value); break; case "udp-relay-mode": proxy["udp-relay-mode"] = value; @@ -940,22 +1289,22 @@ function URI_TUIC(line: string): IProxyTuicConfig { proxy["congestion-controller"] = value; break; case "max-udp-relay-packet-size": - proxy["max-udp-relay-packet-size"] = parseInt(value); + proxy["max-udp-relay-packet-size"] = parseInteger(value); break; case "fast-open": - proxy["fast-open"] = /(TRUE)|1/i.test(value); + proxy["fast-open"] = parseBoolOrPresence(value); break; case "skip-cert-verify": - proxy["skip-cert-verify"] = /(TRUE)|1/i.test(value); + case "allow-insecure": + proxy["skip-cert-verify"] = parseBoolOrPresence(value); break; case "max-open-streams": - proxy["max-open-streams"] = parseInt(value); + proxy["max-open-streams"] = parseInteger(value); break; case "sni": - proxy["sni"] = value; + proxy.sni = value; break; - case "allow-insecure": - proxy["skip-cert-verify"] = /(TRUE)|1/i.test(value); + default: break; } } @@ -964,18 +1313,26 @@ function URI_TUIC(line: string): IProxyTuicConfig { } function URI_Wireguard(line: string): IProxyWireguardConfig { - line = line.split(/(wireguard|wg):\/\//)[2]; - const [, , privateKeyRaw, server, , port, , addons = "", nameRaw] = - /^((.*?)@)?(.*?)(:(\d+))?\/?(\?(.*?))?(?:#(.*?))?$/.exec(line)!; - - let portNum = parseInt(`${port}`, 10); - if (isNaN(portNum)) { - portNum = 443; + const afterScheme = stripUriScheme( + line, + ["wireguard", "wg"], + "Invalid wireguard uri", + ); + if (!afterScheme) { + throw new Error("Invalid wireguard uri"); } - const privateKey = decodeURIComponent(privateKeyRaw); - const decodedName = trimStr(decodeURIComponent(nameRaw)); + const { + auth: privateKeyRaw, + host: server, + port, + query: addons, + fragment: nameRaw, + } = parseUrlLike(afterScheme, { errorMessage: "Invalid wireguard uri" }); + const portNum = parsePortOrDefault(port, 443); + const privateKey = safeDecodeURIComponent(privateKeyRaw) ?? privateKeyRaw; + const decodedName = decodeAndTrim(nameRaw); - const name = decodedName ?? `WireGuard ${server}:${port}`; + const name = decodedName ?? `WireGuard ${server}:${portNum}`; const proxy: IProxyWireguardConfig = { type: "wireguard", name, @@ -984,14 +1341,14 @@ function URI_Wireguard(line: string): IProxyWireguardConfig { "private-key": privateKey, udp: true, }; - for (const addon of addons.split("&")) { - let [key, value] = addon.split("="); - key = key.replace(/_/, "-"); - value = decodeURIComponent(value); + + const params = parseQueryStringNormalized(addons); + for (const [key, value] of Object.entries(params)) { switch (key) { case "address": case "ip": - value.split(",").map((i) => { + if (!value) break; + value.split(",").forEach((i) => { const ip = i .trim() .replace(/\/\d+$/, "") @@ -1005,38 +1362,44 @@ function URI_Wireguard(line: string): IProxyWireguardConfig { }); break; case "publickey": + case "public-key": + if (!value) break; proxy["public-key"] = value; break; case "allowed-ips": + if (!value) break; proxy["allowed-ips"] = value.split(","); break; case "pre-shared-key": + if (!value) break; proxy["pre-shared-key"] = value; break; case "reserved": { + if (!value) break; const parsed = value .split(",") - .map((i) => parseInt(i.trim(), 10)) - .filter((i) => Number.isInteger(i)); + .map((i) => parseInteger(i.trim())) + .filter((i): i is number => Number.isInteger(i)); if (parsed.length === 3) { proxy["reserved"] = parsed; } } break; case "udp": - proxy["udp"] = /(TRUE)|1/i.test(value); + proxy.udp = parseBoolOrPresence(value); break; case "mtu": - proxy.mtu = parseInt(value.trim(), 10); + proxy.mtu = parseInteger(value?.trim()); break; case "dialer-proxy": proxy["dialer-proxy"] = value; break; case "remote-dns-resolve": - proxy["remote-dns-resolve"] = /(TRUE)|1/i.test(value); + proxy["remote-dns-resolve"] = parseBoolOrPresence(value); break; case "dns": + if (!value) break; proxy.dns = value.split(","); break; default: @@ -1048,20 +1411,24 @@ function URI_Wireguard(line: string): IProxyWireguardConfig { } function URI_HTTP(line: string): IProxyHttpConfig { - line = line.split(/(http|https):\/\//)[2]; - const [, , authRaw, server, , port, , addons = "", nameRaw] = - /^((.*?)@)?(.*?)(:(\d+))?\/?(\?(.*?))?(?:#(.*?))?$/.exec(line)!; - - let portNum = parseInt(`${port}`, 10); - if (isNaN(portNum)) { - portNum = 443; + const afterScheme = stripUriScheme( + line, + ["http", "https"], + "Invalid http uri", + ); + if (!afterScheme) { + throw new Error("Invalid http uri"); } - let auth = authRaw; - - if (auth) { - auth = decodeURIComponent(auth); - } - const decodedName = trimStr(decodeURIComponent(nameRaw)); + const { + auth: authRaw, + host: server, + port, + query: addons, + fragment: nameRaw, + } = parseUrlLike(afterScheme, { errorMessage: "Invalid http uri" }); + const portNum = parsePortOrDefault(port, 443); + const auth = safeDecodeURIComponent(authRaw) ?? authRaw; + const decodedName = decodeAndTrim(nameRaw); const name = decodedName ?? `HTTP ${server}:${portNum}`; const proxy: IProxyHttpConfig = { @@ -1071,39 +1438,25 @@ function URI_HTTP(line: string): IProxyHttpConfig { port: portNum, }; if (auth) { - const [username, password] = auth.split(":"); + const [username, password] = splitOnce(auth, ":"); proxy.username = username; proxy.password = password; } - for (const addon of addons.split("&")) { - let [key, value] = addon.split("="); - key = key.replace(/_/, "-"); - value = decodeURIComponent(value); + const params = parseQueryStringNormalized(addons); + for (const [key, value] of Object.entries(params)) { switch (key) { case "tls": - proxy.tls = /(TRUE)|1/i.test(value); + proxy.tls = parseBoolOrPresence(value); break; case "fingerprint": - proxy["fingerprint"] = value; + proxy.fingerprint = value; break; case "skip-cert-verify": - proxy["skip-cert-verify"] = /(TRUE)|1/i.test(value); + proxy["skip-cert-verify"] = parseBoolOrPresence(value); break; case "ip-version": - if ( - ["dual", "ipv4", "ipv6", "ipv4-prefer", "ipv6-prefer"].includes(value) - ) { - proxy["ip-version"] = value as - | "dual" - | "ipv4" - | "ipv6" - | "ipv4-prefer" - | "ipv6-prefer"; - } else { - proxy["ip-version"] = "dual"; - } - + proxy["ip-version"] = parseIpVersion(value); break; default: break; @@ -1114,20 +1467,25 @@ function URI_HTTP(line: string): IProxyHttpConfig { } function URI_SOCKS(line: string): IProxySocks5Config { - line = line.split(/socks5:\/\//)[1]; - const [, , authRaw, server, , port, , addons = "", nameRaw] = - /^((.*?)@)?(.*?)(:(\d+))?\/?(\?(.*?))?(?:#(.*?))?$/.exec(line)!; - - let portNum = parseInt(`${port}`, 10); - if (isNaN(portNum)) { - portNum = 443; + const afterScheme = stripUriScheme( + line, + ["socks5", "socks"], + "Invalid socks uri", + ); + if (!afterScheme) { + throw new Error("Invalid socks uri"); } + const { + auth: authRaw, + host: server, + port, + query: addons, + fragment: nameRaw, + } = parseUrlLike(afterScheme, { errorMessage: "Invalid socks uri" }); + const portNum = parsePortOrDefault(port, 443); - let auth = authRaw; - if (auth) { - auth = decodeURIComponent(auth); - } - const decodedName = trimStr(decodeURIComponent(nameRaw)); + const auth = safeDecodeURIComponent(authRaw) ?? authRaw; + const decodedName = decodeAndTrim(nameRaw); const name = decodedName ?? `SOCKS5 ${server}:${portNum}`; const proxy: IProxySocks5Config = { type: "socks5", @@ -1136,41 +1494,28 @@ function URI_SOCKS(line: string): IProxySocks5Config { port: portNum, }; if (auth) { - const [username, password] = auth.split(":"); + const [username, password] = splitOnce(auth, ":"); proxy.username = username; proxy.password = password; } - for (const addon of addons.split("&")) { - let [key, value] = addon.split("="); - key = key.replace(/_/, "-"); - value = decodeURIComponent(value); + const params = parseQueryStringNormalized(addons); + for (const [key, value] of Object.entries(params)) { switch (key) { case "tls": - proxy.tls = /(TRUE)|1/i.test(value); + proxy.tls = parseBoolOrPresence(value); break; case "fingerprint": - proxy["fingerprint"] = value; + proxy.fingerprint = value; break; case "skip-cert-verify": - proxy["skip-cert-verify"] = /(TRUE)|1/i.test(value); + proxy["skip-cert-verify"] = parseBoolOrPresence(value); break; case "udp": - proxy["udp"] = /(TRUE)|1/i.test(value); + proxy.udp = parseBoolOrPresence(value); break; case "ip-version": - if ( - ["dual", "ipv4", "ipv6", "ipv4-prefer", "ipv6-prefer"].includes(value) - ) { - proxy["ip-version"] = value as - | "dual" - | "ipv4" - | "ipv6" - | "ipv4-prefer" - | "ipv6-prefer"; - } else { - proxy["ip-version"] = "dual"; - } + proxy["ip-version"] = parseIpVersion(value); break; default: break;