test: hostile-input tests for the remote-facing parsers
Seeded pseudo-random input, biased towards each parser's control bytes, is fed to the IRC line parser and state machine (plus mIRC formatting), Telnet IAC negotiation, VNC framebuffer updates (including ZRLE behind a real zlib stream, with a check that the decoders are actually reached), ~/.ssh/config parsing and the update manifest. None panic, and Telnet's pending buffer stays bounded. These run on stable in every CI run; they are not coverage-guided, so a cargo-fuzz setup (nightly) remains a possible follow-up.
This commit is contained in:
@@ -1052,6 +1052,23 @@ mod tests {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn hostile_lines_never_panic() {
|
||||
let client = Client::new(Config::default());
|
||||
client.st().nick = "me".into();
|
||||
client.handle_message(&parse_line(":me!u@h JOIN #a").unwrap());
|
||||
let mut n = crate::Noise::new(0x1bc);
|
||||
for _ in 0..5000 {
|
||||
let raw = n.bytes(120, b":!@# ,;=\x01\x02\x03\x0f\x16\x1d\x1fme#aJOINPARTKICKPRIVMSGNOTICE3534330011234567890");
|
||||
let line = String::from_utf8_lossy(&raw);
|
||||
if let Some(m) = parse_line(&line) {
|
||||
client.handle_message(&m);
|
||||
let e = Event { kind: "message".into(), target: m.param(0).into(), from: m.source.clone(), text: m.last().into(), ..Default::default() };
|
||||
let _ = crate::ircformat::format_event(&e, "12:00");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_room_membership_tracks_confirmed_channels() {
|
||||
let updates = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
@@ -322,6 +322,19 @@ mod tests {
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn hostile_negotiation_never_panics() {
|
||||
let c = TelnetClient::new(&Profile::new(crate::models::Protocol::Telnet), Callbacks::noop(), None);
|
||||
let mut n = crate::Noise::new(0x7e1);
|
||||
let mut pending = Vec::new();
|
||||
for _ in 0..5000 {
|
||||
// IAC, SE, SB, WILL, WONT, DO, DONT and the options the client handles.
|
||||
let chunk = n.bytes(64, &[255, 240, 250, 251, 252, 253, 254, 1, 3, 24, 31, 32, 0, b'a']);
|
||||
c.inner.process(&mut pending, &chunk);
|
||||
assert!(pending.len() <= 16 << 10, "pending must stay bounded");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_endings() {
|
||||
let cases = [
|
||||
|
||||
@@ -866,6 +866,44 @@ mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn hostile_updates_never_panic() {
|
||||
use flate2::{Compress, Compression, FlushCompress};
|
||||
let mut n = crate::Noise::new(0x5eed);
|
||||
let (mut fb, mut dec) = (Framebuffer::new(32, 32), Decoders::default());
|
||||
let mut z = Compress::new(Compression::fast(), true);
|
||||
let mut decoded = [0usize; 2]; // successful ZRLE / other updates
|
||||
for _ in 0..3000 {
|
||||
let enc: i32 = [0, 1, 5, 7, 16, -223, -224, -308, 99][n.below(9)];
|
||||
let dim = |n: &mut crate::Noise| n.below(40) as u16;
|
||||
let (x, y, w, h) = (dim(&mut n), dim(&mut n), dim(&mut n), dim(&mut n));
|
||||
let mut msg = vec![0, 0, 1];
|
||||
for v in [x, y, w, h] {
|
||||
msg.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
msg.extend_from_slice(&enc.to_be_bytes());
|
||||
let body = n.bytes(600, &[0, 1, 2, 0xff, 0x80, 0x10, 0x1f]);
|
||||
if enc == 16 {
|
||||
// ZRLE: random tiles behind a real zlib stream, so the decoder itself is reached.
|
||||
let mut out = Vec::with_capacity(body.len() * 2 + 64);
|
||||
z.compress_vec(&body, &mut out, FlushCompress::Sync).unwrap();
|
||||
msg.extend_from_slice(&(out.len() as u32).to_be_bytes());
|
||||
msg.extend_from_slice(&out);
|
||||
} else {
|
||||
msg.extend_from_slice(&body);
|
||||
}
|
||||
match read_update(&mut &msg[..], &mut fb, &mut dec) {
|
||||
Ok(_) => decoded[(enc != 16) as usize] += 1,
|
||||
// The persistent zlib streams may have desynchronised; start them afresh.
|
||||
Err(_) => (dec, z) = (Decoders::default(), Compress::new(Compression::fast(), true)),
|
||||
}
|
||||
if fb.width as usize * fb.height as usize > 1 << 20 {
|
||||
fb = Framebuffer::new(32, 32);
|
||||
}
|
||||
}
|
||||
assert!(decoded.iter().all(|&d| d > 0), "the harness must reach the decoders: {decoded:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn des_response_matches_known_vector() {
|
||||
// openssl enc -des-ecb -K 0e86ceceeef64e26 (bit-reversed "password") over "0123456789abcdef".
|
||||
|
||||
@@ -19,3 +19,34 @@ pub mod validation;
|
||||
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Seeded pseudo-random input for the parsers' "hostile bytes never panic" tests. Not coverage
|
||||
/// guided like cargo-fuzz, but runs on stable in every CI run.
|
||||
#[cfg(test)]
|
||||
pub(crate) struct Noise(u64);
|
||||
|
||||
#[cfg(test)]
|
||||
impl Noise {
|
||||
pub fn new(seed: u64) -> Noise {
|
||||
Noise(seed | 1)
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> u64 {
|
||||
// xorshift64
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n as u64) as usize
|
||||
}
|
||||
|
||||
/// Up to `max` bytes, mostly drawn from `alphabet` (the bytes a parser cares about) and
|
||||
/// otherwise uniformly random.
|
||||
pub fn bytes(&mut self, max: usize, alphabet: &[u8]) -> Vec<u8> {
|
||||
let n = self.below(max + 1);
|
||||
(0..n).map(|_| if self.below(3) > 0 { alphabet[self.below(alphabet.len())] } else { self.next() as u8 }).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,23 @@ fn is_trueish(v: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_configs_never_panic() {
|
||||
let mut n = crate::Noise::new(0x55c);
|
||||
let words: &[&[u8]] =
|
||||
&[b"Host ", b"HostName ", b"Port ", b"User ", b"ProxyJump ", b"IdentityFile ", b"Match ", b"*", b"=", b"\n", b"\"", b" ", b"a@b:22,", b"99999"];
|
||||
for _ in 0..3000 {
|
||||
let mut text = Vec::new();
|
||||
for _ in 0..n.below(40) {
|
||||
text.extend_from_slice(words[n.below(words.len())]);
|
||||
}
|
||||
text.extend(n.bytes(20, b" \t\n="));
|
||||
if let Ok(blocks) = parse(&String::from_utf8_lossy(&text)) {
|
||||
let _ = to_profiles(&blocks, "f");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_CONFIG: &str = "# Test SSH config
|
||||
|
||||
Host *
|
||||
|
||||
@@ -145,6 +145,15 @@ fn leading_int(s: Option<&&str>) -> i64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_manifests_never_panic() {
|
||||
let mut n = crate::Noise::new(0x9d);
|
||||
for _ in 0..5000 {
|
||||
let body = n.bytes(200, br#"{}[]":,0123456789.vlatestVersionreleaseURL"#);
|
||||
let _ = parse_manifest(&body, "1.2.3");
|
||||
}
|
||||
}
|
||||
use std::io::Write;
|
||||
|
||||
/// Serves one HTTP response on a random local port; returns its URL.
|
||||
|
||||
Reference in New Issue
Block a user