proksi 0.10.0

A batteries-included reverse proxy with automatic HTTPS using Cloudflare Pingora and Let's Encrypt.
//! `body_limit { max = "1MiB" }`: answers `413` to requests with a larger body

use anyhow::Result;
use async_trait::async_trait;
use bytes::Bytes;
use http::{header, StatusCode};
use pingora::{http::ResponseHeader, proxy::Session, ErrorType::HTTPStatus};

use crate::{config::RoutePlugin, proxy_server::https_proxy::RouterContext};

use super::Middleware;

const INVALID_MAX: &str =
    "body_limit: `max` must be a size such as \"512KiB\", \"1MiB\" or \"10MB\"";

/// Requests with a larger `Content-Length` are answered right away. Bodies without one
/// (chunked) are counted as they're proxied, and the request ends once it's over `max`.
pub(super) struct BodyLimit {
    max: u64,
}

impl BodyLimit {
    pub(super) fn new(plugin: &RoutePlugin) -> Result<Self, String> {
        let max = plugin
            .config
            .as_ref()
            .and_then(|config| config.get("max"))
            .and_then(serde_json::Value::as_str)
            .and_then(parse_size)
            .ok_or(INVALID_MAX)?;
        Ok(Self { max })
    }
}

#[async_trait]
impl Middleware for BodyLimit {
    async fn request(&self, session: &mut Session, _ctx: &mut RouterContext) -> Result<bool> {
        let content_length = session
            .req_header()
            .headers
            .get(header::CONTENT_LENGTH)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| value.parse::<u64>().ok());
        if content_length.is_none_or(|length| length <= self.max) {
            return Ok(false);
        }

        let mut response = ResponseHeader::build_no_case(StatusCode::PAYLOAD_TOO_LARGE, Some(2))?;
        response.insert_header(header::CONTENT_LENGTH, "0")?;
        response.insert_header(header::CONNECTION, "close")?;
        session.set_keepalive(None);
        session
            .write_response_header(Box::new(response), true)
            .await?;
        Ok(true)
    }

    fn request_body(&self, body: Option<&Bytes>, ctx: &mut RouterContext) -> pingora::Result<()> {
        ctx.request_body_bytes += body.map_or(0, |body| body.len() as u64);
        if ctx.request_body_bytes > self.max {
            return Err(pingora::Error::explain(
                HTTPStatus(413),
                "request body larger than body_limit",
            ));
        }
        Ok(())
    }
}

/// A size like `512KiB`, `1MiB` or `10MB`
fn parse_size(value: &str) -> Option<u64> {
    let split = value.find(|c: char| !c.is_ascii_digit())?;
    let (amount, unit) = value.split_at(split);
    let multiplier: u64 = match unit {
        "B" => 1,
        "KB" => 1000,
        "MB" => 1000 * 1000,
        "GB" => 1000 * 1000 * 1000,
        "KiB" => 1024,
        "MiB" => 1024 * 1024,
        "GiB" => 1024 * 1024 * 1024,
        _ => return None,
    };
    amount
        .parse::<u64>()
        .ok()
        .and_then(|amount| amount.checked_mul(multiplier))
        .filter(|size| *size > 0)
}

#[cfg(test)]
mod tests {
    use crate::config::RoutePlugin;

    use super::{parse_size, BodyLimit};

    fn plugin(config: serde_json::Value) -> RoutePlugin {
        serde_json::from_value(serde_json::json!({ "name": "body_limit", "config": config }))
            .unwrap()
    }

    #[test]
    fn test_parse_size() {
        assert_eq!(parse_size("100B"), Some(100));
        assert_eq!(parse_size("10KB"), Some(10_000));
        assert_eq!(parse_size("512KiB"), Some(512 * 1024));
        assert_eq!(parse_size("2MB"), Some(2_000_000));
        assert_eq!(parse_size("1MiB"), Some(1024 * 1024));
        assert_eq!(parse_size("1GiB"), Some(1024 * 1024 * 1024));
        for invalid in ["", "1", "MiB", "0B", "1.5MiB", "-1KB", "1TB", "1 MiB"] {
            assert_eq!(parse_size(invalid), None, "{invalid}");
        }
    }

    #[test]
    fn test_max() {
        let limit = BodyLimit::new(&plugin(serde_json::json!({ "max": "1KiB" }))).unwrap();
        assert_eq!(limit.max, 1024);
    }

    #[test]
    fn test_invalid_settings() {
        for config in [
            serde_json::json!({}),
            serde_json::json!({ "max": "1TB" }),
            serde_json::json!({ "max": 1024 }),
        ] {
            assert_eq!(
                BodyLimit::new(&plugin(config)).err().as_deref(),
                Some("body_limit: `max` must be a size such as \"512KiB\", \"1MiB\" or \"10MB\"")
            );
        }
    }
}