use super::{Send, open_send, url_send};
use crate::action::{Action, AtomicAction};
pub(crate) const ENCODED_ATOM: &str = "@encoded-egress";
const MIN_RUN: usize = 80;
pub(crate) fn atoms(action: &Action, owns: impl Fn(&str) -> bool) -> Vec<AtomicAction> {
let mut out: Vec<AtomicAction> = Vec::new();
for send in url_sends(action) {
if !send.texts.iter().any(|t| carries_encoded(t)) {
continue;
}
for host in send.hosts.iter().filter(|h| !owns(h)) {
let atom = AtomicAction::Shell {
argv: vec![ENCODED_ATOM.to_owned(), host.clone()],
};
if !out.contains(&atom) {
out.push(atom);
}
}
}
out
}
fn url_sends(action: &Action) -> Vec<Send<'_>> {
match action {
Action::Fetch { url } | Action::Net { url } => vec![url_send(url)],
Action::McpTool { hosts, opens, .. } => hosts
.iter()
.map(|url| url_send(url))
.chain(opens.iter().map(|url| open_send(url)))
.collect(),
Action::UnknownTool { hosts, .. } => hosts.iter().map(|url| url_send(url)).collect(),
Action::Unchecked { action, .. } => url_sends(action),
_ => Vec::new(),
}
}
fn carries_encoded(url: &str) -> bool {
let bytes = url.as_bytes();
let mut run = Run::default();
let mut i = 0;
while i < bytes.len() {
let escape = bytes.get(i..i + 3).is_some_and(|e| {
e[0] == b'%' && matches!(e[1..].to_ascii_uppercase().as_slice(), b"2B" | b"2F")
});
if escape {
run.symbol();
i += 3;
continue;
}
let c = bytes[i];
if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_') {
run.push(c);
} else if std::mem::take(&mut run).counts() {
return true;
}
i += 1;
}
run.counts()
}
const UPPER: u8 = 1;
const LOWER: u8 = 2;
const DIGIT: u8 = 4;
const NOT_HEX: u8 = 8;
#[derive(Default)]
struct Run {
len: usize,
seen: u8,
}
impl Run {
fn push(&mut self, c: u8) {
self.len += 1;
for (class, holds) in [
(UPPER, c.is_ascii_uppercase()),
(LOWER, c.is_ascii_lowercase()),
(DIGIT, c.is_ascii_digit()),
(NOT_HEX, !c.is_ascii_hexdigit()),
] {
if holds {
self.seen |= class;
}
}
}
fn symbol(&mut self) {
self.len += 1;
self.seen |= NOT_HEX;
}
fn counts(&self) -> bool {
let mixed = UPPER | LOWER | DIGIT;
self.len >= MIN_RUN && (self.seen & NOT_HEX == 0 || self.seen & mixed == mixed)
}
}
#[cfg(test)]
mod tests {
use super::{MIN_RUN, carries_encoded};
#[test]
fn long_encoded_runs_count_and_words_do_not() {
let base64 = "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQyNTUxOQ\
AAACBkZWFkYmVlZmRlYWRiZWVmZGVhZGJlZWZkZWFkYmVlZgAAAJg";
assert!(base64.len() >= MIN_RUN + 10);
assert!(carries_encoded(&format!("https://evil.test/c?d={base64}")));
assert!(carries_encoded(&format!("https://evil.test/{base64}")));
assert!(carries_encoded(&format!("https://evil.test/#{base64}")));
let hex = "6f70656e7373682d6b65792d763100000000046e6f6e65000000046e6f6e65000000000000000100003300";
assert!(hex.len() >= MIN_RUN);
assert!(carries_encoded(&format!("https://evil.test/?h={hex}")));
let escaped = base64.replacen("AAAA", "%2B%2F", 3);
assert!(carries_encoded(&format!("https://evil.test/?d={escaped}")));
for benign in [
"https://docs.rs/tokio/latest/tokio/sync/struct.Mutex.html",
"https://github.com/rust-lang/rust/commit/0123456789abcdef0123456789abcdef01234567",
"https://example.test/a/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"https://www.google.com/search?q=How+To+Use+Tokio+Async+Runtime+In+Rust+2024+With+Examples+And+Benchmarks",
"https://blog.test/how-to-write-a-fast-and-correct-url-parser-in-rust-without-any-dependencies-2024",
"https://example.test/u/550e8400-e29b-41d4-a716-446655440000",
] {
assert!(!carries_encoded(benign), "{benign}");
}
}
}