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) -> Option<String> {
match self {
Self::Default => Some(default_csp(
std::env::var("TOPCOAT_DEV_URL").ok().as_deref(),
)),
Self::Custom(policy) => Some(policy),
Self::Off => None,
}
}
}
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::InvalidContentSecurityPolicy {
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 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;