use super::*;
fn response() -> Response {
Response::builder().body(Body::empty()).unwrap()
}
fn layer(csp: Option<&str>) -> PanelHeaders {
PanelHeaders::new("/admin", csp, None).unwrap()
}
#[test]
fn a_header_the_handler_set_wins() {
let mut response = response();
response
.headers_mut()
.insert(CSP, header::HeaderValue::from_static("default-src 'none'"));
response.headers_mut().insert(
header::REFERRER_POLICY,
header::HeaderValue::from_static("no-referrer"),
);
insert_missing(&mut response, &layer(Some(DEFAULT_CSP)).headers);
let headers = response.headers();
assert_eq!(headers.get(&CSP).unwrap(), "default-src 'none'");
assert_eq!(headers.get(header::REFERRER_POLICY).unwrap(), "no-referrer");
assert_eq!(headers.get_all(&CSP).iter().count(), 1, "never duplicated");
assert_eq!(headers.get(header::CACHE_CONTROL).unwrap(), "no-store");
}
#[test]
fn a_policy_with_a_line_break_is_a_declaration_error() {
let error = PanelHeaders::new("/admin", Some("'self'\r\nX-Evil: 1"), None).unwrap_err();
assert!(matches!(
error,
crate::DeclarationErrorKind::ContentSecurityPolicy(
crate::PolicyFault::InvalidHeader { .. }
)
));
}
#[test]
fn hsts_is_sent_only_when_asked() {
let hsts = |max_age| {
PanelHeaders::new("/admin", None, max_age)
.unwrap()
.headers
.into_iter()
.find(|(name, _)| name == header::STRICT_TRANSPORT_SECURITY)
.map(|(_, value)| value)
};
assert_eq!(hsts(None), None);
assert_eq!(
hsts(Some(std::time::Duration::from_secs(31_536_000))).unwrap(),
"max-age=31536000"
);
}
#[test]
fn the_dev_server_origin_may_load_scripts_and_connect() {
let policy = default_csp(Some("http://127.0.0.1:3001/base"));
assert!(
policy.contains("script-src 'self' 'unsafe-inline' 'unsafe-eval' http://127.0.0.1:3001;"),
"{policy}"
);
assert!(
policy.contains("connect-src 'self' http://127.0.0.1:3001 ws://127.0.0.1:3001;"),
"{policy}"
);
assert_eq!(default_csp(None), DEFAULT_CSP);
}
#[test]
fn a_dev_url_that_is_no_http_origin_adds_nothing() {
for url in [
"javascript:alert(1)",
"http://",
"http://host; script-src *",
"ftp://host",
"localhost:3001",
] {
assert_eq!(default_csp(Some(url)), DEFAULT_CSP, "{url}");
}
}
fn sources(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(directive, source)| ((*directive).to_owned(), (*source).to_owned()))
.collect()
}
#[test]
fn a_source_joins_the_directive_the_policy_has() {
let policy = with_sources(
"default-src 'self'; form-action 'self'",
&sources(&[
("form-action", "https://pay.example"),
("form-action", "https://pay.example"),
]),
);
assert_eq!(
policy.as_deref(),
Ok("default-src 'self'; form-action 'self' https://pay.example")
);
}
#[test]
fn a_missing_fetch_directive_starts_from_default_src() {
let policy = with_sources(
"default-src 'self' https://cdn.example; object-src 'none'",
&sources(&[
("connect-src", "https://api.example"),
("object-src", "https://flash.example"),
]),
);
assert_eq!(
policy.as_deref(),
Ok(
"default-src 'self' https://cdn.example; object-src https://flash.example; \
connect-src 'self' https://cdn.example https://api.example"
)
);
}
#[test]
fn what_no_policy_could_carry_is_refused() {
for (directive, source) in [
("form-action", ""),
("form-action", "https://a.example; script-src *"),
("form-action", "https://a.example https://b.example"),
("form-action", "https://a.example,https://b.example"),
("Form-Action", "https://a.example"),
("sandbox", "allow-forms"),
] {
assert_eq!(
with_sources(DEFAULT_CSP, &sources(&[(directive, source)])),
Err(refused_source(directive, source)),
"{directive} {source}"
);
}
assert_eq!(
Policy::Off.resolve(&sources(&[("form-action", "https://a.example")])),
Err(refused_source("form-action", "https://a.example"))
);
assert_eq!(Policy::Off.resolve(&[]), Ok(None));
}
fn file_response(content_type: Option<&str>) -> Response {
let mut response = response();
if let Some(content_type) = content_type {
response.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_str(content_type).unwrap(),
);
}
response
}
fn policy(response: &Response) -> &str {
response.headers().get(&CSP).unwrap().to_str().unwrap()
}
fn disposition(response: &Response) -> Option<&str> {
response
.headers()
.get(header::CONTENT_DISPOSITION)
.map(|value| value.to_str().unwrap())
}
#[test]
fn the_allow_list_renders_inline() {
for content_type in [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"text/plain",
"TEXT/PLAIN; charset=utf-8",
] {
let mut response = file_response(Some(content_type));
harden_served_file(&mut response);
assert_eq!(
response
.headers()
.get(header::X_CONTENT_TYPE_OPTIONS)
.unwrap(),
"nosniff",
"{content_type} must not be sniffed"
);
assert!(
policy(&response).contains("sandbox"),
"{content_type} must carry the sandbox policy"
);
assert_eq!(
disposition(&response),
None,
"{content_type} renders inline"
);
}
}
#[test]
fn everything_else_downloads() {
for content_type in [
"image/svg+xml",
"text/html; charset=utf-8",
"application/pdf",
] {
let mut response = file_response(Some(content_type));
harden_served_file(&mut response);
assert_eq!(
disposition(&response),
Some("attachment"),
"{content_type} must download"
);
assert!(
policy(&response).contains("sandbox"),
"{content_type} must carry the sandbox policy"
);
}
}
#[test]
fn a_missing_content_type_downloads() {
let mut response = file_response(None);
harden_served_file(&mut response);
assert_eq!(disposition(&response), Some("attachment"));
}
#[test]
fn an_existing_disposition_is_kept_only_when_it_downloads() {
let mut response = file_response(Some("text/html"));
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
header::HeaderValue::from_static("attachment; filename=\"report.html\""),
);
harden_served_file(&mut response);
assert_eq!(
disposition(&response),
Some("attachment; filename=\"report.html\""),
"the directory route's own filename survives"
);
let mut response = file_response(Some("text/html"));
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
header::HeaderValue::from_static("inline"),
);
harden_served_file(&mut response);
assert_eq!(
disposition(&response),
Some("attachment"),
"an inline disposition is replaced, never left to render"
);
}
#[test]
fn a_not_modified_response_carries_the_policy_and_no_disposition() {
let mut response = file_response(None);
*response.status_mut() = StatusCode::NOT_MODIFIED;
harden_served_file(&mut response);
assert_eq!(
response
.headers()
.get(header::X_CONTENT_TYPE_OPTIONS)
.unwrap(),
"nosniff"
);
assert!(policy(&response).contains("sandbox"));
assert_eq!(disposition(&response), None);
}
#[test]
fn an_existing_policy_is_overwritten() {
let mut response = file_response(Some("text/html"));
response
.headers_mut()
.insert(CSP, header::HeaderValue::from_static("script-src *"));
harden_served_file(&mut response);
assert_eq!(
policy(&response),
"default-src 'none'; img-src 'self'; media-src 'self'; \
style-src 'unsafe-inline'; sandbox; frame-ancestors 'self'"
);
}