Skip to main content

isb_daemon/daemon/workspaces/
preview.rs

1//! Previews served by isb itself (docs/concepts/workspaces.md#previews-through-isb):
2//! a published workspace port at a URL of isb's, behind isb's sign-in, for
3//! hosts with no domain or ingress.
4//!
5//! The security design:
6//!
7//! - **An origin of its own per preview.** A preview runs whatever the
8//!   workspace serves, so it never shares isb's origin: on isb's origin its
9//!   scripts could call isb's API with the viewer's session (a same-origin
10//!   request carries the cookie and may set `X-Isb-Csrf`). Each port gets the
11//!   host `<port>-<workspace>-<org>` under the preview domain: `--preview-domain`,
12//!   else, when isb is reached on loopback, `localhost`, whose subdomains
13//!   browsers resolve to loopback on their own. Previews of different ports,
14//!   workspaces and orgs are then different origins from each other and from
15//!   isb, and `x.localhost` is not even the same site as `localhost` or
16//!   `127.0.0.1`, so a preview cannot set cookies for isb either.
17//! - **isb's session never reaches it.** The session cookie is host-only, so
18//!   the browser does not send it to a preview host; the proxy also drops it,
19//!   and Cloudflare Access' assertion, before anything reaches the app.
20//! - **A one-time link, then a cookie for that preview alone.** The Ports
21//!   tab asks `workspace_port_open` (members and up, audited) for a link
22//!   carrying a random token, good once and for 60 seconds, bound to that
23//!   preview's host and the caller. Redeeming it sets `isb_preview`:
24//!   random, HttpOnly, `SameSite=Strict`, host-only, 8 hours, and answers
25//!   with a page that refreshes to the app, so the token never reaches the
26//!   app or a `Referer`. Every request then needs that cookie; the caller's
27//!   membership is checked again every minute, and a port that is no longer
28//!   published stops answering at once.
29//! - **Nothing of isb's UI applies.** Preview hosts are matched by `Host`
30//!   ahead of everything else on the listener, so isb's pages, API and
31//!   headers (its Content-Security-Policy among them) are never served there.
32
33use std::collections::HashMap;
34use std::net::{IpAddr, SocketAddr};
35
36use super::proxy::{self, COOKIE};
37use super::*;
38use crate::server::http::{Request, Response};
39
40/// The path on a preview host that redeems a one-time link.
41pub(super) const OPEN_PATH: &str = "/__isb_preview";
42const TOKEN_TTL: u64 = 60;
43const SESSION_TTL: u64 = 8 * 3600;
44const RECHECK: u64 = 60;
45
46/// Where a preview host's requests go.
47#[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/// Who opened a preview.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub(super) enum Who {
58    /// A signed-in user, by id: their membership is checked again.
59    User(i64),
60    /// The unix socket or a superadmin: trusted until the session ends.
61    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/// `--preview-domain`: `[http(s)://]DOMAIN[:PORT]`.
73#[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
120/// The preview host's first label for a port: `<port>-<workspace>-<org>`,
121/// or `<port>-<hash>` when that is longer than a DNS label.
122pub(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
135/// The preview label a `Host` names: one label under the preview domain,
136/// or under `localhost`. `None` for every other host (isb's own).
137pub(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
155/// The base URL a preview gets: the preview domain's, else, when the
156/// caller's own isb URL (`origin`) is on loopback, `localhost`'s.
157pub(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/// The previews: where each host goes, open links, sessions.
193#[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    /// `--preview-domain`, once at startup.
207    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    /// A one-time link token for `label`.
224    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    /// Spend a link token on `label`'s host: a new session id, once.
241    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    /// The session `sid` on `label`'s host, if it is one, with whether its
260    /// caller is due to be checked again.
261    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
278/// Whether `who` may still reach `org`'s workspace ports: an enabled user
279/// who is a platform admin or the org's member (or above).
280fn 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
293/// A page of the preview's own (no isb UI): plain, no scripts, no referrer.
294fn page(status: u16, msg: &str) -> Response {
295    let esc = msg
296        .replace('&', "&amp;")
297        .replace('<', "&lt;")
298        .replace('>', "&gt;");
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
310/// A query parameter, percent-decoded.
311fn 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
340/// Where to go after the link is spent: a path on the preview's own host.
341pub(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
366/// Spend a link: the session cookie, and a page that refreshes to the app.
367fn 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
390/// One request on a preview host.
391fn 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
429/// The listener's preview routes: requests for a preview host, by `Host`.
430pub(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        // isb's own hosts, and anything deeper or odd, are not previews.
463        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        // Another preview's host cannot spend it, and that burns it.
512        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        // The session works on its host only, and is re-checked after a minute.
527        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        // Spent: a second visit is refused.
576        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}