use super::{
DryRun, Error, RestrictionCategory, get_hash_key,
get_restriction_category_conf, get_str_conf, get_str_slice_conf,
};
use async_trait::async_trait;
use bytes::Bytes;
use http::StatusCode;
use pingap_config::PluginConf;
use pingap_core::{Ctx, HttpResponse, Plugin, PluginStep, RequestPluginResult};
use pingora::proxy::Session;
use regex::RegexSet;
use std::borrow::Cow;
use tracing::debug;
type Result<T, E = Error> = std::result::Result<T, E>;
const CATEGORY: &str = "ja4_restriction";
pub struct Ja4Restriction {
plugin_step: PluginStep,
ja4_list: RegexSet,
restriction_category: RestrictionCategory,
forbidden_resp: HttpResponse,
dry_run: DryRun,
hash_value: String,
}
impl TryFrom<&PluginConf> for Ja4Restriction {
type Error = Error;
fn try_from(value: &PluginConf) -> Result<Self> {
let invalid = |message: String| Error::Invalid {
category: CATEGORY.to_string(),
message,
};
let patterns = get_str_slice_conf(value, "ja4_list");
if patterns.is_empty() {
return Err(invalid("ja4_list is required".to_string()));
}
let ja4_list = RegexSet::new(&patterns)
.map_err(|e| invalid(format!("ja4_list: {e}")))?;
let mut message = get_str_conf(value, "message");
if message.is_empty() {
message = "Request is forbidden".to_string();
}
Ok(Self {
hash_value: get_hash_key(value),
plugin_step: PluginStep::Request,
ja4_list,
restriction_category: get_restriction_category_conf(
value, CATEGORY,
)?,
forbidden_resp: HttpResponse {
status: StatusCode::FORBIDDEN,
body: Bytes::from(message),
..Default::default()
},
dry_run: DryRun::new(value),
})
}
}
impl Ja4Restriction {
pub fn new(params: &PluginConf) -> Result<Self> {
debug!(
params = pingap_config::masked_toml(params),
"new ja4 restriction plugin"
);
Self::try_from(params)
}
}
#[async_trait]
impl Plugin for Ja4Restriction {
#[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 found = ctx
.conn
.ja4
.as_ref()
.is_some_and(|ja4| self.ja4_list.is_match(ja4.ja4()));
if !self.restriction_category.allows(found) {
let fingerprint = |ctx: &mut Ctx| {
ctx.conn
.ja4
.as_ref()
.map(|ja4| ja4.ja4().to_string())
.unwrap_or_default()
};
return Ok(self.dry_run.refuse(
CATEGORY,
session,
ctx,
self.forbidden_resp.clone(),
fingerprint,
));
}
Ok(RequestPluginResult::Continue)
}
}
register_plugin!("ja4_restriction", Ja4Restriction);
#[cfg(test)]
mod tests {
use super::*;
use pingap_core::Ja4Fingerprint;
use pingap_core::ja4::testing::{client_hello_body, spec_example_body};
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tokio_test::io::Builder;
const BROWSER: &str = "t13d1516h2_8daaf6152771_e5627efa2ab1";
const SCRIPT: &str = "t00i000000_000000000000_000000000000";
fn new_plugin(conf: &str) -> Result<Ja4Restriction> {
Ja4Restriction::new(&toml::from_str::<PluginConf>(conf).unwrap())
}
fn ctx_of(hello: Option<Vec<u8>>) -> Ctx {
let mut ctx = Ctx::default();
ctx.conn.ja4 = hello
.and_then(|hello| Ja4Fingerprint::from_client_hello(&hello))
.map(Arc::new);
ctx
}
fn browser() -> Option<Vec<u8>> {
Some(spec_example_body())
}
fn script() -> Option<Vec<u8>> {
Some(client_hello_body(0x9999, &[], Some(&[])))
}
async fn run(
plugin: &Ja4Restriction,
ctx: &mut Ctx,
) -> RequestPluginResult {
let input = b"GET /api HTTP/1.1\r\nHost: example.com\r\n\r\n";
let mock_io = Builder::new().read(input).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
plugin
.handle_request(PluginStep::Request, &mut session, ctx)
.await
.unwrap()
}
#[test]
fn test_ja4_restriction_params() {
let plugin = new_plugin(&format!(
"ja4_list = [\"{SCRIPT}\", \"^t12\"]\ntype = \"deny\"\n"
))
.unwrap();
assert_eq!(RestrictionCategory::Deny, plugin.restriction_category);
assert_eq!(2, plugin.ja4_list.len());
assert_eq!(false, plugin.dry_run.is_on());
let plugin =
new_plugin(&format!("ja4_list = [\"{BROWSER}\"]\n")).unwrap();
assert_eq!(RestrictionCategory::Allow, plugin.restriction_category);
assert_eq!(
"Plugin ja4_restriction invalid, message: ja4_list is required",
new_plugin("type = \"deny\"\n").err().unwrap().to_string()
);
assert_eq!(
true,
new_plugin("ja4_list = [\"(\"]\n")
.err()
.unwrap()
.to_string()
.starts_with(
"Plugin ja4_restriction invalid, message: ja4_list:"
)
);
assert_eq!(
"Plugin ja4_restriction invalid, message: Invalid type(block), expect allow or deny",
new_plugin("ja4_list = [\"^t13\"]\ntype = \"block\"\n")
.err()
.unwrap()
.to_string()
);
}
#[tokio::test]
async fn test_ja4_restriction() {
assert_eq!(BROWSER, ctx_of(browser()).conn.ja4.unwrap().ja4());
assert_eq!(SCRIPT, ctx_of(script()).conn.ja4.unwrap().ja4());
let deny = new_plugin(&format!(
"ja4_list = [\"{SCRIPT}\"]\ntype = \"deny\"\nmessage = \"no scripts\"\n"
))
.unwrap();
let RequestPluginResult::Respond(resp) =
run(&deny, &mut ctx_of(script())).await
else {
panic!("a listed fingerprint is turned away");
};
assert_eq!(StatusCode::FORBIDDEN, resp.status);
assert_eq!(Bytes::from_static(b"no scripts"), resp.body);
assert_eq!(
RequestPluginResult::Continue,
run(&deny, &mut ctx_of(browser())).await
);
assert_eq!(
RequestPluginResult::Continue,
run(&deny, &mut ctx_of(None)).await
);
let allow = new_plugin("ja4_list = [\"^t13d\"]\n").unwrap();
assert_eq!(
RequestPluginResult::Continue,
run(&allow, &mut ctx_of(browser())).await
);
let RequestPluginResult::Respond(resp) =
run(&allow, &mut ctx_of(script())).await
else {
panic!("a fingerprint the list does not allow is turned away");
};
assert_eq!(StatusCode::FORBIDDEN, resp.status);
let RequestPluginResult::Respond(_) =
run(&allow, &mut ctx_of(None)).await
else {
panic!("no fingerprint is not one the list allows");
};
}
#[tokio::test]
async fn test_ja4_restriction_dry_run() {
let deny = new_plugin(&format!(
"ja4_list = [\"{SCRIPT}\"]\ntype = \"deny\"\ndry_run = true\n"
))
.unwrap();
assert_eq!(true, deny.dry_run.is_on());
assert_eq!(
RequestPluginResult::Continue,
run(&deny, &mut ctx_of(script())).await
);
let conf = toml::from_str::<PluginConf>(
"category = \"ja4_restriction\"\nja4_list = [\"^t13\"]\ndry_run = \"yes\"\n",
)
.unwrap();
assert_eq!(
"Plugin ja4_restriction invalid, message: dry_run must be true or false, got string \"yes\"",
crate::get_plugin_factory()
.create(&conf)
.err()
.unwrap()
.to_string()
);
}
}