Skip to main content

vcon_server/
console.rs

1//! The console: a web page served on the TLS listener at `/console/`. It
2//! is a client of the HTTP API and nothing more. Its three files are built
3//! into the binary and need no key: they are the same for every caller,
4//! hold no data and change nothing. Every request the page makes is an API
5//! request that carries the key the user signed in with.
6
7use axum::Router;
8use axum::extract::Request;
9use axum::http::header::{CONTENT_TYPE, LOCATION};
10use axum::http::{HeaderName, HeaderValue, StatusCode};
11use axum::middleware::{self, Next};
12use axum::response::{IntoResponse, Response};
13use axum::routing::{any, get};
14
15/// The page, its script and its styles, as built into the binary.
16const PAGE: &[u8] = include_bytes!("../console/index.html");
17const SCRIPT: &[u8] = include_bytes!("../console/console.js");
18const STYLE: &[u8] = include_bytes!("../console/console.css");
19
20/// The headers on every console answer: no inline code, nothing from
21/// another origin, no framing, no sniffing, no referrer, no caching.
22pub const HEADERS: [(&str, &str); 7] = [
23    (
24        "content-security-policy",
25        "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; \
26         connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; \
27         require-trusted-types-for 'script'; trusted-types 'none'",
28    ),
29    ("x-frame-options", "DENY"),
30    ("x-content-type-options", "nosniff"),
31    ("referrer-policy", "no-referrer"),
32    ("cache-control", "no-store"),
33    ("cross-origin-opener-policy", "same-origin"),
34    ("cross-origin-resource-policy", "same-origin"),
35];
36
37/// The console's routes, merged into the API's router: its three files,
38/// `/console` sent on to `/console/`, and 404 for any other path under
39/// it, each answer with the headers.
40pub fn router<S: Clone + Send + Sync + 'static>() -> Router<S> {
41    Router::new()
42        .route(
43            "/console",
44            get(|| async { (StatusCode::PERMANENT_REDIRECT, [(LOCATION, "/console/")]) }),
45        )
46        .route("/console/", get(|| file(PAGE, "text/html; charset=utf-8")))
47        .route(
48            "/console/console.js",
49            get(|| file(SCRIPT, "text/javascript; charset=utf-8")),
50        )
51        .route(
52            "/console/console.css",
53            get(|| file(STYLE, "text/css; charset=utf-8")),
54        )
55        .route("/console/{*other}", any(|| async { StatusCode::NOT_FOUND }))
56        // On the console's own routes only, so the API's answers and its
57        // fallback are left as they are.
58        .route_layer(middleware::from_fn(with_headers))
59}
60
61async fn file(body: &'static [u8], media_type: &'static str) -> Response {
62    ([(CONTENT_TYPE, media_type)], body).into_response()
63}
64
65/// Puts every header in `HEADERS` on a console answer.
66async fn with_headers(request: Request, next: Next) -> Response {
67    let mut answer = next.run(request).await;
68    for (name, value) in HEADERS {
69        answer.headers_mut().insert(
70            HeaderName::from_static(name),
71            HeaderValue::from_static(value),
72        );
73    }
74    answer
75}
76
77#[cfg(test)]
78mod tests {
79    use std::path::PathBuf;
80
81    fn source(name: &str) -> String {
82        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
83            .join("console")
84            .join(name);
85        std::fs::read_to_string(path).expect("the source file")
86    }
87
88    /// The headers say what the design says, word for word: a weaker
89    /// policy fails here, not only in a browser.
90    #[test]
91    fn the_headers_are_the_designed_ones() {
92        let headers: Vec<(&str, &str)> = super::HEADERS.to_vec();
93        assert_eq!(
94            headers,
95            [
96                (
97                    "content-security-policy",
98                    "default-src 'none'; script-src 'self'; style-src 'self'; \
99                     img-src 'self'; connect-src 'self'; base-uri 'none'; \
100                     form-action 'none'; frame-ancestors 'none'; \
101                     require-trusted-types-for 'script'; trusted-types 'none'"
102                ),
103                ("x-frame-options", "DENY"),
104                ("x-content-type-options", "nosniff"),
105                ("referrer-policy", "no-referrer"),
106                ("cache-control", "no-store"),
107                ("cross-origin-opener-policy", "same-origin"),
108                ("cross-origin-resource-policy", "same-origin"),
109            ]
110        );
111    }
112
113    /// The page loads its one script and one stylesheet and holds no
114    /// inline script, style or event handler, which the CSP would refuse.
115    #[test]
116    fn the_page_holds_no_inline_code() {
117        let page = source("index.html");
118        assert!(
119            page.contains(r#"<script type="module" src="console.js"></script>"#),
120            "the page loads its script"
121        );
122        assert!(
123            page.contains(r#"<link rel="stylesheet" href="console.css">"#),
124            "the page loads its styles"
125        );
126        let lower = page.to_lowercase();
127        assert_eq!(lower.matches("<script").count(), 1, "one script element");
128        assert!(!lower.contains("<style"), "no style element");
129        assert!(!lower.contains(" style="), "no style attribute");
130        assert!(!has_event_handler(&page), "no event-handler attribute");
131    }
132
133    /// Whether markup holds an attribute such as `onclick=`: whitespace,
134    /// `on`, a name of letters, then `=` after any spaces.
135    fn has_event_handler(html: &str) -> bool {
136        let lower = html.to_lowercase();
137        lower
138            .match_indices("on")
139            .filter(|(at, _)| lower[..*at].ends_with(char::is_whitespace))
140            .any(|(at, _)| {
141                let rest = &lower[at + 2..];
142                let name = rest.chars().take_while(char::is_ascii_lowercase).count();
143                name > 0 && rest[name..].trim_start().starts_with('=')
144            })
145    }
146
147    #[test]
148    fn an_event_handler_is_found_wherever_it_is() {
149        for found in [
150            "<b onclick=\"x\">",
151            "<b\nonload = x>",
152            "<img src=a ONERROR=x>",
153        ] {
154            assert!(has_event_handler(found), "{found}");
155        }
156        for clean in [
157            "<b class=on>",
158            "<b>on=</b>",
159            "<a href=\"x\">done</a>",
160            "<b on=x>",
161        ] {
162            assert!(!has_event_handler(clean), "{clean}");
163        }
164    }
165
166    /// The script reaches the page only through text, never markup, runs
167    /// no code from strings, and keeps nothing in browser storage.
168    #[test]
169    fn the_script_uses_no_html_sink_and_no_storage() {
170        let script = source("console.js");
171        for refused in [
172            "innerHTML",
173            "outerHTML",
174            "insertAdjacentHTML",
175            "document.write",
176            "eval(",
177            "Function(",
178            "setTimeout(\"",
179            "setInterval(\"",
180            "localStorage",
181            "sessionStorage",
182            "indexedDB",
183            "document.cookie",
184            "caches.",
185        ] {
186            assert!(!script.contains(refused), "the script uses {refused}");
187        }
188        assert!(script.contains("textContent"), "values are set as text");
189    }
190
191    /// Every request goes through one call of `fetch`, which never lets
192    /// the browser keep the answer, send a cookie or follow a redirect.
193    #[test]
194    fn every_request_goes_through_one_fetch() {
195        let script = source("console.js");
196        assert_eq!(script.matches("fetch(").count(), 1, "one fetch call");
197        for option in [
198            r#"cache: "no-store""#,
199            r#"credentials: "omit""#,
200            r#"redirect: "error""#,
201        ] {
202            assert!(script.contains(option), "fetch is made with {option}");
203        }
204    }
205}