mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix(desktop): avoid browser-blocked server ports #961
This commit is contained in:
parent
ace8862ff2
commit
c80b943e34
@ -20,6 +20,7 @@ import {
|
||||
reserveLocalPort,
|
||||
reserveServerPort,
|
||||
resolveHostTriple,
|
||||
SERVER_STATE_FILE,
|
||||
spawnSidecar,
|
||||
waitForServer,
|
||||
windowsPowerShellOverride,
|
||||
@ -225,8 +226,15 @@ describe('Electron sidecar manager', () => {
|
||||
expect(windowsPowerShellOverride('powershell.exe', 'linux')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses only in-range integer h5Access.fixedPort values', () => {
|
||||
it('parses only browser-safe in-range integer h5Access.fixedPort values', () => {
|
||||
expect(parseH5FixedPort('{"h5Access":{"fixedPort":28670}}')).toBe(28670)
|
||||
for (const port of [
|
||||
1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000,
|
||||
6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
||||
]) {
|
||||
expect(parseH5FixedPort(`{"h5Access":{"fixedPort":${port}}}`)).toBeNull()
|
||||
}
|
||||
expect(parseH5FixedPort('{"h5Access":{"fixedPort":5062}}')).toBe(5062)
|
||||
expect(parseH5FixedPort('{"h5Access":{"fixedPort":80}}')).toBeNull()
|
||||
expect(parseH5FixedPort('{"h5Access":{"fixedPort":70000}}')).toBeNull()
|
||||
expect(parseH5FixedPort('{"h5Access":{"fixedPort":"3456"}}')).toBeNull()
|
||||
@ -243,6 +251,15 @@ describe('Electron sidecar manager', () => {
|
||||
// Nothing stored yet: no preferred ports.
|
||||
expect(preferredServerPorts(env)).toEqual([])
|
||||
|
||||
// A browser-blocked port persisted by an older build is ignored.
|
||||
writeFileSync(
|
||||
path.join(configDir, SERVER_STATE_FILE),
|
||||
JSON.stringify({ lastPort: 5061 }),
|
||||
'utf-8',
|
||||
)
|
||||
expect(readLastServerPort(env)).toBeNull()
|
||||
expect(preferredServerPorts(env)).toEqual([])
|
||||
|
||||
// Sticky port from the previous run.
|
||||
writeLastServerPort(50123, env)
|
||||
expect(readLastServerPort(env)).toBe(50123)
|
||||
@ -288,6 +305,41 @@ describe('Electron sidecar manager', () => {
|
||||
await expect(reserveServerPort('127.0.0.1', [0, -1, 1.5, 70000])).resolves.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('skips preferred ports blocked by browser fetch', async () => {
|
||||
const port = await reserveServerPort('127.0.0.1', [5061])
|
||||
expect(port).not.toBe(5061)
|
||||
})
|
||||
|
||||
it('retries when the OS assigns a browser-blocked random port', async () => {
|
||||
const reserveCandidate = vi.fn()
|
||||
.mockResolvedValueOnce(5061)
|
||||
.mockResolvedValueOnce(5062)
|
||||
|
||||
await expect(reserveLocalPort('127.0.0.1', { reserveCandidate })).resolves.toBe(5062)
|
||||
expect(reserveCandidate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops retrying after repeated browser-blocked random ports', async () => {
|
||||
const reserveCandidate = vi.fn().mockResolvedValue(5061)
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await expect(reserveLocalPort('127.0.0.1', { reserveCandidate }))
|
||||
.rejects.toThrow('Could not reserve a browser-safe local port')
|
||||
expect(reserveCandidate).toHaveBeenCalledTimes(128)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates random port reservation errors', async () => {
|
||||
const reserveCandidate = vi.fn().mockRejectedValue(new Error('bind failed'))
|
||||
|
||||
await expect(reserveLocalPort('127.0.0.1', { reserveCandidate }))
|
||||
.rejects.toThrow('bind failed')
|
||||
expect(reserveCandidate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not treat a raw TCP accept as server readiness without healthy /health', async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(503, { 'content-type': 'application/json' })
|
||||
|
||||
@ -4,6 +4,7 @@ import type { Readable } from 'node:stream'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { isBrowserSafePort } from '../../src/lib/browserSafePort'
|
||||
|
||||
export const SERVER_BIND_HOST = '0.0.0.0'
|
||||
export const SERVER_CONTROL_HOST = '127.0.0.1'
|
||||
@ -15,6 +16,7 @@ export const SERVER_STATE_FILE = 'desktop-server-state.json'
|
||||
// Mirrors the server-side fixedPort range (h5AccessService MIN/MAX_FIXED_PORT).
|
||||
const MIN_FIXED_PORT = 1024
|
||||
const MAX_FIXED_PORT = 65535
|
||||
const MAX_PORT_RESERVATION_ATTEMPTS = 128
|
||||
|
||||
export type SidecarChild = ChildProcessByStdio<null, Readable, Readable>
|
||||
|
||||
@ -58,7 +60,11 @@ export function httpToWebSocketUrl(serverHttpUrl: string): string {
|
||||
return serverHttpUrl
|
||||
}
|
||||
|
||||
export async function reserveLocalPort(bindHost = SERVER_BIND_HOST): Promise<number> {
|
||||
export type ReserveLocalPortDeps = {
|
||||
reserveCandidate?: (bindHost: string) => Promise<number>
|
||||
}
|
||||
|
||||
async function reserveLocalPortCandidate(bindHost: string): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once('error', error => reject(error))
|
||||
@ -75,6 +81,19 @@ export async function reserveLocalPort(bindHost = SERVER_BIND_HOST): Promise<num
|
||||
})
|
||||
}
|
||||
|
||||
export async function reserveLocalPort(
|
||||
bindHost = SERVER_BIND_HOST,
|
||||
deps: ReserveLocalPortDeps = {},
|
||||
): Promise<number> {
|
||||
const reserveCandidate = deps.reserveCandidate ?? reserveLocalPortCandidate
|
||||
for (let attempt = 0; attempt < MAX_PORT_RESERVATION_ATTEMPTS; attempt++) {
|
||||
const port = await reserveCandidate(bindHost)
|
||||
if (isBrowserSafePort(port)) return port
|
||||
console.error(`[desktop] OS assigned browser-blocked server port ${port}; retrying`)
|
||||
}
|
||||
throw new Error('Could not reserve a browser-safe local port')
|
||||
}
|
||||
|
||||
function canBindPort(bindHost: string, port: number): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const server = net.createServer()
|
||||
@ -95,7 +114,14 @@ export async function reserveServerPort(
|
||||
preferred: number[],
|
||||
): Promise<number> {
|
||||
for (const port of preferred) {
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
console.error(`[desktop] preferred server port ${port} is invalid; skipping`)
|
||||
continue
|
||||
}
|
||||
if (!isBrowserSafePort(port)) {
|
||||
console.error(`[desktop] preferred server port ${port} is blocked by browser fetch; skipping`)
|
||||
continue
|
||||
}
|
||||
if (await canBindPort(bindHost, port)) return port
|
||||
console.error(`[desktop] preferred server port ${port} unavailable`)
|
||||
}
|
||||
@ -119,7 +145,7 @@ export function parseH5FixedPort(contents: string): number | null {
|
||||
if (!h5Access || typeof h5Access !== 'object') return null
|
||||
const port = (h5Access as Record<string, unknown>).fixedPort
|
||||
if (typeof port !== 'number' || !Number.isInteger(port)) return null
|
||||
return port >= MIN_FIXED_PORT && port <= MAX_FIXED_PORT ? port : null
|
||||
return port >= MIN_FIXED_PORT && port <= MAX_FIXED_PORT && isBrowserSafePort(port) ? port : null
|
||||
}
|
||||
|
||||
export function readH5FixedPort(env: NodeJS.ProcessEnv = process.env): number | null {
|
||||
@ -138,7 +164,7 @@ export function readLastServerPort(env: NodeJS.ProcessEnv = process.env): number
|
||||
if (!state || typeof state !== 'object') return null
|
||||
const port = (state as Record<string, unknown>).lastPort
|
||||
if (typeof port !== 'number' || !Number.isInteger(port)) return null
|
||||
return port > 0 && port <= 65535 ? port : null
|
||||
return isBrowserSafePort(port) ? port : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -230,6 +230,7 @@ const WINDOW_STATE_FILE: &str = "window-state.json";
|
||||
const TERMINAL_CONFIG_FILE: &str = "terminal-config.json";
|
||||
const APP_MODE_FILE: &str = "app-mode.json";
|
||||
const SERVER_STATE_FILE: &str = "desktop-server-state.json";
|
||||
const MAX_PORT_RESERVATION_ATTEMPTS: usize = 128;
|
||||
const MIN_WINDOW_WIDTH: u32 = 960;
|
||||
const MIN_WINDOW_HEIGHT: u32 = 640;
|
||||
const MIN_VISIBLE_PIXELS: i64 = 64;
|
||||
@ -1449,7 +1450,9 @@ fn parse_h5_fixed_port(contents: &str) -> Option<u16> {
|
||||
let value: serde_json::Value = serde_json::from_str(contents).ok()?;
|
||||
let port = value.get("h5Access")?.get("fixedPort")?.as_u64()?;
|
||||
if (1024..=65535).contains(&port) {
|
||||
u16::try_from(port).ok()
|
||||
u16::try_from(port)
|
||||
.ok()
|
||||
.filter(|port| is_browser_safe_port(*port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@ -1606,21 +1609,57 @@ fn default_shell(_custom_bash: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep this list aligned with the WHATWG Fetch bad-port table and the
|
||||
// Electron/renderer predicate. https://fetch.spec.whatwg.org/#bad-port
|
||||
const FETCH_BLOCKED_PORTS: &[u16] = &[
|
||||
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101,
|
||||
102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427,
|
||||
465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990,
|
||||
993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667,
|
||||
6668, 6669, 6679, 6697, 10080,
|
||||
];
|
||||
|
||||
fn is_browser_safe_port(port: u16) -> bool {
|
||||
!FETCH_BLOCKED_PORTS.contains(&port)
|
||||
}
|
||||
|
||||
fn reserve_browser_safe_port<F>(mut reserve_candidate: F) -> Result<u16, String>
|
||||
where
|
||||
F: FnMut() -> Result<u16, String>,
|
||||
{
|
||||
for _ in 0..MAX_PORT_RESERVATION_ATTEMPTS {
|
||||
let port = reserve_candidate()?;
|
||||
if is_browser_safe_port(port) {
|
||||
return Ok(port);
|
||||
}
|
||||
eprintln!("[desktop] OS assigned browser-blocked server port {port}; retrying");
|
||||
}
|
||||
Err("could not reserve a browser-safe local port".to_string())
|
||||
}
|
||||
|
||||
fn reserve_local_port(bind_host: &str) -> Result<u16, String> {
|
||||
let listener = TcpListener::bind(format!("{bind_host}:0"))
|
||||
.map_err(|err| format!("bind local port: {err}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|err| format!("read local port: {err}"))?
|
||||
.port();
|
||||
drop(listener);
|
||||
Ok(port)
|
||||
reserve_browser_safe_port(|| {
|
||||
let listener = TcpListener::bind(format!("{bind_host}:0"))
|
||||
.map_err(|err| format!("bind local port: {err}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|err| format!("read local port: {err}"))?
|
||||
.port();
|
||||
drop(listener);
|
||||
Ok(port)
|
||||
})
|
||||
}
|
||||
|
||||
/// 按优先级尝试给定端口(h5Access.fixedPort > 上次使用的端口),
|
||||
/// 全部被占用时回退到 OS 随机分配。保证 app 总能启动。
|
||||
fn reserve_local_port_with_preference(bind_host: &str, preferred: &[u16]) -> Result<u16, String> {
|
||||
for &port in preferred {
|
||||
if !is_browser_safe_port(port) {
|
||||
eprintln!(
|
||||
"[desktop] preferred server port {port} is blocked by browser fetch; skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match TcpListener::bind(format!("{bind_host}:{port}")) {
|
||||
Ok(listener) => {
|
||||
drop(listener);
|
||||
@ -2086,8 +2125,9 @@ fn kill_windows_sidecars() {
|
||||
mod tests {
|
||||
use super::{
|
||||
decode_terminal_output, default_utf8_locale, dir_has_portable_data, ensure_utf8_locale,
|
||||
has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path,
|
||||
parse_env_block, parse_h5_fixed_port, reserve_local_port_with_preference,
|
||||
has_meaningful_intersection, is_browser_safe_port, is_persistable_window_state,
|
||||
normalize_terminal_bash_path, parse_env_block, parse_h5_fixed_port,
|
||||
reserve_browser_safe_port, reserve_local_port_with_preference,
|
||||
resolve_agent_powershell_path_override, resolve_desktop_terminal_shell,
|
||||
resolve_terminal_cwd, run_notification_bridge, select_h5_dist_dir, DesktopTerminalConfig,
|
||||
StoredServerState, StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST,
|
||||
@ -2406,6 +2446,10 @@ mod tests {
|
||||
parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":28670}}"#),
|
||||
Some(28670)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":5061}}"#),
|
||||
None
|
||||
);
|
||||
// Out of range, wrong type, missing, or null all fall back to None.
|
||||
assert_eq!(parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":80}}"#), None);
|
||||
assert_eq!(
|
||||
@ -2449,6 +2493,59 @@ mod tests {
|
||||
assert!(reserve_local_port_with_preference("127.0.0.1", &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_blocked_preferred_port_is_skipped() {
|
||||
let reserved = reserve_local_port_with_preference("127.0.0.1", &[5061])
|
||||
.expect("reserve browser-safe fallback");
|
||||
assert_ne!(reserved, 5061);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_blocked_random_port_is_retried() {
|
||||
let mut candidates = [5061, 5062].into_iter();
|
||||
let reserved = reserve_browser_safe_port(|| {
|
||||
candidates
|
||||
.next()
|
||||
.ok_or_else(|| "ran out of test ports".to_string())
|
||||
})
|
||||
.expect("reserve browser-safe candidate");
|
||||
assert_eq!(reserved, 5062);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_blocked_random_port_retry_is_bounded() {
|
||||
let mut attempts = 0;
|
||||
let error = reserve_browser_safe_port(|| {
|
||||
attempts += 1;
|
||||
Ok(5061)
|
||||
})
|
||||
.expect_err("blocked candidates must eventually fail");
|
||||
assert_eq!(error, "could not reserve a browser-safe local port");
|
||||
assert_eq!(attempts, 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_port_reservation_errors_are_propagated() {
|
||||
let error = reserve_browser_safe_port(|| Err("bind failed".to_string()))
|
||||
.expect_err("reservation errors must propagate");
|
||||
assert_eq!(error, "bind failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_safe_port_matches_fetch_blocking_contract() {
|
||||
for port in [
|
||||
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95,
|
||||
101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161,
|
||||
179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563,
|
||||
587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060,
|
||||
5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
||||
] {
|
||||
assert!(!is_browser_safe_port(port), "port {port} should be blocked");
|
||||
}
|
||||
assert!(is_browser_safe_port(5062));
|
||||
assert!(is_browser_safe_port(28670));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_server_state_round_trips_camel_case_json() {
|
||||
let state = StoredServerState { last_port: 28670 };
|
||||
|
||||
@ -1125,7 +1125,31 @@ describe('Settings > General tab', () => {
|
||||
target: { value: '99' },
|
||||
})
|
||||
|
||||
expect(within(section).getByText('Port must be an integer between 1024 and 65535.')).toBeInTheDocument()
|
||||
expect(within(section).getByText('Port must be a browser-safe integer between 1024 and 65535.')).toBeInTheDocument()
|
||||
expect(within(section).getByRole('button', { name: 'Save H5 settings' })).toBeDisabled()
|
||||
expect(useSettingsStore.getState().updateH5AccessSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a browser-blocked fixed port before saving', () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
token: 'h5_persisted_token',
|
||||
tokenPreview: 'h5_pers...oken',
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: 'http://192.168.0.102:54064',
|
||||
fixedPort: null,
|
||||
disconnectGraceSeconds: null,
|
||||
},
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('H5 Access'))
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
fireEvent.change(within(section).getByLabelText('Fixed port'), {
|
||||
target: { value: '5061' },
|
||||
})
|
||||
|
||||
expect(within(section).getByRole('button', { name: 'Save H5 settings' })).toBeDisabled()
|
||||
expect(useSettingsStore.getState().updateH5AccessSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -1068,7 +1068,7 @@ export const en = {
|
||||
'settings.general.h5AccessCurrentPortUnknown': 'Auto',
|
||||
'settings.general.h5AccessFixedPort': 'Fixed port',
|
||||
'settings.general.h5AccessFixedPortPlaceholder': 'Auto',
|
||||
'settings.general.h5AccessFixedPortInvalid': 'Port must be an integer between 1024 and 65535.',
|
||||
'settings.general.h5AccessFixedPortInvalid': 'Port must be a browser-safe integer between 1024 and 65535.',
|
||||
'settings.general.h5AccessFixedPortHint': 'Without a fixed port the previous port is reused automatically; pin one for reverse proxies and other setups that need a stable port. Port changes apply after restarting the app.',
|
||||
'settings.general.h5AccessFixedPortRestartNote': 'Fixed port {fixedPort} takes effect after restarting the app; currently still running on {activePort}.',
|
||||
'settings.general.h5AccessDisconnectGrace': 'Disconnect grace (sec)',
|
||||
|
||||
@ -1070,7 +1070,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.general.h5AccessCurrentPortUnknown': '自動',
|
||||
'settings.general.h5AccessFixedPort': '固定ポート',
|
||||
'settings.general.h5AccessFixedPortPlaceholder': '自動',
|
||||
'settings.general.h5AccessFixedPortInvalid': 'ポートは 1024〜65535 の整数で指定してください。',
|
||||
'settings.general.h5AccessFixedPortInvalid': 'ポートは 1024〜65535 のブラウザーで使用可能な整数で指定してください。',
|
||||
'settings.general.h5AccessFixedPortHint': '固定ポート未設定の場合は前回のポートを自動的に再利用します。リバースプロキシなど安定したポートが必要な場合は固定を推奨します。変更はアプリ再起動後に反映されます。',
|
||||
'settings.general.h5AccessFixedPortRestartNote': '固定ポート {fixedPort} はアプリ再起動後に有効になります。現在は {activePort} で稼働中です。',
|
||||
'settings.general.h5AccessDisconnectGrace': '切断後の保持(秒)',
|
||||
|
||||
@ -1070,7 +1070,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.general.h5AccessCurrentPortUnknown': '자동',
|
||||
'settings.general.h5AccessFixedPort': '고정 포트',
|
||||
'settings.general.h5AccessFixedPortPlaceholder': '자동',
|
||||
'settings.general.h5AccessFixedPortInvalid': '포트는 1024~65535 사이의 정수여야 합니다.',
|
||||
'settings.general.h5AccessFixedPortInvalid': '포트는 1024~65535 사이의 브라우저에서 허용되는 정수여야 합니다.',
|
||||
'settings.general.h5AccessFixedPortHint': '고정 포트를 설정하지 않으면 이전 포트를 자동으로 재사용합니다. 리버스 프록시 등 안정적인 포트가 필요한 경우 고정을 권장합니다. 변경 사항은 앱 재시작 후 적용됩니다.',
|
||||
'settings.general.h5AccessFixedPortRestartNote': '고정 포트 {fixedPort}은(는) 앱 재시작 후 적용됩니다. 현재는 {activePort}에서 실행 중입니다.',
|
||||
'settings.general.h5AccessDisconnectGrace': '연결 끊김 유지(초)',
|
||||
|
||||
@ -1070,7 +1070,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.h5AccessCurrentPortUnknown': '自動',
|
||||
'settings.general.h5AccessFixedPort': '固定埠',
|
||||
'settings.general.h5AccessFixedPortPlaceholder': '自動',
|
||||
'settings.general.h5AccessFixedPortInvalid': '埠需為 1024-65535 之間的整數。',
|
||||
'settings.general.h5AccessFixedPortInvalid': '埠需為 1024-65535 之間且瀏覽器允許存取的整數。',
|
||||
'settings.general.h5AccessFixedPortHint': '不設固定埠時會自動沿用上次的埠;反向代理等需要穩定埠的場景建議固定。修改埠後重啟應用程式生效。',
|
||||
'settings.general.h5AccessFixedPortRestartNote': '固定埠 {fixedPort} 將在重啟應用程式後生效,目前仍執行在 {activePort}。',
|
||||
'settings.general.h5AccessDisconnectGrace': '斷線保活(秒)',
|
||||
|
||||
@ -1070,7 +1070,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.h5AccessCurrentPortUnknown': '自动',
|
||||
'settings.general.h5AccessFixedPort': '固定端口',
|
||||
'settings.general.h5AccessFixedPortPlaceholder': '自动',
|
||||
'settings.general.h5AccessFixedPortInvalid': '端口需为 1024-65535 之间的整数。',
|
||||
'settings.general.h5AccessFixedPortInvalid': '端口需为 1024-65535 之间且浏览器允许访问的整数。',
|
||||
'settings.general.h5AccessFixedPortHint': '不设固定端口时会自动复用上次的端口;反向代理等需要稳定端口的场景建议固定。修改端口后重启应用生效。',
|
||||
'settings.general.h5AccessFixedPortRestartNote': '固定端口 {fixedPort} 将在重启应用后生效,当前仍运行在 {activePort}。',
|
||||
'settings.general.h5AccessDisconnectGrace': '断连保活(秒)',
|
||||
|
||||
28
desktop/src/lib/browserSafePort.test.ts
Normal file
28
desktop/src/lib/browserSafePort.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isBrowserSafePort } from './browserSafePort'
|
||||
|
||||
const WHATWG_FETCH_BLOCKED_PORTS = [
|
||||
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53,
|
||||
69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117,
|
||||
119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514,
|
||||
515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989,
|
||||
990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061,
|
||||
6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
||||
]
|
||||
|
||||
describe('browser-safe desktop ports', () => {
|
||||
it('rejects the complete WHATWG Fetch bad-port table', () => {
|
||||
for (const port of WHATWG_FETCH_BLOCKED_PORTS) {
|
||||
expect(isBrowserSafePort(port), `port ${port}`).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts valid ports outside the blocked table', () => {
|
||||
for (const port of [80, 3456, 5062, 28670, 65535]) {
|
||||
expect(isBrowserSafePort(port), `port ${port}`).toBe(true)
|
||||
}
|
||||
expect(isBrowserSafePort(-1)).toBe(false)
|
||||
expect(isBrowserSafePort(1.5)).toBe(false)
|
||||
expect(isBrowserSafePort(65536)).toBe(false)
|
||||
})
|
||||
})
|
||||
15
desktop/src/lib/browserSafePort.ts
Normal file
15
desktop/src/lib/browserSafePort.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// Keep this list aligned with the WHATWG Fetch bad-port table. Desktop server
|
||||
// URLs are consumed by both Node/Electron and browser fetch implementations.
|
||||
// https://fetch.spec.whatwg.org/#bad-port
|
||||
const FETCH_BLOCKED_PORTS = new Set([
|
||||
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53,
|
||||
69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117,
|
||||
119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514,
|
||||
515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989,
|
||||
990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061,
|
||||
6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
||||
])
|
||||
|
||||
export function isBrowserSafePort(port: number): boolean {
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535 && !FETCH_BLOCKED_PORTS.has(port)
|
||||
}
|
||||
@ -63,6 +63,7 @@ import { formatBytes } from '../lib/formatBytes'
|
||||
import { isDesktopRuntime } from '../lib/desktopRuntime'
|
||||
import { getDesktopHost } from '../lib/desktopHost'
|
||||
import { publicAssetPath } from '../lib/publicAsset'
|
||||
import { isBrowserSafePort } from '../lib/browserSafePort'
|
||||
import {
|
||||
getDesktopNotificationPermission,
|
||||
notifyDesktop,
|
||||
@ -165,7 +166,7 @@ function parseH5FixedPortDraft(draft: string): number | null | 'invalid' {
|
||||
if (!trimmed) return null
|
||||
if (!/^\d{1,5}$/.test(trimmed)) return 'invalid'
|
||||
const port = Number(trimmed)
|
||||
return port >= 1024 && port <= 65535 ? port : 'invalid'
|
||||
return port >= 1024 && port <= 65535 && isBrowserSafePort(port) ? port : 'invalid'
|
||||
}
|
||||
|
||||
// Mirrors the server-side disconnect grace range (h5AccessService
|
||||
|
||||
@ -210,4 +210,12 @@ describe('/api/h5-access', () => {
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
test('PUT rejects a browser-blocked fixedPort', async () => {
|
||||
const response = await api('PUT', '/api/h5-access', {
|
||||
body: { fixedPort: 5061 },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
@ -161,7 +161,27 @@ describe('H5AccessService', () => {
|
||||
expect(await legacyService.validateToken(result.token)).toBe(true)
|
||||
})
|
||||
|
||||
test('updateSettings manages fixedPort within the allowed range', async () => {
|
||||
test('ignores a browser-blocked fixed port from older settings', async () => {
|
||||
await fs.mkdir(path.dirname(getManagedSettingsPath()), { recursive: true })
|
||||
await fs.writeFile(
|
||||
getManagedSettingsPath(),
|
||||
JSON.stringify({
|
||||
h5Access: {
|
||||
fixedPort: 5061,
|
||||
allowedOrigins: ['https://example.com'],
|
||||
disconnectGraceSeconds: 600,
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const settings = await new H5AccessService().getSettings()
|
||||
expect(settings.fixedPort).toBeNull()
|
||||
expect(settings.allowedOrigins).toEqual(['https://example.com'])
|
||||
expect(settings.disconnectGraceSeconds).toBe(600)
|
||||
})
|
||||
|
||||
test('updateSettings manages browser-safe fixedPort values', async () => {
|
||||
const service = new H5AccessService()
|
||||
|
||||
const updated = await service.updateSettings({ fixedPort: 28670 })
|
||||
@ -184,6 +204,14 @@ describe('H5AccessService', () => {
|
||||
await expect(service.updateSettings({ fixedPort: 3456.5 })).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
})
|
||||
for (const fixedPort of [
|
||||
1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000,
|
||||
6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
||||
]) {
|
||||
await expect(service.updateSettings({ fixedPort })).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('updateSettings manages the disconnect grace period within range', async () => {
|
||||
|
||||
28
src/server/services/browserSafePort.test.ts
Normal file
28
src/server/services/browserSafePort.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { isBrowserSafePort } from './browserSafePort.js'
|
||||
|
||||
const WHATWG_FETCH_BLOCKED_PORTS = [
|
||||
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53,
|
||||
69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117,
|
||||
119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514,
|
||||
515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989,
|
||||
990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061,
|
||||
6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
||||
]
|
||||
|
||||
describe('browser-safe server ports', () => {
|
||||
test('rejects the complete WHATWG Fetch bad-port table', () => {
|
||||
for (const port of WHATWG_FETCH_BLOCKED_PORTS) {
|
||||
expect(isBrowserSafePort(port)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('accepts valid ports outside the blocked table', () => {
|
||||
for (const port of [80, 3456, 5062, 28670, 65535]) {
|
||||
expect(isBrowserSafePort(port)).toBe(true)
|
||||
}
|
||||
expect(isBrowserSafePort(-1)).toBe(false)
|
||||
expect(isBrowserSafePort(1.5)).toBe(false)
|
||||
expect(isBrowserSafePort(65536)).toBe(false)
|
||||
})
|
||||
})
|
||||
15
src/server/services/browserSafePort.ts
Normal file
15
src/server/services/browserSafePort.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// Keep this list aligned with the WHATWG Fetch bad-port table and the desktop
|
||||
// port selector. H5 fixed ports must remain reachable from a browser/WebView.
|
||||
// https://fetch.spec.whatwg.org/#bad-port
|
||||
const FETCH_BLOCKED_PORTS = new Set([
|
||||
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53,
|
||||
69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117,
|
||||
119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514,
|
||||
515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989,
|
||||
990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061,
|
||||
6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
||||
])
|
||||
|
||||
export function isBrowserSafePort(port: number): boolean {
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535 && !FETCH_BLOCKED_PORTS.has(port)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import os from 'node:os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { isBrowserSafePort } from './browserSafePort.js'
|
||||
import { ManagedSettingsService } from './managedSettingsService.js'
|
||||
import { ProviderService } from './providerService.js'
|
||||
|
||||
@ -529,7 +530,7 @@ function normalizeFixedPort(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
return null
|
||||
}
|
||||
if (value < MIN_FIXED_PORT || value > MAX_FIXED_PORT) {
|
||||
if (value < MIN_FIXED_PORT || value > MAX_FIXED_PORT || !isBrowserSafePort(value)) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
@ -715,7 +716,7 @@ export class H5AccessService {
|
||||
nextFixedPort = normalizeFixedPort(input.fixedPort)
|
||||
if (nextFixedPort === null) {
|
||||
throw ApiError.badRequest(
|
||||
`fixedPort must be an integer between ${MIN_FIXED_PORT} and ${MAX_FIXED_PORT}`,
|
||||
`fixedPort must be a browser-safe integer between ${MIN_FIXED_PORT} and ${MAX_FIXED_PORT}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user