Files
fluxer/fluxer_media_proxy/src/http_headers.rs
T

462 lines
17 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::range::ByteRange;
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header};
pub const ROBOTS: &str = "noindex, nofollow, nosnippet, noimageindex, notranslate, max-snippet:0, max-image-preview:none, max-video-preview:0";
pub const MEDIA_CSP: &str = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'none'; script-src-attr 'none'; script-src-elem 'none'; style-src 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; sandbox allow-same-origin";
pub const STRICT_TRANSPORT_SECURITY: &str = "max-age=31536000; includeSubDomains; preload";
pub const REFERRER_POLICY: &str = "strict-origin-when-cross-origin";
pub const PERMISSIONS_POLICY: &str = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
pub fn parse_content_length(headers: &HeaderMap) -> Option<u64> {
let content_lengths = headers.get_all(header::CONTENT_LENGTH);
let mut values = content_lengths.iter();
let raw = values.next()?.to_str().ok()?;
if values.next().is_some() || raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
raw.parse().ok()
}
pub fn add_security_headers(headers: &mut HeaderMap) {
set_static_header(
headers,
HeaderName::from_static("strict-transport-security"),
STRICT_TRANSPORT_SECURITY,
);
set_static_header(headers, header::X_CONTENT_TYPE_OPTIONS, "nosniff");
set_static_header(
headers,
HeaderName::from_static("referrer-policy"),
REFERRER_POLICY,
);
set_static_header(headers, HeaderName::from_static("x-frame-options"), "DENY");
set_static_header(
headers,
HeaderName::from_static("permissions-policy"),
PERMISSIONS_POLICY,
);
set_static_header(headers, header::CONTENT_SECURITY_POLICY, MEDIA_CSP);
}
pub fn neutralize_script_content_type(status: StatusCode, headers: &mut HeaderMap) {
let values = headers.get_all(header::CONTENT_TYPE);
let scriptable = values.iter().any(|value| {
value
.to_str()
.map_or(true, crate::mime::is_javascript_content_type)
});
let untyped = values.iter().all(|value| {
value
.as_bytes()
.iter()
.all(|byte| matches!(byte, b' ' | b'\t'))
});
let serves_body = matches!(status, StatusCode::OK | StatusCode::PARTIAL_CONTENT);
if scriptable || (untyped && serves_body) {
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(crate::mime::INERT_CONTENT_TYPE),
);
}
}
fn set_static_header(headers: &mut HeaderMap, name: HeaderName, value: &'static str) {
headers
.entry(name)
.or_insert(HeaderValue::from_static(value));
}
pub fn add_media_headers(
headers: &mut HeaderMap,
size: usize,
content_type: &str,
byte_range: Option<ByteRange>,
) {
add_security_headers(headers);
let streamable = content_type.starts_with("video/") || content_type.starts_with("audio/");
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
headers.insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
);
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(if streamable {
"public, max-age=31536000, no-transform"
} else {
"public, max-age=31536000"
}),
);
headers.insert(
"CDN-Cache-Control",
HeaderValue::from_static("public, max-age=31536000"),
);
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(MEDIA_CSP),
);
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(content_type)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
headers.insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert("X-Robots-Tag", HeaderValue::from_static(ROBOTS));
if let Some(r) = byte_range {
headers.insert(
header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes {}-{}/{}", r.start, r.end, size))
.expect("content-range is ASCII"),
);
}
}
pub fn add_unsatisfiable_headers(headers: &mut HeaderMap, size: usize) {
add_security_headers(headers);
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
headers.insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
);
headers.insert(
header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes */{size}")).expect("content-range is ASCII"),
);
headers.insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
headers.insert("X-Robots-Tag", HeaderValue::from_static(ROBOTS));
}
#[cfg(test)]
mod tests {
use super::*;
fn header_names(headers: &HeaderMap) -> Vec<String> {
let mut names: Vec<String> = headers
.keys()
.map(|name| name.as_str().to_owned())
.collect();
names.sort();
names
}
fn value(headers: &HeaderMap, name: &str) -> String {
headers
.get(name)
.unwrap_or_else(|| panic!("missing {name}"))
.to_str()
.expect("header value is ASCII")
.to_owned()
}
const MEDIA_HEADER_NAMES: [&str; 13] = [
"accept-ranges",
"access-control-allow-origin",
"cache-control",
"cdn-cache-control",
"content-security-policy",
"content-type",
"permissions-policy",
"referrer-policy",
"strict-transport-security",
"vary",
"x-content-type-options",
"x-frame-options",
"x-robots-tag",
];
#[test]
fn media_headers_distinguish_streamable_content_and_ranges() {
let mut image_headers = HeaderMap::new();
add_media_headers(&mut image_headers, 100, "image/png", None);
assert_eq!(header_names(&image_headers), MEDIA_HEADER_NAMES.to_vec());
assert_eq!(
value(&image_headers, "cache-control"),
"public, max-age=31536000"
);
assert_eq!(value(&image_headers, "content-type"), "image/png");
assert!(image_headers.get(header::CONTENT_RANGE).is_none());
let mut video_headers = HeaderMap::new();
add_media_headers(
&mut video_headers,
100,
"video/mp4",
Some(ByteRange { start: 10, end: 19 }),
);
let mut ranged_names = MEDIA_HEADER_NAMES.to_vec();
ranged_names.push("content-range");
ranged_names.sort();
assert_eq!(header_names(&video_headers), ranged_names);
assert_eq!(
value(&video_headers, "cache-control"),
"public, max-age=31536000, no-transform"
);
assert_eq!(value(&video_headers, "content-range"), "bytes 10-19/100");
let mut audio_headers = HeaderMap::new();
add_media_headers(&mut audio_headers, 100, "audio/mpeg", None);
assert_eq!(
value(&audio_headers, "cache-control"),
"public, max-age=31536000, no-transform"
);
}
#[test]
fn media_headers_carry_the_frozen_policy_values_and_no_entity_tag() {
let mut headers = HeaderMap::new();
add_media_headers(&mut headers, 100, "image/png", None);
assert_eq!(value(&headers, "accept-ranges"), "bytes");
assert_eq!(value(&headers, "access-control-allow-origin"), "*");
assert_eq!(
value(&headers, "cdn-cache-control"),
"public, max-age=31536000"
);
assert_eq!(value(&headers, "content-security-policy"), MEDIA_CSP);
assert!(headers.get(header::EXPIRES).is_none());
assert!(headers.get(header::LAST_MODIFIED).is_none());
assert_eq!(
value(&headers, "strict-transport-security"),
STRICT_TRANSPORT_SECURITY
);
assert_eq!(value(&headers, "referrer-policy"), REFERRER_POLICY);
assert_eq!(value(&headers, "permissions-policy"), PERMISSIONS_POLICY);
assert_eq!(value(&headers, "x-frame-options"), "DENY");
assert_eq!(value(&headers, "x-content-type-options"), "nosniff");
assert_eq!(value(&headers, "x-robots-tag"), ROBOTS);
assert_eq!(value(&headers, "vary"), "Accept-Encoding");
assert!(headers.get(header::ETAG).is_none());
}
#[test]
fn media_headers_overwrite_inherited_security_values_and_fall_back_on_invalid_content_types() {
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src *"),
);
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("sniff-everything"),
);
add_media_headers(&mut headers, 100, "image/\u{7f}png", None);
assert_eq!(value(&headers, "content-security-policy"), MEDIA_CSP);
assert_eq!(value(&headers, "x-content-type-options"), "nosniff");
assert_eq!(value(&headers, "content-type"), "application/octet-stream");
assert_eq!(
headers
.get_all(header::CONTENT_SECURITY_POLICY)
.iter()
.count(),
1
);
}
#[test]
fn shorter_cache_policies_replace_the_stored_media_policy_completely() {
for policy in [
"public, max-age=300",
"public, max-age=86400",
"no-store",
"private, no-store",
] {
let mut headers = HeaderMap::new();
add_media_headers(&mut headers, 100, "image/png", None);
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(policy));
headers.insert("CDN-Cache-Control", HeaderValue::from_static(policy));
assert_eq!(headers.get_all(header::CACHE_CONTROL).iter().count(), 1);
assert_eq!(value(&headers, "cache-control"), policy);
assert_eq!(value(&headers, "cdn-cache-control"), policy);
}
}
#[test]
fn unsatisfiable_headers_state_the_full_size_without_a_body_representation() {
let mut headers = HeaderMap::new();
add_unsatisfiable_headers(&mut headers, 4096);
assert_eq!(
header_names(&headers),
vec![
"accept-ranges",
"access-control-allow-origin",
"content-range",
"content-security-policy",
"permissions-policy",
"referrer-policy",
"strict-transport-security",
"vary",
"x-content-type-options",
"x-frame-options",
"x-robots-tag",
]
);
assert_eq!(value(&headers, "content-range"), "bytes */4096");
assert_eq!(value(&headers, "vary"), "Accept-Encoding");
assert_eq!(value(&headers, "x-robots-tag"), ROBOTS);
assert!(headers.get(header::CONTENT_TYPE).is_none());
assert!(headers.get(header::CACHE_CONTROL).is_none());
assert!(headers.get("CDN-Cache-Control").is_none());
}
#[test]
fn neutralizing_rewrites_only_javascript_or_opaque_content_types() {
let mut script = HeaderMap::new();
script.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/javascript"),
);
neutralize_script_content_type(StatusCode::OK, &mut script);
assert_eq!(value(&script, "content-type"), "text/plain; charset=utf-8");
let mut image = HeaderMap::new();
image.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
neutralize_script_content_type(StatusCode::OK, &mut image);
assert_eq!(value(&image, "content-type"), "image/png");
let mut opaque = HeaderMap::new();
opaque.insert(
header::CONTENT_TYPE,
HeaderValue::from_bytes(&[0xC3, 0x28]).expect("opaque header value"),
);
neutralize_script_content_type(StatusCode::OK, &mut opaque);
assert_eq!(value(&opaque, "content-type"), "text/plain; charset=utf-8");
let mut duplicated = HeaderMap::new();
duplicated.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
duplicated.append(
header::CONTENT_TYPE,
HeaderValue::from_static("text/javascript"),
);
neutralize_script_content_type(StatusCode::OK, &mut duplicated);
assert_eq!(duplicated.get_all(header::CONTENT_TYPE).iter().count(), 1);
assert_eq!(
value(&duplicated, "content-type"),
"text/plain; charset=utf-8"
);
let mut junk = HeaderMap::new();
junk.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("video/mp4, text/javascript x"),
);
neutralize_script_content_type(StatusCode::NOT_FOUND, &mut junk);
assert_eq!(value(&junk, "content-type"), "text/plain; charset=utf-8");
let mut absent = HeaderMap::new();
neutralize_script_content_type(StatusCode::RANGE_NOT_SATISFIABLE, &mut absent);
assert!(absent.is_empty());
}
#[test]
fn neutralizing_types_only_an_untyped_full_or_partial_response() {
for status in [StatusCode::OK, StatusCode::PARTIAL_CONTENT] {
for blank in [None, Some(""), Some(" \t ")] {
let mut headers = HeaderMap::new();
if let Some(blank) = blank {
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(blank));
}
neutralize_script_content_type(status, &mut headers);
assert_eq!(headers.get_all(header::CONTENT_TYPE).iter().count(), 1);
assert_eq!(
value(&headers, "content-type"),
"text/plain; charset=utf-8",
"{status} {blank:?}"
);
}
}
for status in [
StatusCode::NO_CONTENT,
StatusCode::NOT_MODIFIED,
StatusCode::NOT_FOUND,
StatusCode::METHOD_NOT_ALLOWED,
StatusCode::RANGE_NOT_SATISFIABLE,
] {
let mut headers = HeaderMap::new();
neutralize_script_content_type(status, &mut headers);
assert!(headers.is_empty(), "{status}");
}
}
#[test]
fn content_length_parser_requires_one_canonical_decimal_value() {
let mut headers = HeaderMap::new();
assert_eq!(parse_content_length(&headers), None);
headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("123"));
assert_eq!(parse_content_length(&headers), Some(123));
headers.append(header::CONTENT_LENGTH, HeaderValue::from_static("123"));
assert_eq!(parse_content_length(&headers), None);
headers.clear();
headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("+1"));
assert_eq!(parse_content_length(&headers), None);
headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_static("18446744073709551616"),
);
assert_eq!(parse_content_length(&headers), None);
for raw in ["", " 1", "1 ", "0x10", "1_0", "12.0", "-1", "1,1"] {
headers.clear();
let header_value = HeaderValue::from_str(raw).expect("visible ascii header value");
headers.insert(header::CONTENT_LENGTH, header_value);
assert_eq!(parse_content_length(&headers), None, "accepted {raw:?}");
}
headers.clear();
headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_bytes(&[0xC3, 0x28]).expect("opaque header value"),
);
assert_eq!(parse_content_length(&headers), None);
headers.clear();
headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_static("18446744073709551615"),
);
assert_eq!(parse_content_length(&headers), Some(u64::MAX));
headers.clear();
headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("00"));
assert_eq!(parse_content_length(&headers), Some(0));
}
#[test]
fn media_headers_always_cache_forever() {
let mut headers = HeaderMap::new();
add_media_headers(&mut headers, 10, "image/png", None);
assert_eq!(
"public, max-age=31536000",
headers.get(header::CACHE_CONTROL).unwrap()
);
let mut streamable = HeaderMap::new();
add_media_headers(&mut streamable, 10, "video/mp4", None);
assert_eq!(
"public, max-age=31536000, no-transform",
streamable.get(header::CACHE_CONTROL).unwrap()
);
let mut audio = HeaderMap::new();
add_media_headers(&mut audio, 10, "audio/ogg", None);
assert_eq!(
"public, max-age=31536000, no-transform",
audio.get(header::CACHE_CONTROL).unwrap()
);
}
#[test]
fn media_headers_pair_the_policy_with_cdn_cache_control_and_omit_expires() {
let mut headers = HeaderMap::new();
add_media_headers(&mut headers, 10, "image/png", None);
assert_eq!(
"public, max-age=31536000",
headers.get("CDN-Cache-Control").unwrap()
);
assert!(
headers.get(header::EXPIRES).is_none(),
"Cache-Control is authoritative; a fixed Expires date only contradicts it"
);
}
}