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 hold the rules the design sets, so a weaker policy
89    /// fails here, not only in a browser: the CSP allows nothing by
90    /// default, each source is the console's own origin or none, and no
91    /// script may run but the console's file; the page is never framed,
92    /// cached, sniffed or sent a referrer.
93    #[test]
94    fn the_headers_are_the_designed_ones() {
95        let headers: std::collections::BTreeMap<&str, &str> = super::HEADERS.into_iter().collect();
96        assert_eq!(headers.len(), super::HEADERS.len(), "each header once");
97        let csp: std::collections::BTreeMap<&str, Vec<&str>> = headers["content-security-policy"]
98            .split(';')
99            .map(|d| {
100                let mut words = d.split_whitespace();
101                (words.next().expect("a directive"), words.collect())
102            })
103            .collect();
104        for (directive, sources) in [
105            ("default-src", ["'none'"]),
106            ("script-src", ["'self'"]),
107            ("frame-ancestors", ["'none'"]),
108            ("base-uri", ["'none'"]),
109            ("form-action", ["'none'"]),
110            ("require-trusted-types-for", ["'script'"]),
111            ("trusted-types", ["'none'"]),
112        ] {
113            assert_eq!(csp.get(directive), Some(&sources.to_vec()), "{directive}");
114        }
115        for (directive, sources) in &csp {
116            assert!(
117                sources
118                    .iter()
119                    .all(|s| ["'none'", "'self'", "'script'"].contains(s)),
120                "{directive} allows only the console's own origin: {sources:?}"
121            );
122        }
123        for (name, value) in [
124            ("x-frame-options", "DENY"),
125            ("x-content-type-options", "nosniff"),
126            ("referrer-policy", "no-referrer"),
127            ("cache-control", "no-store"),
128            ("cross-origin-opener-policy", "same-origin"),
129            ("cross-origin-resource-policy", "same-origin"),
130        ] {
131            assert_eq!(headers.get(name), Some(&value), "{name}");
132        }
133    }
134
135    /// The page loads its one script and one stylesheet and holds no
136    /// inline script, style or event handler, which the CSP would refuse.
137    #[test]
138    fn the_page_holds_no_inline_code() {
139        let page = source("index.html");
140        assert!(
141            page.contains(r#"<script type="module" src="console.js"></script>"#),
142            "the page loads its script"
143        );
144        assert!(
145            page.contains(r#"<link rel="stylesheet" href="console.css">"#),
146            "the page loads its styles"
147        );
148        let lower = page.to_lowercase();
149        assert_eq!(lower.matches("<script").count(), 1, "one script element");
150        assert!(!lower.contains("<style"), "no style element");
151        assert!(!lower.contains(" style="), "no style attribute");
152        assert!(!has_event_handler(&page), "no event-handler attribute");
153    }
154
155    /// Whether markup holds an attribute such as `onclick=`: whitespace,
156    /// `on`, a name of letters, then `=` after any spaces.
157    fn has_event_handler(html: &str) -> bool {
158        let lower = html.to_lowercase();
159        lower
160            .match_indices("on")
161            .filter(|(at, _)| lower[..*at].ends_with(char::is_whitespace))
162            .any(|(at, _)| {
163                let rest = &lower[at + 2..];
164                let name = rest.chars().take_while(char::is_ascii_lowercase).count();
165                name > 0 && rest[name..].trim_start().starts_with('=')
166            })
167    }
168
169    #[test]
170    fn an_event_handler_is_found_wherever_it_is() {
171        for found in [
172            "<b onclick=\"x\">",
173            "<b\nonload = x>",
174            "<img src=a ONERROR=x>",
175        ] {
176            assert!(has_event_handler(found), "{found}");
177        }
178        for clean in [
179            "<b class=on>",
180            "<b>on=</b>",
181            "<a href=\"x\">done</a>",
182            "<b on=x>",
183        ] {
184            assert!(!has_event_handler(clean), "{clean}");
185        }
186    }
187
188    /// The script reaches the page only through text, never markup, runs
189    /// no code from strings, and keeps nothing in browser storage.
190    #[test]
191    fn the_script_uses_no_html_sink_and_no_storage() {
192        let script = source("console.js");
193        for refused in [
194            "innerHTML",
195            "outerHTML",
196            "insertAdjacentHTML",
197            "document.write",
198            "eval(",
199            "Function(",
200            "setTimeout(\"",
201            "setInterval(\"",
202            "localStorage",
203            "sessionStorage",
204            "indexedDB",
205            "document.cookie",
206            "caches.",
207        ] {
208            assert!(!script.contains(refused), "the script uses {refused}");
209        }
210        assert!(script.contains("textContent"), "values are set as text");
211    }
212
213    /// Every request goes through one call of `fetch`, which never lets
214    /// the browser keep the answer, send a cookie or follow a redirect.
215    #[test]
216    fn every_request_goes_through_one_fetch() {
217        let script = source("console.js");
218        assert_eq!(script.matches("fetch(").count(), 1, "one fetch call");
219        for option in [
220            r#"cache: "no-store""#,
221            r#"credentials: "omit""#,
222            r#"redirect: "error""#,
223        ] {
224            assert!(script.contains(option), "fetch is made with {option}");
225        }
226    }
227}