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]
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 #[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 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 #[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 #[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}