From b2dce5196208485d239338999ffd3026566e5480 Mon Sep 17 00:00:00 2001 From: Pietro Albini Date: Mon, 18 Jan 2021 12:08:12 +0100 Subject: [PATCH 1/3] web: add a content security policy for non-rustdoc pages --- Cargo.lock | 1 + Cargo.toml | 1 + src/web/csp.rs | 176 +++++++++++++++++++++++++++++++ src/web/mod.rs | 5 + src/web/page/web_page.rs | 20 +++- src/web/rustdoc.rs | 8 +- templates/base.html | 11 +- templates/core/home.html | 2 +- templates/crate/details.html | 4 +- templates/crate/features.html | 6 +- templates/macros.html | 10 +- templates/releases/activity.html | 4 +- templates/releases/releases.html | 2 +- templates/style/_utils.scss | 4 + 14 files changed, 231 insertions(+), 23 deletions(-) create mode 100644 src/web/csp.rs diff --git a/Cargo.lock b/Cargo.lock index 32e3eef30..90c45ad40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,6 +690,7 @@ dependencies = [ "failure", "font-awesome-as-a-crate", "futures-util", + "getrandom 0.2.2", "git2", "iron", "kuchiki", diff --git a/Cargo.toml b/Cargo.toml index 4c77da363..7bbd35d57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ font-awesome-as-a-crate = { path = "crates/font-awesome-as-a-crate" } dashmap = "3.11.10" string_cache = "0.8.0" postgres-types = { version = "0.2", features = ["derive"] } +getrandom = "0.2.1" # Async tokio = { version = "1.0", features = ["rt-multi-thread"] } diff --git a/src/web/csp.rs b/src/web/csp.rs new file mode 100644 index 000000000..cc888005a --- /dev/null +++ b/src/web/csp.rs @@ -0,0 +1,176 @@ +use iron::{AfterMiddleware, BeforeMiddleware, IronResult, Request, Response}; + +pub(super) struct Csp { + nonce: String, + suppress: bool, +} + +impl Csp { + fn new() -> Self { + let mut random = [0u8; 36]; + getrandom::getrandom(&mut random).expect("failed to generate a nonce"); + Self { + nonce: base64::encode(&random), + suppress: false, + } + } + + pub(super) fn suppress(&mut self, suppress: bool) { + self.suppress = suppress; + } + + pub(super) fn nonce(&self) -> &str { + &self.nonce + } + + fn render(&self, content_type: ContentType) -> Option { + if self.suppress { + return None; + } + let mut result = String::new(); + + // Disable everything by default + result.push_str("default-src 'none'"); + + // Disable the HTML tag to prevent injected HTML content from changing the base URL + // of all relative links included in the website. + result.push_str("; base-uri 'none'"); + + // Allow loading images from the same origin. This is added to every response regardless of + // the MIME type to allow loading favicons. + // + // Images from other HTTPS origins are also temporary allowed until issue #66 is fixed. + result.push_str("; img-src 'self' https:"); + + match content_type { + ContentType::Html => self.render_html(&mut result), + ContentType::Svg => self.render_svg(&mut result), + ContentType::Other => {} + } + + Some(result) + } + + fn render_html(&self, result: &mut String) { + // Allow loading any CSS file from the current origin. + result.push_str("; style-src 'self'"); + + // Allow loading any font from the current origin. + result.push_str("; font-src 'self'"); + + // Only allow scripts with the random nonce attached to them. + // + // We can't just allow 'self' here, as users can upload arbitrary .js files as part of + // their documentation and 'self' would allow their execution. Instead, every allowed + // script must include the random nonce in it, which an attacker is not able to guess. + result.push_str(&format!("; script-src 'nonce-{}'", self.nonce)); + } + + fn render_svg(&self, result: &mut String) { + // SVG images are subject to the Content Security Policy, and without a directive allowing + // style="" inside the file the image will be rendered badly. + result.push_str("; style-src 'self' 'unsafe-inline'"); + } +} + +impl iron::typemap::Key for Csp { + type Value = Csp; +} + +enum ContentType { + Html, + Svg, + Other, +} + +pub(super) struct CspMiddleware; + +impl BeforeMiddleware for CspMiddleware { + fn before(&self, req: &mut Request) -> IronResult<()> { + req.extensions.insert::(Csp::new()); + Ok(()) + } +} + +impl AfterMiddleware for CspMiddleware { + fn after(&self, req: &mut Request, mut res: Response) -> IronResult { + let csp = req.extensions.get_mut::().expect("missing CSP"); + + let content_type = res + .headers + .get_raw("Content-Type") + .and_then(|headers| headers.get(0)) + .map(|header| header.as_slice()); + + let preset = match content_type { + Some(b"text/html; charset=utf-8") => ContentType::Html, + Some(b"text/svg+xml") => ContentType::Svg, + _ => ContentType::Other, + }; + + if let Some(rendered) = csp.render(preset) { + res.headers.set_raw( + "Content-Security-Policy", + vec![rendered.as_bytes().to_vec()], + ); + } + Ok(res) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_random_nonce() { + let csp1 = Csp::new(); + let csp2 = Csp::new(); + assert_ne!(csp1.nonce(), csp2.nonce()); + } + + #[test] + fn test_csp_suppressed() { + let mut csp = Csp::new(); + csp.suppress(true); + + assert!(csp.render(ContentType::Other).is_none()); + assert!(csp.render(ContentType::Html).is_none()); + assert!(csp.render(ContentType::Svg).is_none()); + } + + #[test] + fn test_csp_other() { + let csp = Csp::new(); + assert_eq!( + Some("default-src 'none'; base-uri 'none'; img-src 'self' https:".into()), + csp.render(ContentType::Other) + ); + } + + #[test] + fn test_csp_svg() { + let csp = Csp::new(); + assert_eq!( + Some( + "default-src 'none'; base-uri 'none'; img-src 'self' https:; \ + style-src 'self' 'unsafe-inline'" + .into() + ), + csp.render(ContentType::Svg) + ); + } + + #[test] + fn test_csp_html() { + let csp = Csp::new(); + assert_eq!( + Some(format!( + "default-src 'none'; base-uri 'none'; img-src 'self' https:; \ + style-src 'self'; font-src 'self'; script-src 'nonce-{}'", + csp.nonce() + )), + csp.render(ContentType::Html) + ); + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 8a254bb60..8c8cc6ad8 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -80,6 +80,7 @@ macro_rules! extension { mod build_details; mod builds; mod crate_details; +mod csp; mod error; mod extensions; mod features; @@ -94,6 +95,7 @@ mod statics; use crate::{impl_webpage, Context}; use chrono::{DateTime, Utc}; +use csp::CspMiddleware; use error::Nope; use extensions::InjectExtensions; use failure::Error; @@ -128,6 +130,9 @@ impl CratesfyiHandler { let mut chain = Chain::new(base); chain.link_before(inject_extensions); + chain.link_before(CspMiddleware); + chain.link_after(CspMiddleware); + chain } diff --git a/src/web/page/web_page.rs b/src/web/page/web_page.rs index f19b759c4..6b0a18205 100644 --- a/src/web/page/web_page.rs +++ b/src/web/page/web_page.rs @@ -1,5 +1,6 @@ use super::TemplateData; use crate::ctry; +use crate::web::csp::Csp; use iron::{headers::ContentType, response::Response, status::Status, IronResult, Request}; use serde::Serialize; use std::borrow::Cow; @@ -35,12 +36,29 @@ macro_rules! impl_webpage { }; } +#[derive(Serialize)] +struct TemplateContext<'a, T> { + csp_nonce: &'a str, + #[serde(flatten)] + page: &'a T, +} + /// The central trait that rendering pages revolves around, it handles selecting and rendering the template pub trait WebPage: Serialize + Sized { /// Turn the current instance into a `Response`, ready to be served // TODO: We could cache similar pages using the `&Context` fn into_response(self, req: &Request) -> IronResult { - let ctx = Context::from_serialize(&self).unwrap(); + let csp_nonce = req + .extensions + .get::() + .expect("missing CSP from the request extensions") + .nonce(); + + let ctx = Context::from_serialize(&TemplateContext { + csp_nonce, + page: &self, + }) + .unwrap(); let rendered = ctry!( req, req.extensions diff --git a/src/web/rustdoc.rs b/src/web/rustdoc.rs index 918ea91e2..25e7c923c 100644 --- a/src/web/rustdoc.rs +++ b/src/web/rustdoc.rs @@ -4,7 +4,7 @@ use crate::{ db::Pool, utils, web::{ - crate_details::CrateDetails, error::Nope, file::File, match_version, + crate_details::CrateDetails, csp::Csp, error::Nope, file::File, match_version, metrics::RenderingTimesRecorder, redirect_base, MatchSemver, MetaData, }, Config, Metrics, Storage, @@ -253,6 +253,12 @@ pub fn rustdoc_html_server_handler(req: &mut Request) -> IronResult { let metrics = extension!(req, Metrics).clone(); let mut rendering_time = RenderingTimesRecorder::new(&metrics.rustdoc_rendering_times); + // Pages generated by Rustdoc are not ready to be served with a CSP yet. + req.extensions + .get_mut::() + .expect("missing CSP") + .suppress(true); + // Get the request parameters let router = extension!(req, Router); diff --git a/templates/base.html b/templates/base.html index 1a07dcf86..1470d0b3b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,7 +17,7 @@ {%- block title -%} Docs.rs {%- endblock title -%} - + {%- block css -%}{%- endblock css -%} @@ -29,11 +29,10 @@ {%- block header %}{% endblock header -%} {%- block body -%}{%- endblock body -%} - - - - - {%- block javascript -%}{%- endblock javascript -%} + + + {%- block javascript -%}{%- endblock javascript -%} + diff --git a/templates/core/home.html b/templates/core/home.html index 60ee86472..33a713b12 100644 --- a/templates/core/home.html +++ b/templates/core/home.html @@ -72,7 +72,7 @@

{{ "cubes" | fas(fw=true) }} Docs.rs

{%- endblock body -%} {%- block javascript -%} - + {# Load the script for each provided language #} {%- for language in languages -%} - + {%- endfor -%} {# Activate highlighting #} - {% endmacro highlight_js %} @@ -22,7 +20,7 @@ {# Makes the appropriate CSS imports for highlighting #} {% macro highlight_css() %} {# Load the highlighting theme css #} - + -