Skip to content

Commit b2dce51

Browse files
committed
web: add a content security policy for non-rustdoc pages
1 parent 9ac7ef0 commit b2dce51

File tree

14 files changed

+231
-23
lines changed

14 files changed

+231
-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/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;

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>

templates/macros.html

+4-6
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,23 @@
44
#}
55
{% macro highlight_js(languages) %}
66
{# Load the highlight script #}
7-
<script src="/-/static/highlightjs/highlight.min.js" type="text/javascript"
8-
charset="utf-8"></script>
7+
<script nonce="{{ csp_nonce }}" src="/-/static/highlightjs/highlight.min.js" type="text/javascript"></script>
98

109
{# Load the script for each provided language #}
1110
{%- for language in languages -%}
12-
<script src="/-/static/highlightjs/languages/{{ language }}.min.js"
13-
type="text/javascript" charset="utf-8"></script>
11+
<script nonce="{{ csp_nonce }}" src="/-/static/highlightjs/languages/{{ language }}.min.js" type="text/javascript"></script>
1412
{%- endfor -%}
1513

1614
{# Activate highlighting #}
17-
<script type="text/javascript" charset="utf-8">
15+
<script nonce="{{ csp_nonce }}" type="text/javascript">
1816
hljs.initHighlighting();
1917
</script>
2018
{% endmacro highlight_js %}
2119

2220
{# Makes the appropriate CSS imports for highlighting #}
2321
{% macro highlight_css() %}
2422
{# Load the highlighting theme css #}
25-
<script>
23+
<script nonce="{{ csp_nonce }}" type="text/javascript">
2624
// Choose which highlight.js theme to load based on the user theme
2725
var stylesheet;
2826
switch(document.documentElement.dataset.theme) {

templates/releases/activity.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818
<link rel="stylesheet" href="/-/static/chartjs/chart.min.css">
1919
{%- endblock -%}
2020
{%- block javascript -%}
21-
<script src="/-/static/chartjs/chart.min.js" type="text/javascript"></script>
21+
<script nonce="{{ csp_nonce }}" src="/-/static/chartjs/chart.min.js" type="text/javascript"></script>
2222

23-
<script type="text/javascript">
23+
<script nonce="{{ csp_nonce }}" type="text/javascript">
2424
// We're including the CSS file manually to avoid issues with the CSP.
2525
Chart.platform.disableCSSInjection = true;
2626

templates/releases/releases.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
{%- endblock body -%}
8484

8585
{%- block javascript -%}
86-
<script type="text/javascript" charset="utf-8">
86+
<script nonce="{{ csp_nonce }}" type="text/javascript" charset="utf-8">
8787
function getKey(ev) {
8888
if ("key" in ev && typeof ev.key != "undefined") {
8989
return ev.key;

0 commit comments

Comments
 (0)