use axum::body::{Body, Bytes};
use axum::http::header::{
CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, IF_MODIFIED_SINCE,
IF_NONE_MATCH, SET_COOKIE, VARY,
};
use axum::http::{HeaderValue, Request, Response};
use axum::middleware::Next;
use futures::StreamExt as _;
use super::{Consent, find_cookie};
const MAX_SPLICE_BODY_BYTES: usize = 2 * 1024 * 1024;
const RENDERED_BANNER_MARKER: &str =
r#"<section class="autumn-consent-banner" role="region" aria-label="Cookie consent">"#;
#[must_use]
pub fn consent_banner_markup(csrf_token: Option<&str>, csrf_field_name: &str) -> maud::Markup {
maud::html! {
section class="autumn-consent-banner" role="region" aria-label="Cookie consent" {
p class="autumn-consent-banner__message" {
"This site uses cookies. Strictly-necessary cookies (login, security) are always on. "
"Others, like analytics, only run if you accept them."
}
form method="post" action="/consent/accept" class="autumn-consent-banner__actions" {
@if let Some(token) = csrf_token {
input type="hidden" name=(csrf_field_name) value=(token);
}
button
type="submit"
formaction="/consent/reject"
class="autumn-consent-banner__button autumn-consent-banner__button--reject"
{
"Reject non-essential"
}
button
type="submit"
class="autumn-consent-banner__button autumn-consent-banner__button--accept"
{
"Accept all"
}
}
}
}
}
pub async fn inject_consent_banner(
mut request: Request<Body>,
next: Next,
policy_version: u32,
csrf_cookie_name: &str,
csrf_form_field: &str,
) -> Response<Body> {
if request
.extensions()
.get::<crate::static_gen::RenderDeadlineExempt>()
.is_some()
{
return next.run(request).await;
}
let consent = Consent::from_headers(request.headers());
let request_csrf_cookie = find_cookie(request.headers(), csrf_cookie_name);
let needs_prompt = consent.needs_prompt(policy_version);
if needs_prompt {
request.headers_mut().remove(IF_NONE_MATCH);
request.headers_mut().remove(IF_MODIFIED_SINCE);
}
let mut response = next.run(request).await;
if !is_html_response(&response) {
return response;
}
if !needs_prompt {
response
.headers_mut()
.append(VARY, HeaderValue::from_static("Cookie"));
return response;
}
let csrf_token =
extract_response_csrf_cookie(&response, csrf_cookie_name).or(request_csrf_cookie);
let banner_html = consent_banner_markup(csrf_token.as_deref(), csrf_form_field).into_string();
splice_into_response(response, &banner_html).await
}
fn extract_response_csrf_cookie(
response: &Response<Body>,
csrf_cookie_name: &str,
) -> Option<String> {
response
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|set_cookie| {
let rest = set_cookie
.strip_prefix(csrf_cookie_name)?
.strip_prefix('=')?;
let value = rest.split(';').next().unwrap_or(rest);
Some(value.to_owned())
})
}
fn is_html_response(response: &Response<Body>) -> bool {
if response.headers().contains_key(CONTENT_ENCODING) {
return false;
}
response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|content_type| {
let essence = content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim();
essence.eq_ignore_ascii_case("text/html")
})
}
enum CollectedBody {
Full(Bytes),
Oversized(Body),
Errored { prefix: Bytes, error: axum::Error },
}
async fn collect_body_prefix(body: Body, limit: usize) -> CollectedBody {
let mut buf = Vec::<u8>::new();
let mut stream = body.into_data_stream();
loop {
match stream.next().await {
None => break,
Some(Err(error)) => {
return CollectedBody::Errored {
prefix: Bytes::from(buf),
error,
};
}
Some(Ok(chunk)) => {
let remaining = limit.saturating_sub(buf.len());
if chunk.len() > remaining {
let mut leading = Vec::with_capacity(2);
if !buf.is_empty() {
leading.push(Ok::<Bytes, axum::Error>(Bytes::from(buf)));
}
leading.push(Ok::<Bytes, axum::Error>(chunk));
let body = Body::from_stream(futures::stream::iter(leading).chain(stream));
return CollectedBody::Oversized(body);
}
buf.extend_from_slice(&chunk);
}
}
}
CollectedBody::Full(Bytes::from(buf))
}
async fn splice_into_response(response: Response<Body>, snippet: &str) -> Response<Body> {
let (mut parts, body) = response.into_parts();
match collect_body_prefix(body, MAX_SPLICE_BODY_BYTES).await {
CollectedBody::Full(bytes) => {
if contains_ascii_case_insensitive(&bytes, RENDERED_BANNER_MARKER.as_bytes()) {
parts
.headers
.insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store"));
parts
.headers
.append(VARY, HeaderValue::from_static("Cookie"));
return Response::from_parts(parts, Body::from(bytes));
}
let updated = splice_before_body_close(&bytes, snippet);
if updated == bytes.as_ref() {
return Response::from_parts(parts, Body::from(bytes));
}
parts
.headers
.insert(CONTENT_LENGTH, HeaderValue::from(updated.len()));
parts
.headers
.insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store"));
parts
.headers
.append(VARY, HeaderValue::from_static("Cookie"));
Response::from_parts(parts, Body::from(updated))
}
CollectedBody::Oversized(body) => {
parts
.headers
.append(VARY, HeaderValue::from_static("Cookie"));
Response::from_parts(parts, body)
}
CollectedBody::Errored { prefix, error } => {
parts.headers.remove(CONTENT_LENGTH);
let frames: Vec<Result<Bytes, axum::Error>> = if prefix.is_empty() {
vec![Err(error)]
} else {
vec![Ok(prefix), Err(error)]
};
Response::from_parts(parts, Body::from_stream(futures::stream::iter(frames)))
}
}
}
fn splice_before_body_close(body: &[u8], snippet: &str) -> Vec<u8> {
if let Some(index) = rfind_ascii_case_insensitive(body, b"</body>") {
let mut out = Vec::with_capacity(body.len() + snippet.len());
out.extend_from_slice(&body[..index]);
out.extend_from_slice(snippet.as_bytes());
out.extend_from_slice(&body[index..]);
return out;
}
let mut out = body.to_vec();
out.extend_from_slice(snippet.as_bytes());
out
}
fn rfind_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
(0..=haystack.len() - needle.len())
.rev()
.find(|&i| haystack[i..i + needle.len()].eq_ignore_ascii_case(needle))
}
fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
if haystack.len() < needle.len() {
return false;
}
(0..=haystack.len() - needle.len())
.any(|i| haystack[i..i + needle.len()].eq_ignore_ascii_case(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::http::{Method, StatusCode};
use axum::routing::get;
use tower::ServiceExt;
#[test]
fn banner_has_accessible_region_and_label() {
let html = consent_banner_markup(None, "_csrf").into_string();
assert!(html.contains(r#"role="region""#));
assert!(html.contains(r#"aria-label="Cookie consent""#));
}
#[test]
fn banner_reject_and_accept_share_the_same_base_button_class() {
let html = consent_banner_markup(None, "_csrf").into_string();
assert!(html.contains("Reject non-essential"));
assert!(html.contains("Accept all"));
let button_count = html.matches("autumn-consent-banner__button\"").count()
+ html.matches("autumn-consent-banner__button ").count();
assert!(
button_count >= 2,
"both buttons must carry the shared base class: {html}"
);
}
#[test]
fn banner_omits_csrf_field_when_no_token_given() {
let html = consent_banner_markup(None, "_csrf").into_string();
assert!(!html.contains("_csrf"));
}
#[test]
fn banner_includes_csrf_hidden_field_when_token_given() {
let html = consent_banner_markup(Some("tok-123"), "_csrf").into_string();
assert!(html.contains(r#"name="_csrf""#));
assert!(html.contains(r#"value="tok-123""#));
}
#[test]
fn banner_needs_no_script_tag() {
let html = consent_banner_markup(Some("tok"), "_csrf").into_string();
assert!(!html.contains("<script"), "banner must need no JS: {html}");
}
#[test]
fn banner_buttons_are_keyboard_reachable_native_submit_buttons() {
let html = consent_banner_markup(None, "_csrf").into_string();
assert!(html.contains(r#"type="submit""#));
assert!(!html.contains("tabindex=\"-1\""));
}
#[test]
fn splice_inserts_before_last_body_close_tag() {
let out = splice_before_body_close(b"<html><body><main>ok</main></body></html>", "<snip>");
let s = String::from_utf8(out).unwrap();
assert!(s.contains("<snip></body>"));
}
#[test]
fn splice_appends_when_no_body_tag_but_html_shell_present() {
let out = splice_before_body_close(b"<html><main>ok</main></html>", "<snip>");
let s = String::from_utf8(out).unwrap();
assert!(s.ends_with("<snip>"));
}
#[test]
fn splice_appends_when_no_recognizable_html_wrapper_tag_is_present() {
let out = splice_before_body_close(b"<!doctype html><main>ok</main>", "<snip>");
let s = String::from_utf8(out).unwrap();
assert!(s.ends_with("<snip>"), "{s}");
}
#[test]
fn splice_matches_uppercase_and_mixed_case_tags() {
let out = splice_before_body_close(b"<HTML><BODY><main>ok</main></BODY></HTML>", "<snip>");
let s = String::from_utf8(out).unwrap();
assert!(
s.contains("<snip></BODY>"),
"an uppercase `</BODY>` is exactly as valid HTML as lowercase: {s}"
);
}
#[test]
fn splice_appends_when_only_uppercase_html_shell_present() {
let out = splice_before_body_close(b"<HTML><main>ok</main></HTML>", "<snip>");
let s = String::from_utf8(out).unwrap();
assert!(s.ends_with("<snip>"), "{s}");
}
#[test]
fn splice_preserves_non_utf8_bytes_in_a_legacy_charset_document() {
let mut body = b"<html><body>caf\xE9".to_vec();
body.extend_from_slice(b"</body></html>");
let out = splice_before_body_close(&body, "<snip>");
let out_str = String::from_utf8_lossy(&out);
assert!(
out.windows(4).any(|w| w == b"caf\xE9"),
"non-UTF-8 bytes must survive splicing untouched, not become U+FFFD: {out_str}"
);
assert!(out_str.contains("<snip></body>"));
}
#[test]
fn html_response_detected_by_content_type() {
let response = Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::empty())
.unwrap();
assert!(is_html_response(&response));
}
#[test]
fn html_response_detected_regardless_of_media_type_case() {
let response = Response::builder()
.header(CONTENT_TYPE, "Text/HTML; charset=utf-8")
.body(Body::empty())
.unwrap();
assert!(is_html_response(&response));
}
#[test]
fn json_response_is_not_html() {
let response = Response::builder()
.header(CONTENT_TYPE, "application/json")
.body(Body::empty())
.unwrap();
assert!(!is_html_response(&response));
}
#[test]
fn media_type_that_merely_contains_text_html_as_a_substring_is_not_html() {
let response = Response::builder()
.header(CONTENT_TYPE, "text/html-patch+json")
.body(Body::empty())
.unwrap();
assert!(!is_html_response(&response));
let response = Response::builder()
.header(CONTENT_TYPE, r#"application/json; profile="text/html""#)
.body(Body::empty())
.unwrap();
assert!(!is_html_response(&response));
}
#[test]
fn encoded_html_response_is_skipped() {
let response = Response::builder()
.header(CONTENT_TYPE, "text/html")
.header(CONTENT_ENCODING, "gzip")
.body(Body::empty())
.unwrap();
assert!(!is_html_response(&response));
}
#[test]
fn extracts_csrf_value_from_fresh_set_cookie() {
let response = Response::builder()
.header(SET_COOKIE, "autumn-csrf=fresh-token; Path=/; HttpOnly")
.body(Body::empty())
.unwrap();
assert_eq!(
extract_response_csrf_cookie(&response, "autumn-csrf"),
Some("fresh-token".to_owned())
);
}
#[test]
fn no_csrf_set_cookie_yields_none() {
let response = Response::builder()
.header(SET_COOKIE, "autumn.sid=abc; Path=/")
.body(Body::empty())
.unwrap();
assert_eq!(extract_response_csrf_cookie(&response, "autumn-csrf"), None);
}
#[test]
fn extract_response_csrf_cookie_honors_custom_cookie_name() {
let response = Response::builder()
.header(SET_COOKIE, "my-csrf=custom-token; Path=/")
.body(Body::empty())
.unwrap();
assert_eq!(
extract_response_csrf_cookie(&response, "my-csrf"),
Some("custom-token".to_owned())
);
assert_eq!(extract_response_csrf_cookie(&response, "autumn-csrf"), None);
}
fn html_page() -> Response<Body> {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(
"<html><body><main>hello</main></body></html>".to_owned(),
))
.unwrap()
}
fn app_with_policy_version(version: u32) -> Router {
Router::new()
.route("/", get(|| async { html_page() }))
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, version, "autumn-csrf", "_csrf").await
}))
}
#[tokio::test]
async fn gate_withholds_non_essential_cookie_while_session_cookie_is_unaffected() {
use crate::session::{MemoryStore, Session, SessionConfig, SessionLayer};
const POLICY_VERSION: u32 = 1;
async fn handler(session: Session, consent: Consent) -> Response<Body> {
session.insert("visited", "true").await;
let mut response = html_page();
if consent.allows("analytics", POLICY_VERSION) {
response
.headers_mut()
.append(SET_COOKIE, HeaderValue::from_static("analytics=on; Path=/"));
}
response
}
let app = || {
Router::new()
.route("/", get(handler))
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, POLICY_VERSION, "autumn-csrf", "_csrf").await
}))
.layer(SessionLayer::new(
MemoryStore::new(),
SessionConfig::default(),
))
};
let response = app()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let set_cookies: Vec<String> = response
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok().map(str::to_owned))
.collect();
assert!(
set_cookies.iter().any(|c| c.starts_with("autumn.sid=")),
"strictly-necessary session cookie must still be set: {set_cookies:?}"
);
assert!(
!set_cookies.iter().any(|c| c.starts_with("analytics=")),
"non-essential cookie must NOT be set without consent: {set_cookies:?}"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("autumn-consent-banner"), "{html}");
let cookie = super::super::accept_all_cookie(&["analytics"], POLICY_VERSION);
let raw_value = cookie
.split(';')
.next()
.unwrap()
.strip_prefix("autumn.consent=")
.unwrap();
let response = app()
.oneshot(
Request::builder()
.uri("/")
.header("cookie", format!("autumn.consent={raw_value}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let set_cookies: Vec<String> = response
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok().map(str::to_owned))
.collect();
assert!(
set_cookies.iter().any(|c| c.starts_with("analytics=")),
"analytics cookie must be set once accepted: {set_cookies:?}"
);
}
#[tokio::test]
async fn injects_banner_when_no_consent_cookie_present() {
let app = app_with_policy_version(1);
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("autumn-consent-banner"), "{html}");
}
#[tokio::test]
async fn omits_banner_when_consent_already_decided_under_current_version() {
let app = app_with_policy_version(1);
let cookie = super::super::accept_all_cookie(&["analytics"], 1);
let raw_value = cookie
.split(';')
.next()
.unwrap()
.strip_prefix("autumn.consent=")
.unwrap();
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("cookie", format!("autumn.consent={raw_value}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(!html.contains("autumn-consent-banner"), "{html}");
}
#[tokio::test]
async fn reprompts_when_recorded_consent_is_from_an_older_policy_version() {
let app = app_with_policy_version(2);
let cookie = super::super::accept_all_cookie(&["analytics"], 1);
let raw_value = cookie
.split(';')
.next()
.unwrap()
.strip_prefix("autumn.consent=")
.unwrap();
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("cookie", format!("autumn.consent={raw_value}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains("autumn-consent-banner"),
"policy bump must re-show the banner: {html}"
);
}
#[tokio::test]
async fn non_html_response_is_left_untouched() {
let app = Router::new()
.route(
"/api",
get(|| async { ([(CONTENT_TYPE, "application/json")], r#"{"status":"ok"}"#) }),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/api").body(Body::empty()).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], br#"{"status":"ok"}"#);
}
#[tokio::test]
async fn banner_carries_csrf_token_freshly_set_by_a_csrf_layer_style_response() {
let app = Router::new()
.route(
"/",
get(|| async {
let mut response = html_page();
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_static("autumn-csrf=minted-token; Path=/"),
);
response
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains(r#"value="minted-token""#), "{html}");
}
#[tokio::test]
async fn content_length_updated_after_injection() {
let app = app_with_policy_version(1);
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let content_length: usize = response
.headers()
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.expect("content-length must be set after injection");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(content_length, body.len());
}
#[tokio::test]
async fn injection_marks_response_uncacheable_and_varying_on_cookie() {
let app = app_with_policy_version(1);
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response
.headers()
.get(CACHE_CONTROL)
.and_then(|v| v.to_str().ok()),
Some("private, no-store"),
"a live per-visitor CSRF token was just embedded; this response must never be shared via a cache"
);
assert_eq!(
response.headers().get(VARY).and_then(|v| v.to_str().ok()),
Some("Cookie")
);
}
#[tokio::test]
async fn no_cache_control_added_when_consent_already_decided() {
let app = app_with_policy_version(1);
let cookie = super::super::accept_all_cookie(&["analytics"], 1);
let raw_value = cookie
.split(';')
.next()
.unwrap()
.strip_prefix("autumn.consent=")
.unwrap();
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("cookie", format!("autumn.consent={raw_value}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
response.headers().get(CACHE_CONTROL).is_none(),
"no banner injected, so this middleware must leave the app's own Cache-Control alone"
);
}
#[tokio::test]
async fn varies_on_cookie_even_when_consent_already_decided_and_nothing_is_injected() {
let app = app_with_policy_version(1);
let cookie = super::super::accept_all_cookie(&["analytics"], 1);
let raw_value = cookie
.split(';')
.next()
.unwrap()
.strip_prefix("autumn.consent=")
.unwrap();
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("cookie", format!("autumn.consent={raw_value}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers().get(VARY).and_then(|v| v.to_str().ok()),
Some("Cookie"),
"a decided-but-not-injected HTML response must still vary on Cookie"
);
}
#[tokio::test]
async fn undecided_visitors_head_request_matches_the_spliced_gets_content_length_and_cache_control()
{
let unspliced_get_len = axum::body::to_bytes(html_page().into_body(), usize::MAX)
.await
.unwrap()
.len();
let banner_len = consent_banner_markup(None, "_csrf").into_string().len();
let app = app_with_policy_version(1);
let response = app
.oneshot(
Request::builder()
.method(Method::HEAD)
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers().get(VARY).and_then(|v| v.to_str().ok()),
Some("Cookie"),
"an undecided visitor's HEAD response must still vary on Cookie"
);
assert_eq!(
response
.headers()
.get(CACHE_CONTROL)
.and_then(|v| v.to_str().ok()),
Some("private, no-store"),
"an undecided visitor's HEAD response must match the GET path's uncacheable directive"
);
assert_eq!(
response
.headers()
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok()),
Some((unspliced_get_len + banner_len).to_string().as_str()),
"an undecided visitor's HEAD response must report the post-splice Content-Length, \
not the pre-splice one"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert!(
body.is_empty(),
"a HEAD response must never carry a spliced-in body"
);
}
#[tokio::test]
async fn decided_visitors_head_request_leaves_representation_metadata_untouched() {
let unspliced_get_len = axum::body::to_bytes(html_page().into_body(), usize::MAX)
.await
.unwrap()
.len();
let app = app_with_policy_version(1);
let cookie = super::super::accept_all_cookie(&["analytics"], 1);
let raw_value = cookie
.split(';')
.next()
.unwrap()
.strip_prefix("autumn.consent=")
.unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::HEAD)
.uri("/")
.header("cookie", format!("autumn.consent={raw_value}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers().get(VARY).and_then(|v| v.to_str().ok()),
Some("Cookie")
);
assert!(response.headers().get(CACHE_CONTROL).is_none());
assert_eq!(
response
.headers()
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok()),
Some(unspliced_get_len.to_string().as_str())
);
}
#[tokio::test]
async fn oversized_body_is_bounded_in_memory_but_served_intact_unmodified() {
let oversized =
"<html><body>".to_owned() + &"x".repeat(MAX_SPLICE_BODY_BYTES + 1) + "</body></html>";
let expected_len = oversized.len();
let app = Router::new()
.route(
"/",
get(move || {
let oversized = oversized.clone();
async move {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(oversized))
.unwrap()
}
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
body.len(),
expected_len,
"an oversized body must be served intact, not truncated or emptied"
);
assert!(
!String::from_utf8_lossy(&body).contains("autumn-consent-banner"),
"an oversized body is served as-is without the banner spliced in \
(splicing would require buffering arbitrarily more)"
);
}
#[tokio::test]
async fn oversized_body_still_varies_on_cookie() {
let oversized =
"<html><body>".to_owned() + &"x".repeat(MAX_SPLICE_BODY_BYTES + 1) + "</body></html>";
let app = Router::new()
.route(
"/",
get(move || {
let oversized = oversized.clone();
async move {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(oversized))
.unwrap()
}
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.headers().get(VARY).and_then(|v| v.to_str().ok()),
Some("Cookie"),
"an oversized undecided-visitor response must still vary on Cookie"
);
}
#[tokio::test]
async fn does_not_splice_a_second_banner_when_the_handler_already_rendered_one() {
let app = Router::new()
.route(
"/",
get(|| async {
let banner = consent_banner_markup(None, "_csrf").into_string();
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(format!("<html><body>{banner}</body></html>")))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(
html.matches(RENDERED_BANNER_MARKER).count(),
1,
"must not splice a second banner when the response already contains one: {html}"
);
}
#[tokio::test]
async fn stamps_cache_guards_when_handler_already_rendered_the_banner() {
let app = Router::new()
.route(
"/",
get(|| async {
let banner = consent_banner_markup(Some("tok"), "_csrf").into_string();
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(format!("<html><body>{banner}</body></html>")))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response
.headers()
.get(CACHE_CONTROL)
.and_then(|v| v.to_str().ok()),
Some("private, no-store"),
"an already-rendered banner still carries a live CSRF token and must not be cached"
);
assert_eq!(
response.headers().get(VARY).and_then(|v| v.to_str().ok()),
Some("Cookie"),
"a shared cache must vary on the visitor's consent/CSRF cookie"
);
}
#[tokio::test]
async fn still_injects_when_page_merely_mentions_the_banner_class_in_prose() {
let app = Router::new()
.route(
"/",
get(|| async {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(
"<html><body><p>Style the banner via the \
<code>autumn-consent-banner</code> class.</p></body></html>"
.to_owned(),
))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(RENDERED_BANNER_MARKER),
"an undecided visitor must still get a real, rendered prompt: {html}"
);
}
#[test]
fn rendered_banner_carries_the_detection_marker_verbatim() {
let html = consent_banner_markup(None, "_csrf").into_string();
assert!(
html.contains(RENDERED_BANNER_MARKER),
"RENDERED_BANNER_MARKER must be kept in sync with consent_banner_markup's \
actual rendered output: {html}"
);
}
#[tokio::test]
async fn body_stream_error_replays_buffered_prefix_then_ends_with_the_same_error() {
let prefix = Bytes::from_static(b"<html><body>partial");
let error = axum::Error::new(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"simulated upstream body error",
));
let frames: Vec<Result<Bytes, axum::Error>> = vec![Ok(prefix.clone()), Err(error)];
let body = Body::from_stream(futures::stream::iter(frames));
let response = Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(body)
.unwrap();
let spliced = splice_into_response(response, "<snip>").await;
let mut stream = spliced.into_body().into_data_stream();
let first = stream
.next()
.await
.expect("the buffered prefix must be replayed")
.expect("the prefix chunk must be Ok");
assert_eq!(
first, prefix,
"bytes read before the error must be replayed, not discarded"
);
let second = stream
.next()
.await
.expect("the reconstructed body must end with an error frame, not silently end");
assert!(
second.is_err(),
"the reconstructed body must end abnormally with the original error"
);
}
#[tokio::test]
async fn honors_custom_csrf_cookie_name_end_to_end() {
let app = Router::new()
.route(
"/",
get(|| async {
let mut response = html_page();
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_static("my-csrf=minted-under-custom-name; Path=/"),
);
response
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "my-csrf", "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(r#"value="minted-under-custom-name""#),
"{html}"
);
}
#[test]
fn banner_uses_default_csrf_field_name() {
let html = consent_banner_markup(Some("tok"), "_csrf").into_string();
assert!(html.contains(r#"name="_csrf""#));
}
#[test]
fn banner_honors_custom_csrf_field_name() {
let html = consent_banner_markup(Some("tok"), "authenticity_token").into_string();
assert!(html.contains(r#"name="authenticity_token""#));
assert!(!html.contains(r#"name="_csrf""#));
}
#[tokio::test]
async fn banner_honors_configured_csrf_form_field_name_end_to_end() {
let app = Router::new()
.route("/", get(|| async { html_page() }))
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "authenticity_token").await
}));
let request = Request::builder()
.uri("/")
.header("cookie", "autumn-csrf=tok-abc")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(r#"name="authenticity_token" value="tok-abc""#),
"{html}"
);
assert!(!html.contains(r#"name="_csrf""#), "{html}");
}
#[tokio::test]
async fn strips_conditional_request_headers_while_prompting_so_etag_cannot_shortcut_to_304() {
let app = Router::new()
.route(
"/",
get(|headers: axum::http::HeaderMap| async move {
let has_inm = headers.contains_key(axum::http::header::IF_NONE_MATCH);
let has_ims = headers.contains_key(axum::http::header::IF_MODIFIED_SINCE);
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(format!(
"<html><body>inm={has_inm} ims={has_ims}</body></html>"
)))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/")
.header(axum::http::header::IF_NONE_MATCH, "\"abc\"")
.header(
axum::http::header::IF_MODIFIED_SINCE,
"Wed, 21 Oct 2015 07:28:00 GMT",
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("inm=false"), "{text}");
assert!(text.contains("ims=false"), "{text}");
}
#[tokio::test]
async fn preserves_conditional_request_headers_when_consent_already_decided() {
let app = Router::new()
.route(
"/",
get(|headers: axum::http::HeaderMap| async move {
let has_inm = headers.contains_key(axum::http::header::IF_NONE_MATCH);
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(format!(
"<html><body>inm={has_inm}</body></html>"
)))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let cookie = super::super::accept_all_cookie(&["analytics"], 1);
let raw_value = cookie
.split(';')
.next()
.unwrap()
.strip_prefix("autumn.consent=")
.unwrap();
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("cookie", format!("autumn.consent={raw_value}"))
.header(axum::http::header::IF_NONE_MATCH, "\"abc\"")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(
text.contains("inm=true"),
"no need to force-bust caching when the banner won't show anyway: {text}"
);
}
#[tokio::test]
async fn skips_injection_entirely_for_a_render_deadline_exempt_request() {
let app = Router::new()
.route("/", get(|| async { html_page() }))
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, "autumn-csrf", "_csrf").await
}));
let request = Request::builder()
.uri("/")
.extension(crate::static_gen::RenderDeadlineExempt)
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
!html.contains("autumn-consent-banner"),
"an internal build/ISR render must never have the banner baked in: {html}"
);
}
}