csaf-crud 1.4.15

CSAF 2.0 / 2.1 advisory CRUD server with HATEOAS JSON API and HTML UI (TLS 1.3, HTTP/1.1 + HTTP/2 + HTTP/3)
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! `GET /lang/{code}` — the UI language switcher.
//!
//! Sets the `csaf_lang` cookie (honoured by [`crate::i18n::from_parts`] on
//! every later request) and redirects back to the page the user came from.
//! The redirect target is taken from the `Referer` **path only**, so it
//! always stays on this origin — there is no open-redirect surface.
//!
//! Ported from the identical route in the sibling
//! `ndaal_public_bsi_grundschutz_oscal_viewer` repo (`src/routes/lang.rs`),
//! adjusted to this crate's own `redirect()` response helper instead of
//! building the 303 by hand.

// Route handlers take state/parts/params by value to match `HandlerFn`.
#![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};

/// One year, in seconds — the language preference is long-lived.
const COOKIE_MAX_AGE: u32 = 31_536_000;

/// `GET /lang/{code}` — pin the UI language and return to the referring page.
///
/// An unsupported `code` is ignored (no cookie is set) but still redirects,
/// so a stray or hand-typed link never errors.
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(&params, "code").and_then(|code| Lang::from_code(&code)) {
        // Secure + HttpOnly: the cookie is HTTPS-only and read only
        // server-side; SameSite=Lax lets a normal top-level navigation
        // (following this very redirect) carry it.
        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
}

/// The same-origin path to return to after switching, derived from `Referer`.
///
/// Only the referer's path-and-query is used, so even a cross-origin referer
/// resolves to a relative location on this site; anything unparsable or
/// protocol-relative falls back to `/`.
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| path.starts_with('/') && !path.starts_with("//"))
        .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");
        // Cross-origin referer collapses to its path only — no open redirect.
        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 missing_or_unsafe_referer_falls_back_to_root() {
        assert_eq!(return_path(&parts_with_referer(None)), "/");
        // Protocol-relative authority is rejected.
        let parts = parts_with_referer(Some("//evil.example/x"));
        assert_eq!(return_path(&parts), "/");
    }

    #[test]
    fn set_lang_response_shape_is_a_303_with_location() {
        // Direct exercise of the response-building helpers `set_lang` uses,
        // without standing up full `AppState` — the redirect status/location
        // contract is what matters here, already covered end-to-end by the
        // Bruno i18n collection against a running server.
        let response = crate::router::redirect("/csaf");
        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        assert_eq!(response.headers().get("location").unwrap(), "/csaf");
    }
}