1use 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
15const PAGE: &[u8] = include_bytes!("../console/index.html");
17const SCRIPT: &[u8] = include_bytes!("../console/console.js");
18const STYLE: &[u8] = include_bytes!("../console/console.css");
19
20pub 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
37pub 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 .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
65async 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 #[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 #[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 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 #[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 #[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}