mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
Client-side hardening of the CPX airport plugin, squashed from 46 commits
forked at 89c2bb0e. Behaviour changes:
- Unified operation model (§0.4/0.5): one deadline + one AbortSignal + one
persistence commit per operation; per-plugin lock → vault lock hierarchy;
tombstone on delete; every wait (lock, DNS preflight, proxy resolution,
vault decrypt) is bounded by the same budget.
- Routing: direct/proxy auto-fallback with a pre-send guard; proxied https
builds its own CONNECT tunnel (an aborted hung CONNECT closes its socket);
invalid local-proxy ports are refused instead of falling back to :80; the
core's inbound credentials are carried to the local proxy; NAT64 and
site-local IPv6 ranges are non-public.
- Gateways: multi-gateway recovery with one rediscovery per operation,
normalized endpoint paths, signed discovery documents (Ed25519, seq/digest
accept/align/rollback/equivocation), commit order vault → plugin.yaml.
- Subscriptions: a fetched subscription is validated by the core (mihomo -t)
against the current override set before it replaces the profile, inside
the profile write critical section (profile.yaml and override.yaml share
one write queue); schedule fields are read at write time; the first
subscription is activated through the real switch flow; profile deletion
removes the record last so any failure stays retryable.
- Devices: a re-login that replaces a still-valid device records it in the
vault (staleDevices) and retires it after the login, after later
successful fetches and on removal; enroll compensation restores the old
vault and keeps an un-revoked new device for retirement.
- Vault: on Linux the vault is persisted only behind a system secret store
(backend name + ciphertext-prefix canary); otherwise it stays in memory.
A cache-miss read releases the caller at the budget while the lock is held
until the decrypt ends.
- Config caches (plugin.yaml, profile.yaml, override.yaml) can no longer be
rolled back by a late cold read.
- Reference gateway and provider guides updated (deploy contract, discoveryUrls
same-origin rule, /revoke after re-login); https-proxy-agent dropped.
Reviewed in a Codex loop (gpt-6, 38 calls): 74 findings, 68 fixed and
verified, 6 invalid, no backlog. Tests: vitest 501, gateway 106.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
156 lines
6.4 KiB
TypeScript
156 lines
6.4 KiB
TypeScript
// 签名发现文档(§5):信封 "<payloadB64>.<sigB64>",Ed25519 对 "CPX2-DISCOVERY\0" || payloadBytes 签名。
|
||
// 校验无需规范化:验的是收到的那串字节,验过之后才 JSON.parse。
|
||
import { verifyRequest } from './device'
|
||
import { isCanonicalB64, sha256Hex } from './encoding'
|
||
import {
|
||
parseGatewayList,
|
||
parseGatewayOrigin,
|
||
isValidEndpointPath,
|
||
normalizeEndpointPath
|
||
} from './gateway-url'
|
||
import { isForbiddenHost } from './net-guard'
|
||
|
||
// 域分离前缀(含结尾 NUL 字节):防止同一密钥签出的其他类型消息被冒用
|
||
export const DISCOVERY_SIGN_PREFIX = Buffer.from('CPX2-DISCOVERY\u0000', 'utf-8')
|
||
// payload 字节上限(两处投放位置共用):经 base64 与签名后约 5.6 KiB,在常见 8 KiB 单头限制内
|
||
export const MAX_DISCOVERY_PAYLOAD_BYTES = 4096
|
||
export const MAX_DISCOVERY_URLS = 8
|
||
const MAX_SEQ = Number.MAX_SAFE_INTEGER // 2^53 − 1
|
||
const PAYLOAD_KEYS = ['spec', 'seq', 'gateways', 'endpoints', 'loginUrl', 'discoveryUrls']
|
||
const REQUIRED_ENDPOINTS = ['enroll', 'challenge', 'config', 'revoke'] as const
|
||
const OPTIONAL_ENDPOINTS = ['bootstrap']
|
||
|
||
function fail(msg: string): never {
|
||
throw new Error(`Invalid signed discovery: ${msg}`)
|
||
}
|
||
|
||
export function buildDiscoverySignInput(payloadBytes: Uint8Array): Buffer {
|
||
return Buffer.concat([DISCOVERY_SIGN_PREFIX, Buffer.from(payloadBytes)])
|
||
}
|
||
|
||
function parseLoginUrl(v: unknown): string {
|
||
if (typeof v !== 'string') fail('loginUrl must be a string')
|
||
let u: URL
|
||
try {
|
||
u = new URL(v)
|
||
} catch {
|
||
fail('loginUrl must be a valid URL')
|
||
}
|
||
if (u.protocol !== 'https:') fail('loginUrl must be https')
|
||
if (u.username || u.password) fail('loginUrl must not contain userinfo')
|
||
if (u.search || u.hash) fail('loginUrl must not contain query or fragment')
|
||
if (isForbiddenHost(u.hostname)) fail('loginUrl must be a public host')
|
||
return u.toString()
|
||
}
|
||
|
||
// 缺失 = 不改(返回 undefined);[] = 清空;非空时规则同 .cpx(1..8、去重、不与 loginUrl 同 origin)
|
||
function parseDiscoveryUrls(v: unknown, loginOrigin: string | undefined): string[] | undefined {
|
||
if (v === undefined) return undefined
|
||
if (!Array.isArray(v) || v.length > MAX_DISCOVERY_URLS) {
|
||
fail(`discoveryUrls must be an array of at most ${MAX_DISCOVERY_URLS} origins`)
|
||
}
|
||
const out: string[] = []
|
||
for (const item of v) {
|
||
const origin = parseGatewayOrigin(item)
|
||
if (!origin) fail('discoveryUrls entries must be public https origins')
|
||
if (loginOrigin && origin === loginOrigin) {
|
||
fail('discoveryUrls must not repeat the loginUrl origin')
|
||
}
|
||
if (out.includes(origin)) fail('discoveryUrls must not contain duplicates')
|
||
out.push(origin)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function parsePayload(bytes: Buffer): IDiscoveryPayload {
|
||
// 严格 UTF-8:非法字节序列直接拒绝,而不是被替换成 U+FFFD 后混进端点路径
|
||
let text: string
|
||
try {
|
||
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||
} catch {
|
||
fail('payload is not valid UTF-8')
|
||
}
|
||
let raw: unknown
|
||
try {
|
||
raw = JSON.parse(text)
|
||
} catch {
|
||
fail('payload is not valid JSON')
|
||
}
|
||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||
fail('payload must be an object')
|
||
}
|
||
const obj = raw as Record<string, unknown>
|
||
for (const k of Object.keys(obj)) {
|
||
if (!PAYLOAD_KEYS.includes(k)) fail(`unknown key "${k}"`)
|
||
}
|
||
if (obj.spec !== 'cpx-plugin/2') fail('spec must be "cpx-plugin/2"')
|
||
const seq = obj.seq
|
||
if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 1 || seq > MAX_SEQ) {
|
||
fail('seq must be an integer in 1..2^53-1')
|
||
}
|
||
const gateways = parseGatewayList(obj.gateways)
|
||
if (!gateways) fail('gateways must be 1..3 public https origins')
|
||
if (typeof obj.endpoints !== 'object' || obj.endpoints === null || Array.isArray(obj.endpoints)) {
|
||
fail('endpoints required')
|
||
}
|
||
const e = obj.endpoints as Record<string, unknown>
|
||
for (const k of Object.keys(e)) {
|
||
if (!REQUIRED_ENDPOINTS.includes(k as never) && !OPTIONAL_ENDPOINTS.includes(k)) {
|
||
fail(`unknown endpoint "${k}"`)
|
||
}
|
||
if (!isValidEndpointPath(e[k])) fail(`endpoints.${k} must be a relative path`)
|
||
}
|
||
const endpoints = {} as IGatewayEndpoints
|
||
for (const k of REQUIRED_ENDPOINTS) {
|
||
const v = e[k]
|
||
if (!isValidEndpointPath(v)) fail(`endpoints.${k} required`)
|
||
endpoints[k] = normalizeEndpointPath(v)
|
||
}
|
||
const loginUrl = obj.loginUrl === undefined ? undefined : parseLoginUrl(obj.loginUrl)
|
||
const discoveryUrls = parseDiscoveryUrls(
|
||
obj.discoveryUrls,
|
||
loginUrl ? new URL(loginUrl).origin : undefined
|
||
)
|
||
const payload: IDiscoveryPayload = { spec: 'cpx-plugin/2', seq, gateways, endpoints }
|
||
if (loginUrl !== undefined) payload.loginUrl = loginUrl
|
||
if (discoveryUrls !== undefined) payload.discoveryUrls = discoveryUrls
|
||
return payload
|
||
}
|
||
|
||
// 格式错 / 验签失败抛 Error。digest = SHA-256(payloadBytes) 的 hex。
|
||
export function parseSigned(
|
||
signed: unknown,
|
||
pubKeyB64: string
|
||
): { payload: IDiscoveryPayload; digest: string } {
|
||
if (typeof signed !== 'string') fail('signed must be a string')
|
||
const parts = signed.split('.')
|
||
if (parts.length !== 2) fail('signed must contain exactly one "."')
|
||
const [payloadB64, sigB64] = parts
|
||
if (!isCanonicalB64(payloadB64) || !isCanonicalB64(sigB64)) fail('non-canonical base64')
|
||
const sig = Buffer.from(sigB64, 'base64')
|
||
if (sig.length !== 64) fail('signature must be exactly 64 bytes')
|
||
const payloadBytes = Buffer.from(payloadB64, 'base64')
|
||
if (payloadBytes.length === 0 || payloadBytes.length > MAX_DISCOVERY_PAYLOAD_BYTES) {
|
||
fail(`payload must be 1..${MAX_DISCOVERY_PAYLOAD_BYTES} bytes`)
|
||
}
|
||
let ok = false
|
||
try {
|
||
ok = verifyRequest(pubKeyB64, buildDiscoverySignInput(payloadBytes), sigB64)
|
||
} catch {
|
||
ok = false
|
||
}
|
||
if (!ok) fail('signature verification failed')
|
||
return { payload: parsePayload(payloadBytes), digest: sha256Hex(payloadBytes) }
|
||
}
|
||
|
||
export type SeqVerdict = 'accept' | 'align' | 'rollback' | 'equivocation'
|
||
|
||
// §5.3 seq / digest 规则:未存 → 任意接受;seq > stored → 接受;seq === stored 且 digest 相同 → 对齐;
|
||
// seq === stored 且 digest 不同 → 拒绝(签发方 equivocation / 多 CDN 不一致);seq < stored → 拒绝。
|
||
export function checkSeq(seq: number, digest: string, signer: DiscoverySigner): SeqVerdict {
|
||
if (signer.minSeq === undefined) return 'accept'
|
||
if (seq > signer.minSeq) return 'accept'
|
||
if (seq < signer.minSeq) return 'rollback'
|
||
return digest === signer.currentDigest ? 'align' : 'equivocation'
|
||
}
|