Skip to main content

isb_server/
web.rs

1//! The web UI, embedded at build time (see `build.rs`) and served by
2//! `isb serve` on its TCP listener.
3//!
4//! - Files come from a table compiled into the binary, so nothing is read
5//!   from disk and a request path can never reach the filesystem.
6//! - Any other GET that is not an API path gets `index.html`, so the UI's
7//!   client-side routes (`/login`, `/account`, ...) load on a refresh.
8//! - It never answers the API: `/api/...`, `/mcp`, `/healthz`,
9//!   `/orgs/<org>/mcp` and `/orgs/<org>/api/...` are left to the server
10//!   (a 404 when nothing else claims them), so a typo in an API path is an
11//!   API error, not a page.
12//! - Vite names its bundles by content hash, so `/assets/*` is cached for a
13//!   year; `index.html` is `no-store`, so a new binary's UI loads at once.
14//! - Every page carries a strict Content-Security-Policy (scripts only from
15//!   this origin, no inline scripts, fetches only to this origin), and
16//!   refuses framing.
17
18use crate::server::Routes;
19use crate::server::http::{Request, Response};
20
21mod assets {
22    include!(concat!(env!("OUT_DIR"), "/web_assets.rs"));
23}
24
25/// Whether this binary carries the real UI (else a page saying it was not
26/// built).
27pub const BUILT: bool = assets::BUILT;
28
29/// Scripts and styles from this origin only, no inline script, fetches and
30/// event streams to this origin only, no plugins, no framing. Inline
31/// *styles* are allowed: the UI's dialog and toast components inject
32/// `<style>` elements at runtime, and a style cannot run code.
33pub const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
34img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; \
35form-action 'self'; frame-ancestors 'none'";
36
37/// The routes for the embedded UI.
38pub fn routes() -> Routes {
39    std::sync::Arc::new(|r: &Request| serve(assets::ASSETS, r))
40}
41
42/// Paths the UI must never answer: they belong to the API, even when
43/// nothing serves them.
44pub fn is_api_path(path: &str) -> bool {
45    let under = |p: &str, prefix: &str| p == prefix || p.starts_with(&format!("{prefix}/"));
46    if under(path, "/api") || under(path, "/mcp") || under(path, "/healthz") {
47        return true;
48    }
49    if let Some(rest) = path.strip_prefix("/orgs/") {
50        if let Some((_, tail)) = rest.split_once('/') {
51            return under(&format!("/{tail}"), "/mcp") || under(&format!("/{tail}"), "/api");
52        }
53    }
54    false
55}
56
57/// Answer `req` from `assets`, or `None` to leave it to the server.
58pub fn serve(assets: &[(&str, &[u8])], req: &Request) -> Option<Response> {
59    if is_api_path(&req.path) {
60        return None;
61    }
62    let head = req.method == "HEAD";
63    if req.method != "GET" && !head {
64        return Some(
65            secure(Response::text(405, "method not allowed")).header("Allow", "GET, HEAD"),
66        );
67    }
68    let find = |p: &str| {
69        assets
70            .binary_search_by(|(k, _)| k.cmp(&p))
71            .ok()
72            .map(|i| assets[i].1)
73    };
74    let path = if req.path == "/" {
75        "/index.html"
76    } else {
77        req.path.as_str()
78    };
79    let (path, body) = match find(path) {
80        Some(b) => (path, b),
81        // A missing bundle or file is a 404, never the app shell: a browser
82        // would otherwise run HTML as a script and report something baffling.
83        None if path.starts_with("/assets/") || has_extension(path) => {
84            return Some(secure(Response::text(404, "not found")));
85        }
86        None => ("/index.html", find("/index.html")?),
87    };
88    let cache = if path == "/index.html" {
89        "no-store"
90    } else if path.starts_with("/assets/") {
91        "public, max-age=31536000, immutable"
92    } else {
93        "public, max-age=3600"
94    };
95    let r = Response::new(200)
96        .header("Content-Type", content_type(path))
97        .header("Cache-Control", cache);
98    Some(secure(if head { r } else { r.body(body.to_vec()) }))
99}
100
101fn has_extension(path: &str) -> bool {
102    path.rsplit('/').next().is_some_and(|f| f.contains('.'))
103}
104
105/// The headers every UI response carries.
106fn secure(r: Response) -> Response {
107    r.header("Content-Security-Policy", CSP)
108        .header("X-Frame-Options", "DENY")
109        .header("X-Content-Type-Options", "nosniff")
110        .header("Referrer-Policy", "same-origin")
111        .header("Cross-Origin-Opener-Policy", "same-origin")
112        .header(
113            "Permissions-Policy",
114            "camera=(), microphone=(), geolocation=(), payment=()",
115        )
116}
117
118fn content_type(path: &str) -> &'static str {
119    let ext = path.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
120    match ext {
121        "html" => "text/html; charset=utf-8",
122        "js" | "mjs" => "text/javascript; charset=utf-8",
123        "css" => "text/css; charset=utf-8",
124        "json" => "application/json",
125        "webmanifest" => "application/manifest+json",
126        "svg" => "image/svg+xml",
127        "png" => "image/png",
128        "jpg" | "jpeg" => "image/jpeg",
129        "webp" => "image/webp",
130        "ico" => "image/x-icon",
131        "woff2" => "font/woff2",
132        "woff" => "font/woff",
133        "txt" => "text/plain; charset=utf-8",
134        "wasm" => "application/wasm",
135        _ => "application/octet-stream",
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::server::http::Peer;
143
144    const ASSETS: &[(&str, &[u8])] = &[
145        ("/assets/index-abc123.css", b"body{}"),
146        ("/assets/index-abc123.js", b"console.log(1)"),
147        ("/favicon-32.png", b"\x89PNG"),
148        ("/index.html", b"<!doctype html><title>isb</title>"),
149    ];
150
151    fn req(method: &str, path: &str) -> Request {
152        Request {
153            method: method.into(),
154            path: path.into(),
155            query: None,
156            headers: vec![],
157            body: vec![],
158            peer: Peer::Tcp("127.0.0.1:1".parse().unwrap()),
159        }
160    }
161
162    fn get(path: &str) -> Option<Response> {
163        serve(ASSETS, &req("GET", path))
164    }
165
166    #[test]
167    fn the_embedded_table_is_sorted_and_has_an_index() {
168        assert!(assets::ASSETS.windows(2).all(|w| w[0].0 < w[1].0));
169        assert!(assets::ASSETS.iter().any(|(p, _)| *p == "/index.html"));
170    }
171
172    #[test]
173    fn files_with_types_and_caching() {
174        let r = get("/assets/index-abc123.js").unwrap();
175        assert_eq!(r.status, 200);
176        assert_eq!(r.body, b"console.log(1)");
177        assert_eq!(
178            r.get_header("content-type"),
179            Some("text/javascript; charset=utf-8")
180        );
181        assert!(r.get_header("cache-control").unwrap().contains("immutable"));
182        let r = get("/assets/index-abc123.css").unwrap();
183        assert_eq!(
184            r.get_header("content-type"),
185            Some("text/css; charset=utf-8")
186        );
187        let r = get("/favicon-32.png").unwrap();
188        assert_eq!(r.get_header("content-type"), Some("image/png"));
189        assert_eq!(r.get_header("cache-control"), Some("public, max-age=3600"));
190    }
191
192    #[test]
193    fn spa_fallback_serves_index_uncached() {
194        for p in [
195            "/",
196            "/index.html",
197            "/login",
198            "/account/tokens",
199            "/invite",
200            "/orgs/ocai",
201            "/orgs/ocai/stacks/web",
202        ] {
203            let r = get(p).unwrap_or_else(|| panic!("{p} not served"));
204            assert_eq!(r.status, 200, "{p}");
205            assert_eq!(r.body, b"<!doctype html><title>isb</title>", "{p}");
206            assert_eq!(r.get_header("cache-control"), Some("no-store"), "{p}");
207            assert_eq!(
208                r.get_header("content-type"),
209                Some("text/html; charset=utf-8")
210            );
211        }
212    }
213
214    #[test]
215    fn missing_files_are_404_not_the_shell() {
216        for p in [
217            "/assets/gone-123.js",
218            "/assets/x",
219            "/robots.txt",
220            "/a/b.png",
221        ] {
222            assert_eq!(get(p).unwrap().status, 404, "{p}");
223        }
224    }
225
226    #[test]
227    fn api_paths_are_never_shadowed() {
228        for p in [
229            "/api",
230            "/api/",
231            "/api/v1/auth/me",
232            "/api/v1/tools/stack_list",
233            "/api/v1/openapi.json",
234            "/api/v1/events",
235            "/api/v2/anything",
236            "/mcp",
237            "/mcp/x",
238            "/healthz",
239            "/orgs/ocai/mcp",
240            "/orgs/ocai/api/v1/tools/stack_list",
241            "/orgs/ocai/api",
242        ] {
243            assert!(is_api_path(p), "{p}");
244            assert!(get(p).is_none(), "{p} answered by the UI");
245            assert!(serve(ASSETS, &req("POST", p)).is_none(), "POST {p}");
246        }
247        for p in [
248            "/",
249            "/login",
250            "/apix",
251            "/mcpx",
252            "/orgs/ocai",
253            "/orgs/ocai/apps",
254        ] {
255            assert!(!is_api_path(p), "{p}");
256        }
257    }
258
259    #[test]
260    fn security_headers_everywhere() {
261        for r in [
262            get("/").unwrap(),
263            get("/assets/index-abc123.js").unwrap(),
264            get("/assets/missing.js").unwrap(),
265            serve(ASSETS, &req("POST", "/login")).unwrap(),
266        ] {
267            let csp = r.get_header("content-security-policy").unwrap();
268            assert!(csp.contains("script-src 'self';"), "{csp}");
269            assert!(csp.contains("connect-src 'self'"));
270            assert!(csp.contains("frame-ancestors 'none'"));
271            assert!(!csp.contains("unsafe-eval"));
272            assert_eq!(r.get_header("x-frame-options"), Some("DENY"));
273            assert_eq!(r.get_header("referrer-policy"), Some("same-origin"));
274            assert_eq!(r.get_header("x-content-type-options"), Some("nosniff"));
275        }
276    }
277
278    #[test]
279    fn methods() {
280        let r = serve(ASSETS, &req("HEAD", "/login")).unwrap();
281        assert_eq!(r.status, 200);
282        assert!(r.body.is_empty());
283        let r = serve(ASSETS, &req("POST", "/login")).unwrap();
284        assert_eq!(r.status, 405);
285        assert_eq!(r.get_header("allow"), Some("GET, HEAD"));
286    }
287}