lenso-web-ingress-plugin 0.4.11

General-purpose linked Rust HTTP Ingress Plugin for Lenso backends.
Documentation
use http::{
    HeaderMap, HeaderName, Method,
    header::{AUTHORIZATION, COOKIE},
};

use crate::SessionCookieConfig;

#[derive(Clone, Debug)]
pub(super) struct SessionCookiePolicy {
    name: String,
    csrf_cookie_name: String,
    csrf_header_name: HeaderName,
}

impl From<&SessionCookieConfig> for SessionCookiePolicy {
    fn from(config: &SessionCookieConfig) -> Self {
        Self {
            name: config.name().to_owned(),
            csrf_cookie_name: config.csrf_cookie_name().to_owned(),
            csrf_header_name: HeaderName::from_bytes(config.csrf_header_name().as_bytes())
                .expect("validated Web Ingress CSRF header name"),
        }
    }
}

impl SessionCookiePolicy {
    pub(super) fn csrf_header_name(&self) -> &HeaderName {
        &self.csrf_header_name
    }
}

#[derive(Debug)]
pub(super) struct CredentialEvidence {
    pub(super) scheme: String,
    pub(super) value: String,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum CredentialRejection {
    BadRequest,
    CsrfForbidden,
}

pub(super) fn select_credential(
    method: &Method,
    headers: &HeaderMap,
    session_cookie: Option<&SessionCookiePolicy>,
) -> Result<Option<CredentialEvidence>, CredentialRejection> {
    let authorization = authorization_credential(headers)?;
    let Some(policy) = session_cookie else {
        return Ok(authorization);
    };
    let cookies = selected_cookies(headers, policy)?;
    let Some(session) = cookies.session else {
        return Ok(authorization);
    };
    if authorization.is_some() {
        return Err(CredentialRejection::BadRequest);
    }
    if !safe_method(method) {
        let csrf_cookie = cookies.csrf.ok_or(CredentialRejection::CsrfForbidden)?;
        let csrf_header = single_header(headers, &policy.csrf_header_name)?
            .ok_or(CredentialRejection::CsrfForbidden)?;
        if csrf_header.is_empty() || !tokens_match(csrf_cookie.as_bytes(), csrf_header.as_bytes()) {
            return Err(CredentialRejection::CsrfForbidden);
        }
    }
    Ok(Some(CredentialEvidence {
        scheme: "session".to_owned(),
        value: session,
    }))
}

fn authorization_credential(
    headers: &HeaderMap,
) -> Result<Option<CredentialEvidence>, CredentialRejection> {
    let Some(value) = single_header(headers, &AUTHORIZATION)? else {
        return Ok(None);
    };
    let Some((scheme, value)) = value.split_once(' ') else {
        return Err(CredentialRejection::BadRequest);
    };
    if scheme.is_empty() || value.is_empty() {
        return Err(CredentialRejection::BadRequest);
    }
    // Fetch-compatible hosts fold duplicate Authorization fields. Token-based
    // schemes cannot contain commas or whitespace; reject that ambiguity before
    // it becomes credential evidence. Parameter-based schemes retain their grammar.
    if (scheme.eq_ignore_ascii_case("bearer") || scheme.eq_ignore_ascii_case("basic"))
        && value
            .bytes()
            .any(|byte| byte == b',' || byte.is_ascii_whitespace())
    {
        return Err(CredentialRejection::BadRequest);
    }
    Ok(Some(CredentialEvidence {
        scheme: scheme.to_ascii_lowercase(),
        value: value.to_owned(),
    }))
}

fn single_header<'a>(
    headers: &'a HeaderMap,
    name: &HeaderName,
) -> Result<Option<&'a str>, CredentialRejection> {
    let mut values = headers.get_all(name).iter();
    let Some(value) = values.next() else {
        return Ok(None);
    };
    if values.next().is_some() {
        return Err(CredentialRejection::BadRequest);
    }
    value
        .to_str()
        .map(Some)
        .map_err(|_| CredentialRejection::BadRequest)
}

#[derive(Debug, Default)]
struct SelectedCookies {
    session: Option<String>,
    csrf: Option<String>,
}

fn selected_cookies(
    headers: &HeaderMap,
    policy: &SessionCookiePolicy,
) -> Result<SelectedCookies, CredentialRejection> {
    let mut selected = SelectedCookies::default();
    for header in headers.get_all(COOKIE) {
        let header = header
            .to_str()
            .map_err(|_| CredentialRejection::BadRequest)?;
        for pair in header
            .split(';')
            .map(str::trim)
            .filter(|pair| !pair.is_empty())
        {
            let Some((name, value)) = pair.split_once('=') else {
                if pair == policy.name || pair == policy.csrf_cookie_name {
                    return Err(CredentialRejection::BadRequest);
                }
                continue;
            };
            let name = name.trim();
            if name == policy.name {
                insert_cookie(&mut selected.session, value)?;
            } else if name == policy.csrf_cookie_name {
                insert_cookie(&mut selected.csrf, value)?;
            }
        }
    }
    Ok(selected)
}

fn insert_cookie(target: &mut Option<String>, value: &str) -> Result<(), CredentialRejection> {
    if target.is_some() {
        return Err(CredentialRejection::BadRequest);
    }
    let value = normalized_cookie_value(value.trim())?;
    if value.is_empty() {
        return Err(CredentialRejection::BadRequest);
    }
    target.replace(value.to_owned());
    Ok(())
}

fn normalized_cookie_value(value: &str) -> Result<&str, CredentialRejection> {
    let value = if value.starts_with('"') || value.ends_with('"') {
        value
            .strip_prefix('"')
            .and_then(|value| value.strip_suffix('"'))
            .ok_or(CredentialRejection::BadRequest)?
    } else {
        value
    };
    if value.bytes().all(cookie_octet) {
        Ok(value)
    } else {
        Err(CredentialRejection::BadRequest)
    }
}

const fn cookie_octet(byte: u8) -> bool {
    matches!(byte, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e)
}

fn safe_method(method: &Method) -> bool {
    method == Method::GET
        || method == Method::HEAD
        || method == Method::OPTIONS
        || method == Method::TRACE
        || method.as_str().eq_ignore_ascii_case("QUERY")
}

fn tokens_match(left: &[u8], right: &[u8]) -> bool {
    if left.len() != right.len() {
        return false;
    }
    left.iter()
        .zip(right)
        .fold(0_u8, |difference, (left, right)| {
            difference | (left ^ right)
        })
        == 0
}