返回 CodeWhale
web.rs
根目录 / crates / tui / src / runtime_api / web.rs
1 //! Embedded, loopback-only browser client for the Runtime API.
2
3 use codewhale_core::secret_eq::constant_time_eq;
4 use std::net::{IpAddr, SocketAddr};
5 use std::sync::{Arc, Mutex};
6 use std::time::{Duration, Instant};
7
8 use axum::Json;
9 use axum::body::Body;
10 use axum::extract::{ConnectInfo, Path, Request, State};
11 use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
12 use axum::response::{IntoResponse, Response};
13 use uuid::Uuid;
14
15 use super::RuntimeApiState;
16
17 const WEB_HTML: &str = include_str!("../runtime_web/index.html");
18 const WEB_CSS: &str = include_str!("../runtime_web/styles.css");
19 const WEB_JS: &str = include_str!("../runtime_web/app.mjs");
20 const WEB_ICON: &[u8] = include_bytes!("../runtime_web/codewhale-192.png");
21 // The nonce remains single-use and loopback-only, but it is handed to a
22 // person through the browser launcher or terminal. Two minutes proved too
23 // short when the launcher was delayed or did not open a tab.
24 pub(super) const BOOTSTRAP_TTL: Duration = Duration::from_secs(10 * 60);
25 const WEB_SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60);
26 const BOOTSTRAP_PREFIX: &str = "cwwb_";
27 const WEB_SESSION_PREFIX: &str = "cwws_";
28 pub(super) const WEB_REQUEST_HEADER: &str = "x-codewhale-web-request";
29 pub(super) const WEB_STREAM_TICKET_QUERY: &str = "web_stream_ticket";
30 const WEB_SESSION_COOKIE_NAME: &str = "codewhale_web_session";
31 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'";
32
33 #[derive(Clone)]
34 pub(super) struct RuntimeWebState {
35 bootstrap: Arc<Mutex<Option<BootstrapCapability>>>,
36 session_token: Arc<str>,
37 request_proof: Arc<Mutex<Option<String>>>,
38 stream_tickets: Arc<Mutex<Vec<BootstrapCapability>>>,
39 session_expires_at: Instant,
40 }
41
42 struct BootstrapCapability {
43 nonce: String,
44 expires_at: Instant,
45 }
46
47 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
48 pub(super) enum BootstrapError {
49 Invalid,
50 Expired,
51 NonLoopback,
52 }
53
54 impl RuntimeWebState {
55 pub(super) fn new() -> (Self, String) {
56 Self::new_with_ttls(BOOTSTRAP_TTL, WEB_SESSION_TTL)
57 }
58
59 fn new_with_ttls(bootstrap_ttl: Duration, session_ttl: Duration) -> (Self, String) {
60 let nonce = format!("{BOOTSTRAP_PREFIX}{}", Uuid::new_v4().simple());
61 let session_token = format!(
62 "{WEB_SESSION_PREFIX}{}{}",
63 Uuid::new_v4().simple(),
64 Uuid::new_v4().simple()
65 );
66 let state = Self {
67 bootstrap: Arc::new(Mutex::new(Some(BootstrapCapability {
68 nonce: nonce.clone(),
69 expires_at: Instant::now() + bootstrap_ttl,
70 }))),
71 session_token: session_token.into(),
72 request_proof: Arc::new(Mutex::new(None)),
73 stream_tickets: Arc::new(Mutex::new(Vec::new())),
74 session_expires_at: Instant::now() + session_ttl,
75 };
76 (state, nonce)
77 }
78
79 pub(super) fn consume(
80 &self,
81 nonce: &str,
82 peer_ip: IpAddr,
83 ) -> Result<(String, String), BootstrapError> {
84 if !peer_ip.is_loopback() {
85 return Err(BootstrapError::NonLoopback);
86 }
87 if !valid_bootstrap_nonce(nonce) {
88 return Err(BootstrapError::Invalid);
89 }
90
91 let mut slot = self
92 .bootstrap
93 .lock()
94 .unwrap_or_else(|poisoned| poisoned.into_inner());
95 let Some(capability) = slot.as_ref() else {
96 return Err(BootstrapError::Invalid);
97 };
98 if Instant::now() >= capability.expires_at {
99 *slot = None;
100 return Err(BootstrapError::Expired);
101 }
102 if !constant_time_eq(nonce.as_bytes(), capability.nonce.as_bytes()) {
103 return Err(BootstrapError::Invalid);
104 }
105
106 let _capability = slot.take().expect("bootstrap capability checked above");
107 let proof = format!(
108 "cwwr_{}{}",
109 Uuid::new_v4().simple(),
110 Uuid::new_v4().simple()
111 );
112 *self.request_proof.lock().unwrap_or_else(|p| p.into_inner()) = Some(proof.clone());
113 Ok((self.session_token.to_string(), proof))
114 }
115
116 pub(super) fn matches_request(&self, cookie_header: Option<&str>, proof: Option<&str>) -> bool {
117 self.matches_session_cookie(cookie_header)
118 && proof.is_some_and(|proof| {
119 self.request_proof
120 .lock()
121 .unwrap_or_else(|p| p.into_inner())
122 .as_ref()
123 .is_some_and(|expected| constant_time_eq(proof.as_bytes(), expected.as_bytes()))
124 })
125 }
126
127 // Reconnects consume short-lived, single-use tickets. Retain up to 32
128 // pending tickets so independent tabs do not replace each other's ticket;
129 // excess requests evict the oldest pending ticket.
130 pub(super) fn refresh_stream_ticket(
131 &self,
132 cookie_header: Option<&str>,
133 proof: Option<&str>,
134 ) -> Option<String> {
135 if !self.matches_request(cookie_header, proof) {
136 return None;
137 }
138 let ticket = format!(
139 "cwwt_{}{}",
140 Uuid::new_v4().simple(),
141 Uuid::new_v4().simple()
142 );
143 let mut tickets = self
144 .stream_tickets
145 .lock()
146 .unwrap_or_else(|p| p.into_inner());
147 let now = Instant::now();
148 tickets.retain(|issued| now < issued.expires_at);
149 if tickets.len() == 32 {
150 tickets.remove(0);
151 }
152 tickets.push(BootstrapCapability {
153 nonce: ticket.clone(),
154 expires_at: now + super::mobile::STREAM_TICKET_TTL,
155 });
156 Some(ticket)
157 }
158
159 pub(super) fn consume_stream_ticket(
160 &self,
161 cookie_header: Option<&str>,
162 ticket: Option<&str>,
163 ) -> bool {
164 if !self.matches_session_cookie(cookie_header) {
165 return false;
166 }
167 let mut tickets = self
168 .stream_tickets
169 .lock()
170 .unwrap_or_else(|p| p.into_inner());
171 let now = Instant::now();
172 tickets.retain(|issued| now < issued.expires_at);
173 let Some(index) = tickets.iter().position(|issued| {
174 ticket
175 .is_some_and(|ticket| constant_time_eq(ticket.as_bytes(), issued.nonce.as_bytes()))
176 }) else {
177 return false;
178 };
179 tickets.remove(index);
180 true
181 }
182
183 pub(super) fn matches_session_cookie(&self, cookie_header: Option<&str>) -> bool {
184 let presented = cookie_value(cookie_header, WEB_SESSION_COOKIE_NAME).unwrap_or_default();
185 let token_matches = constant_time_eq(presented.as_bytes(), self.session_token.as_bytes());
186 token_matches & (Instant::now() < self.session_expires_at)
187 }
188 }
189
190 pub(super) fn bootstrap_url(addr: SocketAddr, nonce: &str) -> String {
191 format!("http://{addr}/__codewhale/bootstrap/{nonce}")
192 }
193
194 pub(super) async fn exchange_bootstrap(
195 State(state): State<RuntimeApiState>,
196 ConnectInfo(peer): ConnectInfo<SocketAddr>,
197 Path(nonce): Path<String>,
198 ) -> Response {
199 let Some(web) = state.web.as_ref() else {
200 return not_found();
201 };
202 let (session_token, request_proof) = match web.consume(&nonce, peer.ip()) {
203 Ok(token) => token,
204 Err(BootstrapError::NonLoopback) => {
205 return secured_text(StatusCode::FORBIDDEN, "bootstrap unavailable");
206 }
207 Err(BootstrapError::Invalid | BootstrapError::Expired) => {
208 return secured_text(StatusCode::UNAUTHORIZED, "bootstrap unavailable");
209 }
210 };
211
212 let cookie = web_session_cookie(&session_token);
213 let mut response = (StatusCode::SEE_OTHER, "").into_response();
214 response.headers_mut().insert(
215 header::LOCATION,
216 HeaderValue::from_str(&format!("/#p={request_proof}")).expect("hex request proof"),
217 );
218 response.headers_mut().insert(
219 header::SET_COOKIE,
220 HeaderValue::from_str(&cookie).expect("percent-encoded Runtime cookie is a valid header"),
221 );
222 secure_headers(&mut response, "text/plain; charset=utf-8");
223 response
224 }
225
226 pub(super) async fn refresh_stream_ticket(
227 State(state): State<RuntimeApiState>,
228 req: Request,
229 ) -> Response {
230 let Some(web) = state.web.as_ref() else {
231 return not_found();
232 };
233 if !super::auth::web_session_request_is_authorized(&req, &state, web) {
234 return super::auth::runtime_token_required_response();
235 }
236 let ticket = web.refresh_stream_ticket(
237 req.headers()
238 .get(header::COOKIE)
239 .and_then(|v| v.to_str().ok()),
240 req.headers()
241 .get(WEB_REQUEST_HEADER)
242 .and_then(|v| v.to_str().ok()),
243 );
244 let Some(ticket) = ticket else {
245 return super::auth::runtime_token_required_response();
246 };
247 let mut response = Json(serde_json::json!({"stream_ticket": ticket})).into_response();
248 secure_headers(&mut response, "application/json");
249 response
250 }
251
252 pub(super) async fn web_page(State(state): State<RuntimeApiState>, headers: HeaderMap) -> Response {
253 let Some(web) = state.web.as_ref() else {
254 return not_found();
255 };
256 web_page_response(web, &headers)
257 }
258
259 fn web_page_response(web: &RuntimeWebState, headers: &HeaderMap) -> Response {
260 let mut html = WEB_HTML.to_owned();
261 // Recover the origin-scoped proof for reloads and independent tabs. A
262 // cookie alone is insufficient: require same-origin or direct navigation
263 // Fetch Metadata. Older clients without it still use the bootstrap proof.
264 // Proofs are generated hex strings, so no HTML escaping is needed here.
265 if headers
266 .get("sec-fetch-site")
267 .is_some_and(|site| site == "same-origin" || site == "none")
268 && web.matches_session_cookie(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()))
269 && let Some(proof) = web
270 .request_proof
271 .lock()
272 .unwrap_or_else(|p| p.into_inner())
273 .as_deref()
274 {
275 html = html.replace(
276 "name=\"codewhale-web-request\" content=\"\"",
277 &format!("name=\"codewhale-web-request\" content=\"{proof}\""),
278 );
279 }
280 secured_asset("text/html; charset=utf-8", html)
281 }
282
283 pub(super) async fn web_styles(State(state): State<RuntimeApiState>) -> Response {
284 if state.web.is_none() {
285 return not_found();
286 }
287 secured_asset("text/css; charset=utf-8", WEB_CSS)
288 }
289
290 pub(super) async fn web_script(State(state): State<RuntimeApiState>) -> Response {
291 if state.web.is_none() {
292 return not_found();
293 }
294 secured_asset("text/javascript; charset=utf-8", WEB_JS)
295 }
296
297 pub(super) async fn web_icon(State(state): State<RuntimeApiState>) -> Response {
298 if state.web.is_none() {
299 return not_found();
300 }
301 let mut response = Response::new(Body::from(WEB_ICON));
302 secure_headers(&mut response, "image/png");
303 response
304 }
305
306 fn web_session_cookie(session_token: &str) -> String {
307 format!("{WEB_SESSION_COOKIE_NAME}={session_token}; HttpOnly; SameSite=Strict; Path=/")
308 }
309
310 fn cookie_value<'a>(cookie_header: Option<&'a str>, name: &str) -> Option<&'a str> {
311 cookie_header.and_then(|cookie| {
312 cookie.split(';').find_map(|pair| {
313 let (key, value) = pair.trim().split_once('=')?;
314 (key == name).then_some(value.trim())
315 })
316 })
317 }
318
319 fn valid_bootstrap_nonce(value: &str) -> bool {
320 value.strip_prefix(BOOTSTRAP_PREFIX).is_some_and(|random| {
321 random.len() == 32 && random.bytes().all(|byte| byte.is_ascii_hexdigit())
322 })
323 }
324
325 fn secured_asset(content_type: &'static str, body: impl IntoResponse) -> Response {
326 let mut response = body.into_response();
327 secure_headers(&mut response, content_type);
328 response
329 }
330
331 fn secured_text(status: StatusCode, body: &'static str) -> Response {
332 let mut response = (status, body).into_response();
333 secure_headers(&mut response, "text/plain; charset=utf-8");
334 response
335 }
336
337 fn not_found() -> Response {
338 secured_text(StatusCode::NOT_FOUND, "not found")
339 }
340
341 fn secure_headers(response: &mut Response, content_type: &'static str) {
342 let headers = response.headers_mut();
343 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
344 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
345 headers.insert(
346 header::CONTENT_SECURITY_POLICY,
347 HeaderValue::from_static(CONTENT_SECURITY_POLICY),
348 );
349 headers.insert(
350 header::X_CONTENT_TYPE_OPTIONS,
351 HeaderValue::from_static("nosniff"),
352 );
353 headers.insert(
354 header::REFERRER_POLICY,
355 HeaderValue::from_static("no-referrer"),
356 );
357 headers.insert(
358 "permissions-policy",
359 HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
360 );
361 }
362
363 #[cfg(test)]
364 mod tests {
365 use super::*;
366
367 #[test]
368 fn runtime_surface_hardening_web_proof_and_ticket_expiry() {
369 let (web, nonce) = RuntimeWebState::new();
370 let (token, proof) = web.consume(&nonce, "127.0.0.1".parse().unwrap()).unwrap();
371 let cookie = web_session_cookie(&token);
372 assert!(!cookie.contains(&proof));
373 assert!(!web.matches_request(None, Some(&proof)));
374 assert!(!web.matches_request(Some(&cookie), Some(&token)));
375 assert!(web.matches_request(Some(&cookie), Some(&proof)));
376 let ticket = web
377 .refresh_stream_ticket(Some(&cookie), Some(&proof))
378 .unwrap();
379 assert!(!web.consume_stream_ticket(None, Some(&ticket)));
380 assert!(!web.consume_stream_ticket(Some(&cookie), Some("wrong-ticket")));
381 web.stream_tickets.lock().unwrap()[0].expires_at = Instant::now();
382 assert!(!web.consume_stream_ticket(Some(&cookie), Some(&ticket)));
383 let ticket = web
384 .refresh_stream_ticket(Some(&cookie), Some(&proof))
385 .unwrap();
386 let mut expired = web.clone();
387 expired.session_expires_at = Instant::now();
388 assert!(!expired.matches_request(Some(&cookie), Some(&proof)));
389 assert!(!expired.consume_stream_ticket(Some(&cookie), Some(&ticket)));
390 }
391
392 #[tokio::test]
393 async fn runtime_surface_review_web_page_recovers_proof_for_reload_and_second_tab() {
394 let (web, nonce) = RuntimeWebState::new();
395 let (token, proof) = web.consume(&nonce, "127.0.0.1".parse().unwrap()).unwrap();
396 let cookie = web_session_cookie(&token);
397 for site in [
398 None,
399 Some("same-origin"),
400 Some("none"),
401 Some("same-site"),
402 Some("cross-site"),
403 ] {
404 for valid_cookie in [false, true] {
405 for expired in [false, true] {
406 let mut session = web.clone();
407 if expired {
408 session.session_expires_at = Instant::now();
409 }
410 let mut headers = HeaderMap::new();
411 if valid_cookie {
412 headers.insert(header::COOKIE, cookie.parse().unwrap());
413 }
414 if let Some(site) = site {
415 headers.insert("sec-fetch-site", site.parse().unwrap());
416 }
417 let response = web_page_response(&session, &headers);
418 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
419 assert!(
420 response.headers()[header::CONTENT_SECURITY_POLICY]
421 .to_str()
422 .unwrap()
423 .contains("frame-ancestors 'none'")
424 );
425 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
426 .await
427 .unwrap();
428 let html = std::str::from_utf8(&body).unwrap();
429 let recovered = html.contains(&format!(
430 "name=\"codewhale-web-request\" content=\"{proof}\""
431 ));
432 assert_eq!(
433 recovered,
434 valid_cookie && !expired && matches!(site, Some("same-origin" | "none")),
435 "site={site:?}, valid_cookie={valid_cookie}, expired={expired}"
436 );
437 assert!(!html.contains(&token));
438 if recovered {
439 assert!(session.matches_request(Some(&cookie), Some(&proof)));
440 }
441 }
442 }
443 }
444 assert_eq!(
445 web.consume(&nonce, "127.0.0.1".parse().unwrap()),
446 Err(BootstrapError::Invalid)
447 );
448 }
449
450 #[test]
451 fn runtime_surface_review_web_tabs_keep_independent_bounded_tickets() {
452 let (web, nonce) = RuntimeWebState::new();
453 let (token, proof) = web.consume(&nonce, "127.0.0.1".parse().unwrap()).unwrap();
454 let cookie = web_session_cookie(&token);
455 let first = web
456 .refresh_stream_ticket(Some(&cookie), Some(&proof))
457 .unwrap();
458 let second = web
459 .refresh_stream_ticket(Some(&cookie), Some(&proof))
460 .unwrap();
461 assert!(web.consume_stream_ticket(Some(&cookie), Some(&first)));
462 assert!(web.consume_stream_ticket(Some(&cookie), Some(&second)));
463 assert!(!web.consume_stream_ticket(Some(&cookie), Some(&first)));
464 assert!(!web.consume_stream_ticket(Some(&cookie), Some(&second)));
465 let oldest = web
466 .refresh_stream_ticket(Some(&cookie), Some(&proof))
467 .unwrap();
468 for _ in 0..32 {
469 web.refresh_stream_ticket(Some(&cookie), Some(&proof))
470 .unwrap();
471 }
472 assert_eq!(web.stream_tickets.lock().unwrap().len(), 32);
473 assert!(!web.consume_stream_ticket(Some(&cookie), Some(&oldest)));
474 }
475
476 #[test]
477 fn bootstrap_is_loopback_only_one_time_and_expires() {
478 let (state, nonce) =
479 RuntimeWebState::new_with_ttls(Duration::from_secs(60), Duration::from_secs(60));
480 assert_eq!(
481 state.consume(&nonce, "192.0.2.4".parse().unwrap()),
482 Err(BootstrapError::NonLoopback)
483 );
484 let (session_token, _) = state
485 .consume(&nonce, "127.0.0.1".parse().unwrap())
486 .expect("valid loopback bootstrap");
487 assert!(session_token.starts_with(WEB_SESSION_PREFIX));
488 assert!(state.matches_session_cookie(Some(&format!(
489 "theme=dark; {WEB_SESSION_COOKIE_NAME}={session_token}"
490 ))));
491 assert_eq!(
492 state.consume(&nonce, "127.0.0.1".parse().unwrap()),
493 Err(BootstrapError::Invalid)
494 );
495
496 let (expired, expired_nonce) =
497 RuntimeWebState::new_with_ttls(Duration::ZERO, Duration::from_secs(60));
498 assert_eq!(
499 expired.consume(&expired_nonce, "::1".parse().unwrap()),
500 Err(BootstrapError::Expired)
501 );
502 }
503
504 #[test]
505 fn web_session_survives_reload_then_expires_and_rejects_wrong_tokens() {
506 let (state, nonce) =
507 RuntimeWebState::new_with_ttls(Duration::from_secs(60), Duration::from_secs(60));
508 let (session_token, _) = state
509 .consume(&nonce, "127.0.0.1".parse().unwrap())
510 .expect("valid loopback bootstrap");
511 let cookie = format!("{WEB_SESSION_COOKIE_NAME}={session_token}");
512 assert!(state.matches_session_cookie(Some(&cookie)));
513 assert!(
514 state.matches_session_cookie(Some(&cookie)),
515 "the same process-local session remains valid across a page reload"
516 );
517 assert!(!state.matches_session_cookie(Some(
518 "codewhale_web_session=cwws_0000000000000000000000000000000000000000000000000000000000000000"
519 )));
520
521 let (expired, _nonce) =
522 RuntimeWebState::new_with_ttls(Duration::from_secs(60), Duration::ZERO);
523 let expired_cookie = format!(
524 "{WEB_SESSION_COOKIE_NAME}={}",
525 expired.session_token.as_ref()
526 );
527 assert!(!expired.matches_session_cookie(Some(&expired_cookie)));
528 }
529
530 #[test]
531 fn bootstrap_rejects_malformed_or_wrong_capabilities_without_consuming() {
532 let (state, nonce) = RuntimeWebState::new();
533 for invalid in ["", "cwwb_short", "cwwb_gggggggggggggggggggggggggggggggg"] {
534 assert_eq!(
535 state.consume(invalid, "127.0.0.1".parse().unwrap()),
536 Err(BootstrapError::Invalid)
537 );
538 }
539 let mut wrong = nonce.clone();
540 wrong.replace_range(wrong.len() - 1.., "0");
541 if wrong == nonce {
542 wrong.replace_range(wrong.len() - 1.., "1");
543 }
544 assert_eq!(
545 state.consume(&wrong, "127.0.0.1".parse().unwrap()),
546 Err(BootstrapError::Invalid)
547 );
548 assert!(
549 state
550 .consume(&nonce, "127.0.0.1".parse().unwrap())
551 .expect("valid bootstrap remains available")
552 .0
553 .starts_with(WEB_SESSION_PREFIX)
554 );
555 }
556
557 #[test]
558 fn cookie_has_exact_security_attributes_without_the_runtime_bearer() {
559 let session_token = format!("{WEB_SESSION_PREFIX}{}", "01".repeat(16));
560 let runtime_bearer = "cwrt_runtime_secret_never_in_browser_storage";
561 let cookie = web_session_cookie(&session_token);
562 assert_eq!(
563 cookie,
564 format!("codewhale_web_session={session_token}; HttpOnly; SameSite=Strict; Path=/")
565 );
566 assert!(!cookie.contains(runtime_bearer));
567 assert!(!cookie.contains("Domain="));
568 }
569
570 #[test]
571 fn launcher_url_contains_only_the_one_time_capability() {
572 let token = "cwrt_runtime_secret_never_in_browser_arguments";
573 let nonce = format!("{BOOTSTRAP_PREFIX}{}", "01".repeat(16));
574 let url = bootstrap_url("127.0.0.1:7878".parse().unwrap(), &nonce);
575 assert!(url.ends_with(&nonce));
576 assert!(!url.contains(token));
577 assert!(!url.contains('?'));
578 assert!(!url.contains('#'));
579 }
580
581 #[test]
582 fn embedded_client_keeps_runtime_bearer_private_and_has_no_unsafe_html_sink() {
583 for asset in [WEB_HTML, WEB_JS] {
584 assert!(!asset.contains("localStorage"));
585 assert!(!asset.contains("codewhale_runtime_token"));
586 assert!(!asset.contains("innerHTML"));
587 assert!(!asset.contains("http://"));
588 assert!(!asset.contains("https://"));
589 }
590 }
591 }
592
592 lines RUST