返回 CodeWhale
web.rs
根目录 / crates / tui / src / runtime_api / web.rs
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
345 lines RUST