fix(runtime): tighten mobile control security

This commit is contained in:
Zhuoran Deng
2026-05-28 06:42:22 +08:00
parent 58b32dabf9
commit c8c5e52168
5 changed files with 233 additions and 42 deletions
+2 -1
View File
@@ -372,6 +372,7 @@ codewhale resume --last # resume the most recent sessi
codewhale resume <SESSION_ID> # resume a specific session by UUID codewhale resume <SESSION_ID> # resume a specific session by UUID
codewhale fork <SESSION_ID> # fork a saved session into a sibling path codewhale fork <SESSION_ID> # fork a saved session into a sibling path
codewhale serve --http # HTTP/SSE API server 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 serve --acp # ACP stdio adapter for Zed/custom agents
codewhale run pr <N> # fetch PR and pre-seed review prompt codewhale run pr <N> # fetch PR and pre-seed review prompt
codewhale mcp list # list configured MCP servers 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 | | [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference |
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes | | [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes |
| [MCP.md](docs/MCP.md) | Model Context Protocol integration | | [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 | | [INSTALL.md](docs/INSTALL.md) | Platform-specific install guide |
| [DOCKER.md](docs/DOCKER.md) | GHCR image, volumes, and Docker usage | | [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 | | [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB mirror and China-friendly install notes |
+2 -1
View File
@@ -316,6 +316,7 @@ codewhale resume --last # 恢复最近会话
codewhale resume <SESSION_ID> # 按 UUID 恢复指定会话 codewhale resume <SESSION_ID> # 按 UUID 恢复指定会话
codewhale fork <SESSION_ID> # 将已保存会话分叉为兄弟路径 codewhale fork <SESSION_ID> # 将已保存会话分叉为兄弟路径
codewhale serve --http # HTTP/SSE API 服务 codewhale serve --http # HTTP/SSE API 服务
codewhale serve --mobile # 局域网移动端控制页,默认启用 token 保护
codewhale serve --acp # Zed/自定义智能体的 ACP stdio 适配器 codewhale serve --acp # Zed/自定义智能体的 ACP stdio 适配器
codewhale run pr <N> # 获取 PR 并预填审查提示 codewhale run pr <N> # 获取 PR 并预填审查提示
codewhale mcp list # 列出已配置 MCP 服务器 codewhale mcp list # 列出已配置 MCP 服务器
@@ -494,7 +495,7 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技
| [CONFIGURATION.md](docs/CONFIGURATION.md) | 完整配置参考 | | [CONFIGURATION.md](docs/CONFIGURATION.md) | 完整配置参考 |
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO 模式 | | [MODES.md](docs/MODES.md) | Plan / Agent / YOLO 模式 |
| [MCP.md](docs/MCP.md) | Model Context Protocol 集成 | | [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) | 各平台安装指南 | | [INSTALL.md](docs/INSTALL.md) | 各平台安装指南 |
| [DOCKER.md](docs/DOCKER.md) | GHCR 镜像、volume 和 Docker 用法 | | [DOCKER.md](docs/DOCKER.md) | GHCR 镜像、volume 和 Docker 用法 |
| [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB 镜像和中国大陆友好安装说明 | | [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB 镜像和中国大陆友好安装说明 |
+71 -9
View File
@@ -578,9 +578,9 @@ struct ServeArgs {
/// Start ACP server over stdio for editor clients such as Zed /// Start ACP server over stdio for editor clients such as Zed
#[arg(long)] #[arg(long)]
acp: bool, acp: bool,
/// Bind host for HTTP server (default localhost) /// Bind host for HTTP server (default localhost; --mobile defaults to 0.0.0.0)
#[arg(long, default_value = "127.0.0.1")] #[arg(long)]
host: String, host: Option<String>,
/// Bind port for HTTP server /// Bind port for HTTP server
#[arg(long, default_value_t = 7878)] #[arg(long, default_value_t = 7878)]
port: u16, port: u16,
@@ -602,6 +602,29 @@ struct ServeArgs {
insecure_no_auth: bool, 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<String>) -> 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)] #[derive(Subcommand, Debug, Clone)]
enum McpCommand { enum McpCommand {
/// List configured MCP servers /// List configured MCP servers
@@ -942,16 +965,17 @@ async fn main() -> Result<()> {
} else if http_selected { } else if http_selected {
let config = load_config_from_cli(&cli)?; let config = load_config_from_cli(&cli)?;
let cors_origins = resolve_cors_origins(&config, &args.cors_origin); let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
let host = if args.mobile && args.host == "127.0.0.1" { let bind_host = resolve_serve_bind_host(args.mobile, args.host);
"0.0.0.0".to_string() if bind_host.mobile_rebound_to_lan {
} else { println!(
args.host "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( runtime_api::run_http_server(
config, config,
workspace, workspace,
runtime_api::RuntimeApiOptions { runtime_api::RuntimeApiOptions {
host, host: bind_host.host,
port: args.port, port: args.port,
workers: args.workers.clamp(1, 8), workers: args.workers.clamp(1, 8),
cors_origins, cors_origins,
@@ -5581,6 +5605,44 @@ async fn run_exec_agent(
Ok(()) 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)] #[cfg(test)]
mod doctor_endpoint_tests { mod doctor_endpoint_tests {
use super::*; use super::*;
+142 -21
View File
@@ -554,8 +554,17 @@ async fn require_runtime_token(
let Some(expected) = state.runtime_token.as_deref() else { let Some(expected) = state.runtime_token.as_deref() else {
return next.run(req).await; return next.run(req).await;
}; };
let authorized = req let authorized = request_has_runtime_token(&req, expected);
.headers()
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) .get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer ")) .and_then(|raw| raw.strip_prefix("Bearer "))
@@ -565,34 +574,61 @@ async fn require_runtime_token(
.get("x-deepseek-runtime-token") .get("x-deepseek-runtime-token")
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.is_some_and(|token| token == expected) .is_some_and(|token| token == expected)
|| token_from_query(req.uri().query()).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()
}
} }
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<String> {
query.and_then(|query| { query.and_then(|query| {
query.split('&').find_map(|pair| { query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?; 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<RuntimeApiState>) -> Response { fn percent_decode_query_component(value: &str) -> Option<String> {
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<RuntimeApiState>, req: Request) -> Response {
if !state.mobile_enabled { if !state.mobile_enabled {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -600,6 +636,11 @@ async fn mobile_page(State(state): State<RuntimeApiState>) -> Response {
) )
.into_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() 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( async fn spawn_test_server_with_root(
root: PathBuf, root: PathBuf,
sessions_dir: PathBuf, sessions_dir: PathBuf,
@@ -3739,6 +3789,77 @@ mod tests {
Ok(()) 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] #[tokio::test]
async fn decide_approval_404s_when_nothing_pending() -> Result<()> { async fn decide_approval_404s_when_nothing_pending() -> Result<()> {
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
+16 -10
View File
@@ -117,7 +117,7 @@ codewhale doctor --json
```bash ```bash
codewhale serve --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] 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 18). Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 18).
@@ -128,8 +128,10 @@ there is no `[app_server]` config section.
`/v1/*` routes require a bearer token unless `--insecure` is explicitly set. `/v1/*` routes require a bearer token unless `--insecure` is explicitly set.
Pass `--auth-token TOKEN` or set `DEEPSEEK_RUNTIME_TOKEN=TOKEN` before starting 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 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 it at startup. `/health` and `/v1/runtime/info` remain public for local
local supervision and bootstrap. 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`, Authenticated clients can provide the token as `Authorization: Bearer TOKEN`,
`X-DeepSeek-Runtime-Token: TOKEN`, or `?token=TOKEN` for EventSource-style `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 `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 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 default, mobile mode binds to `0.0.0.0`, prints a warning, and prints local/LAN
token is generated or supplied, the printed mobile URL includes it as a query URLs. Pass `--host 127.0.0.1` to keep the mobile page loopback-only. If a
parameter; the page stores it locally and removes it from the address bar. 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, 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 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 ## Security boundary
- **Localhost by default**. The server binds to `127.0.0.1` by default. - **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 `--mobile` binds to `0.0.0.0` when no host is supplied so phones on the same
on the same LAN can reach it. Set a non-loopback host only when you trust the LAN can reach it, and the CLI prints a warning for that rebind. Pass
network path or have a reverse-proxy / VPN that authenticates. The runtime `--host 127.0.0.1` for a loopback-only mobile page. Set a non-loopback host
does not provide user isolation or TLS. 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` - **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN`
requires a matching bearer token for `/v1/*` routes. This is a local requires a matching bearer token for `/v1/*` routes. This is a local
convenience guard, not a replacement for TLS, VPN, or a trusted reverse convenience guard, not a replacement for TLS, VPN, or a trusted reverse