| 1 | //! Embedded, loopback-only browser client for the Runtime API. |
| 2 | |
| 3 | use std::net::{IpAddr, SocketAddr}; |
| 4 | use std::sync::{Arc, Mutex}; |
| 5 | use std::time::{Duration, Instant}; |
| 6 | |
| 7 | use axum::extract::{ConnectInfo, Path, State}; |
| 8 | use axum::http::{HeaderValue, StatusCode, header}; |
| 9 | use axum::response::{IntoResponse, Response}; |
| 10 | use uuid::Uuid; |
| 11 | |
| 12 | use super::RuntimeApiState; |
| 13 | |
| 14 | const WEB_HTML: &str = include_str!("../runtime_web/index.html"); |
| 15 | const WEB_CSS: &str = include_str!("../runtime_web/styles.css"); |
| 16 | const WEB_JS: &str = include_str!("../runtime_web/app.mjs"); |
| 17 | const BOOTSTRAP_TTL: Duration = Duration::from_secs(120); |
| 18 | const WEB_SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60); |
| 19 | const BOOTSTRAP_PREFIX: &str = "cwwb_"; |
| 20 | const WEB_SESSION_PREFIX: &str = "cwws_"; |
| 21 | const WEB_SESSION_COOKIE_NAME: &str = "codewhale_web_session"; |
| 22 | const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"; |
| 23 | |
| 24 | #[derive(Clone)] |
| 25 | pub(super) struct RuntimeWebState { |
| 26 | bootstrap: Arc<Mutex<Option<BootstrapCapability>>>, |
| 27 | session_token: Arc<str>, |
| 28 | session_expires_at: Instant, |
| 29 | } |
| 30 | |
| 31 | struct BootstrapCapability { |
| 32 | nonce: String, |
| 33 | expires_at: Instant, |
| 34 | } |
| 35 | |
| 36 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 37 | enum BootstrapError { |
| 38 | Invalid, |
| 39 | Expired, |
| 40 | NonLoopback, |
| 41 | } |
| 42 | |
| 43 | impl RuntimeWebState { |
| 44 | pub(super) fn new() -> (Self, String) { |
| 45 | Self::new_with_ttls(BOOTSTRAP_TTL, WEB_SESSION_TTL) |
| 46 | } |
| 47 | |
| 48 | fn new_with_ttls(bootstrap_ttl: Duration, session_ttl: Duration) -> (Self, String) { |
| 49 | let nonce = format!("{BOOTSTRAP_PREFIX}{}", Uuid::new_v4().simple()); |
| 50 | let session_token = format!( |
| 51 | "{WEB_SESSION_PREFIX}{}{}", |
| 52 | Uuid::new_v4().simple(), |
| 53 | Uuid::new_v4().simple() |
| 54 | ); |
| 55 | let state = Self { |
| 56 | bootstrap: Arc::new(Mutex::new(Some(BootstrapCapability { |
| 57 | nonce: nonce.clone(), |
| 58 | expires_at: Instant::now() + bootstrap_ttl, |
| 59 | }))), |
| 60 | session_token: session_token.into(), |
| 61 | session_expires_at: Instant::now() + session_ttl, |
| 62 | }; |
| 63 | (state, nonce) |
| 64 | } |
| 65 | |
| 66 | fn consume(&self, nonce: &str, peer_ip: IpAddr) -> Result<String, BootstrapError> { |
| 67 | if !peer_ip.is_loopback() { |
| 68 | return Err(BootstrapError::NonLoopback); |
| 69 | } |
| 70 | if !valid_bootstrap_nonce(nonce) { |
| 71 | return Err(BootstrapError::Invalid); |
| 72 | } |
| 73 | |
| 74 | let mut slot = self |
| 75 | .bootstrap |
| 76 | .lock() |
| 77 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 78 | let Some(capability) = slot.as_ref() else { |
| 79 | return Err(BootstrapError::Invalid); |
| 80 | }; |
| 81 | if Instant::now() >= capability.expires_at { |
| 82 | *slot = None; |
| 83 | return Err(BootstrapError::Expired); |
| 84 | } |
| 85 | if !constant_time_eq(nonce.as_bytes(), capability.nonce.as_bytes()) { |
| 86 | return Err(BootstrapError::Invalid); |
| 87 | } |
| 88 | |
| 89 | let _capability = slot.take().expect("bootstrap capability checked above"); |
| 90 | Ok(self.session_token.to_string()) |
| 91 | } |
| 92 | |
| 93 | pub(super) fn matches_session_cookie(&self, cookie_header: Option<&str>) -> bool { |
| 94 | let presented = cookie_value(cookie_header, WEB_SESSION_COOKIE_NAME).unwrap_or_default(); |
| 95 | let token_matches = constant_time_eq(presented.as_bytes(), self.session_token.as_bytes()); |
| 96 | token_matches & (Instant::now() < self.session_expires_at) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | pub(super) fn bootstrap_url(addr: SocketAddr, nonce: &str) -> String { |
| 101 | format!("http://{addr}/__codewhale/bootstrap/{nonce}") |
| 102 | } |
| 103 | |
| 104 | pub(super) async fn exchange_bootstrap( |
| 105 | State(state): State<RuntimeApiState>, |
| 106 | ConnectInfo(peer): ConnectInfo<SocketAddr>, |
| 107 | Path(nonce): Path<String>, |
| 108 | ) -> Response { |
| 109 | let Some(web) = state.web.as_ref() else { |
| 110 | return not_found(); |
| 111 | }; |
| 112 | let session_token = match web.consume(&nonce, peer.ip()) { |
| 113 | Ok(token) => token, |
| 114 | Err(BootstrapError::NonLoopback) => { |
| 115 | return secured_text(StatusCode::FORBIDDEN, "bootstrap unavailable"); |
| 116 | } |
| 117 | Err(BootstrapError::Invalid | BootstrapError::Expired) => { |
| 118 | return secured_text(StatusCode::UNAUTHORIZED, "bootstrap unavailable"); |
| 119 | } |
| 120 | }; |
| 121 | |
| 122 | let cookie = web_session_cookie(&session_token); |
| 123 | let mut response = (StatusCode::SEE_OTHER, "").into_response(); |
| 124 | response |
| 125 | .headers_mut() |
| 126 | .insert(header::LOCATION, HeaderValue::from_static("/")); |
| 127 | response.headers_mut().insert( |
| 128 | header::SET_COOKIE, |
| 129 | HeaderValue::from_str(&cookie).expect("percent-encoded Runtime cookie is a valid header"), |
| 130 | ); |
| 131 | secure_headers(&mut response, "text/plain; charset=utf-8"); |
| 132 | response |
| 133 | } |
| 134 | |
| 135 | pub(super) async fn web_page(State(state): State<RuntimeApiState>) -> Response { |
| 136 | if state.web.is_none() { |
| 137 | return not_found(); |
| 138 | } |
| 139 | secured_asset("text/html; charset=utf-8", WEB_HTML) |
| 140 | } |
| 141 | |
| 142 | pub(super) async fn web_styles(State(state): State<RuntimeApiState>) -> Response { |
| 143 | if state.web.is_none() { |
| 144 | return not_found(); |
| 145 | } |
| 146 | secured_asset("text/css; charset=utf-8", WEB_CSS) |
| 147 | } |
| 148 | |
| 149 | pub(super) async fn web_script(State(state): State<RuntimeApiState>) -> Response { |
| 150 | if state.web.is_none() { |
| 151 | return not_found(); |
| 152 | } |
| 153 | secured_asset("text/javascript; charset=utf-8", WEB_JS) |
| 154 | } |
| 155 | |
| 156 | fn web_session_cookie(session_token: &str) -> String { |
| 157 | format!("{WEB_SESSION_COOKIE_NAME}={session_token}; HttpOnly; SameSite=Strict; Path=/") |
| 158 | } |
| 159 | |
| 160 | fn cookie_value<'a>(cookie_header: Option<&'a str>, name: &str) -> Option<&'a str> { |
| 161 | cookie_header.and_then(|cookie| { |
| 162 | cookie.split(';').find_map(|pair| { |
| 163 | let (key, value) = pair.trim().split_once('=')?; |
| 164 | (key == name).then_some(value.trim()) |
| 165 | }) |
| 166 | }) |
| 167 | } |
| 168 | |
| 169 | fn valid_bootstrap_nonce(value: &str) -> bool { |
| 170 | value.strip_prefix(BOOTSTRAP_PREFIX).is_some_and(|random| { |
| 171 | random.len() == 32 && random.bytes().all(|byte| byte.is_ascii_hexdigit()) |
| 172 | }) |
| 173 | } |
| 174 | |
| 175 | fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { |
| 176 | if left.len() != right.len() { |
| 177 | return false; |
| 178 | } |
| 179 | left.iter() |
| 180 | .zip(right) |
| 181 | .fold(0_u8, |difference, (left, right)| { |
| 182 | difference | (left ^ right) |
| 183 | }) |
| 184 | == 0 |
| 185 | } |
| 186 | |
| 187 | fn secured_asset(content_type: &'static str, body: &'static str) -> Response { |
| 188 | let mut response = body.into_response(); |
| 189 | secure_headers(&mut response, content_type); |
| 190 | response |
| 191 | } |
| 192 | |
| 193 | fn secured_text(status: StatusCode, body: &'static str) -> Response { |
| 194 | let mut response = (status, body).into_response(); |
| 195 | secure_headers(&mut response, "text/plain; charset=utf-8"); |
| 196 | response |
| 197 | } |
| 198 | |
| 199 | fn not_found() -> Response { |
| 200 | secured_text(StatusCode::NOT_FOUND, "not found") |
| 201 | } |
| 202 | |
| 203 | fn secure_headers(response: &mut Response, content_type: &'static str) { |
| 204 | let headers = response.headers_mut(); |
| 205 | headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); |
| 206 | headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); |
| 207 | headers.insert( |
| 208 | header::CONTENT_SECURITY_POLICY, |
| 209 | HeaderValue::from_static(CONTENT_SECURITY_POLICY), |
| 210 | ); |
| 211 | headers.insert( |
| 212 | header::X_CONTENT_TYPE_OPTIONS, |
| 213 | HeaderValue::from_static("nosniff"), |
| 214 | ); |
| 215 | headers.insert( |
| 216 | header::REFERRER_POLICY, |
| 217 | HeaderValue::from_static("no-referrer"), |
| 218 | ); |
| 219 | headers.insert( |
| 220 | "permissions-policy", |
| 221 | HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), |
| 222 | ); |
| 223 | } |
| 224 | |
| 225 | #[cfg(test)] |
| 226 | mod tests { |
| 227 | use super::*; |
| 228 | |
| 229 | #[test] |
| 230 | fn bootstrap_is_loopback_only_one_time_and_expires() { |
| 231 | let (state, nonce) = |
| 232 | RuntimeWebState::new_with_ttls(Duration::from_secs(60), Duration::from_secs(60)); |
| 233 | assert_eq!( |
| 234 | state.consume(&nonce, "192.0.2.4".parse().unwrap()), |
| 235 | Err(BootstrapError::NonLoopback) |
| 236 | ); |
| 237 | let session_token = state |
| 238 | .consume(&nonce, "127.0.0.1".parse().unwrap()) |
| 239 | .expect("valid loopback bootstrap"); |
| 240 | assert!(session_token.starts_with(WEB_SESSION_PREFIX)); |
| 241 | assert!(state.matches_session_cookie(Some(&format!( |
| 242 | "theme=dark; {WEB_SESSION_COOKIE_NAME}={session_token}" |
| 243 | )))); |
| 244 | assert_eq!( |
| 245 | state.consume(&nonce, "127.0.0.1".parse().unwrap()), |
| 246 | Err(BootstrapError::Invalid) |
| 247 | ); |
| 248 | |
| 249 | let (expired, expired_nonce) = |
| 250 | RuntimeWebState::new_with_ttls(Duration::ZERO, Duration::from_secs(60)); |
| 251 | assert_eq!( |
| 252 | expired.consume(&expired_nonce, "::1".parse().unwrap()), |
| 253 | Err(BootstrapError::Expired) |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | #[test] |
| 258 | fn web_session_survives_reload_then_expires_and_rejects_wrong_tokens() { |
| 259 | let (state, nonce) = |
| 260 | RuntimeWebState::new_with_ttls(Duration::from_secs(60), Duration::from_secs(60)); |
| 261 | let session_token = state |
| 262 | .consume(&nonce, "127.0.0.1".parse().unwrap()) |
| 263 | .expect("valid loopback bootstrap"); |
| 264 | let cookie = format!("{WEB_SESSION_COOKIE_NAME}={session_token}"); |
| 265 | assert!(state.matches_session_cookie(Some(&cookie))); |
| 266 | assert!( |
| 267 | state.matches_session_cookie(Some(&cookie)), |
| 268 | "the same process-local session remains valid across a page reload" |
| 269 | ); |
| 270 | assert!(!state.matches_session_cookie(Some( |
| 271 | "codewhale_web_session=cwws_0000000000000000000000000000000000000000000000000000000000000000" |
| 272 | ))); |
| 273 | |
| 274 | let (expired, _nonce) = |
| 275 | RuntimeWebState::new_with_ttls(Duration::from_secs(60), Duration::ZERO); |
| 276 | let expired_cookie = format!( |
| 277 | "{WEB_SESSION_COOKIE_NAME}={}", |
| 278 | expired.session_token.as_ref() |
| 279 | ); |
| 280 | assert!(!expired.matches_session_cookie(Some(&expired_cookie))); |
| 281 | } |
| 282 | |
| 283 | #[test] |
| 284 | fn bootstrap_rejects_malformed_or_wrong_capabilities_without_consuming() { |
| 285 | let (state, nonce) = RuntimeWebState::new(); |
| 286 | for invalid in ["", "cwwb_short", "cwwb_gggggggggggggggggggggggggggggggg"] { |
| 287 | assert_eq!( |
| 288 | state.consume(invalid, "127.0.0.1".parse().unwrap()), |
| 289 | Err(BootstrapError::Invalid) |
| 290 | ); |
| 291 | } |
| 292 | let mut wrong = nonce.clone(); |
| 293 | wrong.replace_range(wrong.len() - 1.., "0"); |
| 294 | if wrong == nonce { |
| 295 | wrong.replace_range(wrong.len() - 1.., "1"); |
| 296 | } |
| 297 | assert_eq!( |
| 298 | state.consume(&wrong, "127.0.0.1".parse().unwrap()), |
| 299 | Err(BootstrapError::Invalid) |
| 300 | ); |
| 301 | assert!( |
| 302 | state |
| 303 | .consume(&nonce, "127.0.0.1".parse().unwrap()) |
| 304 | .expect("valid bootstrap remains available") |
| 305 | .starts_with(WEB_SESSION_PREFIX) |
| 306 | ); |
| 307 | } |
| 308 | |
| 309 | #[test] |
| 310 | fn cookie_has_exact_security_attributes_without_the_runtime_bearer() { |
| 311 | let session_token = format!("{WEB_SESSION_PREFIX}{}", "01".repeat(16)); |
| 312 | let runtime_bearer = "cwrt_runtime_secret_never_in_browser_storage"; |
| 313 | let cookie = web_session_cookie(&session_token); |
| 314 | assert_eq!( |
| 315 | cookie, |
| 316 | format!("codewhale_web_session={session_token}; HttpOnly; SameSite=Strict; Path=/") |
| 317 | ); |
| 318 | assert!(!cookie.contains(runtime_bearer)); |
| 319 | assert!(!cookie.contains("Domain=")); |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn launcher_url_contains_only_the_one_time_capability() { |
| 324 | let token = "cwrt_runtime_secret_never_in_browser_arguments"; |
| 325 | let nonce = format!("{BOOTSTRAP_PREFIX}{}", "01".repeat(16)); |
| 326 | let url = bootstrap_url("127.0.0.1:7878".parse().unwrap(), &nonce); |
| 327 | assert!(url.ends_with(&nonce)); |
| 328 | assert!(!url.contains(token)); |
| 329 | assert!(!url.contains('?')); |
| 330 | assert!(!url.contains('#')); |
| 331 | } |
| 332 | |
| 333 | #[test] |
| 334 | fn embedded_client_has_no_secret_storage_or_unsafe_dynamic_html_sink() { |
| 335 | for asset in [WEB_HTML, WEB_JS] { |
| 336 | assert!(!asset.contains("localStorage")); |
| 337 | assert!(!asset.contains("sessionStorage")); |
| 338 | assert!(!asset.contains("codewhale_runtime_token")); |
| 339 | assert!(!asset.contains("innerHTML")); |
| 340 | assert!(!asset.contains("http://")); |
| 341 | assert!(!asset.contains("https://")); |
| 342 | } |
| 343 | } |
| 344 | } |
| 345 |