|
| 1 | +use crate::config::Config; |
| 2 | +use iron::{AfterMiddleware, BeforeMiddleware, IronResult, Request, Response}; |
| 3 | + |
| 4 | +pub(super) struct Csp { |
| 5 | + nonce: String, |
| 6 | + suppress: bool, |
| 7 | +} |
| 8 | + |
| 9 | +impl Csp { |
| 10 | + fn new() -> Self { |
| 11 | + // Nonces need to be different for each single request in order to maintain security, so we |
| 12 | + // generate a new one with a cryptographically-secure generator for each request. |
| 13 | + let mut random = [0u8; 36]; |
| 14 | + getrandom::getrandom(&mut random).expect("failed to generate a nonce"); |
| 15 | + |
| 16 | + Self { |
| 17 | + nonce: base64::encode(&random), |
| 18 | + suppress: false, |
| 19 | + } |
| 20 | + } |
| 21 | + |
| 22 | + pub(super) fn suppress(&mut self, suppress: bool) { |
| 23 | + self.suppress = suppress; |
| 24 | + } |
| 25 | + |
| 26 | + pub(super) fn nonce(&self) -> &str { |
| 27 | + &self.nonce |
| 28 | + } |
| 29 | + |
| 30 | + fn render(&self, content_type: ContentType) -> Option<String> { |
| 31 | + if self.suppress { |
| 32 | + return None; |
| 33 | + } |
| 34 | + let mut result = String::new(); |
| 35 | + |
| 36 | + // Disable everything by default |
| 37 | + result.push_str("default-src 'none'"); |
| 38 | + |
| 39 | + // Disable the <base> HTML tag to prevent injected HTML content from changing the base URL |
| 40 | + // of all relative links included in the website. |
| 41 | + result.push_str("; base-uri 'none'"); |
| 42 | + |
| 43 | + // Allow loading images from the same origin. This is added to every response regardless of |
| 44 | + // the MIME type to allow loading favicons. |
| 45 | + // |
| 46 | + // Images from other HTTPS origins are also temporary allowed until issue #66 is fixed. |
| 47 | + result.push_str("; img-src 'self' https:"); |
| 48 | + |
| 49 | + match content_type { |
| 50 | + ContentType::Html => self.render_html(&mut result), |
| 51 | + ContentType::Svg => self.render_svg(&mut result), |
| 52 | + ContentType::Other => {} |
| 53 | + } |
| 54 | + |
| 55 | + Some(result) |
| 56 | + } |
| 57 | + |
| 58 | + fn render_html(&self, result: &mut String) { |
| 59 | + // Allow loading any CSS file from the current origin. |
| 60 | + result.push_str("; style-src 'self'"); |
| 61 | + |
| 62 | + // Allow loading any font from the current origin. |
| 63 | + result.push_str("; font-src 'self'"); |
| 64 | + |
| 65 | + // Only allow scripts with the random nonce attached to them. |
| 66 | + // |
| 67 | + // We can't just allow 'self' here, as users can upload arbitrary .js files as part of |
| 68 | + // their documentation and 'self' would allow their execution. Instead, every allowed |
| 69 | + // script must include the random nonce in it, which an attacker is not able to guess. |
| 70 | + result.push_str(&format!("; script-src 'nonce-{}'", self.nonce)); |
| 71 | + } |
| 72 | + |
| 73 | + fn render_svg(&self, result: &mut String) { |
| 74 | + // SVG images are subject to the Content Security Policy, and without a directive allowing |
| 75 | + // style="" inside the file the image will be rendered badly. |
| 76 | + result.push_str("; style-src 'self' 'unsafe-inline'"); |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +impl iron::typemap::Key for Csp { |
| 81 | + type Value = Csp; |
| 82 | +} |
| 83 | + |
| 84 | +enum ContentType { |
| 85 | + Html, |
| 86 | + Svg, |
| 87 | + Other, |
| 88 | +} |
| 89 | + |
| 90 | +pub(super) struct CspMiddleware; |
| 91 | + |
| 92 | +impl BeforeMiddleware for CspMiddleware { |
| 93 | + fn before(&self, req: &mut Request) -> IronResult<()> { |
| 94 | + req.extensions.insert::<Csp>(Csp::new()); |
| 95 | + Ok(()) |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +impl AfterMiddleware for CspMiddleware { |
| 100 | + fn after(&self, req: &mut Request, mut res: Response) -> IronResult<Response> { |
| 101 | + let config = req |
| 102 | + .extensions |
| 103 | + .get::<Config>() |
| 104 | + .expect("missing Config") |
| 105 | + .clone(); |
| 106 | + let csp = req.extensions.get_mut::<Csp>().expect("missing CSP"); |
| 107 | + |
| 108 | + let content_type = res |
| 109 | + .headers |
| 110 | + .get_raw("Content-Type") |
| 111 | + .and_then(|headers| headers.get(0)) |
| 112 | + .map(|header| header.as_slice()); |
| 113 | + |
| 114 | + let preset = match content_type { |
| 115 | + Some(b"text/html; charset=utf-8") => ContentType::Html, |
| 116 | + Some(b"text/svg+xml") => ContentType::Svg, |
| 117 | + _ => ContentType::Other, |
| 118 | + }; |
| 119 | + |
| 120 | + if let Some(rendered) = csp.render(preset) { |
| 121 | + res.headers.set_raw( |
| 122 | + // The Report-Only header tells the browser to just log CSP failures instead of |
| 123 | + // actually enforcing them. This is useful to check if the CSP works without |
| 124 | + // impacting production traffic. |
| 125 | + if config.csp_report_only { |
| 126 | + "Content-Security-Policy-Report-Only" |
| 127 | + } else { |
| 128 | + "Content-Security-Policy" |
| 129 | + }, |
| 130 | + vec![rendered.as_bytes().to_vec()], |
| 131 | + ); |
| 132 | + } |
| 133 | + Ok(res) |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +#[cfg(test)] |
| 138 | +mod tests { |
| 139 | + use super::*; |
| 140 | + |
| 141 | + #[test] |
| 142 | + fn test_random_nonce() { |
| 143 | + let csp1 = Csp::new(); |
| 144 | + let csp2 = Csp::new(); |
| 145 | + assert_ne!(csp1.nonce(), csp2.nonce()); |
| 146 | + } |
| 147 | + |
| 148 | + #[test] |
| 149 | + fn test_csp_suppressed() { |
| 150 | + let mut csp = Csp::new(); |
| 151 | + csp.suppress(true); |
| 152 | + |
| 153 | + assert!(csp.render(ContentType::Other).is_none()); |
| 154 | + assert!(csp.render(ContentType::Html).is_none()); |
| 155 | + assert!(csp.render(ContentType::Svg).is_none()); |
| 156 | + } |
| 157 | + |
| 158 | + #[test] |
| 159 | + fn test_csp_other() { |
| 160 | + let csp = Csp::new(); |
| 161 | + assert_eq!( |
| 162 | + Some("default-src 'none'; base-uri 'none'; img-src 'self' https:".into()), |
| 163 | + csp.render(ContentType::Other) |
| 164 | + ); |
| 165 | + } |
| 166 | + |
| 167 | + #[test] |
| 168 | + fn test_csp_svg() { |
| 169 | + let csp = Csp::new(); |
| 170 | + assert_eq!( |
| 171 | + Some( |
| 172 | + "default-src 'none'; base-uri 'none'; img-src 'self' https:; \ |
| 173 | + style-src 'self' 'unsafe-inline'" |
| 174 | + .into() |
| 175 | + ), |
| 176 | + csp.render(ContentType::Svg) |
| 177 | + ); |
| 178 | + } |
| 179 | + |
| 180 | + #[test] |
| 181 | + fn test_csp_html() { |
| 182 | + let csp = Csp::new(); |
| 183 | + assert_eq!( |
| 184 | + Some(format!( |
| 185 | + "default-src 'none'; base-uri 'none'; img-src 'self' https:; \ |
| 186 | + style-src 'self'; font-src 'self'; script-src 'nonce-{}'", |
| 187 | + csp.nonce() |
| 188 | + )), |
| 189 | + csp.render(ContentType::Html) |
| 190 | + ); |
| 191 | + } |
| 192 | +} |
0 commit comments