1use std::collections::HashMap;
34use std::net::{IpAddr, SocketAddr};
35
36use super::proxy::{self, COOKIE};
37use super::*;
38use crate::server::http::{Request, Response};
39
40pub(super) const OPEN_PATH: &str = "/__isb_preview";
42const TOKEN_TTL: u64 = 60;
43const SESSION_TTL: u64 = 8 * 3600;
44const RECHECK: u64 = 60;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub(super) struct Target {
49 pub org: OrgId,
50 pub ws: String,
51 pub port: u16,
52 pub ip: Option<IpAddr>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub(super) enum Who {
58 User(i64),
60 Superadmin(String),
62}
63
64#[derive(Debug, Clone)]
65struct Grant {
66 label: String,
67 who: Who,
68 expires: u64,
69 checked: u64,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct PreviewBase {
75 scheme: String,
76 domain: String,
77 port: Option<u16>,
78}
79
80impl PreviewBase {
81 pub fn parse(s: &str) -> Result<PreviewBase> {
82 let s = s.trim().trim_end_matches('/').to_ascii_lowercase();
83 let (scheme, rest) = match s.split_once("://") {
84 Some((sc @ ("http" | "https"), r)) => (sc.to_string(), r.to_string()),
85 Some(_) => return Err(Error::invalid("--preview-domain: http:// or https:// only")),
86 None => ("https".to_string(), s.clone()),
87 };
88 let (domain, port) = match rest.rsplit_once(':') {
89 Some((d, p)) => (
90 d.to_string(),
91 Some(
92 p.parse::<u16>()
93 .map_err(|_| Error::invalid("--preview-domain: bad port"))?,
94 ),
95 ),
96 None => (rest, None),
97 };
98 let ok = domain == "localhost"
99 || crate::ingress::domain::check_host(&domain).is_ok() && !domain.starts_with("*.");
100 if !ok || domain.contains('/') {
101 return Err(Error::invalid(format!(
102 "--preview-domain {s:?}: a domain whose subdomains reach this listener, e.g. preview.example.com"
103 )));
104 }
105 Ok(PreviewBase {
106 scheme,
107 domain,
108 port,
109 })
110 }
111
112 pub(super) fn url(&self, label: &str) -> String {
113 match self.port {
114 Some(p) => format!("{}://{label}.{}:{p}", self.scheme, self.domain),
115 None => format!("{}://{label}.{}", self.scheme, self.domain),
116 }
117 }
118}
119
120pub(super) fn label(org: &OrgId, ws: &str, port: u16) -> String {
123 let l = format!("{port}-{ws}-{org}");
124 if l.len() <= 63 {
125 return l;
126 }
127 let mut h: u32 = 0x811c9dc5;
128 for b in format!("{org}/{ws}").bytes() {
129 h ^= b as u32;
130 h = h.wrapping_mul(0x01000193);
131 }
132 format!("{port}-{h:08x}")
133}
134
135pub(super) fn label_of_host(host: &str, base: Option<&PreviewBase>) -> Option<String> {
138 let host = host.trim().to_ascii_lowercase();
139 let name = match host.rsplit_once(':') {
140 Some((n, p)) if p.bytes().all(|b| b.is_ascii_digit()) && !n.ends_with(']') => n,
141 _ => host.as_str(),
142 };
143 let first = base
144 .and_then(|b| name.strip_suffix(&format!(".{}", b.domain)))
145 .or_else(|| name.strip_suffix(".localhost"))?;
146 let ok = !first.is_empty()
147 && first.len() <= 63
148 && first
149 .bytes()
150 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
151 && first.starts_with(|c: char| c.is_ascii_digit());
152 ok.then(|| first.to_string())
153}
154
155pub(super) fn base_for(base: Option<&PreviewBase>, origin: Option<&str>) -> Result<PreviewBase> {
158 if let Some(b) = base {
159 return Ok(b.clone());
160 }
161 let none = || {
162 Error::invalid(
163 "previews through isb need their own origin: run isb serve with --preview-domain DOMAIN (a wildcard name that reaches this listener), or reach isb on localhost (docs/concepts/workspaces.md#previews-through-isb)",
164 )
165 };
166 let o = origin.ok_or_else(none)?.trim().trim_end_matches('/');
167 let (scheme, rest) = o.split_once("://").ok_or_else(none)?;
168 let (host, port) = match rest.rsplit_once(':') {
169 Some((h, p)) if !h.is_empty() && !p.contains(']') => (h, p.parse::<u16>().ok()),
170 _ => (rest, None),
171 };
172 let host = host.trim_start_matches('[').trim_end_matches(']');
173 let loopback = host.eq_ignore_ascii_case("localhost")
174 || host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback());
175 if !loopback || !matches!(scheme, "http" | "https") {
176 return Err(none());
177 }
178 Ok(PreviewBase {
179 scheme: scheme.to_string(),
180 domain: "localhost".into(),
181 port,
182 })
183}
184
185fn random_token() -> String {
186 use ring::rand::SecureRandom;
187 let mut b = [0u8; 32];
188 let _ = ring::rand::SystemRandom::new().fill(&mut b);
189 hex(&b)
190}
191
192#[derive(Default)]
194pub struct Previews {
195 base: OnceLock<Option<PreviewBase>>,
196 index: Mutex<HashMap<String, Target>>,
197 tokens: Mutex<HashMap<String, Grant>>,
198 sessions: Mutex<HashMap<String, Grant>>,
199}
200
201fn locked<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
202 m.lock().unwrap_or_else(|e| e.into_inner())
203}
204
205impl Previews {
206 pub fn set_base(&self, b: Option<PreviewBase>) {
208 let _ = self.base.set(b);
209 }
210
211 pub(super) fn base(&self) -> Option<&PreviewBase> {
212 self.base.get().and_then(Option::as_ref)
213 }
214
215 pub(super) fn set_index(&self, index: HashMap<String, Target>) {
216 *locked(&self.index) = index;
217 }
218
219 pub(super) fn target(&self, label: &str) -> Option<Target> {
220 locked(&self.index).get(label).cloned()
221 }
222
223 pub(super) fn mint(&self, label: &str, who: Who, now: u64) -> String {
225 let t = random_token();
226 let mut m = locked(&self.tokens);
227 m.retain(|_, g| g.expires > now);
228 m.insert(
229 t.clone(),
230 Grant {
231 label: label.into(),
232 who,
233 expires: now + TOKEN_TTL,
234 checked: now,
235 },
236 );
237 t
238 }
239
240 pub(super) fn redeem(&self, token: &str, label: &str, now: u64) -> Option<String> {
242 let g = locked(&self.tokens).remove(token)?;
243 if g.expires <= now || g.label != label {
244 return None;
245 }
246 let sid = random_token();
247 let mut s = locked(&self.sessions);
248 s.retain(|_, g| g.expires > now);
249 s.insert(
250 sid.clone(),
251 Grant {
252 expires: now + SESSION_TTL,
253 ..g
254 },
255 );
256 Some(sid)
257 }
258
259 fn session(&self, sid: &str, label: &str, now: u64) -> Option<(Who, bool)> {
262 let s = locked(&self.sessions);
263 let g = s.get(sid).filter(|g| g.label == label && g.expires > now)?;
264 Some((g.who.clone(), now >= g.checked + RECHECK))
265 }
266
267 fn checked(&self, sid: &str, now: u64) {
268 if let Some(g) = locked(&self.sessions).get_mut(sid) {
269 g.checked = now;
270 }
271 }
272
273 fn end(&self, sid: &str) {
274 locked(&self.sessions).remove(sid);
275 }
276}
277
278fn still_allowed(d: &Daemon, org: &OrgId, who: &Who) -> bool {
281 let Who::User(id) = who else { return true };
282 let Ok(u) = d.users.user(*id) else {
283 return false;
284 };
285 !u.disabled
286 && (u.platform_admin
287 || d.users.memberships(*id).is_ok_and(|ms| {
288 ms.iter()
289 .any(|m| &m.org == org && m.role >= crate::auth::Role::Member)
290 }))
291}
292
293fn page(status: u16, msg: &str) -> Response {
295 let esc = msg
296 .replace('&', "&")
297 .replace('<', "<")
298 .replace('>', ">");
299 Response::new(status)
300 .header("Content-Type", "text/html; charset=utf-8")
301 .header("Content-Security-Policy", "default-src 'none'")
302 .header("Referrer-Policy", "no-referrer")
303 .header("Cache-Control", "no-store")
304 .header("X-Frame-Options", "DENY")
305 .body(format!(
306 "<!doctype html><title>isb preview</title><p>{esc}</p>\n"
307 ))
308}
309
310fn query_param(q: Option<&str>, key: &str) -> Option<String> {
312 let v = q?.split('&').find_map(|kv| {
313 kv.split_once('=')
314 .filter(|(k, _)| *k == key)
315 .map(|(_, v)| v)
316 })?;
317 let b = v.as_bytes();
318 let mut out = Vec::new();
319 let mut i = 0;
320 while i < b.len() {
321 match b[i] {
322 b'%' if i + 2 < b.len() => {
323 let h = std::str::from_utf8(&b[i + 1..i + 3]).ok()?;
324 out.push(u8::from_str_radix(h, 16).ok()?);
325 i += 3;
326 }
327 b'+' => {
328 out.push(b' ');
329 i += 1;
330 }
331 c => {
332 out.push(c);
333 i += 1;
334 }
335 }
336 }
337 String::from_utf8(out).ok()
338}
339
340pub(super) fn safe_next(next: Option<&str>) -> String {
342 match next {
343 Some(n)
344 if n.starts_with('/')
345 && !n.starts_with("//")
346 && n.bytes()
347 .all(|b| b.is_ascii_alphanumeric() || b"/-._~?=&%:+,#".contains(&b)) =>
348 {
349 n.to_string()
350 }
351 _ => "/".into(),
352 }
353}
354
355fn cookie_value(r: &Request, name: &str) -> Option<String> {
356 r.headers
357 .iter()
358 .filter(|(k, _)| k.eq_ignore_ascii_case("cookie"))
359 .flat_map(|(_, v)| v.split(';'))
360 .find_map(|p| {
361 let (k, v) = p.trim().split_once('=')?;
362 (k == name).then(|| v.to_string())
363 })
364}
365
366fn open(p: &Previews, r: &Request, label: &str) -> Response {
368 let token = query_param(r.query.as_deref(), "token");
369 let Some(sid) = token.and_then(|t| p.redeem(&t, label, now())) else {
370 return page(
371 403,
372 "This preview link was used already or has expired. Open the port again from the workspace's Ports tab.",
373 );
374 };
375 let next = safe_next(query_param(r.query.as_deref(), "next").as_deref());
376 let secure = r
377 .header("x-forwarded-proto")
378 .is_some_and(|v| v.eq_ignore_ascii_case("https"));
379 page(200, "Opening the preview...")
380 .header("Refresh", format!("0;url={next}"))
381 .header(
382 "Set-Cookie",
383 format!(
384 "{COOKIE}={sid}; Path=/; HttpOnly; SameSite=Strict; Max-Age={SESSION_TTL}{}",
385 if secure { "; Secure" } else { "" }
386 ),
387 )
388}
389
390fn handle(d: &Daemon, r: &Request, label: &str) -> Response {
392 let p = &d.workspaces.previews;
393 if r.path == OPEN_PATH {
394 return open(p, r, label);
395 }
396 let sid = cookie_value(r, COOKIE);
397 let Some((who, due)) = sid.as_deref().and_then(|s| p.session(s, label, now())) else {
398 return page(
399 401,
400 "Open this preview from the workspace's Ports tab in isb: it needs a link from there.",
401 );
402 };
403 let Some(t) = p.target(label) else {
404 return page(404, "This port is no longer published.");
405 };
406 let sid = sid.unwrap_or_default();
407 if due {
408 if !still_allowed(d, &t.org, &who) {
409 p.end(&sid);
410 return page(403, "You no longer have access to this workspace.");
411 }
412 p.checked(&sid, now());
413 }
414 let Some(ip) = t.ip else {
415 return page(502, &format!("Workspace {} is not running.", t.ws));
416 };
417 match proxy::forward(r, SocketAddr::new(ip, t.port)) {
418 Ok(resp) => resp,
419 Err(e) => page(
420 502,
421 &format!(
422 "Port {} of workspace {}: {e}. Is the server running, listening on 0.0.0.0 (not 127.0.0.1)?",
423 t.port, t.ws
424 ),
425 ),
426 }
427}
428
429pub(in crate::daemon) fn route(d: Arc<Daemon>) -> crate::server::Routes {
431 Arc::new(move |r: &Request| {
432 let label = label_of_host(r.header("host")?, d.workspaces.previews.base())?;
433 Some(handle(&d, r, &label))
434 })
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::server::http::Peer;
441
442 #[test]
443 fn each_port_has_a_host_of_its_own() {
444 let acme = OrgId::new("acme").unwrap();
445 assert_eq!(label(&acme, "workspace", 3000), "3000-workspace-acme");
446 let long = OrgId::new("a".repeat(31)).unwrap();
447 let l = label(&long, &"w".repeat(30), 3000);
448 assert!(l.len() <= 63 && l.starts_with("3000-"), "{l}");
449 let b = PreviewBase::parse("preview.example.com").unwrap();
450 assert_eq!(
451 b.url("3000-workspace-acme"),
452 "https://3000-workspace-acme.preview.example.com"
453 );
454 assert_eq!(
455 label_of_host("3000-workspace-acme.preview.example.com", Some(&b)).as_deref(),
456 Some("3000-workspace-acme")
457 );
458 assert_eq!(
459 label_of_host("3000-workspace-acme.localhost:8192", None).as_deref(),
460 Some("3000-workspace-acme")
461 );
462 for h in [
464 "localhost:8192",
465 "127.0.0.1:8192",
466 "isb.example.com",
467 "preview.example.com",
468 "a.b.preview.example.com",
469 "web.localhost",
470 "3000-x.evil.com",
471 ".localhost",
472 ] {
473 assert_eq!(label_of_host(h, Some(&b)), None, "{h}");
474 }
475 assert!(PreviewBase::parse("ftp://x.example.com").is_err());
476 assert!(PreviewBase::parse("*.example.com").is_err());
477 let local = PreviewBase::parse("http://localhost:8192").unwrap();
478 assert_eq!(local.url("1-w-a"), "http://1-w-a.localhost:8192");
479 }
480
481 #[test]
482 fn without_a_preview_domain_only_a_loopback_isb_gets_previews() {
483 let b = base_for(None, Some("http://127.0.0.1:8192")).unwrap();
484 assert_eq!(
485 b.url("3000-workspace-acme"),
486 "http://3000-workspace-acme.localhost:8192"
487 );
488 let b = base_for(None, Some("http://localhost:9000/")).unwrap();
489 assert_eq!(b.url("x"), "http://x.localhost:9000");
490 assert!(base_for(None, Some("http://[::1]:8192")).is_ok());
491 for o in [
492 None,
493 Some("https://isb.example.com"),
494 Some("http://10.0.0.5:8192"),
495 Some("javascript:alert(1)"),
496 ] {
497 assert!(base_for(None, o).is_err(), "{o:?}");
498 }
499 let set = PreviewBase::parse("https://preview.example.com").unwrap();
500 assert_eq!(
501 base_for(Some(&set), Some("https://isb.example.com")).unwrap(),
502 set
503 );
504 }
505
506 #[test]
507 fn a_link_is_spent_once_on_its_own_host_and_expires() {
508 let p = Previews::default();
509 let t = p.mint("3000-workspace-acme", Who::User(7), 1000);
510 assert_eq!(t.len(), 64);
511 assert!(p.redeem(&t, "3000-workspace-beta", 1001).is_none());
513 assert!(p.redeem(&t, "3000-workspace-acme", 1001).is_none());
514 let t = p.mint("3000-workspace-acme", Who::User(7), 1000);
515 assert!(
516 p.redeem(&t, "3000-workspace-acme", 1000 + TOKEN_TTL)
517 .is_none(),
518 "expired"
519 );
520 let t = p.mint("3000-workspace-acme", Who::User(7), 1000);
521 let sid = p.redeem(&t, "3000-workspace-acme", 1010).unwrap();
522 assert!(
523 p.redeem(&t, "3000-workspace-acme", 1011).is_none(),
524 "used once"
525 );
526 assert_eq!(
528 p.session(&sid, "3000-workspace-acme", 1020),
529 Some((Who::User(7), false))
530 );
531 assert_eq!(
532 p.session(&sid, "3000-workspace-acme", 1000 + RECHECK),
533 Some((Who::User(7), true))
534 );
535 assert_eq!(p.session(&sid, "5173-workspace-acme", 1020), None);
536 assert_eq!(
537 p.session(&sid, "3000-workspace-acme", 1010 + SESSION_TTL),
538 None
539 );
540 p.end(&sid);
541 assert_eq!(p.session(&sid, "3000-workspace-acme", 1020), None);
542 }
543
544 #[test]
545 fn the_link_page_sets_a_strict_host_only_cookie_and_leaks_no_token() {
546 let p = Previews::default();
547 let t = p.mint(
548 "3000-workspace-acme",
549 Who::Superadmin("local".into()),
550 now(),
551 );
552 let r = Request {
553 method: "GET".into(),
554 path: OPEN_PATH.into(),
555 query: Some(format!("token={t}&next=%2Fapp%3Fx%3D1")),
556 headers: vec![("Host".into(), "3000-workspace-acme.localhost:8192".into())],
557 body: vec![],
558 peer: Peer::Tcp("127.0.0.1:1".parse().unwrap()),
559 };
560 let resp = open(&p, &r, "3000-workspace-acme");
561 assert_eq!(resp.status, 200);
562 let c = resp.get_header("set-cookie").unwrap();
563 assert!(c.starts_with("isb_preview="), "{c}");
564 for want in ["HttpOnly", "SameSite=Strict", "Path=/"] {
565 assert!(c.contains(want), "{c}");
566 }
567 assert!(!c.to_ascii_lowercase().contains("domain="), "{c}");
568 assert_eq!(resp.get_header("refresh"), Some("0;url=/app?x=1"));
569 assert_eq!(resp.get_header("referrer-policy"), Some("no-referrer"));
570 assert_eq!(
571 resp.get_header("content-security-policy"),
572 Some("default-src 'none'")
573 );
574 assert!(!String::from_utf8_lossy(&resp.body).contains(&t));
575 assert_eq!(open(&p, &r, "3000-workspace-acme").status, 403);
577 for bad in [
578 "//evil.com",
579 "https://evil.com",
580 "/a\"onload",
581 "javascript:x",
582 ] {
583 assert_eq!(safe_next(Some(bad)), "/", "{bad}");
584 }
585 }
586}