From c8c5e521680d02b43a6813ac08f1afdee5b6741a Mon Sep 17 00:00:00 2001 From: Zhuoran Deng Date: Thu, 28 May 2026 06:42:22 +0800 Subject: [PATCH] fix(runtime): tighten mobile control security --- README.md | 3 +- README.zh-CN.md | 3 +- crates/tui/src/main.rs | 80 +++++++++++++++-- crates/tui/src/runtime_api.rs | 163 +++++++++++++++++++++++++++++----- docs/RUNTIME_API.md | 26 +++--- 5 files changed, 233 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 3213ee15..dc339fd4 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,7 @@ codewhale resume --last # resume the most recent sessi codewhale resume # resume a specific session by UUID codewhale fork # fork a saved session into a sibling path codewhale serve --http # HTTP/SSE API server +codewhale serve --mobile # LAN mobile control page; token-gated by default codewhale serve --acp # ACP stdio adapter for Zed/custom agents codewhale run pr # fetch PR and pre-seed review prompt codewhale mcp list # list configured MCP servers @@ -557,7 +558,7 @@ without recreating skills the user deliberately deleted. | [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference | | [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes | | [MCP.md](docs/MCP.md) | Model Context Protocol integration | -| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server | +| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server and mobile control page | | [INSTALL.md](docs/INSTALL.md) | Platform-specific install guide | | [DOCKER.md](docs/DOCKER.md) | GHCR image, volumes, and Docker usage | | [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB mirror and China-friendly install notes | diff --git a/README.zh-CN.md b/README.zh-CN.md index f079cc80..78f4f3b5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -316,6 +316,7 @@ codewhale resume --last # 恢复最近会话 codewhale resume # 按 UUID 恢复指定会话 codewhale fork # 将已保存会话分叉为兄弟路径 codewhale serve --http # HTTP/SSE API 服务 +codewhale serve --mobile # 局域网移动端控制页,默认启用 token 保护 codewhale serve --acp # Zed/自定义智能体的 ACP stdio 适配器 codewhale run pr # 获取 PR 并预填审查提示 codewhale mcp list # 列出已配置 MCP 服务器 @@ -494,7 +495,7 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技 | [CONFIGURATION.md](docs/CONFIGURATION.md) | 完整配置参考 | | [MODES.md](docs/MODES.md) | Plan / Agent / YOLO 模式 | | [MCP.md](docs/MCP.md) | Model Context Protocol 集成 | -| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务 | +| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务和移动端控制页 | | [INSTALL.md](docs/INSTALL.md) | 各平台安装指南 | | [DOCKER.md](docs/DOCKER.md) | GHCR 镜像、volume 和 Docker 用法 | | [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB 镜像和中国大陆友好安装说明 | diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d0b7d649..b6c859ea 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -578,9 +578,9 @@ struct ServeArgs { /// Start ACP server over stdio for editor clients such as Zed #[arg(long)] acp: bool, - /// Bind host for HTTP server (default localhost) - #[arg(long, default_value = "127.0.0.1")] - host: String, + /// Bind host for HTTP server (default localhost; --mobile defaults to 0.0.0.0) + #[arg(long)] + host: Option, /// Bind port for HTTP server #[arg(long, default_value_t = 7878)] port: u16, @@ -602,6 +602,29 @@ struct ServeArgs { insecure_no_auth: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServeBindHost { + host: String, + mobile_rebound_to_lan: bool, +} + +fn resolve_serve_bind_host(mobile: bool, host: Option) -> ServeBindHost { + match (mobile, host) { + (true, None) => ServeBindHost { + host: "0.0.0.0".to_string(), + mobile_rebound_to_lan: true, + }, + (_, Some(host)) => ServeBindHost { + host, + mobile_rebound_to_lan: false, + }, + (false, None) => ServeBindHost { + host: "127.0.0.1".to_string(), + mobile_rebound_to_lan: false, + }, + } +} + #[derive(Subcommand, Debug, Clone)] enum McpCommand { /// List configured MCP servers @@ -942,16 +965,17 @@ async fn main() -> Result<()> { } else if http_selected { let config = load_config_from_cli(&cli)?; let cors_origins = resolve_cors_origins(&config, &args.cors_origin); - let host = if args.mobile && args.host == "127.0.0.1" { - "0.0.0.0".to_string() - } else { - args.host - }; + let bind_host = resolve_serve_bind_host(args.mobile, args.host); + if bind_host.mobile_rebound_to_lan { + println!( + "WARNING: --mobile is binding to 0.0.0.0 so LAN devices can reach the mobile control page. Use --host 127.0.0.1 to keep mobile loopback-only." + ); + } runtime_api::run_http_server( config, workspace, runtime_api::RuntimeApiOptions { - host, + host: bind_host.host, port: args.port, workers: args.workers.clamp(1, 8), cors_origins, @@ -5581,6 +5605,44 @@ async fn run_exec_agent( Ok(()) } +#[cfg(test)] +mod serve_bind_host_tests { + use super::*; + + #[test] + fn http_defaults_to_loopback() { + assert_eq!( + resolve_serve_bind_host(false, None), + ServeBindHost { + host: "127.0.0.1".to_string(), + mobile_rebound_to_lan: false, + } + ); + } + + #[test] + fn mobile_default_rebinds_to_lan_with_warning_flag() { + assert_eq!( + resolve_serve_bind_host(true, None), + ServeBindHost { + host: "0.0.0.0".to_string(), + mobile_rebound_to_lan: true, + } + ); + } + + #[test] + fn mobile_respects_explicit_loopback_host() { + assert_eq!( + resolve_serve_bind_host(true, Some("127.0.0.1".to_string())), + ServeBindHost { + host: "127.0.0.1".to_string(), + mobile_rebound_to_lan: false, + } + ); + } +} + #[cfg(test)] mod doctor_endpoint_tests { use super::*; diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 23b26085..34960ffa 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -554,8 +554,17 @@ async fn require_runtime_token( let Some(expected) = state.runtime_token.as_deref() else { return next.run(req).await; }; - let authorized = req - .headers() + let authorized = request_has_runtime_token(&req, expected); + + if authorized { + next.run(req).await + } else { + runtime_token_required_response() + } +} + +fn request_has_runtime_token(req: &Request, expected: &str) -> bool { + req.headers() .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|raw| raw.strip_prefix("Bearer ")) @@ -565,34 +574,61 @@ async fn require_runtime_token( .get("x-deepseek-runtime-token") .and_then(|value| value.to_str().ok()) .is_some_and(|token| token == expected) - || token_from_query(req.uri().query()).is_some_and(|token| token == expected); - - if authorized { - next.run(req).await - } else { - ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": { - "message": "runtime API bearer token required", - "status": StatusCode::UNAUTHORIZED.as_u16(), - } - })), - ) - .into_response() - } + || token_from_query(req.uri().query()).is_some_and(|token| token == expected) } -fn token_from_query(query: Option<&str>) -> Option<&str> { +fn runtime_token_required_response() -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": { + "message": "runtime API bearer token required", + "status": StatusCode::UNAUTHORIZED.as_u16(), + } + })), + ) + .into_response() +} + +fn token_from_query(query: Option<&str>) -> Option { query.and_then(|query| { query.split('&').find_map(|pair| { let (key, value) = pair.split_once('=')?; - (key == "token").then_some(value) + (key == "token") + .then(|| percent_decode_query_component(value)) + .flatten() }) }) } -async fn mobile_page(State(state): State) -> Response { +fn percent_decode_query_component(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + let hi = *bytes.get(index + 1)?; + let lo = *bytes.get(index + 2)?; + let hi = (hi as char).to_digit(16)? as u8; + let lo = (lo as char).to_digit(16)? as u8; + decoded.push((hi << 4) | lo); + index += 3; + } + b'+' => { + decoded.push(b' '); + index += 1; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + String::from_utf8(decoded).ok() +} + +async fn mobile_page(State(state): State, req: Request) -> Response { if !state.mobile_enabled { return ( StatusCode::NOT_FOUND, @@ -600,6 +636,11 @@ async fn mobile_page(State(state): State) -> Response { ) .into_response(); } + if let Some(expected) = state.runtime_token.as_deref() + && !request_has_runtime_token(&req, expected) + { + return runtime_token_required_response(); + } Html(MOBILE_HTML).into_response() } @@ -2035,6 +2076,15 @@ mod tests { ); } + #[test] + fn token_from_query_decodes_percent_encoded_token() { + assert_eq!( + token_from_query(Some("since_seq=0&token=abc%20ABC%2B%2F%3F%3A%3D%26%25")), + Some("abc ABC+/?:=&%".to_string()) + ); + assert_eq!(token_from_query(Some("token=bad%ZZ")), None); + } + async fn spawn_test_server_with_root( root: PathBuf, sessions_dir: PathBuf, @@ -3739,6 +3789,77 @@ mod tests { Ok(()) } + #[tokio::test] + async fn mobile_page_requires_runtime_token_when_auth_enabled() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().to_path_buf(); + let sessions_dir = root.join("sessions"); + let token = "abc ABC+/?:=&%".to_string(); + let Some((addr, _runtime_threads, handle)) = spawn_test_server_with_root_token_and_mobile( + root, + sessions_dir, + Some(token.clone()), + true, + ) + .await? + else { + return Ok(()); + }; + let client = reqwest::Client::new(); + + let unauthorized = client.get(format!("http://{addr}/mobile")).send().await?; + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let encoded = url_query_component(&token); + let query = client + .get(format!("http://{addr}/mobile?token={encoded}")) + .send() + .await? + .error_for_status()?; + assert!(query.text().await?.contains("CodeWhale Mobile")); + + let bearer = client + .get(format!("http://{addr}/mobile")) + .bearer_auth(&token) + .send() + .await? + .error_for_status()?; + assert!(bearer.text().await?.contains("CodeWhale Mobile")); + + handle.abort(); + Ok(()) + } + + #[tokio::test] + async fn mobile_insecure_mode_allows_page_and_v1_routes_without_token() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().to_path_buf(); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root_token_and_mobile(root, sessions_dir, None, true).await? + else { + return Ok(()); + }; + let client = reqwest::Client::new(); + + let page = client + .get(format!("http://{addr}/mobile")) + .send() + .await? + .error_for_status()?; + assert!(page.text().await?.contains("CodeWhale Mobile")); + + let summary = client + .get(format!("http://{addr}/v1/threads/summary")) + .send() + .await? + .error_for_status()?; + assert_eq!(summary.status(), StatusCode::OK); + + handle.abort(); + Ok(()) + } + #[tokio::test] async fn decide_approval_404s_when_nothing_pending() -> Result<()> { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 3fdfb7a9..d7ec8331 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -117,7 +117,7 @@ codewhale doctor --json ```bash codewhale serve --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] -codewhale serve --mobile [--port 7878] [--auth-token TOKEN] +codewhale serve --mobile [--host 0.0.0.0] [--port 7878] [--auth-token TOKEN] ``` Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). @@ -128,8 +128,10 @@ there is no `[app_server]` config section. `/v1/*` routes require a bearer token unless `--insecure` is explicitly set. Pass `--auth-token TOKEN` or set `DEEPSEEK_RUNTIME_TOKEN=TOKEN` before starting the server. If neither is set, the process generates a one-time token and prints -it at startup. `/health`, `/mobile`, and `/v1/runtime/info` remain public for -local supervision and bootstrap. +it at startup. `/health` and `/v1/runtime/info` remain public for local +supervision and bootstrap. `/mobile` returns 404 when mobile mode is disabled; +when mobile mode is enabled and auth is enabled, `/mobile` returns 401 unless +the request supplies the runtime token. Authenticated clients can provide the token as `Authorization: Bearer TOKEN`, `X-DeepSeek-Runtime-Token: TOKEN`, or `?token=TOKEN` for EventSource-style @@ -139,9 +141,12 @@ clients that cannot set custom headers. `codewhale serve --mobile` starts the same HTTP/SSE runtime API and serves a phone-friendly control page at `/mobile`. When the bind host is left at the -default, mobile mode binds to `0.0.0.0` and prints local/LAN URLs. If a runtime -token is generated or supplied, the printed mobile URL includes it as a query -parameter; the page stores it locally and removes it from the address bar. +default, mobile mode binds to `0.0.0.0`, prints a warning, and prints local/LAN +URLs. Pass `--host 127.0.0.1` to keep the mobile page loopback-only. If a +runtime token is generated or supplied, the printed mobile URL includes it as a +query parameter; the page stores it locally and removes it from the address bar. +The static HTML page contains no secrets, but it is still token-gated when auth +is enabled so unauthenticated LAN clients cannot fingerprint the mobile surface. The mobile page can list/create threads, send prompts, follow live SSE events, steer or interrupt an active turn, and resolve normal tool approvals through @@ -331,10 +336,11 @@ Common event names: `thread.started`, `thread.forked`, `turn.started`, ## Security boundary - **Localhost by default**. The server binds to `127.0.0.1` by default. - `--mobile` binds to `0.0.0.0` when the host is left at the default so phones - on the same LAN can reach it. Set a non-loopback host only when you trust the - network path or have a reverse-proxy / VPN that authenticates. The runtime - does not provide user isolation or TLS. + `--mobile` binds to `0.0.0.0` when no host is supplied so phones on the same + LAN can reach it, and the CLI prints a warning for that rebind. Pass + `--host 127.0.0.1` for a loopback-only mobile page. Set a non-loopback host + only when you trust the network path or have a reverse-proxy / VPN that + authenticates. The runtime does not provide user isolation or TLS. - **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN` requires a matching bearer token for `/v1/*` routes. This is a local convenience guard, not a replacement for TLS, VPN, or a trusted reverse