diff --git a/Cargo.lock b/Cargo.lock index 6af4237..02bc008 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,17 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.30" @@ -225,9 +236,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -254,11 +267,11 @@ dependencies = [ "askama", "askama_axum", "axum", - "rand", "thiserror", "tokio", "tower", "tower-http", + "tower-sombrero", "vss", ] @@ -778,6 +791,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +[[package]] +name = "tower-sombrero" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "729ac603e3d8eba6330a4901950d4ee8ba9be734a2a3d5913b6d84809ed062c2" +dependencies = [ + "async-trait", + "axum-core", + "futures-util", + "http", + "rand", + "thiserror", + "tower-layer", + "tower-service", +] + [[package]] name = "tracing" version = "0.1.40" diff --git a/Cargo.toml b/Cargo.toml index 6b11353..379dc8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,9 @@ askama = { version = "0.12", features = ["with-axum"], default-features = false tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tower-http = { version = "0.5", features = ["set-header"] } askama_axum = { version = "0.4", default-features = false } +tower-sombrero = { version = "0.0.3", features = ["axum"] } thiserror = "1" tower = "0.4" -rand = "0.8" vss = "0.1" [profile.release] @@ -25,4 +25,4 @@ lto = "fat" codegen-units = 1 [package.metadata.cargo-machete] -ignored = ["askama_axum"] \ No newline at end of file +ignored = ["askama_axum"] diff --git a/src/main.rs b/src/main.rs index 61bfa19..115243f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,21 +9,24 @@ use std::{ use askama::Template; use axum::{ - extract::{ConnectInfo, FromRequestParts, Request, State}, + extract::{ConnectInfo, FromRequestParts, State}, http::{ - header::{ACCESS_CONTROL_ALLOW_ORIGIN, CACHE_CONTROL, CONTENT_SECURITY_POLICY}, + header::{ACCESS_CONTROL_ALLOW_ORIGIN, CACHE_CONTROL}, request::Parts, HeaderName, HeaderValue, StatusCode, }, - middleware::Next, response::{IntoResponse, Response}, routing::{any, get}, Router, }; -use rand::{distributions::Alphanumeric, Rng}; use tokio::net::TcpListener; use tower::ServiceBuilder; use tower_http::set_header::SetResponseHeaderLayer; +use tower_sombrero::{ + csp::CspNonce, + headers::{ContentSecurityPolicy, CspSchemeSource, CspSource, XFrameOptions}, + Sombrero, +}; static ROBOTS_NAME: HeaderName = HeaderName::from_static("x-robots-tag"); static ROBOTS_VALUE: HeaderValue = HeaderValue::from_static("noindex"); @@ -42,7 +45,28 @@ async fn main() { SetResponseHeaderLayer::overriding(ACCESS_CONTROL_ALLOW_ORIGIN.clone(), CORS_STAR.clone()); let no_cache = SetResponseHeaderLayer::overriding(CACHE_CONTROL.clone(), CACHE_CONTROL_PRIVATE.clone()); - let nonce_generator = axum::middleware::from_fn_with_state(state.clone(), nonce_layer); + + let csp = ContentSecurityPolicy::new_empty() + .default_src([CspSource::None]) + .base_uri([CspSource::None]) + .img_src([CspSource::SelfOrigin]) + .style_src([CspSource::Nonce]) + .connect_src([ + CspSource::Host(format!("v4.{}", state.root_dns_name)), + CspSource::Host(format!("v6.{}", state.root_dns_name)), + CspSource::Host("cloudflareinsights.com".to_string()), + ]) + .script_src([ + CspSource::Nonce, + CspSource::UnsafeInline, + CspSource::StrictDynamic, + CspSource::Scheme(CspSchemeSource::Https), + CspSource::Scheme(CspSchemeSource::Https), + ]); + let sombrero = Sombrero::default() + .content_security_policy(csp) + .x_frame_options(XFrameOptions::Deny) + .remove_strict_transport_security(); let app = Router::new() .route("/", get(home)) @@ -52,7 +76,7 @@ async fn main() { ) .route("/robots.txt", get(robots)) .fallback(not_found) - .layer(ServiceBuilder::new().layer(no_cache).layer(nonce_generator)) + .layer(ServiceBuilder::new().layer(no_cache).layer(sombrero)) .with_state(state); println!("Listening on http://localhost:{port} and http://{v6_addr} for ip requests"); @@ -73,20 +97,20 @@ pub struct IndexPage { root_dns_name: Arc, ip: IpAddr, proto: String, - nonce: Nonce, + nonce: String, } #[derive(Template)] #[template(path = "404.hbs", escape = "html", ext = "html")] pub struct NotFoundPage { - nonce: Nonce, + nonce: String, } #[allow(clippy::unused_async)] async fn home( IpAddress(ip): IpAddress, XForwardedProto(proto): XForwardedProto, - nonce: Nonce, + CspNonce(nonce): CspNonce, Accept(accept): Accept, State(state): State, ) -> Result, Error> { @@ -109,7 +133,7 @@ async fn raw(IpAddress(ip): IpAddress) -> Result { } #[allow(clippy::unused_async)] -async fn not_found(nonce: Nonce) -> NotFoundPage { +async fn not_found(nonce: String) -> NotFoundPage { NotFoundPage { nonce } } @@ -214,57 +238,6 @@ impl FromRequestParts for Accept { } } -#[derive(Clone, Debug)] -pub struct Nonce(pub String); - -#[axum::async_trait] -impl FromRequestParts for Nonce { - type Rejection = std::convert::Infallible; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - Ok(parts - .extensions - .get() - .cloned() - .unwrap_or_else(|| Self("no-noncense".to_string()))) - } -} - -impl Display for Nonce { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self.0, f) - } -} - -async fn nonce_layer(State(state): State, mut req: Request, next: Next) -> Response { - let nonce_string = random_string(32); - req.extensions_mut().insert(Nonce(nonce_string.clone())); - let mut resp = next.run(req).await; - let base_dns_name = state.root_dns_name; - let csp_str = format!( - "default-src 'none'; object-src 'none'; img-src 'self'; \ - connect-src v4.{base_dns_name} v6.{base_dns_name} cloudflareinsights.com; \ - style-src 'nonce-{nonce_string}'; \ - script-src 'nonce-{nonce_string}' 'unsafe-inline' 'strict-dynamic' http: https:; \ - base-uri 'none';" - ); - match HeaderValue::from_str(&csp_str) { - Ok(csp) => { - resp.headers_mut().insert(CONTENT_SECURITY_POLICY, csp); - } - Err(source) => eprintln!("ERROR: {source:?}"), - } - resp -} - -fn random_string(length: usize) -> String { - let rng = rand::thread_rng(); - rng.sample_iter(Alphanumeric) - .take(length) - .map(char::from) - .collect() -} - #[derive(thiserror::Error, Debug)] pub enum Error { #[error("No header found")] @@ -272,7 +245,7 @@ pub enum Error { #[error("Could not extract connection info")] ConnectInfo, #[error("Could not get CSP nonce")] - NoNonce, + NoNonce(#[from] tower_sombrero::Error), #[error("Could not convert supplied header to string (this is a configuration issue)")] ToStr(#[from] axum::http::header::ToStrError), #[error("Could not convert supplied header to IP address (this is a configuration issue)")] @@ -284,7 +257,7 @@ impl IntoResponse for Error { let msg = match self { Self::NoHeader => "No header found", Self::ConnectInfo => "Could not extract connection info", - Self::NoNonce => "Could not getc CSP nonce", + Self::NoNonce(_) => "Could not get CSP nonce", Self::ToStr(_) => { "Could not convert supplied header to string (this is a configuration issue)" }