use axum::body::{Body, Bytes};
use axum::http::header::{
ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH,
CONTENT_RANGE, CONTENT_TYPE, ETAG, 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: Option<&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 =
csrf_cookie_name.and_then(|name| find_cookie(request.headers(), name));
let needs_prompt = consent.needs_prompt(policy_version);
if needs_prompt && could_render_a_banner(request.headers()) {
request.headers_mut().remove(IF_NONE_MATCH);
request.headers_mut().remove(IF_MODIFIED_SINCE);
}
let mut response = next.run(request).await;
if !is_html_content_type(&response) {
return response;
}
if is_attachment(&response)
|| response.status() == axum::http::StatusCode::PARTIAL_CONTENT
|| response.headers().contains_key(CONTENT_RANGE)
{
response
.headers_mut()
.append(VARY, HeaderValue::from_static("Cookie"));
return response;
}
if !is_html_response(&response) {
response
.headers_mut()
.append(VARY, HeaderValue::from_static("Cookie"));
return response;
}
if !needs_prompt {
response
.headers_mut()
.append(VARY, HeaderValue::from_static("Cookie"));
return response;
}
let csrf_token = csrf_cookie_name
.and_then(|name| extract_response_csrf_cookie(&response, name))
.or(request_csrf_cookie);
if csrf_cookie_name.is_some() && csrf_token.is_none() {
tracing::debug!(
"consent banner skipped: CSRF is enforced but no token was available \
(typically a pre-rendered static page, where CsrfLayer never runs)"
);
response
.headers_mut()
.append(VARY, HeaderValue::from_static("Cookie"));
return response;
}
let banner_html = consent_banner_markup(csrf_token.as_deref(), csrf_form_field).into_string();
splice_into_response(response, &banner_html).await
}
fn could_render_a_banner(headers: &axum::http::HeaderMap) -> bool {
accepts_html(headers) || htmx_header_is_true(headers, "hx-request")
}
fn htmx_header_is_true(headers: &axum::http::HeaderMap, name: &str) -> bool {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| !value.eq_ignore_ascii_case("false"))
}
fn drop_stale_representation_metadata(headers: &mut axum::http::HeaderMap) {
headers.remove(ETAG);
headers.remove(ACCEPT_RANGES);
for name in ["content-digest", "repr-digest", "digest", "content-md5"] {
headers.remove(name);
}
}
fn accepts_html(headers: &axum::http::HeaderMap) -> bool {
let mut offered = false;
let mut exact: Option<bool> = None;
let mut wildcard: Option<bool> = None;
for value in headers.get_all(axum::http::header::ACCEPT) {
offered = true;
let Ok(accept) = value.to_str() else {
continue;
};
for range in accept.split(',') {
let mut parts = range.split(';').map(str::trim);
let Some(media) = parts.next() else {
continue;
};
let slot = if media.eq_ignore_ascii_case("text/html") {
&mut exact
} else if media.eq_ignore_ascii_case("text/*") {
&mut wildcard
} else {
continue;
};
let accepted = !parts.any(|param| {
param
.split_once('=')
.is_some_and(|(k, v)| k.eq_ignore_ascii_case("q") && is_zero_quality(v))
});
slot.get_or_insert(accepted);
}
}
exact.or(wildcard).unwrap_or(!offered)
}
fn is_zero_quality(v: &str) -> bool {
let v = v.trim();
let (int, frac) = v.split_once('.').unwrap_or((v, ""));
int == "0" && frac.chars().all(|c| c == '0')
}
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;
}
is_html_content_type(response)
}
fn is_html_content_type(response: &Response<Body>) -> bool {
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 bytes.is_empty() {
parts
.headers
.append(VARY, HeaderValue::from_static("Cookie"));
return Response::from_parts(parts, Body::from(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 Some(updated) = splice_before_body_close(&bytes, snippet) else {
parts
.headers
.append(VARY, HeaderValue::from_static("Cookie"));
return Response::from_parts(parts, Body::from(bytes));
};
parts
.headers
.insert(CONTENT_LENGTH, HeaderValue::from(updated.len()));
drop_stale_representation_metadata(&mut parts.headers);
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) -> Option<Vec<u8>> {
if !starts_like_a_document(body) {
return None;
}
if let Some(index) = rfind_ascii_case_insensitive(body, b"</body>")
&& tail_is_document_end(&body[index + b"</body>".len()..])
{
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.into();
}
let mut out = body.to_vec();
out.extend_from_slice(snippet.as_bytes());
out.into()
}
fn tail_is_document_end(tail: &[u8]) -> bool {
let rest = trim_ascii_whitespace(tail);
let rest = if rest.len() >= 7 && rest[..7].eq_ignore_ascii_case(b"</html>") {
trim_ascii_whitespace(&rest[7..])
} else {
rest
};
rest.is_empty()
}
fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] {
let start = bytes
.iter()
.position(|b| !b.is_ascii_whitespace())
.unwrap_or(bytes.len());
let end = bytes
.iter()
.rposition(|b| !b.is_ascii_whitespace())
.map_or(start, |i| i + 1);
&bytes[start..end]
}
fn is_attachment(response: &Response<Body>) -> bool {
response
.headers()
.get(CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.is_some_and(|value| {
value
.split(';')
.next()
.is_some_and(|kind| kind.trim().eq_ignore_ascii_case("attachment"))
})
}
fn starts_like_a_document(body: &[u8]) -> bool {
let mut head = body
.strip_prefix(b"\xEF\xBB\xBF".as_slice())
.unwrap_or(body);
loop {
let trimmed = head
.iter()
.position(|b| !b.is_ascii_whitespace())
.map_or(&[][..], |i| &head[i..]);
let Some(rest) = trimmed.strip_prefix(b"<!--".as_slice()) else {
head = trimmed;
break;
};
let Some(end) = rfind_first(rest, b"-->") else {
return false;
};
head = &rest[end + 3..];
}
let starts_with = |prefix: &[u8]| {
head.len() >= prefix.len() && head[..prefix.len()].eq_ignore_ascii_case(prefix)
};
if starts_with(b"<!doctype") {
return true;
}
starts_with(b"<html")
&& head
.get(b"<html".len())
.is_some_and(|b| *b == b'>' || b.is_ascii_whitespace())
}
fn rfind_first(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
(0..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
}
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>")
.expect("a document must be spliceable");
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>")
.expect("a document must be spliceable");
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>")
.expect("a document must be spliceable");
let s = String::from_utf8(out).unwrap();
assert!(s.ends_with("<snip>"), "{s}");
}
#[test]
fn splice_refuses_a_fragment_rather_than_appending_to_it() {
for fragment in [
&b"<div id=\"nav-auth\">hi</div>"[..],
&b"<li>a row</li>"[..],
&b" <span>leading whitespace</span>"[..],
] {
assert!(
splice_before_body_close(fragment, "<snip>").is_none(),
"a fragment must be left alone: {}",
String::from_utf8_lossy(fragment)
);
}
}
#[test]
fn any_html_whitespace_may_follow_the_opening_tag() {
for doc in [
&b"<html>\n<body>hi</body></html>"[..],
&b"<html lang=\"en\"><body>hi</body></html>"[..],
&b"<html\nlang=\"en\"><body>hi</body></html>"[..],
&b"<html\tlang=\"en\"><body>hi</body></html>"[..],
&b"<html\r\nlang=\"en\"><body>hi</body></html>"[..],
&b"<HTML\nLANG=\"en\"><BODY>hi</BODY></HTML>"[..],
] {
assert!(
splice_before_body_close(doc, "<snip>").is_some(),
"a doctype-less document must still be recognised: {}",
String::from_utf8_lossy(doc)
);
}
assert!(splice_before_body_close(b"<htmlfoo><body>x</body>", "<snip>").is_none());
}
#[tokio::test]
async fn a_download_is_never_spliced_into() {
let app = Router::new()
.route(
"/export.html",
axum::routing::get(|| async {
(
[
(CONTENT_TYPE, "text/html; charset=utf-8"),
(CONTENT_DISPOSITION, "attachment; filename=\"export.html\""),
],
"<!doctype html><html><body>rows</body></html>",
)
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/export.html")
.header(axum::http::header::ACCEPT, "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
String::from_utf8_lossy(&body),
"<!doctype html><html><body>rows</body></html>",
"an exported file must reach the disk exactly as the handler wrote it"
);
}
#[test]
fn a_body_close_that_is_not_the_document_ending_is_appended_past_not_spliced_into() {
for doc in [
&b"<!doctype html><script>const marker = \"</body>\";</script>"[..],
&b"<!doctype html><html><body>hi</body></html><!-- </body> -->"[..],
] {
let out = splice_before_body_close(doc, "<snip>").expect("still a document");
let rendered = String::from_utf8(out).unwrap();
assert!(
rendered.ends_with("<snip>"),
"must append past the false boundary, not splice into it: {rendered}"
);
assert!(
!rendered.contains("<snip>\";</script>") && !rendered.contains("<snip> -->"),
"the banner must not land inside the script or the comment: {rendered}"
);
}
}
#[test]
fn a_real_document_ending_is_still_spliced_before() {
for doc in [
&b"<html><body>hi</body></html>"[..],
&b"<html><body>hi</body></html>\n"[..],
&b"<html><body>hi</body>\n</html>\n\n"[..],
&b"<html><body>hi</body>"[..],
&b"<HTML><BODY>hi</BODY></HTML>"[..],
] {
let rendered =
String::from_utf8(splice_before_body_close(doc, "<snip>").expect("document"))
.unwrap();
assert!(
rendered.to_ascii_lowercase().contains("<snip></body>"),
"a genuine ending must still be spliced before: {rendered}"
);
}
}
#[test]
fn a_fragment_that_merely_mentions_body_close_is_still_a_fragment() {
for fragment in [
&b"<div><script>var end = \"</body>\";</script></div>"[..],
&b"<li><!-- closes with </body> in the source --></li>"[..],
&b"<pre><code></body></code></pre>"[..],
] {
assert!(
splice_before_body_close(fragment, "<snip>").is_none(),
"not a document: {}",
String::from_utf8_lossy(fragment)
);
}
}
#[test]
fn a_utf8_bom_does_not_make_a_document_look_like_a_fragment() {
for doc in [
&b"\xEF\xBB\xBF<!doctype html><html><body>hi</body></html>"[..],
&b"\xEF\xBB\xBF<html><body>hi</body></html>"[..],
&b"\xEF\xBB\xBF\n <!-- built -->\n<!doctype html><html><body>hi</body></html>"[..],
] {
let out = splice_before_body_close(doc, "<snip>")
.expect("a BOM is an encoding artifact, not a fragment marker");
assert!(String::from_utf8(out).unwrap().contains("<snip></body>"));
}
assert!(splice_before_body_close(b"\xEF\xBB\xBF<div>row</div>", "<snip>").is_none());
}
#[test]
fn a_document_behind_a_generator_comment_is_still_a_document() {
let out = splice_before_body_close(
b"<!-- built at 2026-01-01 -->\n<!doctype html><html><body>hi</body></html>",
"<snip>",
)
.expect("a comment preamble does not make it a fragment");
assert!(String::from_utf8(out).unwrap().contains("<snip></body>"));
}
#[test]
fn splice_matches_uppercase_and_mixed_case_tags() {
let out = splice_before_body_close(b"<HTML><BODY><main>ok</main></BODY></HTML>", "<snip>")
.expect("a document must be spliceable");
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>")
.expect("a document must be spliceable");
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>").expect("a document must be spliceable");
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, None, "_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, None, "_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, Some("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, Some("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 = format!(
"<html><body>{}</body></html>",
"x".repeat(MAX_SPLICE_BODY_BYTES + 1)
);
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, Some("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 enforced_csrf_without_a_token_skips_the_banner_rather_than_render_it_broken() {
let app = Router::new()
.route("/about", get(|| async { html_page() }))
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, Some("autumn-csrf"), "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/about")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let rendered = String::from_utf8_lossy(&body);
assert!(
!rendered.contains("autumn-consent-banner"),
"a banner that cannot submit must not be rendered at all; got: {rendered}"
);
assert!(
rendered.contains("<body>"),
"the page itself must still be served intact; got: {rendered}"
);
}
#[tokio::test]
async fn a_csrf_free_app_still_gets_its_banner() {
let app = app_with_policy_version(1);
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!(
String::from_utf8_lossy(&body).contains("autumn-consent-banner"),
"an app with CSRF disabled must still prompt"
);
}
#[tokio::test]
async fn a_json_client_keeps_its_conditional_validators() {
let app = Router::new()
.route(
"/api/posts",
get(|headers: axum::http::HeaderMap| async move {
let seen = headers.contains_key(IF_NONE_MATCH);
Response::builder()
.header(CONTENT_TYPE, "application/json")
.body(Body::from(format!("{{\"saw_if_none_match\":{seen}}}")))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, Some("autumn-csrf"), "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/api/posts")
.header(axum::http::header::ACCEPT, "application/json")
.header(IF_NONE_MATCH, "\"abc\"")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
String::from_utf8_lossy(&body),
"{\"saw_if_none_match\":true}",
"a non-HTML client must keep If-None-Match so its ETag path still 304s"
);
}
#[tokio::test]
async fn an_html_navigation_still_loses_its_validators_while_prompting() {
let app = Router::new()
.route(
"/",
get(|headers: axum::http::HeaderMap| async move {
let seen = headers.contains_key(IF_NONE_MATCH);
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(format!("<html><body>{seen}</body></html>")))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/")
.header(
axum::http::header::ACCEPT,
"text/html,application/xhtml+xml",
)
.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 rendered = String::from_utf8_lossy(&body);
assert!(
rendered.contains("false"),
"an HTML navigation being prompted must have If-None-Match stripped; got: {rendered}"
);
assert!(
rendered.contains("autumn-consent-banner"),
"and it must still be prompted; got: {rendered}"
);
}
#[tokio::test]
async fn an_htmx_fragment_never_receives_a_banner() {
let app = Router::new()
.route(
"/_partials/nav-auth",
get(|| async {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from("<div id=\"nav\">Log in</div>"))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, Some("autumn-csrf"), "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/_partials/nav-auth")
.header("hx-request", "true")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let rendered = String::from_utf8_lossy(&body);
assert!(
!rendered.contains("autumn-consent-banner"),
"an htmx fragment must never carry the banner; got: {rendered}"
);
assert_eq!(
rendered, "<div id=\"nav\">Log in</div>",
"the fragment must be passed through byte-for-byte"
);
}
#[tokio::test]
async fn every_html_pass_through_varies_on_cookie() {
struct Case {
name: &'static str,
csrf_cookie_name: Option<&'static str>,
request: fn(axum::http::request::Builder) -> axum::http::request::Builder,
encoded: bool,
}
let cases = [
Case {
name: "htmx fragment",
csrf_cookie_name: None,
request: |b| b.header("hx-request", "true"),
encoded: false,
},
Case {
name: "compressed HTML",
csrf_cookie_name: None,
request: |b| b,
encoded: true,
},
Case {
name: "CSRF enforced but no token obtainable (static hit)",
csrf_cookie_name: Some("autumn-csrf"),
request: |b| b,
encoded: false,
},
];
for case in cases {
let encoded = case.encoded;
let name = case.csrf_cookie_name;
let app = Router::new()
.route(
"/",
get(move || async move {
let mut builder = Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.header(CACHE_CONTROL, "public, max-age=60");
if encoded {
builder = builder.header(CONTENT_ENCODING, "gzip");
}
builder
.body(Body::from("<html><body>hi</body></html>"))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, name, "_csrf").await
}));
let response = app
.oneshot(
(case.request)(Request::builder().uri("/"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let vary: Vec<_> = response
.headers()
.get_all(VARY)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert!(
vary.iter().any(|v| v.eq_ignore_ascii_case("Cookie")),
"{} must vary on Cookie; got: {vary:?}",
case.name
);
}
}
#[tokio::test]
async fn a_compressed_html_page_still_varies_on_cookie() {
let app = Router::new()
.route(
"/",
get(|| async {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.header(CONTENT_ENCODING, "gzip")
.header(CACHE_CONTROL, "public, max-age=60")
.body(Body::from("<html><body>compressed</body></html>"))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let vary: Vec<_> = response
.headers()
.get_all(VARY)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert!(
vary.iter().any(|v| v.eq_ignore_ascii_case("Cookie")),
"compressed HTML must still vary on Cookie; got: {vary:?}"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
String::from_utf8_lossy(&body),
"<html><body>compressed</body></html>",
"and must be passed through unspliced"
);
}
#[tokio::test]
async fn a_json_response_does_not_gain_a_cookie_vary() {
let app = Router::new()
.route(
"/api",
get(|| async {
Response::builder()
.header(CONTENT_TYPE, "application/json")
.body(Body::from("{}"))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/api").body(Body::empty()).unwrap())
.await
.unwrap();
assert!(
response.headers().get_all(VARY).iter().next().is_none(),
"a JSON response must be left alone"
);
}
#[tokio::test]
async fn an_htmx_fragment_still_varies_on_cookie() {
let app = Router::new()
.route(
"/_partials/nav-auth",
get(|| async {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.header(CACHE_CONTROL, "public, max-age=60")
.body(Body::from("<div>Log in</div>"))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/_partials/nav-auth")
.header("hx-request", "true")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let vary: Vec<_> = response
.headers()
.get_all(VARY)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert!(
vary.iter().any(|v| v.eq_ignore_ascii_case("Cookie")),
"a fragment must still vary on Cookie; got: {vary:?}"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert!(
!String::from_utf8_lossy(&body).contains("autumn-consent-banner"),
"and must still carry no banner"
);
}
#[tokio::test]
async fn a_boosted_navigation_loses_its_validators_despite_the_xhr_accept_default() {
let app = Router::new()
.route(
"/",
get(|headers: axum::http::HeaderMap| async move {
let seen = headers.contains_key(IF_NONE_MATCH);
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(format!("<html><body>{seen}</body></html>")))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("hx-request", "true")
.header("hx-boosted", "true")
.header(axum::http::header::ACCEPT, "*/*")
.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 rendered = String::from_utf8_lossy(&body);
assert!(
rendered.contains("false"),
"a boosted navigation must have If-None-Match stripped; got: {rendered}"
);
assert!(
rendered.contains("autumn-consent-banner"),
"and must still be prompted; got: {rendered}"
);
}
#[test]
fn an_explicit_zero_quality_is_a_refusal_of_html() {
let accept = |v: &str| {
let mut h = axum::http::HeaderMap::new();
h.insert(
axum::http::header::ACCEPT,
HeaderValue::from_str(v).unwrap(),
);
accepts_html(&h)
};
assert!(accept("text/*"));
assert!(accept("application/json, text/*;q=0.8"));
assert!(!accept("text/*;q=0"));
assert!(!accept("*/*"));
assert!(!accept("application/json, text/html;q=0"));
assert!(!accept("text/html;q=0.0"));
assert!(!accept("text/html; q=0.000"));
assert!(accept("text/html"));
assert!(accept("text/html;q=0.1"));
assert!(accept("text/html;q=0.9, application/json"));
assert!(accept("application/json, text/html"));
assert!(!accept("*/*"));
}
#[tokio::test]
async fn a_ranged_response_is_never_spliced_into() {
let app = Router::new()
.route(
"/page.html",
axum::routing::get(|| async {
(
axum::http::StatusCode::PARTIAL_CONTENT,
[
(CONTENT_TYPE, "text/html; charset=utf-8"),
(CONTENT_RANGE, "bytes 0-31/512"),
],
"<!doctype html><html><body>part",
)
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/page.html")
.header(axum::http::header::ACCEPT, "text/html")
.header(axum::http::header::RANGE, "bytes=0-31")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
response
.headers()
.get_all(VARY)
.iter()
.any(|v| v.as_bytes().eq_ignore_ascii_case(b"Cookie")),
"a ranged HTML response still varies on the consent cookie"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let rendered = String::from_utf8_lossy(&body);
assert_eq!(
rendered, "<!doctype html><html><body>part",
"the advertised byte range must be served byte-for-byte"
);
}
#[tokio::test]
async fn an_already_empty_html_response_is_not_spliced_into() {
let app = Router::new()
.route(
"/",
axum::routing::get(|| async {
([(CONTENT_TYPE, "text/html; charset=utf-8")], Body::empty())
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert!(
response
.headers()
.get_all(VARY)
.iter()
.any(|v| v.as_bytes().eq_ignore_ascii_case(b"Cookie")),
"it is still a consent-dependent representation"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert!(
body.is_empty(),
"an empty HTML response must stay empty; got {} bytes",
body.len()
);
}
#[tokio::test]
async fn an_htmx_history_restore_is_a_document_and_is_prompted() {
let app = Router::new()
.route(
"/",
axum::routing::get(|| async {
axum::response::Html("<html><body>hi</body></html>")
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("hx-request", "true")
.header("hx-history-restore-request", "true")
.header(axum::http::header::ACCEPT, "*/*")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let rendered = String::from_utf8_lossy(&body);
assert!(
rendered.contains("autumn-consent-banner"),
"a history restore replaces the body from a full document, so the \
visitor must still be prompted; got: {rendered}"
);
}
#[tokio::test]
async fn every_whole_document_htmx_swap_is_prompted_and_loses_its_validators() {
for extra in [
Some("hx-boosted"),
Some("hx-history-restore-request"),
None, ] {
let app = Router::new()
.route(
"/",
axum::routing::get(|req: axum::extract::Request| async move {
let had_validator = req.headers().contains_key(IF_NONE_MATCH);
axum::response::Html(format!(
"<html><body>had_validator={had_validator}</body></html>"
))
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let mut builder = Request::builder()
.uri("/")
.header("hx-request", "true")
.header(axum::http::header::ACCEPT, "*/*")
.header(IF_NONE_MATCH, "\"abc\"");
if let Some(name) = extra {
builder = builder.header(name, "true");
}
let response = app
.oneshot(builder.body(Body::empty()).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let rendered = String::from_utf8_lossy(&body);
let label = extra.unwrap_or("hx-target=body (no marker header)");
assert!(
rendered.contains("had_validator=false"),
"`{label}` may replace the document, so its validators must be \
stripped while a prompt is due; got: {rendered}"
);
assert!(
rendered.contains("autumn-consent-banner"),
"`{label}` returned a complete document and must be prompted; \
got: {rendered}"
);
}
}
#[tokio::test]
async fn a_plain_xhr_json_client_still_keeps_its_validators() {
let app = Router::new()
.route(
"/api/posts",
get(|headers: axum::http::HeaderMap| async move {
let seen = headers.contains_key(IF_NONE_MATCH);
Response::builder()
.header(CONTENT_TYPE, "application/json")
.body(Body::from(format!("{{\"saw\":{seen}}}")))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/api/posts")
.header(axum::http::header::ACCEPT, "*/*")
.header(IF_NONE_MATCH, "\"abc\"")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
String::from_utf8_lossy(&body),
"{\"saw\":true}",
"a bare */* XHR that is not boosted must keep its validators"
);
}
#[tokio::test]
async fn a_boosted_navigation_is_a_document_and_still_gets_the_banner() {
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, None, "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("hx-request", "true")
.header("hx-boosted", "true")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert!(
String::from_utf8_lossy(&body).contains("autumn-consent-banner"),
"a boosted navigation is a full document and must still be prompted"
);
}
#[tokio::test]
async fn a_full_document_still_receives_the_banner_without_the_htmx_header() {
let app = Router::new()
.route(
"/",
get(|| async {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from("<html><body><h1>Hi</h1></body></html>"))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_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!(
String::from_utf8_lossy(&body).contains("autumn-consent-banner"),
"an ordinary page load must still be prompted"
);
}
#[tokio::test]
async fn oversized_body_still_varies_on_cookie() {
let oversized = format!(
"<html><body>{}</body></html>",
"x".repeat(MAX_SPLICE_BODY_BYTES + 1)
);
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, None, "_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, Some("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, None, "_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, None, "_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, Some("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, Some("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, Some("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, Some("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, Some("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}"
);
}
#[tokio::test]
async fn a_split_accept_header_still_counts_as_offering_html() {
let mut headers = axum::http::HeaderMap::new();
headers.append(
axum::http::header::ACCEPT,
"application/json".parse().unwrap(),
);
headers.append(axum::http::header::ACCEPT, "text/html".parse().unwrap());
assert!(
accepts_html(&headers),
"HTML offered on the second Accept field line must still count"
);
let mut api = axum::http::HeaderMap::new();
api.append(
axum::http::header::ACCEPT,
"application/json".parse().unwrap(),
);
api.append(axum::http::header::ACCEPT, "*/*".parse().unwrap());
assert!(
!accepts_html(&api),
"an API client splitting its Accept must keep its validators"
);
let mut refused = axum::http::HeaderMap::new();
refused.append(
axum::http::header::ACCEPT,
"application/json".parse().unwrap(),
);
refused.append(axum::http::header::ACCEPT, "text/html;q=0".parse().unwrap());
assert!(
!accepts_html(&refused),
"`text/html;q=0` on a later line is an explicit refusal"
);
assert!(
accepts_html(&axum::http::HeaderMap::new()),
"a missing Accept still means yes"
);
}
#[tokio::test]
async fn a_specific_html_refusal_overrides_a_text_wildcard_offer() {
let refuse = |v: &str| {
let mut h = axum::http::HeaderMap::new();
h.append(axum::http::header::ACCEPT, v.parse().unwrap());
accepts_html(&h)
};
assert!(
!refuse("text/*;q=1, text/html;q=0"),
"an exact `text/html;q=0` must override a `text/*` offer"
);
assert!(
!refuse("text/html;q=0, text/*;q=1"),
"order must not matter -- specificity decides, not position"
);
assert!(
refuse("text/*;q=0, text/html"),
"an exact `text/html` offer must override a `text/*;q=0` refusal"
);
assert!(refuse("text/*"), "`text/*` alone still offers html");
assert!(!refuse("text/*;q=0"), "`text/*;q=0` alone still refuses");
let mut split = axum::http::HeaderMap::new();
split.append(axum::http::header::ACCEPT, "text/*;q=1".parse().unwrap());
split.append(axum::http::header::ACCEPT, "text/html;q=0".parse().unwrap());
assert!(
!accepts_html(&split),
"the specific refusal wins even when it arrives on a later line"
);
}
#[tokio::test]
async fn splicing_the_banner_drops_a_now_wrong_etag_and_digest() {
let app = Router::new()
.route(
"/",
get(|| async {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.header(axum::http::header::ETAG, "\"pre-splice\"")
.header("content-digest", "sha-256=:deadbeef:")
.header("digest", "SHA-256=deadbeef")
.header(axum::http::header::ACCEPT_RANGES, "bytes")
.body(Body::from("<html><body>hi</body></html>"))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, None, "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/")
.header(axum::http::header::ACCEPT, "text/html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let headers = response.headers().clone();
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"),
"precondition: this request must actually get a banner: {html}"
);
assert!(
!headers.contains_key(axum::http::header::ETAG),
"a validator for the pre-splice bytes must not survive the splice"
);
assert!(
!headers.contains_key("content-digest"),
"Content-Digest describes bytes that are no longer being sent"
);
assert!(
!headers.contains_key("digest"),
"the obsolete Digest spelling must go too"
);
assert!(
!headers.contains_key(axum::http::header::ACCEPT_RANGES),
"a range offer over bytes this middleware rewrote must be withdrawn: \
a resumed 206 comes from the handler and addresses the original, \
bannerless representation"
);
assert_eq!(
headers
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<usize>().ok()),
Some(html.len()),
"Content-Length must match the spliced body"
);
}
#[tokio::test]
async fn a_response_that_is_not_rewritten_keeps_its_etag() {
let app = Router::new()
.route(
"/data.json",
get(|| async {
Response::builder()
.header(CONTENT_TYPE, "application/json")
.header(axum::http::header::ETAG, "\"kept\"")
.header(axum::http::header::ACCEPT_RANGES, "bytes")
.body(Body::from("{}"))
.unwrap()
}),
)
.layer(axum::middleware::from_fn(move |req, next| async move {
inject_consent_banner(req, next, 1, Some("autumn-csrf"), "_csrf").await
}));
let response = app
.oneshot(
Request::builder()
.uri("/data.json")
.header(axum::http::header::ACCEPT, "application/json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response
.headers()
.get(axum::http::header::ETAG)
.and_then(|v| v.to_str().ok()),
Some("\"kept\""),
"an untouched body keeps its validator"
);
assert_eq!(
response
.headers()
.get(axum::http::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok()),
Some("bytes"),
"and keeps its range offer -- those bytes are still what ships"
);
}
}