1mod methods;
2mod models;
3
4use flate2::read::GzDecoder;
5pub use methods::*;
6pub use models::*;
7use tempfile::{tempdir, tempfile, TempDir};
8
9use crate::APP_CONFIG;
10use color_eyre::{
11 eyre::{bail, ensure},
12 Result,
13};
14use reqwest::{
15 blocking::Client,
16 header::{HeaderMap, HeaderValue},
17 redirect::Policy,
18 Url,
19};
20use std::{
21 fs::File,
22 io::{self, Read, Seek},
23};
24
25#[derive(Clone, Copy)]
26struct ArchiveLimits {
27 entries: usize,
28 download_size: u64,
29 expanded_size: u64,
30 scan_size: u64,
31}
32
33impl ArchiveLimits {
34 fn configured() -> Self {
35 Self {
36 entries: APP_CONFIG.max_archive_entries,
37 download_size: APP_CONFIG.max_download_size,
38 expanded_size: APP_CONFIG.max_expanded_size,
39 scan_size: APP_CONFIG.max_scan_size,
40 }
41 }
42}
43
44pub struct RulesState {
45 pub rules: yara::Rules,
46 pub hash: String,
47}
48
49#[warn(clippy::module_name_repetitions)]
50pub struct DragonflyClient {
51 api_client: Client,
52 download_client: Client,
53 pub rules_state: RulesState,
54 pub reuse_cache: crate::reuse_cache::ReuseCache,
55 base_url: String,
56}
57
58impl DragonflyClient {
59 pub fn new() -> Result<Self> {
60 let api_client = build_api_http_client(
61 &APP_CONFIG.cf_access_client_id,
62 &APP_CONFIG.cf_access_client_secret,
63 )?;
64 let download_client = build_download_http_client()?;
65
66 let rules_response = fetch_rules(&api_client, &APP_CONFIG.base_url)?;
67
68 let mut reuse_cache = crate::reuse_cache::ReuseCache::new(
69 APP_CONFIG.reuse_cache_mode,
70 APP_CONFIG.reuse_cache_entries,
71 APP_CONFIG.reuse_cache_bytes,
72 );
73 if APP_CONFIG.reuse_cache_database {
74 reuse_cache.set_database(crate::durable_cache::DurableCache::new(
75 api_client.clone(),
76 &APP_CONFIG.base_url,
77 "yara",
78 &rules_response.hash,
79 &rules_response.rules,
80 None,
81 )?);
82 }
83 let rules_state = RulesState {
84 rules: rules_response.compile()?,
85 hash: rules_response.hash,
86 };
87
88 Ok(Self {
89 api_client,
90 download_client,
91 rules_state,
92 reuse_cache,
93 base_url: APP_CONFIG.base_url.clone(),
94 })
95 }
96
97 pub fn update_rules(&mut self) -> Result<()> {
99 let response = fetch_rules(&self.api_client, &self.base_url)?;
100 let compiled_rules = response.compile()?;
101 if APP_CONFIG.reuse_cache_database {
102 self.reuse_cache
103 .set_database(crate::durable_cache::DurableCache::new(
104 self.api_client.clone(),
105 &self.base_url,
106 "yara",
107 &response.hash,
108 &response.rules,
109 None,
110 )?);
111 }
112 self.reuse_cache.clear();
113 self.rules_state.rules = compiled_rules;
114 self.rules_state.hash = response.hash;
115
116 Ok(())
117 }
118
119 pub fn bulk_get_job(&self, n_jobs: usize) -> reqwest::Result<Vec<Job>> {
120 fetch_bulk_job(&self.api_client, &self.base_url, n_jobs)
121 }
122
123 pub fn send_result(
125 &self,
126 body: models::ScanResult,
127 stats: &crate::reuse_cache::CacheStats,
128 ) -> reqwest::Result<()> {
129 send_result_with_metrics(&self.api_client, &self.base_url, body, Some(stats))
130 }
131
132 pub(crate) fn download_client(&self) -> &Client {
134 &self.download_client
135 }
136}
137
138fn build_api_http_client(client_id: &str, client_secret: &str) -> Result<Client> {
139 let mut headers = HeaderMap::new();
140 headers.insert("CF-Access-Client-Id", HeaderValue::from_str(client_id)?);
141
142 let mut secret = HeaderValue::from_str(client_secret)?;
143 secret.set_sensitive(true);
144 headers.insert("CF-Access-Client-Secret", secret);
145
146 Ok(Client::builder()
147 .gzip(true)
148 .redirect(Policy::custom(|attempt| {
149 attempt.error("Dragonfly API redirects are not allowed")
150 }))
151 .default_headers(headers)
152 .build()?)
153}
154
155fn build_download_http_client() -> reqwest::Result<Client> {
156 Client::builder().gzip(true).build()
157}
158
159fn stage_download<R: Read>(response: R, limit: u64) -> Result<File> {
160 let read_limit = limit
161 .checked_add(1)
162 .ok_or_else(|| color_eyre::eyre::eyre!("download size limit is too large"))?;
163 let mut file = tempfile()?;
164 let downloaded = io::copy(&mut response.take(read_limit), &mut file)?;
165 ensure!(
166 downloaded <= limit,
167 "compressed distribution exceeds the {limit}-byte download limit"
168 );
169 file.rewind()?;
170
171 Ok(file)
172}
173
174fn extract_tarball(file: File, limits: ArchiveLimits) -> Result<TempDir> {
176 let mut tarball = tar::Archive::new(GzDecoder::new(file));
177 let tmpdir = tempdir()?;
178 let mut entries = 0_usize;
179 let mut expanded_size = 0_u64;
180
181 for entry in tarball.entries()? {
182 let mut entry = entry?;
183 entries = entries
184 .checked_add(1)
185 .ok_or_else(|| color_eyre::eyre::eyre!("tar entry count overflowed"))?;
186 ensure!(
187 entries <= limits.entries,
188 "tar archive exceeds the {}-entry limit",
189 limits.entries
190 );
191
192 let entry_size = entry.size();
193 ensure!(
194 entry_size <= limits.scan_size,
195 "tar entry {} is {entry_size} bytes, exceeding the {}-byte scan limit",
196 entry.path()?.display(),
197 limits.scan_size
198 );
199 expanded_size = expanded_size
200 .checked_add(entry_size)
201 .ok_or_else(|| color_eyre::eyre::eyre!("expanded tar size overflowed"))?;
202 ensure!(
203 expanded_size <= limits.expanded_size,
204 "tar archive exceeds the {}-byte expanded-size limit",
205 limits.expanded_size
206 );
207 ensure!(
208 entry.unpack_in(tmpdir.path())?,
209 "tar entry would unpack outside the temporary directory"
210 );
211 }
212
213 Ok(tmpdir)
214}
215
216fn zip_entry_count(file: &mut File) -> Result<usize> {
217 const END_HEADER_SIZE: usize = 22;
218 const MAX_COMMENT_SIZE: usize = u16::MAX as usize;
219 const SIGNATURE: [u8; 4] = [0x50, 0x4b, 0x05, 0x06];
220
221 let file_size = file.seek(io::SeekFrom::End(0))?;
222 let tail_size = file_size.min((END_HEADER_SIZE + MAX_COMMENT_SIZE) as u64);
223 file.seek(io::SeekFrom::End(
224 -i64::try_from(tail_size).expect("ZIP footer window fits in i64"),
225 ))?;
226
227 let mut tail = vec![0_u8; usize::try_from(tail_size)?];
228 file.read_exact(&mut tail)?;
229 let Some(header_start) =
230 (0..=tail.len().saturating_sub(SIGNATURE.len()))
231 .rev()
232 .find(|&index| {
233 if tail[index..].get(..SIGNATURE.len()) != Some(&SIGNATURE) {
234 return false;
235 }
236 let Some(comment_size) = tail
237 .get(index + 20..index + 22)
238 .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]]) as usize)
239 else {
240 return false;
241 };
242 tail.len() - index == END_HEADER_SIZE + comment_size
243 })
244 else {
245 bail!("ZIP end-of-central-directory record is missing");
246 };
247 let header = &tail[header_start..];
248 let disk_number = u16::from_le_bytes([header[4], header[5]]);
249 let directory_disk = u16::from_le_bytes([header[6], header[7]]);
250 let entries_on_disk = u16::from_le_bytes([header[8], header[9]]);
251 let entries = u16::from_le_bytes([header[10], header[11]]);
252 ensure!(
253 disk_number == 0 && directory_disk == 0 && entries_on_disk == entries,
254 "multi-disk ZIP archives are not supported"
255 );
256 ensure!(
257 entries != u16::MAX,
258 "ZIP64 archives are not accepted in the constrained scanner"
259 );
260 file.rewind()?;
261
262 Ok(entries as usize)
263}
264
265fn extract_zipfile(mut file: File, limits: ArchiveLimits) -> Result<TempDir> {
267 let entry_count = zip_entry_count(&mut file)?;
268 ensure!(
269 entry_count <= limits.entries,
270 "ZIP archive contains {entry_count} entries, exceeding the {}-entry limit",
271 limits.entries
272 );
273 let mut zip = zip::ZipArchive::new(file)?;
274 ensure!(
275 zip.len() == entry_count,
276 "ZIP central-directory entry count changed while parsing"
277 );
278
279 let mut expanded_size = 0_u64;
280 for index in 0..zip.len() {
281 let entry = zip.by_index(index)?;
282 ensure!(
283 matches!(
284 entry.compression(),
285 zip::CompressionMethod::Stored | zip::CompressionMethod::Deflated
286 ),
287 "ZIP entry {} uses unsupported compression method {:?}",
288 entry.name(),
289 entry.compression()
290 );
291 let entry_size = entry.size();
292 ensure!(
293 entry_size <= limits.scan_size,
294 "ZIP entry {} is {entry_size} bytes, exceeding the {}-byte scan limit",
295 entry.name(),
296 limits.scan_size
297 );
298 expanded_size = expanded_size
299 .checked_add(entry_size)
300 .ok_or_else(|| color_eyre::eyre::eyre!("expanded ZIP size overflowed"))?;
301 ensure!(
302 expanded_size <= limits.expanded_size,
303 "ZIP archive exceeds the {}-byte expanded-size limit",
304 limits.expanded_size
305 );
306 }
307
308 let tmpdir = tempdir()?;
309 zip.extract(tmpdir.path())?;
310
311 Ok(tmpdir)
312}
313
314pub fn download_distribution(http_client: &Client, download_url: Url) -> Result<TempDir> {
315 let is_tarball = download_url.as_str().ends_with(".tar.gz");
316 let response = http_client.get(download_url).send()?.error_for_status()?;
317 let limits = ArchiveLimits::configured();
318 let file = stage_download(response, limits.download_size)?;
319
320 if is_tarball {
321 extract_tarball(file, limits)
322 } else {
323 extract_zipfile(file, limits)
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::{
330 build_api_http_client, build_download_http_client, extract_tarball, extract_zipfile,
331 stage_download, ArchiveLimits, DragonflyClient, RulesState,
332 };
333 use flate2::{write::GzEncoder, Compression};
334 use std::{
335 io::{Cursor, Read, Write},
336 net::TcpListener,
337 sync::mpsc,
338 thread,
339 };
340 use yara::Compiler;
341 use zip::{write::SimpleFileOptions, ZipWriter};
342
343 const CLIENT_ID: &str = "test-client.access";
344 const CLIENT_SECRET: &str = "test-secret";
345
346 fn archive_limits() -> ArchiveLimits {
347 ArchiveLimits {
348 entries: 4,
349 download_size: 1024,
350 expanded_size: 1024,
351 scan_size: 1024,
352 }
353 }
354
355 fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
356 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
357 for (name, contents) in files {
358 zip.start_file(*name, SimpleFileOptions::default()).unwrap();
359 zip.write_all(contents).unwrap();
360 }
361 zip.finish().unwrap().into_inner()
362 }
363
364 fn build_tarball(files: &[(&str, &[u8])]) -> Vec<u8> {
365 let encoder = GzEncoder::new(Vec::new(), Compression::fast());
366 let mut tarball = tar::Builder::new(encoder);
367 for (name, contents) in files {
368 let mut header = tar::Header::new_gnu();
369 header.set_size(contents.len().try_into().unwrap());
370 header.set_cksum();
371 tarball.append_data(&mut header, name, *contents).unwrap();
372 }
373 tarball.into_inner().unwrap().finish().unwrap()
374 }
375
376 fn serve_once(response: String) -> (String, mpsc::Receiver<String>) {
377 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
378 let address = listener.local_addr().unwrap();
379 let (sender, receiver) = mpsc::channel();
380
381 thread::spawn(move || {
382 let (mut stream, _) = listener.accept().unwrap();
383 let mut request = Vec::new();
384 let mut buffer = [0_u8; 4096];
385
386 loop {
387 let bytes_read = stream.read(&mut buffer).unwrap();
388 if bytes_read == 0 {
389 break;
390 }
391 request.extend_from_slice(&buffer[..bytes_read]);
392 if request.windows(4).any(|window| window == b"\r\n\r\n") {
393 break;
394 }
395 }
396
397 sender.send(String::from_utf8(request).unwrap()).unwrap();
398 stream.write_all(response.as_bytes()).unwrap();
399 });
400
401 (format!("http://{address}/example.tar.gz"), receiver)
402 }
403
404 #[test]
405 fn distribution_downloads_do_not_send_cloudflare_access_credentials() {
406 let response =
407 String::from("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
408 let (download_url, request) = serve_once(response);
409 let rules = Compiler::new()
410 .unwrap()
411 .add_rules_str("rule test_rule { condition: false }")
412 .unwrap()
413 .compile_rules()
414 .unwrap();
415 let client = DragonflyClient {
416 api_client: build_api_http_client(CLIENT_ID, CLIENT_SECRET).unwrap(),
417 download_client: build_download_http_client().unwrap(),
418 rules_state: RulesState {
419 rules,
420 hash: String::new(),
421 },
422 reuse_cache: crate::reuse_cache::ReuseCache::new(
423 crate::reuse_cache::CacheMode::Off,
424 0,
425 0,
426 ),
427 base_url: String::from("https://dragonfly.example"),
428 };
429
430 client
431 .download_client()
432 .get(download_url)
433 .send()
434 .unwrap()
435 .error_for_status()
436 .unwrap();
437
438 let request = request.recv().unwrap().to_ascii_lowercase();
439 assert!(!request.contains("\r\ncf-access-client-id:"));
440 assert!(!request.contains("\r\ncf-access-client-secret:"));
441 assert!(!request.contains("\r\nauthorization:"));
442 }
443
444 #[test]
445 fn api_client_rejects_redirects_before_forwarding_credentials() {
446 let redirect_target = TcpListener::bind("127.0.0.1:0").unwrap();
447 redirect_target.set_nonblocking(true).unwrap();
448 let target_url = format!("http://{}/capture", redirect_target.local_addr().unwrap());
449 let response = format!(
450 "HTTP/1.1 302 Found\r\nLocation: {target_url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
451 );
452 let (api_url, source_request) = serve_once(response);
453 let api_client = build_api_http_client(CLIENT_ID, CLIENT_SECRET).unwrap();
454
455 let error = api_client.get(api_url).send().unwrap_err();
456
457 assert!(error.is_redirect());
458 assert_eq!(
459 redirect_target.accept().unwrap_err().kind(),
460 std::io::ErrorKind::WouldBlock
461 );
462 let source_request = source_request.recv().unwrap().to_ascii_lowercase();
463 assert!(source_request.contains("\r\ncf-access-client-id:"));
464 assert!(source_request.contains("\r\ncf-access-client-secret:"));
465 }
466
467 #[test]
468 fn stage_download_rejects_oversized_input() {
469 let error = stage_download(Cursor::new(vec![0_u8; 5]), 4).unwrap_err();
470
471 assert!(error.to_string().contains("4-byte download limit"));
472 }
473
474 #[test]
475 fn zip_entry_limit_is_checked_before_extraction() {
476 let bytes = build_zip(&[("one", b"1"), ("two", b"2")]);
477 let file = stage_download(Cursor::new(bytes), 1024).unwrap();
478 let limits = ArchiveLimits {
479 entries: 1,
480 ..archive_limits()
481 };
482
483 let error = extract_zipfile(file, limits).unwrap_err();
484
485 assert!(error.to_string().contains("2 entries"));
486 }
487
488 #[test]
489 fn zip_expanded_size_is_bounded() {
490 let bytes = build_zip(&[("one", b"1234"), ("two", b"5678")]);
491 let file = stage_download(Cursor::new(bytes), 1024).unwrap();
492 let limits = ArchiveLimits {
493 expanded_size: 7,
494 ..archive_limits()
495 };
496
497 let error = extract_zipfile(file, limits).unwrap_err();
498
499 assert!(error.to_string().contains("expanded-size limit"));
500 }
501
502 #[test]
503 fn zip_file_scan_size_is_bounded() {
504 let bytes = build_zip(&[("large", b"12345")]);
505 let file = stage_download(Cursor::new(bytes), 1024).unwrap();
506 let limits = ArchiveLimits {
507 scan_size: 4,
508 ..archive_limits()
509 };
510
511 let error = extract_zipfile(file, limits).unwrap_err();
512
513 assert!(error.to_string().contains("4-byte scan limit"));
514 }
515
516 #[test]
517 fn tar_expanded_size_is_bounded() {
518 let bytes = build_tarball(&[("one", b"1234"), ("two", b"5678")]);
519 let file = stage_download(Cursor::new(bytes), 1024).unwrap();
520 let limits = ArchiveLimits {
521 expanded_size: 7,
522 ..archive_limits()
523 };
524
525 let error = extract_tarball(file, limits).unwrap_err();
526
527 assert!(error.to_string().contains("expanded-size limit"));
528 }
529}