use std::convert::Infallible;
use std::fmt;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::extract::{FromRequestParts, OptionalFromRequestParts};
use axum::http::header::{HeaderName, HeaderValue};
use axum::http::request::Parts;
use axum::http::{Request, Response, StatusCode};
use axum::response::IntoResponse;
use tower::{Layer, Service};
pub const NO_SNIFF: &str = "nosniff";
pub const DENY_FRAMING: &str = "DENY";
pub const REFERRER_POLICY: &str = "strict-origin-when-cross-origin";
pub const HSTS_ONE_YEAR: &str = "max-age=31536000; includeSubDomains";
pub const CSP_NONCE_PLACEHOLDER: &str = "{nonce}";
const NONCE_BYTES: usize = 18;
#[derive(Clone, PartialEq, Eq)]
pub struct CspNonce(Arc<str>);
impl CspNonce {
pub fn generate() -> Result<Self, NonceUnavailable> {
let mut bytes = [0u8; NONCE_BYTES];
getrandom::fill(&mut bytes).map_err(|_| NonceUnavailable)?;
Ok(CspNonce(Arc::from(base64_encode(&bytes))))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn attribute(&self) -> String {
format!(" nonce=\"{}\"", self.0)
}
}
impl fmt::Display for CspNonce {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl fmt::Debug for CspNonce {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "CspNonce({})", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NonceUnavailable;
impl fmt::Display for NonceUnavailable {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("the OS random number generator could not produce a CSP nonce")
}
}
impl std::error::Error for NonceUnavailable {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CspTemplateError {
MissingPlaceholder,
NotAHeaderValue,
}
impl fmt::Display for CspTemplateError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CspTemplateError::MissingPlaceholder => write!(
formatter,
"the CSP template contains no `{CSP_NONCE_PLACEHOLDER}`, so it would be sent \
without a nonce; use `with_csp` for a fixed policy"
),
CspTemplateError::NotAHeaderValue => formatter.write_str(
"the CSP template cannot be encoded as a header value: it contains a control \
character or a non-ASCII byte",
),
}
}
}
impl std::error::Error for CspTemplateError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CspNonceMissing;
impl fmt::Display for CspNonceMissing {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(
"no CSP nonce on this request: build the security headers with \
`SecurityHeaders::with_csp_nonce(..)`",
)
}
}
impl std::error::Error for CspNonceMissing {}
impl IntoResponse for CspNonceMissing {
fn into_response(self) -> Response<axum::body::Body> {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
}
}
impl<S> FromRequestParts<S> for CspNonce
where
S: Send + Sync,
{
type Rejection = CspNonceMissing;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<CspNonce>()
.cloned()
.ok_or(CspNonceMissing)
}
}
impl<S> OptionalFromRequestParts<S> for CspNonce
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Option<Self>, Self::Rejection> {
Ok(parts.extensions.get::<CspNonce>().cloned())
}
}
const BASE64_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(bytes: &[u8]) -> String {
fn digit(value: u32) -> char {
char::from(BASE64_ALPHABET[(value & 0b0011_1111) as usize])
}
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let mut triple = u32::from(chunk[0]) << 16;
triple |= u32::from(chunk.get(1).copied().unwrap_or(0)) << 8;
triple |= u32::from(chunk.get(2).copied().unwrap_or(0));
out.push(digit(triple >> 18));
out.push(digit(triple >> 12));
out.push(if chunk.len() > 1 {
digit(triple >> 6)
} else {
'='
});
out.push(if chunk.len() > 2 { digit(triple) } else { '=' });
}
out
}
#[derive(Clone, Debug)]
pub struct SecurityHeaders {
hsts: bool,
csp: Option<HeaderValue>,
csp_template: Option<Arc<str>>,
}
impl SecurityHeaders {
#[must_use]
pub fn new() -> Self {
SecurityHeaders {
hsts: false,
csp: None,
csp_template: None,
}
}
#[must_use]
pub fn with_hsts(mut self) -> Self {
self.hsts = true;
self
}
#[must_use]
pub fn with_csp(mut self, policy: impl AsRef<str>) -> Self {
self.csp = HeaderValue::from_str(policy.as_ref()).ok();
self.csp_template = None;
self
}
pub fn with_csp_nonce(mut self, template: impl AsRef<str>) -> Result<Self, CspTemplateError> {
let template = template.as_ref();
if !template.contains(CSP_NONCE_PLACEHOLDER) {
return Err(CspTemplateError::MissingPlaceholder);
}
let sample = template.replace(
CSP_NONCE_PLACEHOLDER,
&"A".repeat(NONCE_BYTES.div_ceil(3) * 4),
);
HeaderValue::from_str(&sample).map_err(|_| CspTemplateError::NotAHeaderValue)?;
self.csp = None;
self.csp_template = Some(Arc::from(template));
Ok(self)
}
#[must_use]
pub fn generates_nonce(&self) -> bool {
self.csp_template.is_some()
}
fn csp_value(&self, nonce: Option<&CspNonce>) -> Option<HeaderValue> {
match (&self.csp_template, nonce) {
(Some(template), Some(nonce)) => {
HeaderValue::from_str(&template.replace(CSP_NONCE_PLACEHOLDER, nonce.as_str())).ok()
}
_ => self.csp.clone(),
}
}
}
impl Default for SecurityHeaders {
fn default() -> Self {
Self::new()
}
}
impl<S> Layer<S> for SecurityHeaders {
type Service = SecurityHeadersService<S>;
fn layer(&self, inner: S) -> Self::Service {
SecurityHeadersService {
inner,
config: self.clone(),
}
}
}
#[derive(Clone, Debug)]
pub struct SecurityHeadersService<S> {
inner: S,
config: SecurityHeaders,
}
impl<S> Service<Request<axum::body::Body>> for SecurityHeadersService<S>
where
S: Service<
Request<axum::body::Body>,
Response = Response<axum::body::Body>,
Error = Infallible,
> + Clone
+ Send
+ 'static,
S::Future: Send + 'static,
{
type Response = Response<axum::body::Body>;
type Error = Infallible;
type Future =
Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut request: Request<axum::body::Body>) -> Self::Future {
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
let config = self.config.clone();
let nonce = if config.generates_nonce() {
match CspNonce::generate() {
Ok(nonce) => Some(nonce),
Err(unavailable) => {
return Box::pin(async move {
Ok((StatusCode::INTERNAL_SERVER_ERROR, unavailable.to_string())
.into_response())
});
}
}
} else {
None
};
if let Some(nonce) = &nonce {
request.extensions_mut().insert(nonce.clone());
}
Box::pin(async move {
let mut response = inner.call(request).await?;
apply(response.headers_mut(), &config, nonce.as_ref());
Ok(response)
})
}
}
fn apply(headers: &mut axum::http::HeaderMap, config: &SecurityHeaders, nonce: Option<&CspNonce>) {
fn set(headers: &mut axum::http::HeaderMap, name: HeaderName, value: HeaderValue) {
if !headers.contains_key(&name) {
headers.insert(name, value);
}
}
set(
headers,
axum::http::header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static(NO_SNIFF),
);
set(
headers,
axum::http::header::X_FRAME_OPTIONS,
HeaderValue::from_static(DENY_FRAMING),
);
set(
headers,
axum::http::header::REFERRER_POLICY,
HeaderValue::from_static(REFERRER_POLICY),
);
if config.hsts {
set(
headers,
axum::http::header::STRICT_TRANSPORT_SECURITY,
HeaderValue::from_static(HSTS_ONE_YEAR),
);
}
if let Some(policy) = config.csp_value(nonce) {
set(headers, axum::http::header::CONTENT_SECURITY_POLICY, policy);
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;
fn headers_from(config: &SecurityHeaders) -> HeaderMap {
let mut headers = HeaderMap::new();
apply(&mut headers, config, None);
headers
}
fn value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).map(|v| v.to_str().expect("ascii"))
}
#[derive(Clone)]
struct Echo {
own_csp: Option<&'static str>,
}
impl Service<Request<axum::body::Body>> for Echo {
type Response = Response<axum::body::Body>;
type Error = Infallible;
type Future =
Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, request: Request<axum::body::Body>) -> Self::Future {
let seen = request.extensions().get::<CspNonce>().cloned();
let own_csp = self.own_csp;
Box::pin(async move {
let mut response = Response::new(axum::body::Body::empty());
if let Some(nonce) = seen {
response.headers_mut().insert(
"x-seen-nonce",
HeaderValue::from_str(nonce.as_str()).expect("base64 is header-safe"),
);
}
if let Some(policy) = own_csp {
response.headers_mut().insert(
axum::http::header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(policy),
);
}
Ok(response)
})
}
}
async fn run(config: &SecurityHeaders, own_csp: Option<&'static str>) -> HeaderMap {
let mut service = config.layer(Echo { own_csp });
let response = service
.call(Request::new(axum::body::Body::empty()))
.await
.expect("infallible");
response.headers().clone()
}
fn nonce_policy() -> SecurityHeaders {
SecurityHeaders::new()
.with_csp_nonce("default-src 'self'; script-src 'self' 'nonce-{nonce}'")
.expect("the template carries the placeholder")
}
#[test]
fn the_always_on_headers_are_present() {
let headers = headers_from(&SecurityHeaders::new());
assert_eq!(value(&headers, "x-content-type-options"), Some("nosniff"));
assert_eq!(value(&headers, "x-frame-options"), Some("DENY"));
assert_eq!(
value(&headers, "referrer-policy"),
Some("strict-origin-when-cross-origin")
);
}
#[test]
fn hsts_and_csp_are_absent_unless_asked_for() {
let headers = headers_from(&SecurityHeaders::new());
assert_eq!(value(&headers, "strict-transport-security"), None);
assert_eq!(value(&headers, "content-security-policy"), None);
}
#[test]
fn hsts_and_csp_appear_once_asked_for() {
let headers = headers_from(
&SecurityHeaders::new()
.with_hsts()
.with_csp("default-src 'self'"),
);
assert_eq!(
value(&headers, "strict-transport-security"),
Some("max-age=31536000; includeSubDomains")
);
assert_eq!(
value(&headers, "content-security-policy"),
Some("default-src 'self'")
);
}
#[test]
fn hsts_is_not_submitted_for_preloading() {
assert!(!HSTS_ONE_YEAR.contains("preload"));
}
#[test]
fn a_header_the_handler_already_set_is_left_alone() {
let mut headers = HeaderMap::new();
headers.insert("x-frame-options", HeaderValue::from_static("SAMEORIGIN"));
apply(&mut headers, &SecurityHeaders::new(), None);
assert_eq!(value(&headers, "x-frame-options"), Some("SAMEORIGIN"));
assert_eq!(value(&headers, "x-content-type-options"), Some("nosniff"));
}
#[test]
fn a_policy_that_cannot_be_a_header_value_is_dropped_not_mangled() {
let headers =
headers_from(&SecurityHeaders::new().with_csp("default-src 'self'\n\rinjected"));
assert_eq!(value(&headers, "content-security-policy"), None);
}
#[test]
fn a_template_without_the_placeholder_is_refused() {
let error = SecurityHeaders::new()
.with_csp_nonce("script-src 'self'")
.expect_err("no placeholder");
assert_eq!(error, CspTemplateError::MissingPlaceholder);
}
#[test]
fn a_template_that_cannot_be_a_header_value_is_refused_not_dropped() {
let error = SecurityHeaders::new()
.with_csp_nonce("script-src 'nonce-{nonce}'\n\rinjected")
.expect_err("not a header value");
assert_eq!(error, CspTemplateError::NotAHeaderValue);
}
#[test]
fn a_fixed_policy_and_a_nonce_template_replace_each_other() {
let fixed_last = nonce_policy().with_csp("default-src 'none'");
assert!(!fixed_last.generates_nonce());
assert_eq!(
value(&headers_from(&fixed_last), "content-security-policy"),
Some("default-src 'none'")
);
let nonce_last = SecurityHeaders::new()
.with_csp("default-src 'none'")
.with_csp_nonce("script-src 'nonce-{nonce}'")
.expect("template");
assert!(nonce_last.generates_nonce());
assert_eq!(
value(&headers_from(&nonce_last), "content-security-policy"),
None
);
}
#[tokio::test]
async fn two_requests_get_two_different_nonces() {
let config = nonce_policy();
let first = run(&config, None).await;
let second = run(&config, None).await;
let first = value(&first, "x-seen-nonce").expect("nonce reached the inner service");
let second = value(&second, "x-seen-nonce").expect("nonce reached the inner service");
assert_ne!(first, second, "a reused nonce is a guessable nonce");
}
#[tokio::test]
async fn the_header_carries_the_same_nonce_the_request_extension_did() {
let headers = run(&nonce_policy(), None).await;
let seen = value(&headers, "x-seen-nonce").expect("nonce in extensions");
assert_eq!(
value(&headers, "content-security-policy"),
Some(format!("default-src 'self'; script-src 'self' 'nonce-{seen}'").as_str())
);
}
#[tokio::test]
async fn a_response_that_set_its_own_csp_keeps_it() {
let headers = run(&nonce_policy(), Some("default-src 'none'")).await;
assert_eq!(
value(&headers, "content-security-policy"),
Some("default-src 'none'")
);
assert!(value(&headers, "x-seen-nonce").is_some());
}
#[tokio::test]
async fn no_nonce_is_minted_when_none_was_asked_for() {
let headers = run(&SecurityHeaders::new().with_csp("default-src 'self'"), None).await;
assert_eq!(value(&headers, "x-seen-nonce"), None);
}
#[test]
fn a_nonce_is_base64_and_long_enough_to_be_unguessable() {
let nonce = CspNonce::generate().expect("OS RNG");
assert_eq!(nonce.as_str().len(), 24, "18 random bytes, unpadded");
assert!(
nonce.as_str().bytes().all(|b| BASE64_ALPHABET.contains(&b)),
"a character outside the base64 alphabet would not survive the CSP grammar"
);
assert_eq!(nonce.attribute(), format!(" nonce=\"{nonce}\""));
}
#[test]
fn base64_matches_the_rfc_4648_vectors() {
for (input, expected) in [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("fooba", "Zm9vYmE="),
("foobar", "Zm9vYmFy"),
] {
assert_eq!(base64_encode(input.as_bytes()), expected, "input {input:?}");
}
assert_eq!(base64_encode(&[0xff, 0xef, 0xfe]), "/+/+");
}
}