Skip to content

Commit 9708001

Browse files
authored
Merge pull request #1333 from pietroalbini/csp
Add a Content Security Policy to non-rustdoc pages
2 parents 9ac7ef0 + 666cb2f commit 9708001

15 files changed

+252
-23
lines changed

Cargo.lock

+1
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.2", features = ["derive"] }
60+
getrandom = "0.2.1"
6061

6162
# Async
6263
tokio = { version = "1.0", features = ["rt-multi-thread"] }

src/config.rs

+5
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ pub struct Config {
4747
// For unit-tests the number has to be higher.
4848
pub(crate) random_crate_search_view_size: u32,
4949

50+
// Content Security Policy
51+
pub(crate) csp_report_only: bool,
52+
5053
// Build params
5154
pub(crate) build_attempts: u16,
5255
pub(crate) rustwide_workspace: PathBuf,
@@ -96,6 +99,8 @@ impl Config {
9699

97100
random_crate_search_view_size: env("DOCSRS_RANDOM_CRATE_SEARCH_VIEW_SIZE", 500)?,
98101

102+
csp_report_only: env("DOCSRS_CSP_REPORT_ONLY", false)?,
103+
99104
rustwide_workspace: env("CRATESFYI_RUSTWIDE_WORKSPACE", PathBuf::from(".workspace"))?,
100105
inside_docker: env("DOCS_RS_DOCKER", false)?,
101106
local_docker_image: maybe_env("DOCS_RS_LOCAL_DOCKER_IMAGE")?,

src/web/csp.rs

+192
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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+
}

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;

templates/crate/details.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@
3131
{%- if details.documented_items and details.total_items -%}
3232
{% set percent = details.documented_items * 100 / details.total_items %}
3333
<li class="pure-menu-heading">Coverage</li>
34-
<li class="pure-menu-item" style="text-align:center;"><b>{{ percent | round(precision=2) }}%</b><br>
34+
<li class="pure-menu-item text-center"><b>{{ percent | round(precision=2) }}%</b><br>
3535
<span class="documented-info"><b>{{ details.documented_items }}</b> out of <b>{{ details.total_items }}</b> items documented</span>
3636
{%- if details.total_items_needing_examples and details.items_with_examples -%}
37-
<span style="font-size: 13px;"><b>{{ details.items_with_examples }}</b> out of <b>{{ details.total_items_needing_examples }}</b> items with examples</span>
37+
<span class="documented-info"><b>{{ details.items_with_examples }}</b> out of <b>{{ details.total_items_needing_examples }}</b> items with examples</span>
3838
{%- endif -%}
3939
</li>
4040
{%- endif -%}

templates/crate/features.html

+3-3
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,18 @@
2929
{%- if features -%}
3030
{%- for feature in features -%}
3131
<li class="pure-menu-item">
32-
<a href="#{{ feature.name }}" class="pure-menu-link" style="text-align:center;">
32+
<a href="#{{ feature.name }}" class="pure-menu-link text-center">
3333
{{ feature.name }}
3434
</a>
3535
</li>
3636
{%- endfor -%}
3737
{%- elif features is iterable -%}
3838
<li class="pure-menu-item">
39-
<span style="font-size: 13px;">This release does not have any feature flags.</span>
39+
<span class="documented-info">This release does not have any feature flags.</span>
4040
</li>
4141
{%- else -%}
4242
<li class="pure-menu-item">
43-
<span style="font-size: 13px;">Feature flags data are not available for this release.</span>
43+
<span class="documented-info">Feature flags data are not available for this release.</span>
4444
</li>
4545
{%- endif -%}
4646
</ul>

0 commit comments

Comments
 (0)