firegex/backend/proxysrc/tests/rules.rs

254 lines
8.9 KiB
Rust

//! The filters the backend actually configures, and the boundary case that decides
//! whether they are worth anything.
use std::sync::Arc;
use std::time::Duration;
use fgex_proxy::filter::{
next_connection_id, ChainHandle, ChainSessions, Direction, FilterChain, Verdict,
};
use fgex_proxy::rules::parse_ruleset;
fn chain(json: &str) -> FilterChain {
FilterChain::new(parse_ruleset(json).unwrap(), Duration::from_millis(500))
}
async fn feed(c: &FilterChain, dir: Direction, chunks: &[&[u8]]) -> Vec<Verdict> {
let mut sessions = ChainSessions::new(next_connection_id());
let mut out = Vec::new();
for chunk in chunks {
out.push(c.run(dir, chunk, &mut sessions).await);
}
out
}
/// A block carries the id of the pattern that fired, so the tests can name it.
fn reject(id: &str) -> Verdict {
Verdict::Reject(Some(id.to_string()))
}
#[tokio::test]
async fn regex_rule_blocks_on_match() {
let c = chain(r#"[{"kind":"regex","id":"r1","pattern":"FLAG\\{[a-z]+\\}"}]"#);
let v = feed(&c, Direction::ClientToServer, &[b"nothing here"]).await;
assert_eq!(v, vec![Verdict::Accept]);
let v = feed(&c, Direction::ClientToServer, &[b"give me FLAG{abc} now"]).await;
assert_eq!(v, vec![reject("r1")]);
}
/// The reason the chain keeps a per-direction sessions at all. Split the pattern
/// across two reads and a chunk-only matcher goes blind — which is exactly the
/// evasion nfregex has to reassemble the stream to prevent.
#[tokio::test]
async fn regex_rule_matches_across_a_segment_boundary() {
let c = chain(r#"[{"kind":"regex","id":"r1","pattern":"/etc/passwd"}]"#);
let v = feed(
&c,
Direction::ClientToServer,
&[b"GET /etc/pa", b"sswd HTTP/1.1"],
)
.await;
assert_eq!(
v,
vec![Verdict::Accept, reject("r1")],
"a pattern split over two reads was missed"
);
}
/// The property an overlap window could not have at any size.
///
/// The previous matcher kept the last 4096 bytes and rescanned them with each chunk,
/// so a match spanning more than that was invisible — and raising the number would
/// have cost every byte of every connection. hyperscan carries the automaton state
/// instead: the span is unbounded and costs nothing per chunk.
#[tokio::test]
async fn regex_rule_matches_across_more_than_the_old_window() {
let c = chain(r#"[{"kind":"regex","id":"r1","pattern":"BEGIN.{9000,}END"}]"#);
let filler = vec![b'.'; 3000];
let v = feed(
&c,
Direction::ClientToServer,
&[b"BEGIN", &filler, &filler, &filler, &filler, b"END"],
)
.await;
assert_eq!(
v.last(),
Some(&reject("r1")),
"a match spanning 12000 bytes was missed"
);
}
#[tokio::test]
async fn regex_rule_honours_direction() {
// Only the server's answers are inspected: a request mentioning it is fine.
let c = chain(r#"[{"kind":"regex","id":"r1","pattern":"FLAG","direction":"s2c"}]"#);
let v = feed(&c, Direction::ClientToServer, &[b"where is the FLAG"]).await;
assert_eq!(v, vec![Verdict::Accept]);
let v = feed(&c, Direction::ServerToClient, &[b"here: FLAG{x}"]).await;
assert_eq!(v, vec![reject("r1")]);
}
#[tokio::test]
async fn regex_rule_can_ignore_case() {
let c =
chain(r#"[{"kind":"regex","id":"r1","pattern":"union select","case_sensitive":false}]"#);
let v = feed(&c, Direction::ClientToServer, &[b"1 UNION SELECT 1"]).await;
assert_eq!(v, vec![reject("r1")]);
}
#[tokio::test]
async fn a_bad_rule_rejects_the_whole_ruleset() {
// Half a ruleset is worse than the one already running, so nothing is applied.
let bad =
r#"[{"kind":"regex","id":"ok","pattern":"a"},{"kind":"regex","id":"bad","pattern":"("}]"#;
let err = match parse_ruleset(bad) {
Err(e) => e,
Ok(rules) => panic!(
"a broken pattern was accepted ({} rules built)",
rules.len()
),
};
assert!(err.contains("invalid regex"), "unhelpful error: {err}");
assert!(parse_ruleset("not json").is_err());
assert!(parse_ruleset(r#"[{"kind":"nope","id":"x"}]"#).is_err());
assert!(parse_ruleset("[]").unwrap().is_empty());
}
/// Rules reach connections that are already open, which is what makes editing them
/// during a competition safe.
#[tokio::test]
async fn a_new_ruleset_reaches_an_open_connection() {
let handle = ChainHandle::new(chain("[]"));
let mut sessions = ChainSessions::new(next_connection_id());
let v = handle
.current()
.run(Direction::ClientToServer, b"FLAG{x}", &mut sessions)
.await;
assert_eq!(v, Verdict::Accept);
handle.replace(chain(r#"[{"kind":"regex","id":"r1","pattern":"FLAG"}]"#));
let v = handle
.current()
.run(Direction::ClientToServer, b"FLAG{x}", &mut sessions)
.await;
assert_eq!(v, reject("r1"));
}
/// The sessions is per connection: one client's traffic must never complete another
/// client's pattern.
#[tokio::test]
async fn windows_do_not_leak_between_connections() {
let c = Arc::new(chain(
r#"[{"kind":"regex","id":"r1","pattern":"/etc/passwd"}]"#,
));
let mut first = ChainSessions::new(next_connection_id());
assert_eq!(
c.run(Direction::ClientToServer, b"GET /etc/pa", &mut first)
.await,
Verdict::Accept
);
// A different connection sending the tail must not be blocked by the half a
// previous client left behind.
let mut second = ChainSessions::new(next_connection_id());
assert_eq!(
c.run(Direction::ClientToServer, b"sswd HTTP/1.1", &mut second)
.await,
Verdict::Accept
);
}
/// Blocking rules that share a direction and a case setting are compiled into one
/// automaton, so adding rules stops costing a scan each. Measured against nfregex,
/// the per-rule scan was the difference between 100 MB/s and 2400.
#[tokio::test]
async fn blocking_rules_are_matched_in_one_pass() {
let many: Vec<String> = (0..50)
// Suffixed so no pattern is a prefix of another: the set reports the first
// index that matched, and overlapping patterns would make that ambiguous.
.map(|i| format!(r#"{{"kind":"regex","id":"r{i}","pattern":"needle{i}z"}}"#))
.collect();
let filters = parse_ruleset(&format!("[{}]", many.join(","))).unwrap();
// Two matchers, not fifty: one hyperscan database per direction, each holding
// every pattern that applies that way. A chunk is scanned by exactly one of
// them, so the cost of matching stops depending on how many rules there are —
// the property a per-rule loop gave away and a benchmark caught.
assert_eq!(
filters.len(),
2,
"50 blocking rules should collapse into one matcher per direction, got {}",
filters.len()
);
let c = FilterChain::new(filters, Duration::from_millis(500));
let v = feed(&c, Direction::ClientToServer, &[b"harmless"]).await;
assert_eq!(v, vec![Verdict::Accept]);
// Still attributed to the individual rule that fired.
let v = feed(
&c,
Direction::ClientToServer,
&[b"here is needle37z for you"],
)
.await;
assert_eq!(v, vec![reject("r37")]);
}
/// Rules that cannot share an automaton stay apart, and still work.
#[tokio::test]
async fn only_direction_splits_the_matchers() {
let filters = parse_ruleset(
r#"[{"kind":"regex","id":"a","pattern":"aaa","direction":"c2s"},
{"kind":"regex","id":"b","pattern":"bbb","direction":"s2c"},
{"kind":"regex","id":"c","pattern":"ccc","case_sensitive":false}]"#,
)
.unwrap();
// Two: one blocking matcher per direction.
// Case-insensitivity does not split anything — hyperscan takes it as a per-pattern flag.
assert_eq!(
filters.len(),
2,
"one blocking matcher per direction"
);
let c = FilterChain::new(filters, Duration::from_millis(500));
assert_eq!(
feed(&c, Direction::ClientToServer, &[b"aaa"]).await,
vec![reject("a")]
);
assert_eq!(
feed(&c, Direction::ClientToServer, &[b"bbb"]).await,
vec![Verdict::Accept],
"an s2c rule fired on client traffic"
);
assert_eq!(
feed(&c, Direction::ServerToClient, &[b"CCC"]).await,
vec![reject("c")]
);
}
/// Does the scan actually traverse the whole buffer? Plant the needle at the very
/// last byte of a 64 KiB chunk: a matcher that short-circuits would miss it.
#[tokio::test]
async fn a_needle_at_the_end_of_a_chunk_is_found() {
let rules: Vec<String> = (0..50)
.map(|i| format!(r#"{{"kind":"regex","id":"r{i}","pattern":"needle{i}z"}}"#))
.collect();
let c = chain(&format!("[{}]", rules.join(",")));
let mut chunk = vec![b'x'; 64 * 1024];
let needle = b"needle37z";
let at = chunk.len() - needle.len();
chunk[at..].copy_from_slice(needle);
let mut s = ChainSessions::new(next_connection_id());
assert_eq!(
c.run(Direction::ClientToServer, &chunk, &mut s).await,
reject("r37")
);
}