mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
ad813cda63
commit
e80be8c33a
7 files changed
+1159
-244
No files matched your search
+6
-27
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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_"));
|
||||
}
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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) => ({
|
||||
"&": "&",
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user