fix(runtime-api): mobile listener security — validate_runtime_listener_security, loopback-only mobile, stream ticket query, IPv6 bind

Mobile runtime listener fails closed outside loopback unless the transport is
verified; cookie-auth tests; IPv6 bind address formatting.

(cherry picked from commit 5f789a545; harvested from fix/mobile-runtime-security-20260831 — see codewhale-ops HARVEST_LEDGER-20260903. Conflict: the runtime_api tests were consolidated on main; kept main's shape and appended the new mobile test.)
This commit is contained in:
CodeWhale Bot committed 2026-09-02 22:20:10 -07:00
1 parent ad813cda63
commit e80be8c33a
7 files changed
+1159 -244

No files matched your search

+6 -27
View File
@@ -1305,7 +1305,7 @@ struct ServeArgs {
/// Start ACP server over stdio for editor clients such as Zed
#[arg(long)]
acp: bool,
/// Bind host for HTTP server (default localhost; --mobile defaults to 0.0.0.0)
/// Bind host for HTTP server (default loopback; mobile is always loopback-only)
#[arg(long)]
host: Option<String>,
/// Bind port for HTTP server
@@ -1334,23 +1334,11 @@ struct ServeArgs {
#[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,
},
fn resolve_serve_bind_host(_mobile: bool, host: Option<String>) -> ServeBindHost {
ServeBindHost {
host: host.unwrap_or_else(|| "127.0.0.1".to_string()),
}
}
@@ -2485,11 +2473,6 @@ async fn run_async_main_dispatch(
if args.web && bind_host.host != "127.0.0.1" {
bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
}
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,
@@ -12442,18 +12425,16 @@ mod serve_bind_host_tests {
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() {
fn mobile_defaults_to_loopback() {
assert_eq!(
resolve_serve_bind_host(true, None),
ServeBindHost {
host: "0.0.0.0".to_string(),
mobile_rebound_to_lan: true,
host: "127.0.0.1".to_string(),
}
);
}
@@ -12464,7 +12445,6 @@ mod serve_bind_host_tests {
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,
}
);
}
@@ -12487,7 +12467,6 @@ mod serve_bind_host_tests {
resolve_serve_bind_host(false, None),
ServeBindHost {
host: "127.0.0.1".to_string(),
mobile_rebound_to_lan: false,
}
);
}
+228 -64
View File
@@ -3,14 +3,14 @@
use std::collections::{BTreeMap, BTreeSet};
use std::convert::Infallible;
use std::fs;
use std::net::{IpAddr, SocketAddr, UdpSocket};
use std::net::{IpAddr, SocketAddr};
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use async_stream::stream;
use axum::extract::{Path, Query, Request, State};
use axum::extract::{ConnectInfo, Path, Query, Request, State};
use axum::http::header;
use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
use axum::middleware;
@@ -88,12 +88,13 @@ use codewhale_protocol::fleet::{
};
mod auth;
mod mobile;
mod plugins;
mod sessions;
mod web;
mod workspace;
#[cfg(test)]
use self::auth::{ResolvedRuntimeAuth, token_from_cookie_header};
use self::auth::ResolvedRuntimeAuth;
use self::auth::{
require_runtime_token, resolve_runtime_auth, runtime_auth_status_lines,
runtime_request_is_authorized,
@@ -172,6 +173,7 @@ pub struct RuntimeApiState {
bind_host: String,
bind_port: u16,
mobile_enabled: bool,
mobile: Option<mobile::RuntimeMobileState>,
web: Option<web::RuntimeWebState>,
/// Executable used by Runtime API-owned Fleet manager loops. Stored on
/// state so tests and embedded callers can provide a hermetic worker.
@@ -838,15 +840,7 @@ pub async fn run_http_server(
plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
options: RuntimeApiOptions,
) -> Result<()> {
if options.port == 0 {
bail!("Port must be > 0");
}
if options.web && options.host != "127.0.0.1" {
bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
}
if options.web && options.insecure_no_auth {
bail!("Codewhale web requires Runtime authentication; remove --insecure");
}
validate_runtime_listener_security(&options)?;
let task_default_model = config.default_model();
let task_cfg = TaskManagerConfig::from_runtime(
@@ -894,6 +888,12 @@ pub async fn run_http_server(
} else {
(None, None)
};
let (mobile, mobile_bootstrap) = if options.mobile && auth_enabled {
let (mobile, bootstrap) = mobile::RuntimeMobileState::new();
(Some(mobile), Some(bootstrap))
} else {
(None, None)
};
let skill_state = SkillStateStore::load_default()
.context("load persistent Skill activation state for Runtime API")?;
let sub_agent_manager = runtime_api_sub_agent_manager(&workspace, options.workers);
@@ -915,6 +915,7 @@ pub async fn run_http_server(
bind_host: options.host.clone(),
bind_port: options.port,
mobile_enabled: options.mobile,
mobile,
web,
fleet_codewhale_binary: configured_codewhale_binary(),
mcp_pool: Arc::new(Mutex::new(None)),
@@ -923,9 +924,7 @@ pub async fn run_http_server(
};
let app = build_router(state);
let addr: SocketAddr = format!("{}:{}", options.host, options.port)
.parse()
.with_context(|| format!("Invalid bind address '{}:{}'", options.host, options.port))?;
let addr = runtime_bind_address(&options.host, options.port)?;
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("Failed to bind {addr}"))?;
@@ -946,6 +945,7 @@ pub async fn run_http_server(
auth_enabled,
resolved_auth.generated,
options.show_qr,
mobile_bootstrap.as_deref(),
);
}
if let Some(bootstrap) = web_bootstrap {
@@ -959,7 +959,7 @@ pub async fn run_http_server(
println!("{warning}");
}
}
let is_loopback = options.host == "127.0.0.1" || options.host == "::1";
let is_loopback = is_loopback_bind_host(&options.host);
if is_loopback {
println!("Security: this server is local-first. Do not expose it to untrusted networks.");
} else {
@@ -990,6 +990,42 @@ pub async fn run_http_server(
serve_result
}
/// Mobile control uses plain HTTP only on loopback. It has no TLS or verified
/// overlay transport, so a non-loopback listener would expose the Runtime API
/// to peers that can observe or replay browser traffic.
fn validate_runtime_listener_security(options: &RuntimeApiOptions) -> Result<()> {
if options.port == 0 {
bail!("Port must be > 0");
}
if options.web && options.host != "127.0.0.1" {
bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
}
if options.web && options.insecure_no_auth {
bail!("Codewhale web requires Runtime authentication; remove --insecure");
}
if options.mobile && !is_loopback_bind_host(&options.host) {
bail!(
"Codewhale mobile is loopback-only without TLS or a verified overlay; bind to 127.0.0.1 or ::1"
);
}
Ok(())
}
fn is_loopback_bind_host(host: &str) -> bool {
host.parse::<IpAddr>()
.is_ok_and(|address| address.is_loopback())
}
fn runtime_bind_address(host: &str, port: u16) -> Result<SocketAddr> {
let address = match host.parse::<IpAddr>() {
Ok(IpAddr::V6(_)) => format!("[{host}]:{port}"),
_ => format!("{host}:{port}"),
};
address
.parse()
.with_context(|| format!("Invalid bind address '{host}:{port}'"))
}
fn web_launcher_warning(result: Result<()>) -> Option<String> {
result.err().map(|error| {
format!(
@@ -1248,6 +1284,15 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/__codewhale/bootstrap/{nonce}",
get(web::exchange_bootstrap),
)
.route(
"/__codewhale/mobile/bootstrap/{nonce}",
get(exchange_mobile_bootstrap),
)
.route("/__codewhale/mobile/session", post(exchange_mobile_session))
.route(
"/__codewhale/mobile/stream-ticket",
post(refresh_mobile_stream_ticket),
)
.route("/health", get(health))
.route("/mobile", get(mobile_page))
.route("/mobile/", get(mobile_page))
@@ -1266,41 +1311,185 @@ async fn mobile_page(State(state): State<RuntimeApiState>, req: Request) -> Resp
.into_response();
}
let _ = req;
Html(MOBILE_HTML).into_response()
let mut response = Html(MOBILE_HTML).into_response();
secure_mobile_response(&mut response);
response
}
fn print_mobile_urls(addr: SocketAddr, auth_enabled: bool, generated_auth: bool, show_qr: bool) {
#[derive(Serialize)]
struct MobileSessionResponse {
request_proof: String,
stream_ticket: String,
session_expires_in_seconds: u64,
stream_ticket_expires_in_seconds: u64,
}
async fn exchange_mobile_bootstrap(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path(nonce): Path<String>,
) -> Response {
let Some(mobile_state) = state.mobile.as_ref() else {
return mobile_not_found();
};
let session = match mobile_state.consume_bootstrap(&nonce, peer.ip()) {
Ok(session) => session,
Err(mobile::BootstrapError::NonLoopback) => {
return secured_mobile_text(StatusCode::FORBIDDEN, "bootstrap unavailable");
}
Err(mobile::BootstrapError::Invalid | mobile::BootstrapError::Expired) => {
return secured_mobile_text(StatusCode::UNAUTHORIZED, "bootstrap unavailable");
}
};
let location = format!(
"/mobile#request_proof={}&stream_ticket={}",
session.request_proof, session.stream_ticket
);
let cookie = mobile::mobile_session_cookie(&session.session_cookie);
let mut response = (StatusCode::SEE_OTHER, "").into_response();
response.headers_mut().insert(
header::LOCATION,
HeaderValue::from_str(&location).expect("generated mobile fragment is a valid header"),
);
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie).expect("generated mobile cookie is a valid header"),
);
secure_mobile_response(&mut response);
response
}
async fn exchange_mobile_session(State(state): State<RuntimeApiState>, req: Request) -> Response {
let Some(mobile_state) = state.mobile.as_ref() else {
return mobile_not_found();
};
let Some(expected) = state.runtime_token.as_deref() else {
return mobile_not_found();
};
if !auth::request_has_header_runtime_token(&req, expected) {
return mobile_unauthorized();
}
mobile_session_response(mobile_state.issue_session())
}
async fn refresh_mobile_stream_ticket(
State(state): State<RuntimeApiState>,
req: Request,
) -> Response {
let Some(mobile_state) = state.mobile.as_ref() else {
return mobile_not_found();
};
if !auth::mobile_session_request_is_authorized(&req, &state, mobile_state) {
return mobile_unauthorized();
}
let ticket = mobile_state.refresh_stream_ticket(
req.headers()
.get(header::COOKIE)
.and_then(|value| value.to_str().ok()),
req.headers()
.get(mobile::MOBILE_REQUEST_HEADER)
.and_then(|value| value.to_str().ok()),
);
let Some(ticket) = ticket else {
return mobile_unauthorized();
};
let mut response = Json(json!({
"stream_ticket": ticket.ticket,
"expires_in_seconds": ticket.expires_in_seconds,
}))
.into_response();
secure_mobile_response(&mut response);
response
}
fn mobile_session_response(session: mobile::MobileSessionBootstrap) -> Response {
let cookie = mobile::mobile_session_cookie(&session.session_cookie);
let mut response = Json(MobileSessionResponse {
request_proof: session.request_proof,
stream_ticket: session.stream_ticket,
session_expires_in_seconds: session.session_ttl_seconds,
stream_ticket_expires_in_seconds: session.stream_ticket_ttl_seconds,
})
.into_response();
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie).expect("generated mobile cookie is a valid header"),
);
secure_mobile_response(&mut response);
response
}
fn mobile_not_found() -> Response {
secured_mobile_text(StatusCode::NOT_FOUND, "not found")
}
fn mobile_unauthorized() -> Response {
let mut response = auth::runtime_token_required_response();
secure_mobile_response(&mut response);
response
}
fn secured_mobile_text(status: StatusCode, body: &'static str) -> Response {
let mut response = (status, body).into_response();
secure_mobile_response(&mut response);
response
}
fn secure_mobile_response(response: &mut Response) {
let headers = response.headers_mut();
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'",
),
);
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
}
fn print_mobile_urls(
addr: SocketAddr,
auth_enabled: bool,
generated_auth: bool,
show_qr: bool,
mobile_bootstrap: Option<&str>,
) {
println!("Mobile control page enabled.");
let port = addr.port();
let qr_url = if addr.ip().is_unspecified() {
println!(" Local: http://127.0.0.1:{port}/mobile");
if let Some(ip) = detect_lan_ip() {
let lan_url = format!("http://{ip}:{port}/mobile");
println!(" LAN: {lan_url}");
lan_url
} else {
println!(" LAN: bind is 0.0.0.0; open http://<this-machine-ip>:{port}/mobile");
format!("http://127.0.0.1:{port}/mobile")
}
} else {
let url = format!("http://{addr}/mobile");
println!(" URL: {url}");
url
};
let url = format!("http://{addr}/mobile");
println!(" URL: {url}");
if auth_enabled {
if generated_auth {
if let Some(bootstrap) = mobile_bootstrap {
let bootstrap_url = mobile::bootstrap_url(addr, bootstrap);
println!(
" Auth uses an unprinted generated token; restart with CODEWHALE_RUNTIME_TOKEN or --auth-token to sign in from another client."
" Bootstrap (single-use, expires in {} min): {bootstrap_url}",
mobile::BOOTSTRAP_TTL.as_secs() / 60
);
} else if generated_auth {
println!(
" Auth uses an unprinted generated token; open the bootstrap URL printed above."
);
} else {
println!(" Enter the configured runtime token in the page connection field.");
println!(
" Use the bootstrap URL; the page also supports one-time bearer entry without storing it."
);
}
}
println!("Mobile security: use only on a trusted LAN/VPN; this server does not provide TLS.");
println!(
"Mobile security: loopback-only; no LAN/VPN device access without a verified transport boundary."
);
if show_qr {
match qrcode::QrCode::new(qr_url.as_bytes()) {
println!(" QR is loopback-only and cannot pair another device.");
match qrcode::QrCode::new(url.as_bytes()) {
Ok(qr) => {
let qr_str = qr.render::<qrcode::render::unicode::Dense1x2>().build();
println!("\n{qr_str}");
@@ -1312,31 +1501,6 @@ fn print_mobile_urls(addr: SocketAddr, auth_enabled: bool, generated_auth: bool,
}
}
#[cfg(test)]
fn url_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => {
use std::fmt::Write as _;
let _ = write!(encoded, "%{byte:02X}");
}
}
}
encoded
}
fn detect_lan_ip() -> Option<String> {
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
// UDP connect only selects the outbound interface locally; no packet is sent.
socket.connect("10.255.255.255:1").ok()?;
let addr = socket.local_addr().ok()?;
Some(addr.ip().to_string())
}
async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
+117 -68
View File
@@ -5,9 +5,7 @@ use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use super::RuntimeApiState;
const RUNTIME_TOKEN_COOKIE: &str = "codewhale_runtime_token";
use super::{RuntimeApiState, mobile};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ResolvedRuntimeAuth {
@@ -82,19 +80,25 @@ pub(super) fn runtime_request_is_authorized(req: &Request, state: &RuntimeApiSta
let Some(expected) = state.runtime_token.as_deref() else {
return true;
};
let cookie_authorized = request_has_runtime_cookie(req, expected)
|| state.web.as_ref().is_some_and(|web| {
web.matches_session_cookie(
req.headers()
.get(header::COOKIE)
.and_then(|value| value.to_str().ok()),
)
});
request_has_header_runtime_token(req, expected)
|| (cookie_authorized && web_cookie_request_is_same_origin(req, state))
if request_has_header_runtime_token(req, expected) {
return true;
}
if state.web.as_ref().is_some_and(|web| {
web.matches_session_cookie(
req.headers()
.get(header::COOKIE)
.and_then(|value| value.to_str().ok()),
) && web_cookie_request_is_same_origin(req, state)
}) {
return true;
}
state
.mobile
.as_ref()
.is_some_and(|mobile| mobile_session_request_is_authorized(req, state, mobile))
}
fn request_has_header_runtime_token(req: &Request, expected: &str) -> bool {
pub(super) fn request_has_header_runtime_token(req: &Request, expected: &str) -> bool {
req.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
@@ -112,15 +116,6 @@ fn request_has_header_runtime_token(req: &Request, expected: &str) -> bool {
.is_some_and(|token| token == expected)
}
fn request_has_runtime_cookie(req: &Request, expected: &str) -> bool {
token_from_cookie_header(
req.headers()
.get(header::COOKIE)
.and_then(|value| value.to_str().ok()),
)
.is_some_and(|token| token == expected)
}
/// The web bootstrap adds cookie authentication to the existing bearer/header
/// boundary. SameSite is site-scoped rather than origin-scoped, so a sibling
/// loopback port can still receive the cookie. Require browser-origin evidence
@@ -128,10 +123,6 @@ fn request_has_runtime_cookie(req: &Request, expected: &str) -> bool {
/// cross-origin cookie request. Bearer and explicit runtime-token headers keep
/// their existing behavior.
fn web_cookie_request_is_same_origin(req: &Request, state: &RuntimeApiState) -> bool {
if state.web.is_none() {
return true;
}
if req
.headers()
.get("sec-fetch-site")
@@ -141,11 +132,7 @@ fn web_cookie_request_is_same_origin(req: &Request, state: &RuntimeApiState) ->
return false;
}
let expected_origin = if state.bind_port == 80 {
format!("http://{}", state.bind_host)
} else {
format!("http://{}:{}", state.bind_host, state.bind_port)
};
let expected_origin = runtime_http_origin(state);
if let Some(origin) = req
.headers()
.get(header::ORIGIN)
@@ -157,7 +144,94 @@ fn web_cookie_request_is_same_origin(req: &Request, state: &RuntimeApiState) ->
matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS)
}
fn runtime_token_required_response() -> Response {
/// Mobile cookies are host-scoped and can be attached to a sibling loopback
/// port. A cookie alone is therefore never Runtime authority: normal fetches
/// must also present an origin-scoped proof and EventSource requests must
/// consume a short-lived stream ticket. The origin/Fetch Metadata check keeps
/// a sibling port from replaying a captured value.
pub(super) fn mobile_session_request_is_authorized(
req: &Request,
state: &RuntimeApiState,
mobile_state: &mobile::RuntimeMobileState,
) -> bool {
if !mobile_cookie_request_is_same_origin(req, state) {
return false;
}
let cookie_header = req
.headers()
.get(header::COOKIE)
.and_then(|value| value.to_str().ok());
if is_mobile_stream_request(req) {
return mobile_state.consume_stream_ticket(cookie_header, mobile_stream_ticket(req));
}
mobile_state.matches_request(
cookie_header,
req.headers()
.get(mobile::MOBILE_REQUEST_HEADER)
.and_then(|value| value.to_str().ok()),
)
}
fn mobile_cookie_request_is_same_origin(req: &Request, state: &RuntimeApiState) -> bool {
let fetch_metadata_is_same_origin = req
.headers()
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
.is_some_and(|site| site.eq_ignore_ascii_case("same-origin"));
if req
.headers()
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
.is_some_and(|site| !site.eq_ignore_ascii_case("same-origin"))
{
return false;
}
match req
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
Some(origin) => origin == runtime_http_origin(state),
None => fetch_metadata_is_same_origin,
}
}
fn runtime_http_origin(state: &RuntimeApiState) -> String {
runtime_http_origin_for_bind(&state.bind_host, state.bind_port)
}
fn runtime_http_origin_for_bind(bind_host: &str, bind_port: u16) -> String {
let host = match bind_host.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(address)) => address.to_string(),
Ok(std::net::IpAddr::V6(address)) => format!("[{address}]"),
Err(_) => bind_host.to_string(),
};
if bind_port == 80 {
format!("http://{host}")
} else {
format!("http://{host}:{bind_port}")
}
}
fn is_mobile_stream_request(req: &Request) -> bool {
req.method() == Method::GET
&& req.uri().path().starts_with("/v1/threads/")
&& req.uri().path().ends_with("/events")
}
fn mobile_stream_ticket(req: &Request) -> Option<&str> {
let mut tickets = req
.uri()
.query()?
.split('&')
.filter_map(|pair| pair.split_once('='))
.filter_map(|(key, value)| (key == mobile::MOBILE_STREAM_TICKET_QUERY).then_some(value));
let ticket = tickets.next()?;
tickets.next().is_none().then_some(ticket)
}
pub(super) fn runtime_token_required_response() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({
@@ -170,41 +244,16 @@ fn runtime_token_required_response() -> Response {
.into_response()
}
pub(super) fn token_from_cookie_header(cookie: Option<&str>) -> Option<String> {
cookie.and_then(|cookie| {
cookie.split(';').find_map(|pair| {
let pair = pair.trim();
let (key, value) = pair.split_once('=')?;
(key == RUNTIME_TOKEN_COOKIE)
.then(|| percent_decode_query_component(value.trim()))
.flatten()
})
})
}
#[cfg(test)]
mod tests {
use super::runtime_http_origin_for_bind;
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;
}
}
#[test]
fn expected_origin_canonicalizes_ipv6_loopback_literals() {
assert_eq!(
runtime_http_origin_for_bind("0:0:0:0:0:0:0:1", 7878),
"http://[::1]:7878"
);
assert_eq!(runtime_http_origin_for_bind("::1", 80), "http://[::1]");
}
String::from_utf8(decoded).ok()
}
+364
View File
@@ -0,0 +1,364 @@
//! Origin-bound browser session support for the loopback mobile control page.
//!
//! The Runtime bearer is deliberately never represented by these values. A
//! one-time terminal bootstrap (or an explicit bearer header at the session
//! endpoint) creates an opaque, process-local HttpOnly cookie plus browser
//! proofs held in origin-scoped session storage. The cookie is host scoped by
//! HTTP semantics and therefore can reach sibling ports; the proofs cannot.
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use uuid::Uuid;
pub(super) const MOBILE_SESSION_COOKIE_NAME: &str = "codewhale_mobile_session";
pub(super) const MOBILE_REQUEST_HEADER: &str = "x-codewhale-mobile-request";
pub(super) const MOBILE_STREAM_TICKET_QUERY: &str = "mobile_stream_ticket";
pub(super) const BOOTSTRAP_TTL: Duration = Duration::from_secs(10 * 60);
pub(super) const SESSION_TTL: Duration = Duration::from_secs(30 * 60);
pub(super) const STREAM_TICKET_TTL: Duration = Duration::from_secs(5 * 60);
const BOOTSTRAP_PREFIX: &str = "cwmb_";
const SESSION_PREFIX: &str = "cwms_";
const REQUEST_PREFIX: &str = "cwmr_";
const STREAM_PREFIX: &str = "cwmt_";
#[derive(Clone)]
pub(super) struct RuntimeMobileState {
bootstrap: Arc<Mutex<Option<BootstrapCapability>>>,
sessions: Arc<Mutex<HashMap<String, MobileSession>>>,
session_ttl: Duration,
stream_ticket_ttl: Duration,
}
struct BootstrapCapability {
nonce: String,
expires_at: Instant,
}
struct MobileSession {
request_proof: String,
stream_ticket: Option<String>,
expires_at: Instant,
stream_ticket_expires_at: Instant,
}
#[derive(Debug, Clone)]
pub(super) struct MobileSessionBootstrap {
pub(super) session_cookie: String,
pub(super) request_proof: String,
pub(super) stream_ticket: String,
pub(super) session_ttl_seconds: u64,
pub(super) stream_ticket_ttl_seconds: u64,
}
#[derive(Debug, Clone)]
pub(super) struct MobileStreamTicket {
pub(super) ticket: String,
pub(super) expires_in_seconds: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum BootstrapError {
Invalid,
Expired,
NonLoopback,
}
impl RuntimeMobileState {
pub(super) fn new() -> (Self, String) {
Self::new_with_ttls(BOOTSTRAP_TTL, SESSION_TTL, STREAM_TICKET_TTL)
}
fn new_with_ttls(
bootstrap_ttl: Duration,
session_ttl: Duration,
stream_ticket_ttl: Duration,
) -> (Self, String) {
let nonce = random_capability(BOOTSTRAP_PREFIX);
let state = Self {
bootstrap: Arc::new(Mutex::new(Some(BootstrapCapability {
nonce: nonce.clone(),
expires_at: Instant::now() + bootstrap_ttl,
}))),
sessions: Arc::new(Mutex::new(HashMap::new())),
session_ttl,
stream_ticket_ttl,
};
(state, nonce)
}
/// Consume the terminal bootstrap once, only from a loopback peer.
pub(super) fn consume_bootstrap(
&self,
nonce: &str,
peer_ip: IpAddr,
) -> Result<MobileSessionBootstrap, BootstrapError> {
if !peer_ip.is_loopback() {
return Err(BootstrapError::NonLoopback);
}
if !valid_bootstrap_nonce(nonce) {
return Err(BootstrapError::Invalid);
}
let mut slot = self
.bootstrap
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let Some(capability) = slot.as_ref() else {
return Err(BootstrapError::Invalid);
};
if Instant::now() >= capability.expires_at {
*slot = None;
return Err(BootstrapError::Expired);
}
if !constant_time_eq(nonce.as_bytes(), capability.nonce.as_bytes()) {
return Err(BootstrapError::Invalid);
}
let _capability = slot.take().expect("bootstrap capability checked above");
drop(slot);
Ok(self.issue_session())
}
/// Create an opaque mobile browser session after explicit bearer proof.
pub(super) fn issue_session(&self) -> MobileSessionBootstrap {
let session_cookie = random_capability(SESSION_PREFIX);
let request_proof = random_capability(REQUEST_PREFIX);
let stream_ticket = random_capability(STREAM_PREFIX);
let now = Instant::now();
let mut sessions = self
.sessions
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
sessions.retain(|_, session| session.expires_at > now);
sessions.insert(
session_cookie.clone(),
MobileSession {
request_proof: request_proof.clone(),
stream_ticket: Some(stream_ticket.clone()),
expires_at: now + self.session_ttl,
stream_ticket_expires_at: now + self.stream_ticket_ttl,
},
);
MobileSessionBootstrap {
session_cookie,
request_proof,
stream_ticket,
session_ttl_seconds: self.session_ttl.as_secs(),
stream_ticket_ttl_seconds: self.stream_ticket_ttl.as_secs(),
}
}
/// Validate a cookie plus the origin-scoped proof used by fetch requests.
pub(super) fn matches_request(
&self,
cookie_header: Option<&str>,
request_proof: Option<&str>,
) -> bool {
let Some(session_cookie) = cookie_value(cookie_header, MOBILE_SESSION_COOKIE_NAME) else {
return false;
};
let Some(request_proof) = request_proof else {
return false;
};
let now = Instant::now();
let mut sessions = self
.sessions
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
sessions.retain(|_, session| session.expires_at > now);
let Some(session) = sessions.get(session_cookie) else {
return false;
};
constant_time_eq(request_proof.as_bytes(), session.request_proof.as_bytes())
}
/// Consume a short-lived stream ticket. A reconnect must mint a fresh one
/// through the cookie-plus-request-proof endpoint.
pub(super) fn consume_stream_ticket(
&self,
cookie_header: Option<&str>,
stream_ticket: Option<&str>,
) -> bool {
let Some(session_cookie) = cookie_value(cookie_header, MOBILE_SESSION_COOKIE_NAME) else {
return false;
};
let Some(stream_ticket) = stream_ticket else {
return false;
};
let now = Instant::now();
let mut sessions = self
.sessions
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
sessions.retain(|_, session| session.expires_at > now);
let Some(session) = sessions.get_mut(session_cookie) else {
return false;
};
if now >= session.stream_ticket_expires_at {
session.stream_ticket = None;
return false;
}
let matches = session
.stream_ticket
.as_ref()
.is_some_and(|issued| constant_time_eq(stream_ticket.as_bytes(), issued.as_bytes()));
if matches {
session.stream_ticket = None;
}
matches
}
/// Mint a new single-use stream ticket after a normal origin-bound request.
pub(super) fn refresh_stream_ticket(
&self,
cookie_header: Option<&str>,
request_proof: Option<&str>,
) -> Option<MobileStreamTicket> {
let session_cookie = cookie_value(cookie_header, MOBILE_SESSION_COOKIE_NAME)?;
let request_proof = request_proof?;
let now = Instant::now();
let mut sessions = self
.sessions
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
sessions.retain(|_, session| session.expires_at > now);
let session = sessions.get_mut(session_cookie)?;
if !constant_time_eq(request_proof.as_bytes(), session.request_proof.as_bytes()) {
return None;
}
let ticket = random_capability(STREAM_PREFIX);
session.stream_ticket = Some(ticket.clone());
session.stream_ticket_expires_at = now + self.stream_ticket_ttl;
Some(MobileStreamTicket {
ticket,
expires_in_seconds: self.stream_ticket_ttl.as_secs(),
})
}
}
pub(super) fn bootstrap_url(addr: SocketAddr, nonce: &str) -> String {
format!("http://{addr}/__codewhale/mobile/bootstrap/{nonce}")
}
pub(super) fn mobile_session_cookie(session_cookie: &str) -> String {
format!(
"{MOBILE_SESSION_COOKIE_NAME}={session_cookie}; Max-Age={}; HttpOnly; SameSite=Strict; Path=/",
SESSION_TTL.as_secs()
)
}
fn random_capability(prefix: &str) -> String {
format!(
"{prefix}{}{}",
Uuid::new_v4().simple(),
Uuid::new_v4().simple()
)
}
fn cookie_value<'a>(cookie_header: Option<&'a str>, name: &str) -> Option<&'a str> {
cookie_header.and_then(|cookie| {
cookie.split(';').find_map(|pair| {
let (key, value) = pair.trim().split_once('=')?;
(key == name).then_some(value.trim())
})
})
}
fn valid_bootstrap_nonce(value: &str) -> bool {
value.strip_prefix(BOOTSTRAP_PREFIX).is_some_and(|random| {
random.len() == 64 && random.bytes().all(|byte| byte.is_ascii_hexdigit())
})
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0_u8, |difference, (left, right)| {
difference | (left ^ right)
})
== 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bootstrap_is_loopback_only_one_time_and_yields_no_runtime_bearer() {
let (state, nonce) = RuntimeMobileState::new_with_ttls(
Duration::from_secs(60),
Duration::from_secs(60),
Duration::from_secs(60),
);
assert!(matches!(
state.consume_bootstrap(&nonce, "192.0.2.4".parse().unwrap()),
Err(BootstrapError::NonLoopback)
));
let session = state
.consume_bootstrap(&nonce, "127.0.0.1".parse().unwrap())
.expect("valid loopback bootstrap");
assert!(session.session_cookie.starts_with(SESSION_PREFIX));
assert!(session.request_proof.starts_with(REQUEST_PREFIX));
assert!(session.stream_ticket.starts_with(STREAM_PREFIX));
assert_ne!(session.session_cookie, session.request_proof);
assert!(matches!(
state.consume_bootstrap(&nonce, "127.0.0.1".parse().unwrap()),
Err(BootstrapError::Invalid)
));
}
#[test]
fn session_requires_origin_scoped_proof_and_stream_tickets_are_single_use() {
let (state, _nonce) = RuntimeMobileState::new_with_ttls(
Duration::from_secs(60),
Duration::from_secs(60),
Duration::from_secs(60),
);
let session = state.issue_session();
let cookie = format!("{MOBILE_SESSION_COOKIE_NAME}={}", session.session_cookie);
assert!(!state.matches_request(Some(&cookie), None));
assert!(!state.matches_request(Some(&cookie), Some("wrong-proof")));
assert!(state.matches_request(Some(&cookie), Some(&session.request_proof)));
assert!(state.consume_stream_ticket(Some(&cookie), Some(&session.stream_ticket)));
assert!(
!state.consume_stream_ticket(Some(&cookie), Some(&session.stream_ticket)),
"a captured EventSource URL cannot be replayed"
);
let replacement = state
.refresh_stream_ticket(Some(&cookie), Some(&session.request_proof))
.expect("valid browser request can mint one replacement");
assert!(state.consume_stream_ticket(Some(&cookie), Some(&replacement.ticket)));
}
#[test]
fn cookie_has_exact_security_attributes_and_no_domain() {
let session_cookie = format!("{SESSION_PREFIX}{}", "01".repeat(32));
let cookie = mobile_session_cookie(&session_cookie);
assert_eq!(
cookie,
format!(
"{MOBILE_SESSION_COOKIE_NAME}={session_cookie}; Max-Age=1800; HttpOnly; SameSite=Strict; Path=/"
)
);
assert!(!cookie.contains("Domain="));
assert!(!cookie.contains("cwrt_"));
}
#[test]
fn launcher_url_contains_only_the_one_time_capability() {
let nonce = format!("{BOOTSTRAP_PREFIX}{}", "01".repeat(32));
let url = bootstrap_url("127.0.0.1:7878".parse().unwrap(), &nonce);
assert!(url.ends_with(&nonce));
assert!(!url.contains('?'));
assert!(!url.contains('#'));
assert!(!url.contains("cwrt_"));
}
}
+284 -31
View File
@@ -749,6 +749,43 @@ fn runtime_token_scenario() {
}
}
#[test]
fn mobile_listener_fails_closed_outside_loopback_without_verified_transport() {
let non_loopback = RuntimeApiOptions {
host: "0.0.0.0".to_string(),
mobile: true,
insecure_no_auth: true,
..RuntimeApiOptions::default()
};
let err = validate_runtime_listener_security(&non_loopback).unwrap_err();
assert!(err.to_string().contains("mobile is loopback-only"));
for host in ["127.0.0.1", "::1"] {
let loopback = RuntimeApiOptions {
host: host.to_string(),
mobile: true,
..RuntimeApiOptions::default()
};
assert!(
validate_runtime_listener_security(&loopback).is_ok(),
"host={host}"
);
}
assert_eq!(
runtime_bind_address("::1", 7878)
.expect("IPv6 loopback bind address")
.to_string(),
"[::1]:7878"
);
let ordinary_http = RuntimeApiOptions {
host: "0.0.0.0".to_string(),
mobile: false,
..RuntimeApiOptions::default()
};
assert!(validate_runtime_listener_security(&ordinary_http).is_ok());
}
#[test]
fn consumed_legacy_runtime_token_reports_one_value_free_deprecation_line() {
let secret = "legacy-super-secret-token";
@@ -774,28 +811,6 @@ fn explicit_cli_runtime_token_does_not_warn_about_an_unused_legacy_alias() {
assert!(runtime_token_alias_warning(Some(" \t"), &environment).is_some());
}
#[test]
fn url_query_component_percent_encodes_token() {
assert_eq!(
url_query_component("abc ABC+/?:=&%"),
"abc%20ABC%2B%2F%3F%3A%3D%26%25"
);
}
#[test]
fn token_from_cookie_header_decodes_percent_encoded_token() {
assert_eq!(
token_from_cookie_header(Some(
"theme=dark; codewhale_runtime_token=abc%20ABC%2B%2F%3F%3A%3D%26%25"
)),
Some("abc ABC+/?:=&%".to_string())
);
assert_eq!(
token_from_cookie_header(Some("codewhale_runtime_token=bad%ZZ")),
None
);
}
async fn spawn_test_server_with_root(
root: PathBuf,
sessions_dir: PathBuf,
@@ -877,6 +892,7 @@ struct TestServerOverrides {
config: Option<Config>,
config_path: Option<PathBuf>,
config_profile: Option<String>,
mobile: Option<mobile::RuntimeMobileState>,
web: Option<web::RuntimeWebState>,
compat_stream_test_hook: Option<mpsc::UnboundedSender<CompatStreamTestPoint>>,
plugin_discovery: Option<Arc<crate::plugins::PluginDiscoveryContext>>,
@@ -978,6 +994,15 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_overrides(
Err(err) => return Err(err.into()),
};
let addr = listener.local_addr()?;
let mobile = if mobile_enabled && runtime_token.is_some() {
Some(
overrides
.mobile
.unwrap_or_else(|| mobile::RuntimeMobileState::new().0),
)
} else {
None
};
let state = RuntimeApiState {
config: Arc::new(parking_lot::RwLock::new(config)),
workspace,
@@ -1001,6 +1026,7 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_overrides(
bind_host: "127.0.0.1".to_string(),
bind_port: addr.port(),
mobile_enabled,
mobile,
web: overrides.web,
fleet_codewhale_binary: overrides
.fleet_codewhale_binary
@@ -1521,14 +1547,10 @@ async fn runtime_token_guard_protects_v1_routes() -> Result<()> {
let cookie_token = client
.get(format!("http://{addr}/v1/threads/summary"))
.header(
header::COOKIE,
format!("codewhale_runtime_token={}", url_query_component(&token)),
)
.header(header::COOKIE, format!("codewhale_runtime_token={token}"))
.send()
.await?
.error_for_status()?;
assert_eq!(cookie_token.status(), StatusCode::OK);
.await?;
assert_eq!(cookie_token.status(), StatusCode::UNAUTHORIZED);
let codewhale_header = client
.get(format!("http://{addr}/v1/threads/summary"))
@@ -6044,7 +6066,11 @@ async fn mobile_page_is_available_only_when_enabled() -> Result<()> {
assert!(html.contains("Codewhale Mobile"));
assert!(html.contains("/v1/approvals/"));
assert!(html.contains("MAX_VISIBLE_EVENTS = 100"));
assert!(html.contains("replay_limit="));
assert!(html.contains("replay_limit:"));
assert!(html.contains("X-Codewhale-Mobile-Request"));
assert!(!html.contains("localStorage.getItem"));
assert!(!html.contains("localStorage.setItem"));
assert!(!html.contains("setRuntimeTokenCookie"));
handle.abort();
Ok(())
@@ -6071,7 +6097,10 @@ async fn mobile_page_serves_shell_when_auth_enabled() -> Result<()> {
.error_for_status()?;
let html = shell.text().await?;
assert!(html.contains("Codewhale Mobile"));
assert!(html.contains("TOKEN_COOKIE"));
assert!(html.contains("codewhale_mobile_session_proof"));
assert!(html.contains("/__codewhale/mobile/session"));
assert!(!html.contains("codewhale_runtime_token="));
assert!(!html.contains("localStorage.setItem"));
let bearer = client
.get(format!("http://{addr}/mobile"))
@@ -6085,6 +6114,230 @@ async fn mobile_page_serves_shell_when_auth_enabled() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn mobile_bootstrap_uses_opaque_origin_bound_session_and_single_use_stream_ticket()
-> Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path().to_path_buf();
let sessions_dir = root.join("sessions");
let workspace = root.join("workspace");
let token = "mobile-runtime-test-token".to_string();
let (mobile_state, nonce) = mobile::RuntimeMobileState::new();
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_root_token_mobile_workspace_and_overrides(
root,
sessions_dir,
Some(token.clone()),
true,
workspace,
TestServerOverrides {
mobile: Some(mobile_state),
..TestServerOverrides::default()
},
)
.await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client_builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let origin = format!("http://{addr}");
let shell = client.get(format!("{origin}/mobile")).send().await?;
assert_eq!(shell.status(), StatusCode::OK);
assert_eq!(
shell
.headers()
.get(header::REFERRER_POLICY)
.and_then(|value| value.to_str().ok()),
Some("no-referrer")
);
let shell = shell.text().await?;
assert!(!shell.contains(&token));
assert!(!shell.contains("localStorage.setItem"));
assert!(!shell.contains("localStorage.getItem"));
assert!(!shell.contains("codewhale_runtime_token="));
let unauthenticated_session = client
.post(format!("{origin}/__codewhale/mobile/session"))
.send()
.await?;
assert_eq!(unauthenticated_session.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
unauthenticated_session
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let manual_session = client
.post(format!("{origin}/__codewhale/mobile/session"))
.bearer_auth(&token)
.send()
.await?;
assert_eq!(manual_session.status(), StatusCode::OK);
let manual_cookie = manual_session
.headers()
.get(header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.context("manual mobile session Set-Cookie")?
.to_string();
assert!(manual_cookie.starts_with("codewhale_mobile_session=cwms_"));
assert!(manual_cookie.ends_with("; Max-Age=1800; HttpOnly; SameSite=Strict; Path=/"));
assert!(!manual_cookie.contains(&token));
let manual_body = manual_session.text().await?;
assert!(!manual_body.contains(&token));
let bootstrap = client
.get(format!("{origin}/__codewhale/mobile/bootstrap/{nonce}"))
.send()
.await?;
assert_eq!(bootstrap.status(), StatusCode::SEE_OTHER);
let bootstrap_cookie = bootstrap
.headers()
.get(header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.context("bootstrap mobile session Set-Cookie")?
.to_string();
assert!(bootstrap_cookie.starts_with("codewhale_mobile_session=cwms_"));
assert!(!bootstrap_cookie.contains(&token));
let location = bootstrap
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok())
.context("bootstrap mobile redirect")?;
assert!(location.starts_with("/mobile#request_proof=cwmr_"));
assert!(location.contains("&stream_ticket=cwmt_"));
assert!(!location.contains(&token));
let bootstrap_cookie_pair = bootstrap_cookie
.split(';')
.next()
.context("bootstrap mobile cookie pair")?
.to_string();
let fragment = location
.strip_prefix("/mobile#")
.context("bootstrap redirect must use a fragment")?;
let request_proof = fragment
.split('&')
.find_map(|part| part.strip_prefix("request_proof="))
.context("bootstrap request proof")?
.to_string();
let stream_ticket = fragment
.split('&')
.find_map(|part| part.strip_prefix("stream_ticket="))
.context("bootstrap stream ticket")?
.to_string();
let cookie_only = client
.get(format!("{origin}/v1/threads/summary"))
.header(header::COOKIE, &bootstrap_cookie_pair)
.send()
.await?;
assert_eq!(cookie_only.status(), StatusCode::UNAUTHORIZED);
let sibling_port_replay = client
.get(format!("{origin}/v1/threads/summary"))
.header(header::COOKIE, &bootstrap_cookie_pair)
.header(mobile::MOBILE_REQUEST_HEADER, &request_proof)
.header(header::ORIGIN, "http://127.0.0.1:3000")
.header("sec-fetch-site", "same-site")
.send()
.await?;
assert_eq!(sibling_port_replay.status(), StatusCode::UNAUTHORIZED);
let same_origin = client
.get(format!("{origin}/v1/threads/summary"))
.header(header::COOKIE, &bootstrap_cookie_pair)
.header(mobile::MOBILE_REQUEST_HEADER, &request_proof)
.header(header::ORIGIN, &origin)
.header("sec-fetch-site", "same-origin")
.send()
.await?;
assert_eq!(same_origin.status(), StatusCode::OK);
let created: serde_json::Value = client
.post(format!("{origin}/v1/threads"))
.header(header::COOKIE, &bootstrap_cookie_pair)
.header(mobile::MOBILE_REQUEST_HEADER, &request_proof)
.header(header::ORIGIN, &origin)
.header("sec-fetch-site", "same-origin")
.json(&json!({}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let thread_id = created["id"].as_str().context("mobile-created thread id")?;
let stream_url =
format!("{origin}/v1/threads/{thread_id}/events?mobile_stream_ticket={stream_ticket}");
let stream = client
.get(&stream_url)
.header(header::COOKIE, &bootstrap_cookie_pair)
.header(header::ORIGIN, &origin)
.header("sec-fetch-site", "same-origin")
.send()
.await?;
assert_eq!(stream.status(), StatusCode::OK);
drop(stream);
let replayed_stream = client
.get(&stream_url)
.header(header::COOKIE, &bootstrap_cookie_pair)
.header(header::ORIGIN, &origin)
.header("sec-fetch-site", "same-origin")
.send()
.await?;
assert_eq!(replayed_stream.status(), StatusCode::UNAUTHORIZED);
let refreshed: serde_json::Value = client
.post(format!("{origin}/__codewhale/mobile/stream-ticket"))
.header(header::COOKIE, &bootstrap_cookie_pair)
.header(mobile::MOBILE_REQUEST_HEADER, &request_proof)
.header(header::ORIGIN, &origin)
.header("sec-fetch-site", "same-origin")
.send()
.await?
.error_for_status()?
.json()
.await?;
let refreshed_ticket = refreshed["stream_ticket"]
.as_str()
.context("refreshed stream ticket")?;
let refreshed_stream_url =
format!("{origin}/v1/threads/{thread_id}/events?mobile_stream_ticket={refreshed_ticket}");
let sibling_stream_replay = client
.get(&refreshed_stream_url)
.header(header::COOKIE, &bootstrap_cookie_pair)
.header(header::ORIGIN, "http://127.0.0.1:3000")
.header("sec-fetch-site", "same-site")
.send()
.await?;
assert_eq!(sibling_stream_replay.status(), StatusCode::UNAUTHORIZED);
let same_origin_stream = client
.get(&refreshed_stream_url)
.header(header::COOKIE, &bootstrap_cookie_pair)
.header(header::ORIGIN, &origin)
.header("sec-fetch-site", "same-origin")
.send()
.await?;
assert_eq!(same_origin_stream.status(), StatusCode::OK);
drop(same_origin_stream);
let reused_bootstrap = client
.get(format!("{origin}/__codewhale/mobile/bootstrap/{nonce}"))
.send()
.await?;
assert_eq!(reused_bootstrap.status(), StatusCode::UNAUTHORIZED);
handle.abort();
Ok(())
}
#[tokio::test]
async fn mobile_insecure_mode_allows_page_and_v1_routes_without_token() -> Result<()> {
let tmp = tempfile::tempdir()?;
+134 -29
View File
@@ -215,10 +215,10 @@
<section>
<div class="head">
<strong>Connection</strong>
<button id="save-token">Save</button>
<button id="save-token">Connect</button>
</div>
<div class="body grid">
<input id="token" autocomplete="off" spellcheck="false" placeholder="Runtime token">
<input id="token" autocomplete="off" spellcheck="false" placeholder="Runtime token (used once)">
<div id="conn" class="status">Not connected</div>
</div>
</section>
@@ -262,13 +262,18 @@
<script>
const $ = (id) => document.getElementById(id);
const MAX_VISIBLE_EVENTS = 100;
const TOKEN_STORAGE_KEY = "codewhale_runtime_token";
const TOKEN_COOKIE = "codewhale_runtime_token";
const LEGACY_RUNTIME_TOKEN_STORAGE_KEY = "codewhale_runtime_token";
const LEGACY_RUNTIME_TOKEN_COOKIE = "codewhale_runtime_token";
const SESSION_STORAGE_KEY = "codewhale_mobile_session_proof";
const MOBILE_REQUEST_HEADER = "X-Codewhale-Mobile-Request";
const MOBILE_STREAM_TICKET_QUERY = "mobile_stream_ticket";
const state = {
threadId: "",
activeTurnId: "",
source: null,
eventCount: 0
eventCount: 0,
requestProof: "",
streamTicket: ""
};
function setStatus(message, tone = "") {
@@ -281,23 +286,58 @@
return $("token").value.trim();
}
function setRuntimeTokenCookie(value) {
const attrs = "; Path=/; SameSite=Strict";
if (!value) {
document.cookie = TOKEN_COOKIE + "=; Max-Age=0" + attrs;
return;
}
document.cookie = TOKEN_COOKIE + "=" + encodeURIComponent(value) + attrs;
function clearLegacyRuntimeToken() {
localStorage.removeItem(LEGACY_RUNTIME_TOKEN_STORAGE_KEY);
document.cookie = LEGACY_RUNTIME_TOKEN_COOKIE + "=; Max-Age=0; Path=/; SameSite=Strict";
}
function takeTokenFromUrl() {
function saveMobileSession() {
if (!state.requestProof) {
sessionStorage.removeItem(SESSION_STORAGE_KEY);
return;
}
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({
requestProof: state.requestProof,
streamTicket: state.streamTicket
}));
}
function clearMobileSession() {
state.requestProof = "";
state.streamTicket = "";
sessionStorage.removeItem(SESSION_STORAGE_KEY);
}
function loadMobileSession() {
try {
const stored = JSON.parse(sessionStorage.getItem(SESSION_STORAGE_KEY) || "null");
if (stored && typeof stored.requestProof === "string") {
state.requestProof = stored.requestProof;
state.streamTicket = typeof stored.streamTicket === "string" ? stored.streamTicket : "";
}
} catch (_) {
clearMobileSession();
}
}
function useMobileSession(session) {
if (!session || typeof session.request_proof !== "string") {
throw new Error("Mobile session bootstrap was incomplete");
}
state.requestProof = session.request_proof;
state.streamTicket = typeof session.stream_ticket === "string" ? session.stream_ticket : "";
saveMobileSession();
}
function takeMobileSessionFromUrl() {
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : "";
const params = new URLSearchParams(hash);
const urlToken = params.get("token");
if (!urlToken) return;
localStorage.setItem(TOKEN_STORAGE_KEY, urlToken);
setRuntimeTokenCookie(urlToken);
params.delete("token");
const requestProof = params.get("request_proof");
const streamTicket = params.get("stream_ticket");
if (!requestProof || !streamTicket) return;
useMobileSession({ request_proof: requestProof, stream_ticket: streamTicket });
params.delete("request_proof");
params.delete("stream_ticket");
const nextHash = params.toString();
history.replaceState(
null,
@@ -308,15 +348,17 @@
function headers(extra = {}) {
const out = Object.assign({ "Content-Type": "application/json" }, extra);
if (token()) out.Authorization = "Bearer " + token();
if (state.requestProof) out[MOBILE_REQUEST_HEADER] = state.requestProof;
return out;
}
async function api(path, options = {}) {
const res = await fetch(path, Object.assign({}, options, {
credentials: "same-origin",
headers: headers(options.headers || {})
}));
if (!res.ok) {
if (res.status === 401) clearMobileSession();
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
@@ -328,6 +370,51 @@
return res.json();
}
async function connectMobileSession() {
const runtimeToken = token();
if (!runtimeToken) throw new Error("Enter a Runtime token to connect once");
let res;
try {
res = await fetch("/__codewhale/mobile/session", {
method: "POST",
credentials: "same-origin",
headers: { "Authorization": "Bearer " + runtimeToken }
});
} finally {
$("token").value = "";
}
if (!res.ok) {
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
detail = parsed.error?.message || detail;
} catch (_) {}
throw new Error(detail || ("HTTP " + res.status));
}
useMobileSession(await res.json());
}
async function refreshStreamTicket() {
const result = await api("/__codewhale/mobile/stream-ticket", {
method: "POST",
body: "{}"
});
if (!result || typeof result.stream_ticket !== "string") {
throw new Error("Mobile stream ticket was unavailable");
}
state.streamTicket = result.stream_ticket;
saveMobileSession();
return state.streamTicket;
}
async function takeStreamTicket() {
if (!state.requestProof) return "";
const ticket = state.streamTicket || await refreshStreamTicket();
state.streamTicket = "";
saveMobileSession();
return ticket;
}
function escapeHtml(raw) {
return String(raw).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
@@ -481,9 +568,20 @@
$("active-title").textContent = title || id;
$("events").innerHTML = "";
if (state.source) state.source.close();
if (token()) setRuntimeTokenCookie(token());
const qs = "?since_seq=0&replay_limit=" + MAX_VISIBLE_EVENTS;
const source = new EventSource("/v1/threads/" + encodeURIComponent(id) + "/events" + qs);
await openThreadEvents(id);
await loadThreads();
}
async function openThreadEvents(id) {
const streamTicket = await takeStreamTicket();
const query = new URLSearchParams({
since_seq: "0",
replay_limit: String(MAX_VISIBLE_EVENTS)
});
if (streamTicket) query.set(MOBILE_STREAM_TICKET_QUERY, streamTicket);
const source = new EventSource(
"/v1/threads/" + encodeURIComponent(id) + "/events?" + query.toString()
);
state.source = source;
const names = [
"thread.started",
@@ -510,8 +608,16 @@
});
}
source.onopen = () => setStatus("Connected", "ok");
source.onerror = () => setStatus("Event stream disconnected", "warn");
await loadThreads();
source.onerror = () => {
if (state.source !== source || state.threadId !== id) return;
source.close();
setStatus("Event stream disconnected; reconnecting", "warn");
window.setTimeout(() => {
if (state.threadId === id) {
openThreadEvents(id).catch((err) => setStatus(err.message, "bad"));
}
}, 750);
};
}
async function sendPrompt() {
@@ -554,12 +660,11 @@
}
async function boot() {
takeTokenFromUrl();
$("token").value = localStorage.getItem(TOKEN_STORAGE_KEY) || "";
if (token()) setRuntimeTokenCookie(token());
clearLegacyRuntimeToken();
loadMobileSession();
takeMobileSessionFromUrl();
$("save-token").onclick = async () => {
localStorage.setItem(TOKEN_STORAGE_KEY, token());
setRuntimeTokenCookie(token());
await connectMobileSession();
await loadThreads().catch((err) => setStatus(err.message, "bad"));
};
$("refresh").onclick = () => loadThreads().catch((err) => setStatus(err.message, "bad"));
+26 -25
View File
@@ -49,7 +49,7 @@ CLI/API surfaces are not implemented yet.
|---|---|---|
| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |
| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |
| `codewhale app-server --mobile` | HTTP/SSE on `0.0.0.0:7878` + `/mobile` | Runtime API + phone control page |
| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |
| `codewhale app-server --stdio` | JSON-RPC 2.0 over stdio | Local SDK / control probe (no listener) |
| `codewhale app-server --socket [--socket-path P]` | JSON-RPC 2.0 over a `0600` unix domain socket | Desktop daemon: multi-client, peer-uid checked, `daemon/attach` claim handshake (macOS/Linux; Windows named pipe reserved, not implemented) |
| `codewhale app-server` | HTTP on `127.0.0.1:8787` | Legacy in-process app-server (`/healthz`, `/thread`, `/app`, `/prompt`, `/tool`, `/jobs`); `/prompt` and `/thread` messages execute real turns via the runtime bridge |
@@ -408,8 +408,8 @@ codewhale doctor --json
```bash
codewhale app-server --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] [--insecure-no-auth]
codewhale app-server --mobile [--host 0.0.0.0] [--port 7878] [--auth-token TOKEN]
codewhale app-server --mobile --host 127.0.0.1 [--port 7878] [--insecure-no-auth]
codewhale app-server --mobile [--host 127.0.0.1] [--port 7878] [--auth-token TOKEN]
codewhale app-server --mobile --host ::1 [--port 7878] [--insecure-no-auth]
codewhale web [--port 7878]
# Compatibility aliases — identical server, serve flag names:
@@ -423,11 +423,10 @@ The server binds to `localhost` by default. Configuration is via CLI flags —
there is no `[app_server]` config section.
`/v1/*` routes require a bearer token unless `codewhale app-server` is started
with `--insecure-no-auth` on a loopback bind such as `127.0.0.1`. Do not combine
no-auth mode with the `--mobile` default host `0.0.0.0`; use a token for LAN
mobile access, or add `--host 127.0.0.1` for local-only no-auth testing. The
`codewhale serve` compatibility aliases use `--insecure` for the same loopback
escape hatch.
with `--insecure-no-auth` on a loopback bind such as `127.0.0.1`. Mobile mode
is loopback-only: non-loopback hosts are rejected until Runtime has a TLS or
verified-overlay transport boundary. The `codewhale serve` compatibility aliases
use `--insecure` for the same loopback escape hatch.
Pass `--auth-token TOKEN` or set `CODEWHALE_RUNTIME_TOKEN=TOKEN` before starting
the server; `DEEPSEEK_RUNTIME_TOKEN` remains a compatibility alias. If neither
is set, the process generates a Runtime token for that process and does **not**
@@ -438,8 +437,8 @@ the unchanged static shell when it is enabled.
Authenticated clients can provide the token as `Authorization: Bearer TOKEN`,
`X-Codewhale-Runtime-Token: TOKEN`, the legacy
`X-DeepSeek-Runtime-Token: TOKEN`, or the `codewhale_runtime_token` cookie.
Query-string authentication is not supported.
`X-DeepSeek-Runtime-Token: TOKEN`. Query-string and raw Runtime-token cookie
authentication are not supported.
### Local browser client
@@ -494,19 +493,23 @@ explicit contracts for them.
### Mobile control page
`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`, prints a warning, and prints local/LAN
URLs. Pass `--host 127.0.0.1` to keep the mobile page loopback-only. The static
HTML page contains no secrets and is not itself token-gated. Its calls to
`/v1/*` are authenticated: for LAN use, start with an explicit Runtime token
and enter it in the page. Generated Runtime tokens are deliberately unprinted,
so they cannot be copied into another device.
phone-friendly control page at `/mobile`. It binds only to loopback
(`127.0.0.1` or `::1`); a non-loopback host is rejected because this Runtime
surface does not yet provide TLS or a verified overlay transport. The static
HTML page contains no Runtime bearer and is not itself token-gated. When
Runtime auth is enabled, the CLI prints a short-lived, single-use loopback
bootstrap URL. That capability creates a 30-minute, process-local
`Max-Age=1800; HttpOnly; SameSite=Strict` mobile session cookie plus origin-scoped browser
proofs. A sibling port that receives the host-scoped cookie cannot use it by
itself. The page can also exchange an explicitly entered bearer once, then
clears it rather than storing it in browser storage or a cookie. EventSource
connections use separate short-lived, single-use stream tickets.
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
`POST /v1/approvals/{approval_id}`. It is still a local/LAN convenience surface:
do not expose it directly to the public internet without TLS and a trusted
fronting layer.
`POST /v1/approvals/{approval_id}`. It is a local-only convenience surface;
do not expose it directly to another device or public network until Runtime has
a TLS or verified transport boundary.
### Endpoints
@@ -1127,11 +1130,9 @@ clients and does not grant or persist permissions.
## Security boundary
- **Localhost by default**. The server binds to `127.0.0.1` by default.
`--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.
`--mobile` is also loopback-only and rejects a non-loopback host until a TLS
or verified-overlay transport boundary exists. 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