use http::{StatusCode, header};
use topcoat::{
context::Cx,
router::{
Body, Layer, LayerFuture, Next, Path, PathBuf,
request::uri,
response::{Response, response_headers},
},
};
use super::{Panel, state::under_prefix};
use crate::naming::route_path;
const CSP: header::HeaderName = header::CONTENT_SECURITY_POLICY;
pub(crate) const DEFAULT_CSP: &str = "default-src 'self'; \
script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob: https:; font-src 'self' data: https:; \
media-src 'self' data: blob: https:; object-src 'none'; base-uri 'self'; \
form-action 'self'; frame-ancestors 'self'";
#[derive(Debug, Clone)]
pub(crate) enum Policy {
Default,
Custom(String),
Off,
}
impl Policy {
pub(crate) fn resolve(
self,
sources: &[(String, String)],
) -> Result<Option<String>, crate::DeclarationErrorKind> {
let policy = match self {
Self::Default => default_csp(std::env::var("TOPCOAT_DEV_URL").ok().as_deref()),
Self::Custom(policy) => policy,
Self::Off => {
return match sources.first() {
Some((directive, source)) => Err(refused_source(directive, source)),
None => Ok(None),
};
}
};
with_sources(&policy, sources).map(Some)
}
}
pub(crate) fn with_sources(
policy: &str,
sources: &[(String, String)],
) -> Result<String, crate::DeclarationErrorKind> {
if sources.is_empty() {
return Ok(policy.to_owned());
}
let mut directives: Vec<Vec<&str>> = policy
.split(';')
.map(|directive| directive.split_ascii_whitespace().collect::<Vec<_>>())
.filter(|tokens| !tokens.is_empty())
.collect();
let named = |directives: &[Vec<&str>], name: &str| {
directives
.iter()
.position(|tokens| tokens[0].eq_ignore_ascii_case(name))
};
for (directive, source) in sources {
let valid_name = !directive.is_empty()
&& directive
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte == b'-');
let valid_source = !source.is_empty()
&& !source
.chars()
.any(|c| c.is_whitespace() || c == ';' || c == ',');
if !valid_name || !valid_source {
return Err(refused_source(directive, source));
}
let index = match named(&directives, directive) {
Some(index) => index,
None if directive.ends_with("-src") => {
let Some(default) = named(&directives, "default-src") else {
return Err(refused_source(directive, source));
};
let mut tokens = vec![directive.as_str()];
tokens.extend(
directives[default][1..]
.iter()
.filter(|token| **token != "'none'"),
);
directives.push(tokens);
directives.len() - 1
}
None => return Err(refused_source(directive, source)),
};
let tokens = &mut directives[index];
tokens.retain(|token| *token != "'none'");
if !tokens[1..].contains(&source.as_str()) {
tokens.push(source);
}
}
Ok(directives
.iter()
.map(|tokens| tokens.join(" "))
.collect::<Vec<_>>()
.join("; "))
}
fn refused_source(directive: &str, source: &str) -> crate::DeclarationErrorKind {
crate::DeclarationErrorKind::ContentSecurityPolicy(crate::PolicyFault::InvalidSource {
directive: directive.to_owned(),
source: source.to_owned(),
})
}
pub(crate) fn default_csp(dev: Option<&str>) -> String {
let Some(dev) = dev.and_then(origin) else {
return DEFAULT_CSP.to_owned();
};
let socket = dev.replacen("http", "ws", 1);
DEFAULT_CSP.replacen(
"'unsafe-eval';",
&format!("'unsafe-eval' {dev}; connect-src 'self' {dev} {socket};"),
1,
)
}
fn origin(url: &str) -> Option<&str> {
let (scheme, rest) = url.split_once("://")?;
if !matches!(scheme, "http" | "https") {
return None;
}
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let host = &rest[..end];
let valid = !host.is_empty()
&& host
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".-:[]".contains(&byte));
valid.then(|| &url[..scheme.len() + 3 + end])
}
#[derive(Debug, Clone)]
pub(crate) struct PanelHeaders {
prefix: String,
headers: Vec<(header::HeaderName, header::HeaderValue)>,
}
impl PanelHeaders {
pub(crate) fn new(
prefix: impl Into<String>,
csp: Option<&str>,
hsts: Option<std::time::Duration>,
) -> Result<Self, crate::DeclarationErrorKind> {
let mut headers = vec![
(
header::X_CONTENT_TYPE_OPTIONS,
header::HeaderValue::from_static("nosniff"),
),
(
header::REFERRER_POLICY,
header::HeaderValue::from_static("same-origin"),
),
(
header::CACHE_CONTROL,
header::HeaderValue::from_static("no-store"),
),
];
if let Some(policy) = csp {
let value = header::HeaderValue::from_str(policy).map_err(|_| {
crate::DeclarationErrorKind::ContentSecurityPolicy(
crate::PolicyFault::InvalidHeader {
policy: policy.to_owned(),
},
)
})?;
headers.push((CSP, value));
}
if let Some(max_age) = hsts {
let value = header::HeaderValue::from_str(&format!("max-age={}", max_age.as_secs()))
.expect("a decimal number is a valid header value");
headers.push((header::STRICT_TRANSPORT_SECURITY, value));
}
Ok(Self {
prefix: prefix.into(),
headers,
})
}
}
impl Layer for PanelHeaders {
fn path(&self) -> Option<&Path> {
None
}
fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
Box::pin(async move {
if !under_prefix(&self.prefix, uri(cx).path()) {
return next.run(cx, body).await;
}
match next.run(cx, body).await {
Ok(mut response) => {
insert_missing(&mut response, &self.headers);
Ok(response)
}
Err(error) => {
let queued = response_headers(cx);
for (name, value) in &self.headers {
queued.append(name.clone(), value.clone());
}
Err(error)
}
}
})
}
}
fn insert_missing(response: &mut Response, headers: &[(header::HeaderName, header::HeaderValue)]) {
let target = response.headers_mut();
for (name, value) in headers {
if !target.contains_key(name) {
target.insert(name.clone(), value.clone());
}
}
}
impl Panel {
pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = DEFAULT_CSP;
pub fn content_security_policy(mut self, policy: impl Into<String>) -> Self {
self.content_security_policy = Policy::Custom(policy.into());
self
}
pub fn content_security_policy_source(
mut self,
directive: impl Into<String>,
source: impl Into<String>,
) -> Self {
self.content_security_policy_sources
.push((directive.into(), source.into()));
self
}
pub fn without_content_security_policy(mut self) -> Self {
self.content_security_policy = Policy::Off;
self
}
pub fn strict_transport_security(mut self, max_age: std::time::Duration) -> Self {
self.strict_transport_security = Some(max_age);
self
}
}
const SERVED_FILE_CSP: &str = "default-src 'none'; img-src 'self'; media-src 'self'; \
style-src 'unsafe-inline'; sandbox; frame-ancestors 'self'";
const INLINE_TYPES: &[&str] = &[
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"text/plain",
];
#[derive(Debug, Clone)]
pub(crate) struct ServedDir {
pub(crate) path: String,
pub(crate) dir: std::path::PathBuf,
pub(crate) signed: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct ServedFileHeaders {
path: PathBuf,
signed: bool,
}
impl ServedFileHeaders {
pub(crate) fn new(pattern: &str, signed: bool) -> Self {
Self {
path: route_path(pattern),
signed,
}
}
}
impl Layer for ServedFileHeaders {
fn path(&self) -> Option<&Path> {
Some(&self.path)
}
fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
Box::pin(async move {
if self.signed {
let uri = uri(cx);
let now = crate::upload::signed::now();
if !crate::upload::signed::verify(cx, uri.path(), uri.query(), now) {
return Err(topcoat::router::error::not_found().into());
}
}
let mut response = next.run(cx, body).await?;
harden_served_file(&mut response);
if self.signed {
response.headers_mut().insert(
header::CACHE_CONTROL,
header::HeaderValue::from_static("private"),
);
}
Ok(response)
})
}
}
fn harden_served_file(response: &mut Response) {
let not_modified = response.status() == StatusCode::NOT_MODIFIED;
let headers = response.headers_mut();
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
header::HeaderValue::from_static("nosniff"),
);
headers.insert(CSP, header::HeaderValue::from_static(SERVED_FILE_CSP));
if not_modified || is_inline(headers.get(header::CONTENT_TYPE)) {
return;
}
let already_an_attachment = headers
.get(header::CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("attachment"));
if !already_an_attachment {
headers.insert(
header::CONTENT_DISPOSITION,
header::HeaderValue::from_static("attachment"),
);
}
}
fn is_inline(content_type: Option<&header::HeaderValue>) -> bool {
let Some(mime) = content_type.and_then(|value| value.to_str().ok()) else {
return false;
};
let mime = mime.split(';').next().unwrap_or_default().trim();
INLINE_TYPES.contains(&mime.to_ascii_lowercase().as_str())
}
#[cfg(test)]
mod tests;