diff --git a/packages/core/src/mihomo/proxy-sanitizer.ts b/packages/core/src/mihomo/proxy-sanitizer.ts index ff21e4d..632703d 100644 --- a/packages/core/src/mihomo/proxy-sanitizer.ts +++ b/packages/core/src/mihomo/proxy-sanitizer.ts @@ -6,7 +6,6 @@ const STANDARD_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A- const REALITY_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/; const WIREGUARD_KEY_PATTERN = /^[A-Za-z0-9+/]{43}=$/; const CERTIFICATE_FINGERPRINT_HEX_PATTERN = /^[A-Fa-f0-9]{64}$/; -const SSH_HOST_KEY_PATTERN = /^ssh-(?:ed25519|rsa|dss|ecdsa-[A-Za-z0-9-]+)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/; const SSH_SERVER_FINGERPRINT_PATTERN = /^SHA256:[A-Za-z0-9+/]{43}=?$/; const VLESS_ENCRYPTION_PATTERN = /^mlkem768x25519plus\.(?:native|xorpub|random)\.(?:1rtt|0rtt)\.[A-Za-z0-9+/=_-]+(?:\.[A-Za-z0-9+/=_-]+)*$/; @@ -95,12 +94,61 @@ function normalizePemPrivateKey(value: unknown): string | null { return normalized; } +function isAsciiAlphaNumericOrHyphen(value: string): boolean { + if (!value) return false; + for (const char of value) { + const ok = (char >= "a" && char <= "z") || (char >= "A" && char <= "Z") || (char >= "0" && char <= "9") || char === "-"; + if (!ok) return false; + } + return true; +} + +function isBase64Token(value: string): boolean { + if (!value) return false; + for (const char of value) { + const ok = + (char >= "a" && char <= "z") || + (char >= "A" && char <= "Z") || + (char >= "0" && char <= "9") || + char === "+" || + char === "/" || + char === "="; + if (!ok) return false; + } + return true; +} + +function findWhitespaceIndex(value: string): number { + for (let i = 0; i < value.length; i += 1) { + if (value[i].trim() === "") return i; + } + return -1; +} + +function isSupportedSshHostKeyType(value: string): boolean { + if (value === "ssh-ed25519" || value === "ssh-rsa" || value === "ssh-dss") return true; + return value.startsWith("ssh-ecdsa-") && isAsciiAlphaNumericOrHyphen(value.slice("ssh-ecdsa-".length)); +} + +function isSshHostKey(value: string): boolean { + const typeEnd = findWhitespaceIndex(value); + if (typeEnd <= 0) return false; + const keyType = value.slice(0, typeEnd); + if (!isSupportedSshHostKeyType(keyType)) return false; + + const rest = value.slice(typeEnd).trimStart(); + if (!rest) return false; + const keyEnd = findWhitespaceIndex(rest); + const key = keyEnd === -1 ? rest : rest.slice(0, keyEnd); + return isBase64Token(key); +} + function normalizeSshHostKeys(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; const keys = value .filter((item): item is string => typeof item === "string") .map((item) => item.trim()) - .filter((item) => SSH_HOST_KEY_PATTERN.test(item)); + .filter((item) => isSshHostKey(item)); return keys.length > 0 ? keys : undefined; } diff --git a/packages/core/src/parser/protocols/anytls.ts b/packages/core/src/parser/protocols/anytls.ts index 630cea7..512fbe3 100644 --- a/packages/core/src/parser/protocols/anytls.ts +++ b/packages/core/src/parser/protocols/anytls.ts @@ -27,19 +27,56 @@ function parseIntParam(params: URLSearchParams, keys: string[]): number | undefi return undefined; } +function hasUserInfoAuthority(raw: string): boolean { + const hashIndex = raw.indexOf("#"); + const beforeHash = hashIndex === -1 ? raw : raw.slice(0, hashIndex); + const queryIndex = beforeHash.indexOf("?"); + let authority = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex); + if (authority.endsWith("/")) authority = authority.slice(0, -1); + return authority.includes("@"); +} + +function splitAtFirstQuery(raw: string): { token: string; suffix: string } | null { + const queryIndex = raw.indexOf("?"); + if (queryIndex === -1) return null; + return { token: raw.slice(0, queryIndex), suffix: raw.slice(queryIndex) }; +} + +function isBase64UrlToken(value: string): boolean { + if (!value) return false; + for (const char of value) { + const ok = + (char >= "a" && char <= "z") || + (char >= "A" && char <= "Z") || + (char >= "0" && char <= "9") || + char === "+" || + char === "/" || + char === "=" || + char === "_" || + char === "-"; + if (!ok) return false; + } + return true; +} + +function stripThroughLastColon(value: string): string { + const colonIndex = value.lastIndexOf(":"); + return colonIndex === -1 ? value : value.slice(colonIndex + 1); +} + function normalizeEncodedUserinfoUri(uri: string): { uri: string; usedEncodedUserinfo: boolean } { const raw = uri.slice("anytls://".length).trim(); - if (/^(.*?)@(.*?)(?::(\d+))?\/?(?:\?(.*?))?(?:#(.*?))?$/.test(raw)) { + if (hasUserInfoAuthority(raw)) { return { uri, usedEncodedUserinfo: false }; } - const match = /^(.*?)(\?.*?)$/.exec(raw); - if (!match) return { uri, usedEncodedUserinfo: false }; - const token = match[1].trim(); - if (!/^[A-Za-z0-9+/=_-]+$/.test(token) || token.includes('.') || token.includes(':')) { + const parts = splitAtFirstQuery(raw); + if (!parts) return { uri, usedEncodedUserinfo: false }; + const token = parts.token.trim(); + if (!isBase64UrlToken(token) || token.includes(".") || token.includes(":")) { return { uri, usedEncodedUserinfo: false }; } const decoded = decodeBase64(token); - return { uri: `anytls://${decoded}${match[2]}`, usedEncodedUserinfo: true }; + return { uri: `anytls://${decoded}${parts.suffix}`, usedEncodedUserinfo: true }; } function normalizeAnyTlsSecurity(raw: string | null): "tls" { @@ -114,7 +151,7 @@ export function parseAnyTLS(uri: string): AnyTLSNode { const pass = safeDecodeURIComponent(url.password); if (user || pass) { const merged = pass ? `${user}:${pass}` : user; - return normalized.usedEncodedUserinfo ? merged.replace(/^.*?:/g, "") : merged; + return normalized.usedEncodedUserinfo ? stripThroughLastColon(merged) : merged; } return ( diff --git a/packages/core/src/parser/protocols/hysteria2.ts b/packages/core/src/parser/protocols/hysteria2.ts index 1786ade..be20284 100644 --- a/packages/core/src/parser/protocols/hysteria2.ts +++ b/packages/core/src/parser/protocols/hysteria2.ts @@ -31,8 +31,14 @@ function parseHopIntervalParam(params: URLSearchParams, keys: string[]): number return undefined; } +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === "/") end -= 1; + return end === value.length ? value : value.slice(0, end); +} + function splitAuthority(value: string): { userInfo: string; server: string; portSpec: string } { - const raw = value.trim().replace(/\/+$/, ""); + const raw = trimTrailingSlashes(value.trim()); if (!raw) return { userInfo: "", server: "", portSpec: "" }; const atIndex = raw.lastIndexOf("@"); @@ -45,7 +51,7 @@ function splitAuthority(value: string): { userInfo: string; server: string; port const server = hostPort.slice(1, end); const rest = hostPort.slice(end + 1); if (rest.startsWith(":")) { - return { userInfo, server, portSpec: rest.slice(1).replace(/\/+$/, "") }; + return { userInfo, server, portSpec: trimTrailingSlashes(rest.slice(1)) }; } return { userInfo, server, portSpec: "" }; } @@ -58,7 +64,7 @@ function splitAuthority(value: string): { userInfo: string; server: string; port return { userInfo, server: hostPort.slice(0, colonIndex), - portSpec: hostPort.slice(colonIndex + 1).replace(/\/+$/, ""), + portSpec: trimTrailingSlashes(hostPort.slice(colonIndex + 1)), }; } diff --git a/packages/core/src/parser/protocols/simple-proxy.ts b/packages/core/src/parser/protocols/simple-proxy.ts index 68270de..0ed34e8 100644 --- a/packages/core/src/parser/protocols/simple-proxy.ts +++ b/packages/core/src/parser/protocols/simple-proxy.ts @@ -19,19 +19,31 @@ import { parseJsonObject, parseJsonStringMap } from "../json-utils"; type SimpleProxyType = "http" | "https" | "socks5" | "socks4" | "ssh"; type SimpleProxyNode = SocksNode | HttpNode | SshNode; +function takeTerminalEnclosedValue(input: string, open: string, close: string): { value: string; enclosed?: string } { + if (!input.endsWith(close)) return { value: input }; + const openIndex = input.lastIndexOf(open); + if (openIndex === -1) return { value: input }; + const enclosed = input.slice(openIndex + open.length, input.length - close.length); + if (enclosed.includes(close)) return { value: input }; + return { + value: input.slice(0, openIndex), + enclosed, + }; +} + function stripMetaSuffix(input: string): { value: string; name?: string } { let remaining = input.trim(); let name: string | undefined; - const braceMatch = remaining.match(/\{([^}]*)\}$/); - if (braceMatch) { - name = braceMatch[1]; - remaining = remaining.slice(0, -braceMatch[0].length); + const brace = takeTerminalEnclosedValue(remaining, "{", "}"); + if (brace.enclosed !== undefined) { + name = brace.enclosed; + remaining = brace.value; } - const bracketMatch = remaining.match(/\[([^\]]*)\]$/); - if (bracketMatch) { - remaining = remaining.slice(0, -bracketMatch[0].length); + const bracket = takeTerminalEnclosedValue(remaining, "[", "]"); + if (bracket.enclosed !== undefined) { + remaining = bracket.value; } return { value: remaining, name }; diff --git a/packages/core/src/parser/protocols/vless.ts b/packages/core/src/parser/protocols/vless.ts index de747a5..127c841 100644 --- a/packages/core/src/parser/protocols/vless.ts +++ b/packages/core/src/parser/protocols/vless.ts @@ -114,19 +114,61 @@ function buildXhttpOptsFromQuery(params: URLSearchParams, fallbackPath: string, }; } +function isDigitString(value: string): boolean { + if (!value) return false; + for (const char of value) { + if (char < "0" || char > "9") return false; + } + return true; +} + +function splitAtFirstQuery(raw: string): { token: string; suffix: string } | null { + const queryIndex = raw.indexOf("?"); + if (queryIndex === -1) return null; + return { token: raw.slice(0, queryIndex), suffix: raw.slice(queryIndex) }; +} + +function hasStandardVlessAuthority(raw: string): boolean { + const hashIndex = raw.indexOf("#"); + const beforeHash = hashIndex === -1 ? raw : raw.slice(0, hashIndex); + const queryIndex = beforeHash.indexOf("?"); + let authority = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex); + if (authority.endsWith("/")) authority = authority.slice(0, -1); + + const atIndex = authority.lastIndexOf("@"); + if (atIndex <= 0) return false; + const hostPort = authority.slice(atIndex + 1); + if (!hostPort) return false; + + if (hostPort.startsWith("[")) { + const bracketEnd = hostPort.indexOf("]"); + if (bracketEnd === -1) return false; + const afterBracket = hostPort.slice(bracketEnd + 1); + return afterBracket.startsWith(":") && isDigitString(afterBracket.slice(1)); + } + + const colonIndex = hostPort.lastIndexOf(":"); + return colonIndex > 0 && isDigitString(hostPort.slice(colonIndex + 1)); +} + +function stripThroughLastColon(value: string): string { + const colonIndex = value.lastIndexOf(":"); + return colonIndex === -1 ? value : value.slice(colonIndex + 1); +} + function normalizeShadowrocketUri(uri: string): { uri: string; isShadowrocket: boolean } { const raw = uri.slice("vless://".length).trim(); - if (/^(.*?)@(.*?):(\d+)\/?(\?(.*?))?(?:#(.*?))?$/.test(raw)) { + if (hasStandardVlessAuthority(raw)) { return { uri, isShadowrocket: false }; } - const match = /^(.*?)(\?.*?)$/.exec(raw); - if (!match) { + const parts = splitAtFirstQuery(raw); + if (!parts) { return { uri, isShadowrocket: false }; } - const decoded = decodeBase64(match[1]); - return { uri: `vless://${decoded}${match[2]}`, isShadowrocket: true }; + const decoded = decodeBase64(parts.token.trim()); + return { uri: `vless://${decoded}${parts.suffix}`, isShadowrocket: true }; } function parseShadowrocketHeaderValue(raw: string): Record | undefined { @@ -155,7 +197,7 @@ export function parseVLESS(uri: string): VLESSNode { } return user; })(); - const uuid = normalized.isShadowrocket ? uuidRaw.replace(/^.*?:/g, "") : uuidRaw; + const uuid = normalized.isShadowrocket ? stripThroughLastColon(uuidRaw) : uuidRaw; const server = url.hostname; const port = parseInt(url.port || "443", 10); const name = diff --git a/packages/core/src/parser/protocols/vmess.ts b/packages/core/src/parser/protocols/vmess.ts index 588fbf1..1383ee1 100644 --- a/packages/core/src/parser/protocols/vmess.ts +++ b/packages/core/src/parser/protocols/vmess.ts @@ -65,6 +65,88 @@ import { stripOuterQuotes, } from "./vmess-utils"; +function isAlphaString(value: string): boolean { + if (!value) return false; + for (const char of value) { + const lower = char.toLowerCase(); + if (lower < "a" || lower > "z") return false; + } + return true; +} + +function isDigitString(value: string): boolean { + if (!value) return false; + for (const char of value) { + if (char < "0" || char > "9") return false; + } + return true; +} + +function containsWhitespace(value: string): boolean { + for (const char of value) { + if (char.trim() === "") return true; + } + return false; +} + +function parseStandardVmessParts(value: string): { + networkRaw: string; + tlsTag: string; + uuid: string; + aid: string; + server: string; + port: string; + query: string; +} | null { + const queryIndex = value.indexOf("?"); + const query = queryIndex === -1 ? "" : value.slice(queryIndex + 1); + let base = queryIndex === -1 ? value : value.slice(0, queryIndex); + if (base.endsWith("/")) base = base.slice(0, -1); + + const schemeIndex = base.indexOf(":"); + if (schemeIndex <= 0) return null; + const networkTag = base.slice(0, schemeIndex); + const rest = base.slice(schemeIndex + 1); + + const plusIndex = networkTag.indexOf("+"); + const networkRaw = plusIndex === -1 ? networkTag : networkTag.slice(0, plusIndex); + const tlsTag = plusIndex === -1 ? "" : networkTag.slice(plusIndex + 1); + if (!isAlphaString(networkRaw) || (tlsTag && !isAlphaString(tlsTag))) return null; + + const atIndex = rest.indexOf("@"); + if (atIndex <= 0) return null; + const identity = rest.slice(0, atIndex); + const hostPort = rest.slice(atIndex + 1); + + const dashIndex = identity.lastIndexOf("-"); + if (dashIndex <= 0) return null; + const uuid = identity.slice(0, dashIndex); + const aid = identity.slice(dashIndex + 1); + if (!uuid || uuid.includes("@") || containsWhitespace(uuid) || !isDigitString(aid)) return null; + + const colonIndex = hostPort.lastIndexOf(":"); + if (colonIndex <= 0) return null; + const server = hostPort.slice(0, colonIndex); + const port = hostPort.slice(colonIndex + 1); + if (!server || !isDigitString(port)) return null; + + return { networkRaw, tlsTag, uuid, aid, server, port, query }; +} + +function parseKitsunebiBase(value: string): { uuid: string; server: string; portWithMaybePath: string } | null { + const atIndex = value.indexOf("@"); + if (atIndex <= 0) return null; + const uuid = value.slice(0, atIndex); + const hostPort = value.slice(atIndex + 1); + const colonIndex = hostPort.indexOf(":"); + if (colonIndex <= 0) return null; + return { + uuid, + server: hostPort.slice(0, colonIndex), + portWithMaybePath: hostPort.slice(colonIndex + 1), + }; +} + function parseShadowrocketStyleConfig(uri: string): VMessConfig { const raw = uri.slice(8).trim(); const hashIndex = raw.indexOf("#"); @@ -120,12 +202,12 @@ function parseStandardVmessStyleConfig(uri: string): VMessConfig { const hashIndex = raw.indexOf("#"); const remarks = hashIndex === -1 ? "" : safeDecodeFormUrlEncoded(raw.slice(hashIndex + 1)); const withoutHash = hashIndex === -1 ? raw : raw.slice(0, hashIndex); - const match = withoutHash.match(/^([a-z]+)(?:\+([a-z]+))?:([^@\s]+?)-(\d+)@(.+?):(\d+)(?:\/?\?(.*))?$/i); - if (!match) { + const parsed = parseStandardVmessParts(withoutHash); + if (!parsed) { throw new Error("无效的标准 VMess 链接"); } - const [, networkRaw, tlsTag, uuid, aid, server, port, query = ""] = match; + const { networkRaw, tlsTag, uuid, aid, server, port, query } = parsed; const params = new URLSearchParams(query); const ech = params.has("ech") ? params.get("ech") || "" : undefined; const network = networkRaw.toLowerCase(); @@ -169,12 +251,12 @@ function parseKitsunebiStyleConfig(uri: string): VMessConfig { const queryIndex = withoutHash.indexOf("?"); const addition = queryIndex === -1 ? "" : withoutHash.slice(queryIndex + 1); const base = queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex); - const match = base.match(/^(.*?)@(.*?):(.*)$/); - if (!match) { + const parsed = parseKitsunebiBase(base); + if (!parsed) { throw new Error("无效的 Kitsunebi VMess 链接"); } - const [, uuid, server, portWithMaybePath] = match; + const { uuid, server, portWithMaybePath } = parsed; let port = portWithMaybePath; let path = ""; const slashIndex = portWithMaybePath.indexOf("/"); diff --git a/packages/core/src/proxy-group-name.ts b/packages/core/src/proxy-group-name.ts index 8b821d4..534597f 100644 --- a/packages/core/src/proxy-group-name.ts +++ b/packages/core/src/proxy-group-name.ts @@ -6,11 +6,17 @@ export type EmojiSplitResult = { export function splitLeadingEmoji(raw: string): EmojiSplitResult { const name = typeof raw === "string" ? raw.trim() : ""; - const match = name.match(/^(\S+)\s+(.+)$/); - if (!match) return { emoji: "", label: name, hasEmojiPrefix: false }; - - const first = match[1]; - const rest = match[2].trim(); + const separatorIndex = (() => { + for (let i = 0; i < name.length; i += 1) { + if (name[i].trim() === "") return i; + } + return -1; + })(); + if (separatorIndex <= 0) return { emoji: "", label: name, hasEmojiPrefix: false }; + + const first = name.slice(0, separatorIndex); + const rest = name.slice(separatorIndex).trim(); + if (!rest) return { emoji: "", label: name, hasEmojiPrefix: false }; // 若首段包含字母/数字/中文,大概率不是“emoji 前缀” if (/[A-Za-z0-9\u4e00-\u9fff]/.test(first)) { diff --git a/packages/core/src/redos-regression.test.ts b/packages/core/src/redos-regression.test.ts new file mode 100644 index 0000000..6591ed8 --- /dev/null +++ b/packages/core/src/redos-regression.test.ts @@ -0,0 +1,86 @@ +import { performance } from "node:perf_hooks"; +import { describe, expect, it } from "vitest"; +import { sanitizeMihomoProxyNode } from "./mihomo/proxy-sanitizer"; +import { parseAnyTLS } from "./parser/protocols/anytls"; +import { parseHysteria2 } from "./parser/protocols/hysteria2"; +import { parseSimpleProxy } from "./parser/protocols/simple-proxy"; +import { parseVLESS } from "./parser/protocols/vless"; +import { parseVMess } from "./parser/protocols/vmess"; +import { splitLeadingEmoji } from "./proxy-group-name"; +import { ensureCustomRuleId } from "./rules/custom-rule-utils"; + +const PRIVATE_KEY = ["-----BEGIN OPENSSH ", "PRIVATE ", "KEY-----\nabc\n-----END OPENSSH ", "PRIVATE ", "KEY-----"].join(""); + +function expectFast(label: string, action: () => void): void { + const started = performance.now(); + action(); + const elapsed = performance.now() - started; + expect(elapsed, label).toBeLessThan(250); +} + +function ignoreExpectedParserError(action: () => void): void { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } +} + +describe("ReDoS regression coverage", () => { + it("keeps malformed simple proxy suffix parsing linear", () => { + expectFast("brace suffix", () => { + ignoreExpectedParserError(() => parseSimpleProxy("{{|".repeat(10_000), "http")); + }); + expectFast("bracket suffix", () => { + ignoreExpectedParserError(() => parseSimpleProxy("[\\".repeat(12_000), "http")); + }); + }); + + it("keeps URI protocol normalizers linear for long malformed authorities", () => { + expectFast("AnyTLS encoded-userinfo detector", () => { + ignoreExpectedParserError(() => parseAnyTLS(`anytls://${"@".repeat(25_000)}a`)); + }); + expectFast("VLESS Shadowrocket detector", () => { + ignoreExpectedParserError(() => parseVLESS(`vless://${"@".repeat(25_000)}a`)); + }); + expectFast("Hysteria2 authority trim", () => { + ignoreExpectedParserError(() => parseHysteria2(`hysteria2://secret@hy2.example.com:443${"/".repeat(25_000)}x`)); + }); + expectFast("VMess standard variant detector", () => { + ignoreExpectedParserError(() => parseVMess(`vmess://a:!-0@${"a:0?".repeat(25_000)}`)); + }); + expectFast("VMess Kitsunebi variant detector", () => { + ignoreExpectedParserError(() => parseVMess(`vmess1://${"@:".repeat(25_000)}`)); + }); + }); + + it("keeps shared sanitizers and naming helpers linear", () => { + expectFast("SSH host key sanitizer", () => { + const node = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + "private-key": PRIVATE_KEY, + "host-key": [`ssh-rsa +${" ".repeat(25_000)}`], + }); + expect(node).toHaveProperty("host-key"); + }); + + expectFast("custom rule slug", () => { + const rule = ensureCustomRuleId( + { type: "DOMAIN", value: `${"-".repeat(50_000)}example.com`, target: "Proxy" }, + 0 + ); + expect(rule.id).toContain("example"); + }); + + expectFast("emoji prefix splitter", () => { + expect(splitLeadingEmoji(`! ${" ".repeat(50_000)}Node`)).toEqual({ + emoji: "!", + label: "Node", + hasEmojiPrefix: true, + }); + }); + }); +}); diff --git a/packages/core/src/rules/custom-rule-utils.ts b/packages/core/src/rules/custom-rule-utils.ts index ed610c6..e35c2d9 100644 --- a/packages/core/src/rules/custom-rule-utils.ts +++ b/packages/core/src/rules/custom-rule-utils.ts @@ -25,11 +25,19 @@ function toTrimmedString(value: unknown): string { } function toSlug(value: string): string { - const normalized = value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return normalized || "item"; + let out = ""; + let pendingDash = false; + for (const char of value.toLowerCase()) { + const isAsciiLetterOrDigit = (char >= "a" && char <= "z") || (char >= "0" && char <= "9"); + if (isAsciiLetterOrDigit) { + if (pendingDash && out) out += "-"; + out += char; + pendingDash = false; + } else { + pendingDash = true; + } + } + return out || "item"; } function buildDeterministicCustomRuleId(rule: Pick, index: number): string {