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>
90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { MAX_PLUGIN_FILE_BYTES } from './constants'
|
|
import { fetchRemotePlugin } from './remote'
|
|
const getAppConfig = vi.fn()
|
|
const getControledMihomoConfig = vi.fn()
|
|
const requestOnce = vi.fn()
|
|
|
|
vi.mock('../../config/app', () => ({
|
|
getAppConfig: (...args: unknown[]) => getAppConfig(...args)
|
|
}))
|
|
vi.mock('../../config/controledMihomo', () => ({
|
|
getControledMihomoConfig: (...args: unknown[]) => getControledMihomoConfig(...args)
|
|
}))
|
|
vi.mock('./http-client', () => ({
|
|
requestOnce: (...args: unknown[]) => requestOnce(...args)
|
|
}))
|
|
|
|
beforeEach(() => {
|
|
getAppConfig.mockReset().mockResolvedValue({ subscriptionTimeout: 1234 })
|
|
getControledMihomoConfig.mockReset().mockResolvedValue({ 'mixed-port': 17890 })
|
|
requestOnce.mockReset().mockResolvedValue({ status: 200, headers: {}, body: '{"magic":"CPXF"}' })
|
|
})
|
|
|
|
describe('fetchRemotePlugin', () => {
|
|
it('downloads an https descriptor with the guarded plugin client', async () => {
|
|
const result = await fetchRemotePlugin('https://provider.example/app.cpx?channel=stable')
|
|
|
|
expect(Buffer.from(result, 'base64').toString('utf-8')).toBe('{"magic":"CPXF"}')
|
|
expect(requestOnce).toHaveBeenCalledWith(
|
|
'https://provider.example/app.cpx?channel=stable',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
timeout: 1234,
|
|
maxBytes: MAX_PLUGIN_FILE_BYTES,
|
|
lookup: expect.any(Function),
|
|
proxy: undefined
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses the configured local proxy when enabled', async () => {
|
|
getAppConfig.mockResolvedValue({ subscriptionTimeout: 5000, pluginUseProxy: true })
|
|
await fetchRemotePlugin('https://provider.example/app.cpx')
|
|
expect(requestOnce.mock.calls[0][1].proxy).toEqual({ host: '127.0.0.1', port: 17890 })
|
|
})
|
|
|
|
it('R2-ISS-066: carries the core inbound credentials to the local proxy, like plugin requests do', async () => {
|
|
getAppConfig.mockResolvedValue({ subscriptionTimeout: 5000, pluginUseProxy: true })
|
|
getControledMihomoConfig.mockResolvedValue({ 'mixed-port': 17890, authentication: ['u:p:w'] })
|
|
await fetchRemotePlugin('https://provider.example/app.cpx')
|
|
expect(requestOnce.mock.calls[0][1].proxy).toEqual({
|
|
host: '127.0.0.1',
|
|
port: 17890,
|
|
auth: { user: 'u', pass: 'p:w' }
|
|
})
|
|
})
|
|
|
|
it('R2-ISS-066: a disabled mixed-port makes the proxied download fail instead of targeting port 80', async () => {
|
|
getAppConfig.mockResolvedValue({ subscriptionTimeout: 5000, pluginUseProxy: true })
|
|
getControledMihomoConfig.mockResolvedValue({ 'mixed-port': 0 })
|
|
await expect(fetchRemotePlugin('https://provider.example/app.cpx')).rejects.toMatchObject({
|
|
code: 'CPX_PROXY_CONNECT_FAILED'
|
|
})
|
|
expect(requestOnce).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each([
|
|
'http://provider.example/app.cpx',
|
|
'https://user:password@provider.example/app.cpx',
|
|
'https://localhost/app.cpx',
|
|
'https://127.0.0.1/app.cpx',
|
|
'https://provider.example/app.cpx#fragment'
|
|
])('rejects an unsafe download URL: %s', async (url) => {
|
|
await expect(fetchRemotePlugin(url)).rejects.toThrow()
|
|
expect(requestOnce).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects non-success responses', async () => {
|
|
requestOnce.mockResolvedValue({ status: 404, headers: {}, body: 'not found' })
|
|
await expect(fetchRemotePlugin('https://provider.example/app.cpx')).rejects.toThrow(/404/)
|
|
})
|
|
|
|
it('propagates the network size guard', async () => {
|
|
requestOnce.mockRejectedValue(new Error('Response too large'))
|
|
await expect(fetchRemotePlugin('https://provider.example/app.cpx')).rejects.toThrow(
|
|
/too large/i
|
|
)
|
|
})
|
|
})
|