#![allow(clippy::needless_pass_by_value)]
use http::{Response, header};
use crate::app_state::AppState;
use crate::i18n::{COOKIE_NAME, Lang};
use crate::router::{Body, path_param, redirect};
const COOKIE_MAX_AGE: u32 = 31_536_000;
pub async fn set_lang(
_state: AppState,
parts: http::request::Parts,
params: Vec<(String, String)>,
) -> Response<Body> {
let mut response = redirect(&return_path(&parts));
if let Some(lang) = path_param(¶ms, "code").and_then(|code| Lang::from_code(&code)) {
let cookie = format!(
"{COOKIE_NAME}={}; Path=/; Max-Age={COOKIE_MAX_AGE}; SameSite=Lax; Secure; HttpOnly",
lang.code()
);
if let Ok(value) = header::HeaderValue::from_str(&cookie) {
response.headers_mut().insert(header::SET_COOKIE, value);
}
}
response
}
fn is_safe_local_path(path: &str) -> bool {
path.starts_with('/')
&& !path.starts_with("//")
&& !path.contains('\\')
&& !path.chars().any(|c| c.is_control() || c.is_whitespace())
}
#[must_use]
pub fn return_path(parts: &http::request::Parts) -> String {
parts
.headers
.get(header::REFERER)
.and_then(|value| value.to_str().ok())
.and_then(|referer| referer.parse::<http::Uri>().ok())
.and_then(|uri| uri.path_and_query().map(|pq| pq.as_str().to_owned()))
.filter(|path| is_safe_local_path(path))
.unwrap_or_else(|| "/".to_owned())
}
#[cfg(test)]
mod tests {
#![allow(clippy::missing_panics_doc)]
use http::{Request, StatusCode};
use super::return_path;
fn parts_with_referer(referer: Option<&str>) -> http::request::Parts {
let mut builder = Request::builder().uri("/lang/de");
if let Some(referer) = referer {
builder = builder.header("referer", referer);
}
builder.body(()).expect("request builds").into_parts().0
}
#[test]
fn referer_path_is_kept_but_host_is_dropped() {
let parts = parts_with_referer(Some("https://localhost:8180/csaf/ndaal-sa-2026-001?x=1"));
assert_eq!(return_path(&parts), "/csaf/ndaal-sa-2026-001?x=1");
let parts = parts_with_referer(Some("https://evil.example/csaf/ndaal-sa-2026-001"));
assert_eq!(return_path(&parts), "/csaf/ndaal-sa-2026-001");
}
#[test]
fn a_backslash_after_the_leading_slash_is_rejected() {
for referer in [
"/\\evil.example",
"/\\/evil.example",
"https://h/\\evil.example",
"https://h/ok\\evil",
] {
let parts = parts_with_referer(Some(referer));
assert_eq!(return_path(&parts), "/", "{referer:?}");
}
}
#[test]
fn missing_or_unsafe_referer_falls_back_to_root() {
assert_eq!(return_path(&parts_with_referer(None)), "/");
let parts = parts_with_referer(Some("//evil.example/x"));
assert_eq!(return_path(&parts), "/");
}
mod properties {
use http::Request;
use proptest::prelude::*;
use super::super::return_path;
fn parts_with_referer(referer: &str) -> Option<http::request::Parts> {
let value = http::HeaderValue::from_bytes(referer.as_bytes()).ok()?;
let (parts, ()) = Request::builder()
.uri("/lang/de")
.header("referer", value)
.body(())
.ok()?
.into_parts();
Some(parts)
}
proptest! {
#![proptest_config(ProptestConfig { cases: 1024, .. ProptestConfig::default() })]
#[test]
fn the_return_path_is_always_a_safe_local_path(
prefix in prop_oneof![
Just(""), Just("/"), Just("//"), Just("/\\"), Just("\\"),
Just("https://h/"), Just("https://h/\\"), Just("http://h//"),
],
tail in "[ -~\u{80}-\u{ff}]{0,80}",
) {
let referer = format!("{prefix}{tail}");
let Some(parts) = parts_with_referer(&referer) else { return Ok(()) };
let path = return_path(&parts);
prop_assert!(path.starts_with('/'), "{path:?}");
prop_assert!(!path.starts_with("//"), "{path:?}");
prop_assert!(!path.contains('\\'), "{path:?}");
prop_assert!(!path.chars().any(|c| c.is_control() || c.is_whitespace()), "{path:?}");
}
#[test]
fn a_cross_origin_referer_keeps_only_its_path(
host in "[a-z][a-z0-9.-]{0,20}",
path in "/[a-z0-9_.-]{1,12}(/[a-z0-9_.-]{1,12}){0,3}",
query in proptest::option::of("[a-z]{1,6}=[a-z0-9]{0,6}"),
) {
let tail = query.map(|q| format!("?{q}")).unwrap_or_default();
let referer = format!("https://{host}{path}{tail}");
let parts = parts_with_referer(&referer).expect("visible ASCII");
prop_assert_eq!(return_path(&parts), format!("{path}{tail}"));
}
}
}
#[test]
fn set_lang_response_shape_is_a_303_with_location() {
let response = crate::router::redirect("/csaf");
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/csaf");
}
}