use super::{Error, get_hash_key, get_step_conf_in, get_str_slice_conf};
use async_trait::async_trait;
use http::HeaderValue;
use http::header::{CONTENT_LENGTH, HOST, HeaderName, TRANSFER_ENCODING};
use pingap_config::PluginConf;
use pingap_core::{
Ctx, HttpHeader, Plugin, PluginStep, RequestPluginResult, convert_header,
convert_header_value, protect_from_connection_header,
resolve_static_header_value,
};
use pingora::proxy::Session;
use std::borrow::Cow;
use std::str::FromStr;
use tracing::debug;
type Result<T, E = Error> = std::result::Result<T, E>;
const CATEGORY: &str = "request_headers";
pub struct RequestHeaders {
add_headers: Vec<HttpHeader>,
remove_headers: Vec<HeaderName>,
set_headers: Vec<HttpHeader>,
set_headers_not_exists: Vec<HttpHeader>,
rename_headers: Vec<(HeaderName, HeaderName)>,
own_headers: Vec<HeaderName>,
plugin_step: PluginStep,
hash_value: String,
}
fn not_to_change(name: &HeaderName) -> Option<&'static str> {
if name == CONTENT_LENGTH || name == TRANSFER_ENCODING {
Some("is not for this plugin to change")
} else if name == HOST {
Some("is set with proxy_set_headers of the location")
} else {
None
}
}
impl TryFrom<&PluginConf> for RequestHeaders {
type Error = Error;
fn try_from(value: &PluginConf) -> Result<Self> {
let hash_value = get_hash_key(value);
let invalid = |message: String| Error::Invalid {
category: CATEGORY.to_string(),
message,
};
let step = get_step_conf_in(
value,
CATEGORY,
PluginStep::Request,
&[PluginStep::Request, PluginStep::ProxyUpstream],
)?;
let name_of = |key: &str, name: &str| -> Result<HeaderName> {
let name = HeaderName::from_str(name.trim()).map_err(|_| {
invalid(format!("{key}: {name:?} is not a header name"))
})?;
if let Some(reason) = not_to_change(&name) {
return Err(invalid(format!("{key}: {name} {reason}")));
}
Ok(name)
};
let headers_of = |key: &str| -> Result<Vec<HttpHeader>> {
get_str_slice_conf(value, key)
.iter()
.map(|item| {
let header = convert_header(item)
.map_err(|e| invalid(format!("{key}: {e}")))?;
let (name, value) = header.ok_or_else(|| {
invalid(format!("{key}: {item:?} should be name:value"))
})?;
let name = name_of(key, name.as_str())?;
Ok((name, resolve_static_header_value(value)))
})
.collect()
};
let add_headers = headers_of("add_headers")?;
let set_headers = headers_of("set_headers")?;
let set_headers_not_exists = headers_of("set_headers_not_exists")?;
let remove_headers = get_str_slice_conf(value, "remove_headers")
.iter()
.map(|item| name_of("remove_headers", item))
.collect::<Result<Vec<_>>>()?;
let rename_headers = get_str_slice_conf(value, "rename_headers")
.iter()
.map(|item| {
let (from, to) = item.split_once(':').ok_or_else(|| {
invalid(format!(
"rename_headers: {item:?} should be old-name:new-name"
))
})?;
Ok((
name_of("rename_headers", from)?,
name_of("rename_headers", to)?,
))
})
.collect::<Result<Vec<_>>>()?;
let own_headers = add_headers
.iter()
.chain(set_headers.iter())
.chain(set_headers_not_exists.iter())
.map(|(name, _)| name)
.chain(rename_headers.iter().map(|(_, to)| to))
.cloned()
.collect();
Ok(Self {
hash_value,
add_headers,
remove_headers,
set_headers,
set_headers_not_exists,
rename_headers,
own_headers,
plugin_step: step,
})
}
}
impl RequestHeaders {
pub fn new(params: &PluginConf) -> Result<Self> {
debug!(
params = pingap_config::masked_toml(params),
"new request headers plugin"
);
Self::try_from(params)
}
}
#[async_trait]
impl Plugin for RequestHeaders {
#[inline]
fn config_key(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.hash_value)
}
#[inline]
async fn handle_request(
&self,
step: PluginStep,
session: &mut Session,
ctx: &mut Ctx,
) -> pingora::Result<RequestPluginResult> {
if step != self.plugin_step {
return Ok(RequestPluginResult::Skipped);
}
let resolve = |headers: &[HttpHeader]| -> Vec<HeaderValue> {
headers
.iter()
.map(|(_, value)| {
convert_header_value(value, session, ctx)
.unwrap_or_else(|| value.clone())
})
.collect()
};
let add_values = resolve(&self.add_headers);
let set_values = resolve(&self.set_headers);
let default_values = resolve(&self.set_headers_not_exists);
let header = session.req_header_mut();
for ((name, _), value) in self.add_headers.iter().zip(add_values) {
let _ = header.append_header(name, value);
}
for name in &self.remove_headers {
header.remove_header(name);
}
for ((name, _), value) in self.set_headers.iter().zip(set_values) {
let _ = header.insert_header(name, value);
}
for ((name, _), value) in
self.set_headers_not_exists.iter().zip(default_values)
{
if !header.headers.contains_key(name) {
let _ = header.insert_header(name, value);
}
}
for (from, to) in &self.rename_headers {
let values: Vec<HeaderValue> =
header.headers.get_all(from).iter().cloned().collect();
if values.is_empty() {
continue;
}
header.remove_header(from);
for value in values {
let _ = header.append_header(to, value);
}
}
protect_from_connection_header(header, &self.own_headers);
Ok(RequestPluginResult::Continue)
}
}
register_plugin!("request_headers", RequestHeaders);
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tokio_test::io::Builder;
fn new_plugin(conf: &str) -> Result<RequestHeaders> {
RequestHeaders::new(&toml::from_str::<PluginConf>(conf).unwrap())
}
async fn headers_after(
plugin: &RequestHeaders,
step: PluginStep,
request: &str,
) -> (RequestPluginResult, Vec<String>) {
let mock_io = Builder::new().read(request.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let result = plugin
.handle_request(step, &mut session, &mut Ctx::default())
.await
.unwrap();
let mut headers: Vec<String> = session
.req_header()
.headers
.iter()
.map(|(name, value)| {
format!("{name}: {}", value.to_str().unwrap_or_default())
})
.collect();
headers.sort();
(result, headers)
}
const REQUEST: &str = "GET /api?id=1 HTTP/1.1\r\nHost: example.com\r\nCookie: a=1\r\nCookie: b=2\r\nX-Old: one\r\nX-Old: two\r\nX-Keep: kept\r\nX-Client: curl\r\n\r\n";
#[tokio::test]
async fn test_request_headers() {
let plugin = new_plugin(
r#"
add_headers = ["X-Keep: added", "X-New: 1"]
remove_headers = ["Cookie"]
set_headers = ["X-Client: pingap", "X-From: $http_x_client", "X-Scheme: $scheme"]
set_headers_not_exists = ["X-Keep: default", "X-Trace: none"]
rename_headers = ["X-Old: X-Renamed", "X-Missing: X-Never"]
"#,
)
.unwrap();
let (result, headers) =
headers_after(&plugin, PluginStep::Request, REQUEST).await;
assert_eq!(true, result == RequestPluginResult::Continue);
assert_eq!(
vec![
"host: example.com",
"x-client: pingap",
"x-from: curl",
"x-keep: added",
"x-keep: kept",
"x-new: 1",
"x-renamed: one",
"x-renamed: two",
"x-scheme: http",
"x-trace: none",
],
headers
);
let (result, headers) =
headers_after(&plugin, PluginStep::ProxyUpstream, REQUEST).await;
assert_eq!(true, result == RequestPluginResult::Skipped);
assert_eq!(true, headers.contains(&"cookie: a=1".to_string()));
let plugin = new_plugin(
"step = \"proxy_upstream\"\nremove_headers = [\"Cookie\"]",
)
.unwrap();
let (result, headers) =
headers_after(&plugin, PluginStep::ProxyUpstream, REQUEST).await;
assert_eq!(true, result == RequestPluginResult::Continue);
assert_eq!(
false,
headers.iter().any(|header| header.starts_with("cookie")),
"{headers:?}"
);
}
#[tokio::test]
async fn test_request_headers_are_not_the_clients_to_drop() {
let plugin = new_plugin(
r#"
add_headers = ["X-Added: 1"]
set_headers = ["X-Real-IP: $remote_addr"]
set_headers_not_exists = ["X-Trace: none"]
rename_headers = ["X-Api-Key: X-Internal-Key"]
"#,
)
.unwrap();
let request = "GET / HTTP/1.1\r\nHost: example.com\r\nX-Api-Key: k\r\nX-Mine: 1\r\nConnection: x-real-ip, X-Added, keep-alive, X-Internal-Key, X-Trace, X-Mine\r\n\r\n";
let (_, headers) =
headers_after(&plugin, PluginStep::Request, request).await;
let connection: Vec<_> = headers
.iter()
.filter(|header| header.starts_with("connection:"))
.collect();
assert_eq!(vec!["connection: keep-alive, X-Mine"], connection);
}
#[test]
fn test_request_headers_params() {
let error = |conf: &str| new_plugin(conf).err().unwrap().to_string();
let prefix = "Plugin request_headers invalid, message: ";
for (conf, message) in [
(
r#"set_headers = ["X-Client"]"#,
r#"set_headers: "X-Client" should be name:value"#,
),
(
r#"remove_headers = ["X Bad"]"#,
r#"remove_headers: "X Bad" is not a header name"#,
),
(
r#"rename_headers = ["X-Old"]"#,
r#"rename_headers: "X-Old" should be old-name:new-name"#,
),
(
r#"rename_headers = ["X-Old: X New"]"#,
r#"rename_headers: " X New" is not a header name"#,
),
(
r#"remove_headers = ["Content-Length"]"#,
"remove_headers: content-length is not for this plugin to change",
),
(
r#"set_headers = ["Transfer-Encoding: chunked"]"#,
"set_headers: transfer-encoding is not for this plugin to change",
),
(
r#"set_headers = ["Host: internal.example.com"]"#,
"set_headers: host is set with proxy_set_headers of the location",
),
(
r#"rename_headers = ["X-Host: Host"]"#,
"rename_headers: host is set with proxy_set_headers of the location",
),
(
r#"step = "response""#,
"Invalid step(response), expect one of: request, proxy_upstream",
),
] {
assert_eq!(format!("{prefix}{message}"), error(conf), "{conf}");
}
assert_eq!(true, new_plugin("").is_ok());
}
}