beck_rt/http.rs
1//! HTTP: the first paint, the assets, the probes, and the websocket upgrade.
2//!
3//! "First paint is free SSR: evaluate pure `view` against the current accumulator, ship HTML."
4//! The document below is therefore not a shell that fetches data — it *is* the data, rendered, and
5//! the socket that opens afterwards resumes from the `seq` the render reflects. There is no
6//! loading state anywhere in a Beck program because there is nothing to load.
7
8use std::net::SocketAddr;
9use std::sync::Arc;
10
11use anyhow::Result;
12use http_body_util::Full;
13use hyper::body::{Bytes, Incoming};
14use hyper::header::{
15 HeaderValue, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, SEC_WEBSOCKET_ACCEPT,
16 SEC_WEBSOCKET_KEY, UPGRADE,
17};
18use hyper::service::service_fn;
19use hyper::{Method, Request, Response, StatusCode};
20use hyper_util::rt::TokioIo;
21use tokio::net::TcpListener;
22use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
23use tokio_tungstenite::tungstenite::protocol::{Role, WebSocketConfig};
24use tokio_tungstenite::WebSocketStream;
25
26use crate::app::App;
27use crate::dash::Dashboard;
28
29pub async fn serve(
30 app: Arc<App>,
31 addr: SocketAddr,
32 shutdown: tokio::sync::watch::Receiver<bool>,
33) -> Result<()> {
34 serve_with_dashboard(app, addr, shutdown, None).await
35}
36
37/// Serve, optionally with the dashboard mounted under `/_beck`.
38///
39/// Optional because the dashboard needs the infrastructure graph, which `beck-rt` cannot build —
40/// it does not depend on `beck-infra`, and should not: the runtime does not know what Kubernetes
41/// is. Whoever assembles the process knows both, and passes one in.
42pub async fn serve_with_dashboard(
43 app: Arc<App>,
44 addr: SocketAddr,
45 mut shutdown: tokio::sync::watch::Receiver<bool>,
46 dashboard: Option<Arc<Dashboard>>,
47) -> Result<()> {
48 let listener = TcpListener::bind(addr).await?;
49 tracing::info!(%addr, store = app.store_kind(), "listening");
50
51 loop {
52 let accepted = tokio::select! {
53 accepted = listener.accept() => accepted,
54 _ = shutdown.changed() => {
55 if *shutdown.borrow() {
56 tracing::info!("draining: no longer accepting connections");
57 // And the ones already accepted: a subscription is a task of its own, and a
58 // server that only stops *accepting* leaves every open socket up for as long
59 // as the process lives (§5.2's third clause, `App::drain`).
60 app.drain();
61 return Ok(());
62 }
63 continue;
64 }
65 };
66
67 let (stream, _peer) = accepted?;
68 stream.set_nodelay(true)?;
69 let app = app.clone();
70 let dashboard = dashboard.clone();
71 tokio::spawn(async move {
72 let service = service_fn(move |req| route(app.clone(), dashboard.clone(), req));
73 if let Err(e) = hyper::server::conn::http1::Builder::new()
74 .serve_connection(TokioIo::new(stream), service)
75 .with_upgrades()
76 .await
77 {
78 tracing::debug!(error = %e, "connection closed");
79 }
80 });
81 }
82}
83
84/// The port the local listener actually bound, for tests that ask for port 0.
85pub async fn bind(addr: SocketAddr) -> Result<TcpListener> {
86 Ok(TcpListener::bind(addr).await?)
87}
88
89async fn route(
90 app: Arc<App>,
91 dashboard: Option<Arc<Dashboard>>,
92 req: Request<Incoming>,
93) -> Result<Response<Full<Bytes>>> {
94 let path = req.uri().path().to_string();
95 if req.method() == Method::GET {
96 if let Some(d) = &dashboard {
97 if let Some((content_type, body)) = d.route(&path, &app) {
98 return Ok(asset(&body, content_type));
99 }
100 }
101 }
102 match (req.method(), path.as_str()) {
103 (&Method::GET, "/healthz") | (&Method::GET, "/readyz") => Ok(text("ok")),
104 (&Method::GET, "/beck-patch.js") => Ok(asset(crate::PATCH_CLIENT, "text/javascript")),
105 (&Method::GET, "/beck-thin.js") => Ok(asset(crate::THIN_CLIENT, "text/javascript")),
106 (&Method::GET, "/beck-mode-b.js") => Ok(asset(crate::MODE_B_CLIENT, "text/javascript")),
107 // The worker's cache is keyed by the program it is caching, so the id is substituted here
108 // rather than fetched by the worker: a worker that had to ask the server which program it
109 // was would be asking the one thing it exists to survive the absence of.
110 (&Method::GET, "/beck-sw.js") => Ok(asset(
111 &crate::SERVICE_WORKER.replace("%WIRE%", app.runtime().wire_id()),
112 "text/javascript",
113 )),
114 // The component's slice, for a browser that renders it (§5.1's Mode B). Derived from the
115 // running program rather than read from disk, so a tab can never load a bundle the server
116 // is not itself executing.
117 (&Method::GET, "/beck-bundle.bpk") => Ok(bytes(
118 beck_core::Bundle::of(app.runtime().placed()).to_bytes(),
119 "application/octet-stream",
120 )),
121 (&Method::GET, "/beck-kernel.wasm") => Ok(kernel()),
122 // The program's own sheet: one rule per class its pages can carry (`docs/104` §104.4),
123 // derived at startup from the program this process is executing.
124 (&Method::GET, "/beck.css") => Ok(asset(app.stylesheet(), "text/css")),
125 (&Method::GET, "/beck-devtools.js") => Ok(asset(crate::DEVTOOLS_CLIENT, "text/javascript")),
126 // What the panel draws: the program's own signal graph and the dataflow the compiler made
127 // of its view. Derived from the running program, so a panel cannot describe a version of
128 // the program this process is not executing.
129 (&Method::GET, "/beck-signals.json") => Ok(asset(
130 crate::signals::document(app.runtime().placed(), app.runtime().plan()),
131 "application/json",
132 )),
133 (&Method::GET, LOGIN_PATH) => login(app, &req).await,
134 (&Method::GET, CALLBACK_PATH) => callback(app, &req).await,
135 (&Method::GET, LOGOUT_PATH) => Ok(logout()),
136 (&Method::GET, "/socket") => upgrade(app, req),
137 // Every other GET is the application, at that route. A Beck program has one page and that
138 // page is a function of `session.path`, so there is nothing here to match a route against:
139 // the *program* decides what `/done` means, and this decides only that `/done` is a page
140 // rather than a missing file. Which is what makes a deep link and a reload work — the
141 // route is established by the request that renders, and not by a script afterwards.
142 (&Method::GET, _) => document(app, req).await,
143 _ => Ok(not_found()),
144 }
145}
146
147/// A path this process answers itself, so a program's routes cannot be shadowed by one.
148///
149/// The list is derived from the `match` above rather than written twice — `route_is_reserved` is
150/// what the gate holds it to. It exists as a function because a program's author needs to know
151/// which routes are not theirs, and "read the router" is not an answer.
152pub fn reserved_routes() -> &'static [&'static str] {
153 &[
154 "/healthz",
155 "/readyz",
156 "/socket",
157 "/beck.css",
158 "/beck-patch.js",
159 "/beck-thin.js",
160 "/beck-mode-b.js",
161 "/beck-devtools.js",
162 "/beck-sw.js",
163 "/beck-bundle.bpk",
164 "/beck-kernel.wasm",
165 "/beck-signals.json",
166 LOGIN_PATH,
167 CALLBACK_PATH,
168 LOGOUT_PATH,
169 ]
170}
171
172/// Where the login flow lives. Fixed paths rather than configurable ones: they are registered with
173/// an identity provider as a redirect URI, and a path an operator can move is a path that stops
174/// matching what was registered.
175pub const LOGIN_PATH: &str = "/auth/login";
176pub const CALLBACK_PATH: &str = "/auth/callback";
177pub const LOGOUT_PATH: &str = "/auth/logout";
178
179/// The cookie carrying the credential a verified connection is identified by.
180///
181/// Under [`crate::oidc`] its value is the **ID token itself** — the issuer's, not one this process
182/// made — so every connection re-verifies the issuer's signature rather than a local session's.
183const SESSION_COOKIE: &str = "beck_id";
184/// The cookie carrying the sealed login transaction, alive only between `/auth/login` and
185/// `/auth/callback`.
186const TRANSACTION_COOKIE: &str = "beck_login";
187
188/// What this request *claims* to be: the session cookie, then a bearer credential, then
189/// `?actor=alice`, then nothing.
190///
191/// A claim, not an actor. [`crate::identity`] is what turns one into the other, and this function
192/// deliberately does no defaulting: "nobody said" and "somebody said `dev`" are different facts,
193/// and the provider is what decides whether the first is acceptable.
194///
195/// The cookie comes first because it is the one a **browser** sends by itself, and the query
196/// parameter comes last because a credential in a URL is a credential in a log file — it stays
197/// because it is what `beck run` on a laptop has always used, and under a verifying provider it
198/// carries a token nobody would put there.
199fn claimed_actor(headers: &hyper::HeaderMap, query: Option<&str>) -> String {
200 if let Some(cookie) = cookie(headers, SESSION_COOKIE) {
201 return cookie;
202 }
203 if let Some(bearer) = headers
204 .get(hyper::header::AUTHORIZATION)
205 .and_then(|v| v.to_str().ok())
206 .and_then(|v| v.strip_prefix("Bearer "))
207 {
208 return bearer.trim().to_string();
209 }
210 query
211 .and_then(|q| {
212 crate::oidc::query_params(q)
213 .into_iter()
214 .find(|(k, _)| k == "actor")
215 .map(|(_, v)| v)
216 })
217 .unwrap_or_default()
218}
219
220/// One cookie's value out of a `Cookie` header, or nothing.
221fn cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
222 headers
223 .get_all(hyper::header::COOKIE)
224 .iter()
225 .filter_map(|v| v.to_str().ok())
226 .flat_map(|v| v.split(';'))
227 .filter_map(|pair| pair.trim().split_once('='))
228 .find(|(k, _)| *k == name)
229 .map(|(_, v)| v.to_string())
230}
231
232/// A cookie a script cannot read and a cross-site request does not send.
233///
234/// `HttpOnly` because the value is a credential and §5.1's thin client has no reason to read it.
235/// `SameSite=Lax` rather than `Strict` because the identity provider redirects the browser back to
236/// `/auth/callback` and `Strict` would withhold the transaction cookie on exactly that navigation.
237/// `Secure` is **not** set: §6.5's gateway terminates TLS in front of a plaintext hop, so setting
238/// it would make the cookie unusable in the deployment this project generates — the same reason
239/// `same_origin` does not compare schemes, and it is recorded in `docs/48` §48.13 rather than left
240/// to be discovered.
241fn set_cookie(name: &str, value: &str, max_age: i64) -> String {
242 format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}")
243}
244
245/// Send the browser somewhere, setting or clearing cookies on the way.
246fn redirect(to: &str, cookies: &[String]) -> Response<Full<Bytes>> {
247 let mut builder = Response::builder()
248 .status(StatusCode::FOUND)
249 .header(hyper::header::LOCATION, to)
250 .header(CACHE_CONTROL, HeaderValue::from_static("no-store"));
251 for cookie in cookies {
252 builder = builder.header(hyper::header::SET_COOKIE, cookie);
253 }
254 builder
255 .body(Full::new(Bytes::new()))
256 .expect("a redirect builds")
257}
258
259/// `/auth/login` — begin the authorization-code flow.
260async fn login(app: Arc<App>, req: &Request<Incoming>) -> Result<Response<Full<Bytes>>> {
261 let Some(party) = app.identity().login() else {
262 return Ok(not_found());
263 };
264 let return_to = req
265 .uri()
266 .query()
267 .and_then(|q| {
268 crate::oidc::query_params(q)
269 .into_iter()
270 .find(|(k, _)| k == "next")
271 .map(|(_, v)| v)
272 })
273 .unwrap_or_else(|| "/".to_string());
274 match party.begin_login(&return_to) {
275 Ok(begun) => Ok(redirect(
276 &begun.url,
277 &[set_cookie(
278 TRANSACTION_COOKIE,
279 &begun.transaction,
280 crate::oidc::LOGIN_WINDOW_MS / 1_000,
281 )],
282 )),
283 Err(why) => {
284 tracing::warn!(why, "a login could not be started");
285 Ok(unavailable())
286 }
287 }
288}
289
290/// `/auth/callback` — the browser is back from the identity provider.
291async fn callback(app: Arc<App>, req: &Request<Incoming>) -> Result<Response<Full<Bytes>>> {
292 if app.identity().login().is_none() {
293 return Ok(not_found());
294 }
295 let transaction = cookie(req.headers(), TRANSACTION_COOKIE).unwrap_or_default();
296 let query = req.uri().query().unwrap_or_default().to_string();
297 // Synchronous, and `beck_core::net::Outbound` is deliberately synchronous too (a tree-walker
298 // cannot await), so the token exchange goes on a blocking thread rather than on a worker
299 // serving pages.
300 let holder = app.clone();
301 let completed = tokio::task::spawn_blocking(move || match holder.identity().login() {
302 Some(party) => party.complete_login(&query, &transaction),
303 None => Err("this process has no relying party".to_string()),
304 })
305 .await;
306
307 match completed {
308 Ok(Ok(done)) => {
309 tracing::info!(actor = %done.verified.subject, "a login completed");
310 let seconds =
311 (done.verified.expires_at_millis - app.clock().now_millis()).max(0) / 1_000;
312 Ok(redirect(
313 &done.return_to,
314 &[
315 set_cookie(SESSION_COOKIE, &done.id_token, seconds),
316 // The transaction is spent. Clearing it is not tidiness: a state and a PKCE
317 // verifier that outlive their one use are a replayable login.
318 set_cookie(TRANSACTION_COOKIE, "", 0),
319 ],
320 ))
321 }
322 Ok(Err(why)) => {
323 // Specific to the operator, and the browser is told it was refused and nothing else.
324 tracing::warn!(why, "a login did not complete");
325 crate::telemetry::telemetry().unauthenticated.incr();
326 Ok(Response::builder()
327 .status(StatusCode::UNAUTHORIZED)
328 .header(
329 hyper::header::SET_COOKIE,
330 set_cookie(TRANSACTION_COOKIE, "", 0),
331 )
332 .body(Full::new(Bytes::from_static(b"unauthenticated")))?)
333 }
334 Err(e) => {
335 tracing::warn!(error = %e, "the token exchange panicked");
336 Ok(unavailable())
337 }
338 }
339}
340
341/// `/auth/logout` — forget the credential.
342///
343/// Local only: it clears this app's cookie and does not call the issuer's end-session endpoint, so
344/// the browser is still signed in to the identity provider and `/auth/login` will complete without
345/// another password. That is the ordinary meaning of "log out of this app" and `docs/48` §48.13 says
346/// so rather than leaving somebody to find out.
347fn logout() -> Response<Full<Bytes>> {
348 redirect(
349 "/",
350 &[
351 set_cookie(SESSION_COOKIE, "", 0),
352 set_cookie(TRANSACTION_COOKIE, "", 0),
353 ],
354 )
355}
356
357fn not_found() -> Response<Full<Bytes>> {
358 Response::builder()
359 .status(StatusCode::NOT_FOUND)
360 .body(Full::new(Bytes::from_static(b"not found")))
361 .expect("static response builds")
362}
363
364fn unavailable() -> Response<Full<Bytes>> {
365 Response::builder()
366 .status(StatusCode::SERVICE_UNAVAILABLE)
367 .body(Full::new(Bytes::from_static(b"identity is unavailable")))
368 .expect("static response builds")
369}
370
371async fn document(app: Arc<App>, req: Request<Incoming>) -> Result<Response<Full<Bytes>>> {
372 // The server-rendered document is a *view*, so it is behind the same question the socket is:
373 // rendering a page for whoever asked would leak exactly what a per-session view exists to keep
374 // separate. Under `DevIdentity` a claim of `dev` is what an unauthenticated laptop gets, and
375 // that default lives in one place.
376 let claimed = claimed_actor(req.headers(), req.uri().query());
377 let claimed = if claimed.is_empty() && !app.identity().verifies() {
378 "dev".to_string()
379 } else {
380 claimed
381 };
382 let actor = match app.identity().verify(&claimed) {
383 Ok(a) => a,
384 Err(why) => {
385 tracing::warn!(
386 reason = why.reason(),
387 "identity refused for a document request"
388 );
389 crate::telemetry::telemetry().unauthenticated.incr();
390 // A provider that can run a login flow sends the browser to it rather than answering
391 // 401: a person who is not signed in has somewhere to go, and a person whose token has
392 // expired has the same somewhere.
393 if app.identity().login().is_some() {
394 return Ok(redirect(LOGIN_PATH, &[set_cookie(SESSION_COOKIE, "", 0)]));
395 }
396 return Ok(Response::builder()
397 .status(StatusCode::UNAUTHORIZED)
398 .body(Full::new(Bytes::from_static(b"unauthenticated")))?);
399 }
400 };
401 let seq = app.head();
402 // The route this document is *of*. First paint is a render of the page at this path, so a deep
403 // link and a reload produce the page the client would have navigated to rather than the root's
404 // page followed by a correction — which is the whole difference between a router and a
405 // redirect.
406 let who = crate::program::At {
407 who: actor,
408 path: std::sync::Arc::from(req.uri().path()),
409 };
410 let body = app.render(&who).await?.render();
411 let actor = &who.who;
412 // The claims go into the document because Mode B's client renders the same view against the
413 // same `Session`, and it has no provider to ask: a client left to fill in a blank map would
414 // show a different page than the one it is hydrating. They are what the server already
415 // verified and already rendered against, so the document is not telling the browser anything
416 // the page it carries does not — and the browser's copy is advice, exactly as its `validate`
417 // is: the server verifies the token again on the socket and every command goes through the
418 // chokepoint there (§3.5).
419 let claims = serde_json::to_string(
420 &actor
421 .claims()
422 .iter()
423 .map(|(k, v)| (k.as_ref(), v.as_ref()))
424 .collect::<std::collections::BTreeMap<&str, &str>>(),
425 )
426 .unwrap_or_else(|_| "{}".into());
427 let actor = beck_core::html::escape_attr(actor.name());
428 let claims = beck_core::html::escape_attr(&claims);
429
430 let html = shell(
431 &app.runtime().placed().program.name,
432 seq,
433 &actor,
434 &claims,
435 &body,
436 // Which residue this page needs is the component's rendering mode, and the server is the
437 // one that knows it: a Mode B document that loaded the thin client would sit waiting for
438 // DOM patches the server is never going to send.
439 match app.runtime().placed().render.mode {
440 beck_core::render::Mode::Server => "/beck-thin.js",
441 beck_core::render::Mode::Client => "/beck-mode-b.js",
442 },
443 );
444 Ok(Response::builder()
445 .header(
446 CONTENT_TYPE,
447 HeaderValue::from_static("text/html; charset=utf-8"),
448 )
449 .header(CACHE_CONTROL, HeaderValue::from_static("no-store"))
450 .body(Full::new(Bytes::from(html)))?)
451}
452
453/// The document around a rendered page: what the browser needs in order to become a client of it.
454///
455/// A function rather than a `format!` inside [`document`] so that the attributes it writes can be
456/// held against the attributes the served JavaScript reads, in both directions
457/// (`the_document_carries_every_attribute_the_residue_reads_off_it`). An attribute a client reads
458/// and this does not write is a page that never connects, and nothing else in this workspace would
459/// say so.
460///
461/// `seq` is the position the page reflects, so the first socket message either finds nothing to do
462/// or is exactly the gap — that is what made hydration free in Phase 0. `actor` and `claims` are
463/// already escaped: they are the identity provider's strings, and the caller is where the value to
464/// escape exists.
465///
466/// `#b-root` is the subscription's frame: a patch path is child indices *from it*, so it has to be
467/// an element of its own rather than the body — whose other children are the two script tags below,
468/// which an insertion at the frame's root would otherwise be counted against.
469fn shell(title: &str, seq: u64, actor: &str, claims: &str, body: &str, client: &str) -> String {
470 format!(
471 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
472 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
473 <title>{title}</title><link rel=\"stylesheet\" href=\"/beck.css\">\
474 </head><body>\
475 <div id=\"b-root\" data-b-seq=\"{seq}\" data-b-actor=\"{actor}\" \
476 data-b-claims=\"{claims}\">{body}</div>\
477 <script src=\"/beck-patch.js\" defer></script>\
478 <script src=\"{client}\" defer></script></body></html>"
479 )
480}
481
482/// Is this upgrade coming from a page the server itself served?
483///
484/// `Origin` is a header a **browser** sets and a script cannot forge, so it answers exactly one
485/// question: is the page asking for this socket the one this server rendered? The check is
486/// `Origin`'s authority against `Host`, and it is what stops a page on any other host opening a
487/// socket to a Beck app with whatever ambient credentials the visitor's browser carries
488/// ([`docs/42`](../../../../../docs/42-security-assurance.md) §42.6, third bullet).
489///
490/// Three decisions, because each could have gone the other way:
491///
492/// * **An absent `Origin` is allowed.** Non-browser clients do not send one — `beck test`, a
493/// script, a load generator — and the attack this defends against needs a browser, which always
494/// sends one. Refusing an absent header would break every non-browser client for no security
495/// gain, since an attacker running their own client is not subject to a browser's rules anyway.
496/// * **The scheme is not compared.** Behind a TLS-terminating gateway — which is what
497/// [`docs/06`](../../../../../docs/06-kubernetes-and-packaging.md) §6.5's HTTPRoute is — the page
498/// is `https://app.example` and the request arriving here is plain HTTP. Comparing schemes would
499/// refuse every deployment this project generates.
500/// * **There is no allowlist.** A Beck app serves its own page (§5.2's first paint), so same-origin
501/// is not a policy choice but a description. A deployment that genuinely needs a cross-origin
502/// client has nothing to configure yet, and §42.6 is where that is recorded.
503///
504/// Takes the headers rather than the request so it can be tested as what it is — a function of two
505/// strings — rather than through a socket.
506pub(crate) fn same_origin(headers: &hyper::HeaderMap) -> bool {
507 let Some(origin) = headers.get(ORIGIN) else {
508 return true;
509 };
510 let Ok(origin) = origin.to_str() else {
511 return false;
512 };
513 // `Origin: null` — a sandboxed iframe or a `file://` page — has no authority and matches
514 // nothing, which is the answer it should get.
515 let authority = origin
516 .split_once("://")
517 .map(|(_, rest)| rest)
518 .unwrap_or(origin);
519 headers
520 .get(hyper::header::HOST)
521 .and_then(|h| h.to_str().ok())
522 .is_some_and(|host| host == authority)
523}
524
525/// What a client may send, in numbers this project chose.
526///
527/// [`docs/42`](../../../../../docs/42-security-assurance.md) §42.6's second bullet: the upgrade
528/// passed `None`, so the limits were tungstenite's defaults — 64 MiB a message and 16 MiB a frame.
529/// Bounded, but by somebody else's judgement. These are the arguments for these numbers:
530///
531/// * **256 KiB a message, and the same a frame.** A client sends two things: a `hello` naming a
532/// subscription and an actor, and a `Cmd` carrying one value of the program's own `union
533/// Command`. The largest field either can hold is text a person typed into a form, and 256 KiB
534/// is around a hundred pages of it. Nothing legitimate approaches it and 64 MiB is 256× further
535/// away.
536/// * **8 KiB of read buffer**, down from 128 KiB. It is **eagerly allocated per connection**, and
537/// §5.3 makes per-subscriber memory a number this project reports rather than hopes about — the
538/// library's own default is tuned for high read load, and a Beck client sends a few hundred
539/// bytes when somebody clicks something.
540/// * **8 MiB of write buffer at most**, down from unbounded. It only grows past
541/// `write_buffer_size` when writes are failing, so this is backpressure against a client that
542/// has stopped reading rather than a limit on what a healthy one is sent.
543///
544/// Outgoing patches are unaffected: `max_message_size` and `max_frame_size` bound what is *read*.
545fn socket_limits() -> WebSocketConfig {
546 WebSocketConfig::default()
547 .read_buffer_size(8 * 1024)
548 .max_write_buffer_size(8 << 20)
549 .max_message_size(Some(256 << 10))
550 .max_frame_size(Some(256 << 10))
551}
552
553fn upgrade(app: Arc<App>, mut req: Request<Incoming>) -> Result<Response<Full<Bytes>>> {
554 if !same_origin(req.headers()) {
555 // Coarse to the caller on purpose, and the same shape `docs/48` §48.2 chose for a refused
556 // identity: a cross-origin page learns that it was refused and nothing about why.
557 return Ok(Response::builder()
558 .status(StatusCode::FORBIDDEN)
559 .body(Full::new(Bytes::from_static(b"forbidden")))?);
560 }
561 let key = req
562 .headers()
563 .get(SEC_WEBSOCKET_KEY)
564 .map(|k| derive_accept_key(k.as_bytes()));
565 let Some(accept) = key else {
566 return Ok(Response::builder()
567 .status(StatusCode::BAD_REQUEST)
568 .body(Full::new(Bytes::from_static(
569 b"expected a websocket upgrade",
570 )))?);
571 };
572
573 // A browser's credential is in a cookie, which the `hello` frame cannot see: the document may
574 // not contain the token, because a script that reads the document reads the token. So the
575 // *upgrade* is where a cookie-carrying connection is identified, and the frame's `actor` is
576 // then not consulted at all (`crate::session::run_as`).
577 //
578 // A connection with no cookie is not refused here — `beck test`, a script and the corpus
579 // harnesses all connect without one — it is passed on as `None` and `session::run_as` asks the
580 // provider about the `hello` frame's claim exactly as before.
581 let verified = match cookie(req.headers(), SESSION_COOKIE) {
582 Some(claim) => match app.identity().verify(&claim) {
583 Ok(actor) => Some(actor),
584 Err(why) => {
585 tracing::warn!(reason = why.reason(), "identity refused at the upgrade");
586 crate::telemetry::telemetry().unauthenticated.incr();
587 return Ok(Response::builder()
588 .status(StatusCode::UNAUTHORIZED)
589 .header(hyper::header::SET_COOKIE, set_cookie(SESSION_COOKIE, "", 0))
590 .body(Full::new(Bytes::from_static(b"unauthenticated")))?);
591 }
592 },
593 None => None,
594 };
595
596 tokio::spawn(async move {
597 match hyper::upgrade::on(&mut req).await {
598 Ok(upgraded) => {
599 let socket = WebSocketStream::from_raw_socket(
600 TokioIo::new(upgraded),
601 Role::Server,
602 Some(socket_limits()),
603 )
604 .await;
605 if let Err(e) = crate::session::run_as(app, socket, verified).await {
606 tracing::debug!(error = %e, "subscription ended");
607 }
608 }
609 Err(e) => tracing::debug!(error = %e, "upgrade failed"),
610 }
611 });
612
613 Ok(Response::builder()
614 .status(StatusCode::SWITCHING_PROTOCOLS)
615 .header(CONNECTION, HeaderValue::from_static("Upgrade"))
616 .header(UPGRADE, HeaderValue::from_static("websocket"))
617 .header(SEC_WEBSOCKET_ACCEPT, accept)
618 .body(Full::new(Bytes::new()))?)
619}
620
621fn text(s: &str) -> Response<Full<Bytes>> {
622 Response::builder()
623 .header(CONTENT_TYPE, HeaderValue::from_static("text/plain"))
624 .body(Full::new(Bytes::from(s.to_string())))
625 .expect("static response builds")
626}
627
628fn asset(body: &str, mime: &'static str) -> Response<Full<Bytes>> {
629 Response::builder()
630 .header(CONTENT_TYPE, HeaderValue::from_static(mime))
631 .header(
632 CACHE_CONTROL,
633 HeaderValue::from_static("public, max-age=60"),
634 )
635 .body(Full::new(Bytes::from(body.to_string())))
636 .expect("static response builds")
637}
638
639fn bytes(body: Vec<u8>, mime: &'static str) -> Response<Full<Bytes>> {
640 Response::builder()
641 .header(CONTENT_TYPE, HeaderValue::from_static(mime))
642 .header(
643 CACHE_CONTROL,
644 HeaderValue::from_static("public, max-age=60"),
645 )
646 .body(Full::new(Bytes::from(body)))
647 .expect("static response builds")
648}
649
650/// Where the Mode B kernel is looked for.
651///
652/// `BECK_KERNEL` names the module; without it, the path `cargo build -p beck-wasm --release
653/// --target wasm32-unknown-unknown` writes to, relative to the working directory. The kernel is a
654/// *build artefact of this workspace* rather than something compiled into the binary, because
655/// building it needs a target the compiler's own build does not: making `beck` depend on a wasm
656/// toolchain to serve a Mode A page would be the wrong trade.
657pub fn kernel_path() -> std::path::PathBuf {
658 std::env::var_os("BECK_KERNEL").map_or_else(
659 || std::path::PathBuf::from("target/wasm32-unknown-unknown/release/beck_wasm.wasm"),
660 std::path::PathBuf::from,
661 )
662}
663
664/// The kernel, or a refusal that says what to do about it.
665fn kernel() -> Response<Full<Bytes>> {
666 let path = kernel_path();
667 match std::fs::read(&path) {
668 Ok(module) => bytes(module, "application/wasm"),
669 Err(e) => {
670 tracing::error!(path = %path.display(), error = %e, "no Mode B kernel to serve");
671 Response::builder()
672 .status(StatusCode::NOT_FOUND)
673 .body(Full::new(Bytes::from(format!(
674 "no kernel at {}: build it with `cargo build -p beck-wasm --release \
675 --target wasm32-unknown-unknown`, or set BECK_KERNEL",
676 path.display()
677 ))))
678 .expect("static response builds")
679 }
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 /// The frame root the served JavaScript looks for, and the attributes it reads off it.
686 ///
687 /// Both clients open with `document.getElementById("b-root")` and give up if it is missing, so
688 /// a document without it is a page that never connects — and nothing would have said so,
689 /// because no test in this workspace runs JavaScript (`docs/94` §94.15).
690 #[test]
691 fn the_document_carries_the_frame_root_the_residue_looks_for() {
692 for client in [crate::THIN_CLIENT, crate::MODE_B_CLIENT] {
693 assert!(
694 client.contains(r#"getElementById("b-root")"#),
695 "a client that does not look for the frame root"
696 );
697 }
698 for read in ["dataset.bActor", "dataset.bSeq"] {
699 assert!(
700 crate::THIN_CLIENT.contains(read) || crate::MODE_B_CLIENT.contains(read),
701 "nothing reads {read}"
702 );
703 }
704 }
705
706 /// The other direction, which is the one that bites: **every attribute a client reads is one
707 /// the document writes.**
708 ///
709 /// The test above says something reads each attribute somebody thought to list; it cannot fail
710 /// when a client starts reading an attribute the server never writes. That failure is silent —
711 /// `dataset.bClaims` on a document without it is `undefined`, so the Mode B kernel would build
712 /// a `Session` with no claims and refuse commands the server accepts — and it is exactly the
713 /// shape of the defect Mode B's own `#b-root` finding was (`docs/94` §94.15).
714 ///
715 /// So the list is derived from the residue rather than written down: whatever `dataset.bFoo`
716 /// the shipped JavaScript reads, `data-b-foo` has to be in the shell.
717 #[test]
718 fn the_document_carries_every_attribute_the_residue_reads_off_it() {
719 let page = super::shell("t", 7, "ana", "{}", "<p>hi</p>", "/beck-thin.js");
720 let mut checked = 0;
721 for client in [crate::THIN_CLIENT, crate::MODE_B_CLIENT] {
722 for (i, _) in client.match_indices("dataset.b") {
723 let name: String = client[i + "dataset.".len()..]
724 .chars()
725 .take_while(|c| c.is_ascii_alphanumeric())
726 .collect();
727 // `bActor` is `data-b-actor`; a second capital would be a second dash.
728 let mut attr = String::from("data-");
729 for c in name.chars() {
730 if c.is_ascii_uppercase() {
731 attr.push('-');
732 attr.push(c.to_ascii_lowercase());
733 } else {
734 attr.push(c);
735 }
736 }
737 assert!(
738 page.contains(&format!("{attr}=")),
739 "the residue reads `dataset.{name}` and the document has no `{attr}`"
740 );
741 checked += 1;
742 }
743 }
744 // A rename that made the loop match nothing would otherwise pass in silence.
745 assert!(checked >= 3, "only {checked} attribute reads were found");
746 }
747
748 use super::*;
749 use hyper::HeaderMap;
750
751 fn headers(pairs: &[(hyper::header::HeaderName, &str)]) -> HeaderMap {
752 let mut h = HeaderMap::new();
753 for (k, v) in pairs {
754 h.insert(k.clone(), HeaderValue::from_str(v).expect("a legal header"));
755 }
756 h
757 }
758
759 #[test]
760 fn a_page_this_server_served_may_open_a_socket() {
761 assert!(same_origin(&headers(&[
762 (ORIGIN, "http://app.example"),
763 (hyper::header::HOST, "app.example"),
764 ])));
765 // …including on a port, which is what `beck run` on a laptop looks like.
766 assert!(same_origin(&headers(&[
767 (ORIGIN, "http://localhost:8080"),
768 (hyper::header::HOST, "localhost:8080"),
769 ])));
770 }
771
772 #[test]
773 fn a_page_on_another_host_may_not() {
774 assert!(!same_origin(&headers(&[
775 (ORIGIN, "https://evil.example"),
776 (hyper::header::HOST, "app.example"),
777 ])));
778 // A different port is a different origin, which is the browser's rule and not ours.
779 assert!(!same_origin(&headers(&[
780 (ORIGIN, "http://app.example:9999"),
781 (hyper::header::HOST, "app.example:8080"),
782 ])));
783 // A prefix is not an authority: `app.example.evil.test` must not pass as `app.example`.
784 assert!(!same_origin(&headers(&[
785 (ORIGIN, "https://app.example.evil.test"),
786 (hyper::header::HOST, "app.example"),
787 ])));
788 }
789
790 /// `Origin: null` is what a sandboxed iframe and a `file://` page send, and it is a value with
791 /// no authority — so it matches no host and is refused rather than treated as absent.
792 #[test]
793 fn a_null_origin_is_refused_rather_than_ignored() {
794 assert!(!same_origin(&headers(&[
795 (ORIGIN, "null"),
796 (hyper::header::HOST, "app.example"),
797 ])));
798 }
799
800 /// The decision that lets every non-browser client keep working.
801 ///
802 /// `beck test`, a script and a load generator send no `Origin`; the attack this defends against
803 /// needs a browser, and a browser always sends one.
804 #[test]
805 fn a_client_that_is_not_a_browser_sends_no_origin_and_is_allowed() {
806 assert!(same_origin(&headers(&[(
807 hyper::header::HOST,
808 "app.example"
809 )])));
810 assert!(same_origin(&HeaderMap::new()));
811 }
812
813 /// The scheme is deliberately not compared: behind a TLS-terminating gateway the page is
814 /// `https://` and the request arriving here is not.
815 #[test]
816 fn the_scheme_is_not_part_of_the_comparison() {
817 assert!(same_origin(&headers(&[
818 (ORIGIN, "https://app.example"),
819 (hyper::header::HOST, "app.example"),
820 ])));
821 }
822
823 /// The numbers, asserted so that changing one is a decision rather than an edit.
824 ///
825 /// `docs/82` §82.3 is the argument for each; this is what stops the file drifting back to
826 /// somebody else's defaults without the argument moving too.
827 #[test]
828 fn the_socket_limits_are_the_numbers_this_project_chose() {
829 let c = socket_limits();
830 assert_eq!(c.max_message_size, Some(256 << 10));
831 assert_eq!(c.max_frame_size, Some(256 << 10));
832 assert_eq!(c.read_buffer_size, 8 * 1024);
833 assert_eq!(c.max_write_buffer_size, 8 << 20);
834 // …and every one of them is tighter than the library's, which is the point.
835 let d = WebSocketConfig::default();
836 assert!(c.max_message_size < d.max_message_size);
837 assert!(c.max_frame_size < d.max_frame_size);
838 assert!(c.read_buffer_size < d.read_buffer_size);
839 assert!(c.max_write_buffer_size < d.max_write_buffer_size);
840 }
841}