From c80b943e34d1a7daf35509dea758df77632b27d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 11 Jul 2026 17:45:23 +0800 Subject: [PATCH] fix(desktop): avoid browser-blocked server ports #961 --- .../electron/services/sidecarManager.test.ts | 54 +++++++- desktop/electron/services/sidecarManager.ts | 34 ++++- desktop/src-tauri/src/lib.rs | 119 ++++++++++++++++-- .../src/__tests__/generalSettings.test.tsx | 26 +++- desktop/src/i18n/locales/en.ts | 2 +- desktop/src/i18n/locales/jp.ts | 2 +- desktop/src/i18n/locales/kr.ts | 2 +- desktop/src/i18n/locales/zh-TW.ts | 2 +- desktop/src/i18n/locales/zh.ts | 2 +- desktop/src/lib/browserSafePort.test.ts | 28 +++++ desktop/src/lib/browserSafePort.ts | 15 +++ desktop/src/pages/Settings.tsx | 3 +- src/server/__tests__/h5-access-api.test.ts | 8 ++ .../__tests__/h5-access-service.test.ts | 30 ++++- src/server/services/browserSafePort.test.ts | 28 +++++ src/server/services/browserSafePort.ts | 15 +++ src/server/services/h5AccessService.ts | 5 +- 17 files changed, 349 insertions(+), 26 deletions(-) create mode 100644 desktop/src/lib/browserSafePort.test.ts create mode 100644 desktop/src/lib/browserSafePort.ts create mode 100644 src/server/services/browserSafePort.test.ts create mode 100644 src/server/services/browserSafePort.ts diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index f763ee1f..cdd2e875 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -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' }) diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index 4bcc3afe..99331237 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -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 @@ -58,7 +60,11 @@ export function httpToWebSocketUrl(serverHttpUrl: string): string { return serverHttpUrl } -export async function reserveLocalPort(bindHost = SERVER_BIND_HOST): Promise { +export type ReserveLocalPortDeps = { + reserveCandidate?: (bindHost: string) => Promise +} + +async function reserveLocalPortCandidate(bindHost: string): Promise { 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 { + 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 { return new Promise(resolve => { const server = net.createServer() @@ -95,7 +114,14 @@ export async function reserveServerPort( preferred: number[], ): Promise { 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).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).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 } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 424a771b..9aa7f94c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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 { 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(mut reserve_candidate: F) -> Result +where + F: FnMut() -> Result, +{ + 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 { - 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 { 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 }; diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index b7c98afc..5e107628 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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() + + 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() }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 5541718a..8648e5cf 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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)', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 9a51207c..de1e7c04 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1070,7 +1070,7 @@ export const jp: Record = { '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': '切断後の保持(秒)', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 0547728d..38c3416f 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1070,7 +1070,7 @@ export const kr: Record = { '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': '연결 끊김 유지(초)', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 5cfe92c1..6494986a 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1070,7 +1070,7 @@ export const zh: Record = { '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': '斷線保活(秒)', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 954898d3..052ced57 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1070,7 +1070,7 @@ export const zh: Record = { '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': '断连保活(秒)', diff --git a/desktop/src/lib/browserSafePort.test.ts b/desktop/src/lib/browserSafePort.test.ts new file mode 100644 index 00000000..c39c22b7 --- /dev/null +++ b/desktop/src/lib/browserSafePort.test.ts @@ -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) + }) +}) diff --git a/desktop/src/lib/browserSafePort.ts b/desktop/src/lib/browserSafePort.ts new file mode 100644 index 00000000..be2208c1 --- /dev/null +++ b/desktop/src/lib/browserSafePort.ts @@ -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) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 09a5b34d..b3ed6d79 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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 diff --git a/src/server/__tests__/h5-access-api.test.ts b/src/server/__tests__/h5-access-api.test.ts index e6d155b7..440245a4 100644 --- a/src/server/__tests__/h5-access-api.test.ts +++ b/src/server/__tests__/h5-access-api.test.ts @@ -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) + }) }) diff --git a/src/server/__tests__/h5-access-service.test.ts b/src/server/__tests__/h5-access-service.test.ts index a7073c52..310463b3 100644 --- a/src/server/__tests__/h5-access-service.test.ts +++ b/src/server/__tests__/h5-access-service.test.ts @@ -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 () => { diff --git a/src/server/services/browserSafePort.test.ts b/src/server/services/browserSafePort.test.ts new file mode 100644 index 00000000..ad046935 --- /dev/null +++ b/src/server/services/browserSafePort.test.ts @@ -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) + }) +}) diff --git a/src/server/services/browserSafePort.ts b/src/server/services/browserSafePort.ts new file mode 100644 index 00000000..b6919fa5 --- /dev/null +++ b/src/server/services/browserSafePort.ts @@ -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) +} diff --git a/src/server/services/h5AccessService.ts b/src/server/services/h5AccessService.ts index 64ac772a..b99e668d 100644 --- a/src/server/services/h5AccessService.ts +++ b/src/server/services/h5AccessService.ts @@ -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}`, ) } }