fix(desktop): avoid browser-blocked server ports #961

This commit is contained in:
程序员阿江(Relakkes) 2026-07-11 17:45:23 +08:00
parent ace8862ff2
commit c80b943e34
17 changed files with 349 additions and 26 deletions

View File

@ -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' })

View File

@ -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
}

View File

@ -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 };

View File

@ -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()
})

View File

@ -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)',

View File

@ -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': '切断後の保持(秒)',

View File

@ -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': '연결 끊김 유지(초)',

View File

@ -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': '斷線保活(秒)',

View File

@ -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': '断连保活(秒)',

View 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)
})
})

View 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)
}

View File

@ -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

View File

@ -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)
})
})

View File

@ -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 () => {

View 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)
})
})

View 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)
}

View File

@ -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}`,
)
}
}