use axum::Router;
use axum::extract::Request;
use axum::http::header::{CONTENT_TYPE, LOCATION};
use axum::http::{HeaderName, HeaderValue, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get};
const PAGE: &[u8] = include_bytes!("../console/index.html");
const SCRIPT: &[u8] = include_bytes!("../console/console.js");
const STYLE: &[u8] = include_bytes!("../console/console.css");
pub const HEADERS: [(&str, &str); 7] = [
(
"content-security-policy",
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; \
connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; \
require-trusted-types-for 'script'; trusted-types 'none'",
),
("x-frame-options", "DENY"),
("x-content-type-options", "nosniff"),
("referrer-policy", "no-referrer"),
("cache-control", "no-store"),
("cross-origin-opener-policy", "same-origin"),
("cross-origin-resource-policy", "same-origin"),
];
pub fn router<S: Clone + Send + Sync + 'static>() -> Router<S> {
Router::new()
.route(
"/console",
get(|| async { (StatusCode::PERMANENT_REDIRECT, [(LOCATION, "/console/")]) }),
)
.route("/console/", get(|| file(PAGE, "text/html; charset=utf-8")))
.route(
"/console/console.js",
get(|| file(SCRIPT, "text/javascript; charset=utf-8")),
)
.route(
"/console/console.css",
get(|| file(STYLE, "text/css; charset=utf-8")),
)
.route("/console/{*other}", any(|| async { StatusCode::NOT_FOUND }))
.route_layer(middleware::from_fn(with_headers))
}
async fn file(body: &'static [u8], media_type: &'static str) -> Response {
([(CONTENT_TYPE, media_type)], body).into_response()
}
async fn with_headers(request: Request, next: Next) -> Response {
let mut answer = next.run(request).await;
for (name, value) in HEADERS {
answer.headers_mut().insert(
HeaderName::from_static(name),
HeaderValue::from_static(value),
);
}
answer
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
fn source(name: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("console")
.join(name);
std::fs::read_to_string(path).expect("the source file")
}
#[test]
fn the_headers_are_the_designed_ones() {
let headers: std::collections::BTreeMap<&str, &str> = super::HEADERS.into_iter().collect();
assert_eq!(headers.len(), super::HEADERS.len(), "each header once");
let csp: std::collections::BTreeMap<&str, Vec<&str>> = headers["content-security-policy"]
.split(';')
.map(|d| {
let mut words = d.split_whitespace();
(words.next().expect("a directive"), words.collect())
})
.collect();
for (directive, sources) in [
("default-src", ["'none'"]),
("script-src", ["'self'"]),
("frame-ancestors", ["'none'"]),
("base-uri", ["'none'"]),
("form-action", ["'none'"]),
("require-trusted-types-for", ["'script'"]),
("trusted-types", ["'none'"]),
] {
assert_eq!(csp.get(directive), Some(&sources.to_vec()), "{directive}");
}
for (directive, sources) in &csp {
assert!(
sources
.iter()
.all(|s| ["'none'", "'self'", "'script'"].contains(s)),
"{directive} allows only the console's own origin: {sources:?}"
);
}
for (name, value) in [
("x-frame-options", "DENY"),
("x-content-type-options", "nosniff"),
("referrer-policy", "no-referrer"),
("cache-control", "no-store"),
("cross-origin-opener-policy", "same-origin"),
("cross-origin-resource-policy", "same-origin"),
] {
assert_eq!(headers.get(name), Some(&value), "{name}");
}
}
#[test]
fn the_page_holds_no_inline_code() {
let page = source("index.html");
assert!(
page.contains(r#"<script type="module" src="console.js"></script>"#),
"the page loads its script"
);
assert!(
page.contains(r#"<link rel="stylesheet" href="console.css">"#),
"the page loads its styles"
);
let lower = page.to_lowercase();
assert_eq!(lower.matches("<script").count(), 1, "one script element");
assert!(!lower.contains("<style"), "no style element");
assert!(!lower.contains(" style="), "no style attribute");
assert!(!has_event_handler(&page), "no event-handler attribute");
}
fn has_event_handler(html: &str) -> bool {
let lower = html.to_lowercase();
lower
.match_indices("on")
.filter(|(at, _)| lower[..*at].ends_with(char::is_whitespace))
.any(|(at, _)| {
let rest = &lower[at + 2..];
let name = rest.chars().take_while(char::is_ascii_lowercase).count();
name > 0 && rest[name..].trim_start().starts_with('=')
})
}
#[test]
fn an_event_handler_is_found_wherever_it_is() {
for found in [
"<b onclick=\"x\">",
"<b\nonload = x>",
"<img src=a ONERROR=x>",
] {
assert!(has_event_handler(found), "{found}");
}
for clean in [
"<b class=on>",
"<b>on=</b>",
"<a href=\"x\">done</a>",
"<b on=x>",
] {
assert!(!has_event_handler(clean), "{clean}");
}
}
#[test]
fn the_script_uses_no_html_sink_and_no_storage() {
let script = source("console.js");
for refused in [
"innerHTML",
"outerHTML",
"insertAdjacentHTML",
"document.write",
"eval(",
"Function(",
"setTimeout(\"",
"setInterval(\"",
"localStorage",
"sessionStorage",
"indexedDB",
"document.cookie",
"caches.",
] {
assert!(!script.contains(refused), "the script uses {refused}");
}
assert!(script.contains("textContent"), "values are set as text");
}
#[test]
fn every_request_goes_through_one_fetch() {
let script = source("console.js");
assert_eq!(script.matches("fetch(").count(), 1, "one fetch call");
for option in [
r#"cache: "no-store""#,
r#"credentials: "omit""#,
r#"redirect: "error""#,
] {
assert!(script.contains(option), "fetch is made with {option}");
}
}
}