Skip to content

Commit 6706ea6

Browse files
committed
web: add a content security policy for non-rustdoc pages
1 parent 93a49e5 commit 6706ea6

File tree

14 files changed

+254
-29
lines changed

14 files changed

+254
-29
lines changed

Cargo.lock

+24-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ font-awesome-as-a-crate = { path = "crates/font-awesome-as-a-crate" }
5757
dashmap = "3.11.10"
5858
string_cache = "0.8.0"
5959
postgres-types = { version = "0.1.3", features = ["derive"] }
60+
getrandom = "0.2.1"
6061

6162
# Async
6263
tokio = { version = "0.2.22", features = ["rt-threaded"] }

src/web/csp.rs

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

src/web/mod.rs

+5
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ macro_rules! extension {
8080
mod build_details;
8181
mod builds;
8282
mod crate_details;
83+
mod csp;
8384
mod error;
8485
mod extensions;
8586
mod features;
@@ -94,6 +95,7 @@ mod statics;
9495

9596
use crate::{impl_webpage, Context};
9697
use chrono::{DateTime, Utc};
98+
use csp::CspMiddleware;
9799
use error::Nope;
98100
use extensions::InjectExtensions;
99101
use failure::Error;
@@ -128,6 +130,9 @@ impl CratesfyiHandler {
128130
let mut chain = Chain::new(base);
129131
chain.link_before(inject_extensions);
130132

133+
chain.link_before(CspMiddleware);
134+
chain.link_after(CspMiddleware);
135+
131136
chain
132137
}
133138

src/web/page/web_page.rs

+19-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use super::TemplateData;
22
use crate::ctry;
3+
use crate::web::csp::Csp;
34
use iron::{headers::ContentType, response::Response, status::Status, IronResult, Request};
45
use serde::Serialize;
56
use std::borrow::Cow;
@@ -35,12 +36,29 @@ macro_rules! impl_webpage {
3536
};
3637
}
3738

39+
#[derive(Serialize)]
40+
struct TemplateContext<'a, T> {
41+
csp_nonce: &'a str,
42+
#[serde(flatten)]
43+
page: &'a T,
44+
}
45+
3846
/// The central trait that rendering pages revolves around, it handles selecting and rendering the template
3947
pub trait WebPage: Serialize + Sized {
4048
/// Turn the current instance into a `Response`, ready to be served
4149
// TODO: We could cache similar pages using the `&Context`
4250
fn into_response(self, req: &Request) -> IronResult<Response> {
43-
let ctx = Context::from_serialize(&self).unwrap();
51+
let csp_nonce = req
52+
.extensions
53+
.get::<Csp>()
54+
.expect("missing CSP from the request extensions")
55+
.nonce();
56+
57+
let ctx = Context::from_serialize(&TemplateContext {
58+
csp_nonce,
59+
page: &self,
60+
})
61+
.unwrap();
4462
let rendered = ctry!(
4563
req,
4664
req.extensions

src/web/rustdoc.rs

+7-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::{
44
db::Pool,
55
utils,
66
web::{
7-
crate_details::CrateDetails, error::Nope, file::File, match_version,
7+
crate_details::CrateDetails, csp::Csp, error::Nope, file::File, match_version,
88
metrics::RenderingTimesRecorder, redirect_base, MatchSemver, MetaData,
99
},
1010
Config, Metrics, Storage,
@@ -253,6 +253,12 @@ pub fn rustdoc_html_server_handler(req: &mut Request) -> IronResult<Response> {
253253
let metrics = extension!(req, Metrics).clone();
254254
let mut rendering_time = RenderingTimesRecorder::new(&metrics.rustdoc_rendering_times);
255255

256+
// Pages generated by Rustdoc are not ready to be served with a CSP yet.
257+
req.extensions
258+
.get_mut::<Csp>()
259+
.expect("missing CSP")
260+
.suppress(true);
261+
256262
// Get the request parameters
257263
let router = extension!(req, Router);
258264

templates/base.html

+5-6
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
<title>{%- block title -%} Docs.rs {%- endblock title -%}</title>
1919

20-
<script type="text/javascript">{%- include "theme.js" -%}</script>
20+
<script nonce="{{ csp_nonce }}">{%- include "theme.js" -%}</script>
2121
{%- block css -%}{%- endblock css -%}
2222
</head>
2323

@@ -29,11 +29,10 @@
2929
{%- block header %}{% endblock header -%}
3030

3131
{%- block body -%}{%- endblock body -%}
32-
</body>
33-
34-
<script type="text/javascript" src="/-/static/menu.js?{{ docsrs_version() | slugify }}"></script>
35-
<script type="text/javascript" src="/-/static/index.js?{{ docsrs_version() | slugify }}"></script>
3632

37-
{%- block javascript -%}{%- endblock javascript -%}
33+
<script type="text/javascript" nonce="{{ csp_nonce }}" src="/-/static/menu.js?{{ docsrs_version() | slugify }}"></script>
34+
<script type="text/javascript" nonce="{{ csp_nonce }}" src="/-/static/index.js?{{ docsrs_version() | slugify }}"></script>
3835

36+
{%- block javascript -%}{%- endblock javascript -%}
37+
</body>
3938
</html>

templates/core/home.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ <h1 class="brand">{{ "cubes" | fas(fw=true) }} Docs.rs</h1>
7272
{%- endblock body -%}
7373

7474
{%- block javascript -%}
75-
<script type="text/javascript" charset="utf-8">
75+
<script type="text/javascript" nonce="{{ csp_nonce }}">
7676
function getKey(ev) {
7777
if ("key" in ev && typeof ev.key != "undefined") {
7878
return ev.key;

0 commit comments

Comments
 (0)