1use serde_json::Value;
2use std::collections::HashMap;
3use std::net::{Ipv4Addr, Ipv6Addr, ToSocketAddrs};
4use std::path::{Path, PathBuf};
5
6use crate::error::{CodeSynapseError, Result};
7
8pub const MAX_FETCH_BYTES: u64 = 52_428_800;
9pub const MAX_TEXT_BYTES: u64 = 10_485_760;
10pub const MAX_GRAPH_FILE_BYTES: u64 = 512 * 1024 * 1024;
11
12const MAX_LABEL_LEN: usize = 256;
13const METADATA_MAX_VALUE_LEN: usize = 512;
14const METADATA_MAX_LIST_ITEMS: usize = 50;
15
16static BLOCKED_HOSTS: &[&str] = &[
17 "metadata.google.internal",
18 "metadata.google.com",
19 "169.254.169.254",
20];
21
22fn is_private_ipv4(ip: Ipv4Addr) -> bool {
23 let o = ip.octets();
24 o[0] == 127
25 || o[0] == 10
26 || (o[0] == 172 && (16..=31).contains(&o[1]))
27 || (o[0] == 192 && o[1] == 168)
28 || (o[0] == 169 && o[1] == 254)
29 || (o[0] == 100 && (64..=127).contains(&o[1]))
30 || o[0] == 0
31 || o == [255, 255, 255, 255]
32}
33
34fn is_private_ipv6(ip: Ipv6Addr) -> bool {
35 let segs = ip.segments();
36 if segs == [0, 0, 0, 0, 0, 0, 0, 1] {
37 return true;
38 }
39 if (segs[0] & 0xffc0) == 0xfe80 {
40 return true;
41 }
42 if (segs[0] & 0xfe00) == 0xfc00 {
43 return true;
44 }
45 if segs[0] == 0
47 && segs[1] == 0
48 && segs[2] == 0
49 && segs[3] == 0
50 && segs[4] == 0
51 && segs[5] == 0xffff
52 {
53 let v4 = Ipv4Addr::new(
54 (segs[6] >> 8) as u8,
55 segs[6] as u8,
56 (segs[7] >> 8) as u8,
57 segs[7] as u8,
58 );
59 return is_private_ipv4(v4);
60 }
61 false
62}
63
64fn parse_url_parts(url: &str) -> Option<(String, String)> {
65 let lower = url.to_lowercase();
66 let scheme_end = lower.find("://")?;
67 let scheme = lower[..scheme_end].to_string();
68 let rest = &lower[scheme_end + 3..];
69
70 let host = if rest.starts_with('[') {
71 let close = rest.find(']')?;
72 rest[..=close].to_string()
73 } else {
74 let host_end = rest.find(['/', '?', '#', ':']).unwrap_or(rest.len());
75 rest[..host_end].to_string()
76 };
77
78 Some((scheme, host))
79}
80
81pub fn validate_url(url: &str) -> Result<()> {
82 let (scheme, host) = parse_url_parts(url)
83 .ok_or_else(|| CodeSynapseError::Validation(format!("Invalid URL: {url:?}")))?;
84
85 if scheme != "http" && scheme != "https" {
86 return Err(CodeSynapseError::Validation(format!(
87 "Blocked URL scheme '{scheme}' - only http and https are allowed. Got: {url:?}"
88 )));
89 }
90
91 if host.is_empty() {
92 return Err(CodeSynapseError::Validation(format!(
93 "URL has no host: {url:?}"
94 )));
95 }
96
97 if BLOCKED_HOSTS.contains(&host.as_str()) {
98 return Err(CodeSynapseError::Validation(format!(
99 "Blocked cloud metadata endpoint '{host}'. Got: {url:?}"
100 )));
101 }
102
103 let resolve_target = format!("{host}:80");
104 let addrs = resolve_target.to_socket_addrs().map_err(|e| {
105 CodeSynapseError::Validation(format!(
106 "DNS resolution failed for '{host}': {e}. Got: {url:?}"
107 ))
108 })?;
109
110 for addr in addrs {
111 let blocked = match addr.ip() {
112 std::net::IpAddr::V4(v4) => is_private_ipv4(v4),
113 std::net::IpAddr::V6(v6) => is_private_ipv6(v6),
114 };
115 if blocked {
116 return Err(CodeSynapseError::Validation(format!(
117 "Blocked private/internal IP {} (resolved from '{host}'). Got: {url:?}",
118 addr.ip()
119 )));
120 }
121 }
122
123 Ok(())
124}
125
126pub fn validate_path(path: &Path, base: &Path) -> Result<PathBuf> {
127 if !base.exists() {
128 return Err(CodeSynapseError::Validation(format!(
129 "Base directory does not exist: {}",
130 base.display()
131 )));
132 }
133 let base_canon = base.canonicalize()?;
134 let resolved = path
135 .canonicalize()
136 .map_err(|_| CodeSynapseError::NotFound(format!("Path not found: {}", path.display())))?;
137 if !resolved.starts_with(&base_canon) {
138 return Err(CodeSynapseError::Validation(format!(
139 "Path {:?} escapes the allowed directory {}",
140 path,
141 base_canon.display()
142 )));
143 }
144 Ok(resolved)
145}
146
147pub fn check_file_size(path: &Path, max_bytes: u64) -> Result<()> {
148 let size = match path.metadata() {
149 Ok(m) => m.len(),
150 Err(_) => return Ok(()),
151 };
152 if size > max_bytes {
153 return Err(CodeSynapseError::Validation(format!(
154 "File {} is {size} bytes, exceeds {max_bytes}-byte cap",
155 path.display()
156 )));
157 }
158 Ok(())
159}
160
161fn strip_control_chars(s: &str) -> String {
162 s.chars().filter(|&c| c > '\x1f' && c != '\x7f').collect()
163}
164
165fn html_escape(s: &str) -> String {
166 let mut out = String::with_capacity(s.len());
167 for c in s.chars() {
168 match c {
169 '&' => out.push_str("&"),
170 '<' => out.push_str("<"),
171 '>' => out.push_str(">"),
172 '"' => out.push_str("""),
173 '\'' => out.push_str("'"),
174 other => out.push(other),
175 }
176 }
177 out
178}
179
180pub fn sanitize_label(text: Option<&str>) -> String {
181 let text = match text {
182 None => return String::new(),
183 Some(t) => t,
184 };
185 strip_control_chars(text)
186 .chars()
187 .take(MAX_LABEL_LEN)
188 .collect()
189}
190
191fn sanitize_metadata_str_value(value: &str) -> String {
192 let text = strip_control_chars(value);
193 let text = html_escape(&text);
194 text.chars().take(METADATA_MAX_VALUE_LEN).collect()
195}
196
197fn sanitize_json_value(value: &Value) -> Value {
198 match value {
199 Value::Bool(b) => Value::Bool(*b),
200 Value::Number(n) => Value::Number(n.clone()),
201 Value::Null => Value::Null,
202 Value::String(s) => Value::String(sanitize_metadata_str_value(s)),
203 Value::Array(arr) => Value::Array(
204 arr.iter()
205 .take(METADATA_MAX_LIST_ITEMS)
206 .map(sanitize_json_value)
207 .collect(),
208 ),
209 Value::Object(obj) => Value::Object(
210 obj.iter()
211 .filter_map(|(k, v)| {
212 let ck = sanitize_metadata_str_value(k);
213 if ck.is_empty() {
214 None
215 } else {
216 Some((ck, sanitize_json_value(v)))
217 }
218 })
219 .collect(),
220 ),
221 }
222}
223
224pub fn sanitize_metadata(metadata: Option<&HashMap<String, Value>>) -> HashMap<String, Value> {
225 let Some(meta) = metadata else {
226 return HashMap::new();
227 };
228 let mut result = HashMap::new();
229 for (key, value) in meta {
230 let ck = sanitize_metadata_str_value(key);
231 if ck.is_empty() {
232 continue;
233 }
234 result.insert(ck, sanitize_json_value(value));
235 }
236 result
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use std::fs;
243
244 #[test]
245 fn test_validate_url_allows_https() {
246 assert!(validate_url("https://example.com/page").is_ok());
247 }
248
249 #[test]
250 fn test_validate_url_blocks_file_scheme() {
251 let e = validate_url("file:///etc/passwd").unwrap_err();
252 assert!(e.to_string().contains("Blocked URL scheme"));
253 }
254
255 #[test]
256 fn test_validate_url_blocks_ftp_scheme() {
257 let e = validate_url("ftp://example.com/file").unwrap_err();
258 assert!(e.to_string().contains("Blocked URL scheme"));
259 }
260
261 #[test]
262 fn test_validate_url_blocks_data_scheme() {
263 let e = validate_url("data:text/html,<h1>hi</h1>").unwrap_err();
264 let msg = e.to_string();
265 assert!(msg.contains("Blocked URL scheme") || msg.contains("Invalid URL"));
266 }
267
268 #[test]
269 fn test_validate_url_blocks_loopback_ipv4() {
270 let e = validate_url("http://127.0.0.1/secret").unwrap_err();
271 assert!(e.to_string().contains("Blocked private/internal IP"));
272 }
273
274 #[test]
275 fn test_validate_url_blocks_loopback_alt() {
276 let e = validate_url("http://127.0.0.2/secret").unwrap_err();
277 assert!(e.to_string().contains("Blocked private/internal IP"));
278 }
279
280 #[test]
281 fn test_validate_url_blocks_private_10() {
282 let e = validate_url("http://10.0.0.1/").unwrap_err();
283 assert!(e.to_string().contains("Blocked private/internal IP"));
284 }
285
286 #[test]
287 fn test_validate_url_blocks_private_172() {
288 let e = validate_url("http://172.16.0.1/").unwrap_err();
289 assert!(e.to_string().contains("Blocked private/internal IP"));
290 }
291
292 #[test]
293 fn test_validate_url_blocks_private_192() {
294 let e = validate_url("http://192.168.1.1/").unwrap_err();
295 assert!(e.to_string().contains("Blocked private/internal IP"));
296 }
297
298 #[test]
299 fn test_validate_url_blocks_link_local() {
300 let e = validate_url("http://169.254.0.1/").unwrap_err();
301 assert!(e.to_string().contains("Blocked private/internal IP"));
302 }
303
304 #[test]
305 fn test_validate_url_blocks_cgn() {
306 let e = validate_url("http://100.64.0.1/").unwrap_err();
307 assert!(e.to_string().contains("Blocked private/internal IP"));
308 }
309
310 #[test]
311 fn test_validate_url_blocks_cloud_metadata_hostname() {
312 let e = validate_url("http://metadata.google.internal/").unwrap_err();
313 let msg = e.to_string();
314 assert!(
315 msg.contains("Blocked cloud metadata endpoint")
316 || msg.contains("Blocked private/internal IP")
317 || msg.contains("DNS resolution failed")
318 );
319 }
320
321 #[test]
322 fn test_validate_url_blocks_aws_metadata_ip() {
323 let e = validate_url("http://169.254.169.254/latest/meta-data/").unwrap_err();
324 assert!(e.to_string().contains("Blocked"));
325 }
326
327 #[test]
328 fn test_validate_url_blocks_ipv6_loopback() {
329 let e = validate_url("http://[::1]/").unwrap_err();
330 let msg = e.to_string();
331 assert!(msg.contains("Blocked") || msg.contains("DNS"));
332 }
333
334 #[test]
335 fn test_is_private_ipv4_loopback() {
336 assert!(is_private_ipv4(Ipv4Addr::new(127, 0, 0, 1)));
337 assert!(is_private_ipv4(Ipv4Addr::new(127, 255, 255, 254)));
338 }
339
340 #[test]
341 fn test_is_private_ipv4_rfc1918() {
342 assert!(is_private_ipv4(Ipv4Addr::new(10, 0, 0, 1)));
343 assert!(is_private_ipv4(Ipv4Addr::new(172, 16, 0, 1)));
344 assert!(is_private_ipv4(Ipv4Addr::new(172, 31, 255, 255)));
345 assert!(is_private_ipv4(Ipv4Addr::new(192, 168, 0, 1)));
346 }
347
348 #[test]
349 fn test_is_private_ipv4_not_private() {
350 assert!(!is_private_ipv4(Ipv4Addr::new(8, 8, 8, 8)));
351 assert!(!is_private_ipv4(Ipv4Addr::new(172, 32, 0, 1)));
352 assert!(!is_private_ipv4(Ipv4Addr::new(1, 1, 1, 1)));
353 }
354
355 #[test]
356 fn test_is_private_ipv4_cgn() {
357 assert!(is_private_ipv4(Ipv4Addr::new(100, 64, 0, 1)));
358 assert!(is_private_ipv4(Ipv4Addr::new(100, 127, 255, 255)));
359 assert!(!is_private_ipv4(Ipv4Addr::new(100, 128, 0, 1)));
360 }
361
362 #[test]
363 fn test_is_private_ipv6_loopback() {
364 assert!(is_private_ipv6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)));
365 }
366
367 #[test]
368 fn test_is_private_ipv6_link_local() {
369 assert!(is_private_ipv6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)));
370 }
371
372 #[test]
373 fn test_is_private_ipv6_ula() {
374 assert!(is_private_ipv6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)));
375 assert!(is_private_ipv6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)));
376 }
377
378 #[test]
379 fn test_is_private_ipv6_mapped_private_v4() {
380 let ip = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xc0a8, 0x0101);
382 assert!(is_private_ipv6(ip));
383 }
384
385 #[test]
386 fn test_validate_path_allows_valid() {
387 let dir = std::env::temp_dir().join("codesynapse_sec_valid");
388 fs::create_dir_all(&dir).unwrap();
389 let file = dir.join("data.json");
390 fs::write(&file, b"{}").unwrap();
391 let result = validate_path(&file, &dir);
392 assert!(result.is_ok());
393 let _ = fs::remove_dir_all(&dir);
394 }
395
396 #[test]
397 fn test_validate_path_blocks_traversal() {
398 let dir = std::env::temp_dir().join("codesynapse_sec_trav");
399 fs::create_dir_all(&dir).unwrap();
400 let outside_file = std::env::temp_dir().join("codesynapse_sec_trav_outside.txt");
401 fs::write(&outside_file, b"x").unwrap();
402 let result = validate_path(&outside_file, &dir);
403 assert!(result.is_err());
404 let msg = result.unwrap_err().to_string();
405 assert!(msg.contains("escapes"));
406 let _ = fs::remove_dir_all(&dir);
407 let _ = fs::remove_file(&outside_file);
408 }
409
410 #[test]
411 fn test_validate_path_nonexistent_base() {
412 let base = Path::new("/nonexistent_codesynapse_base_dir_test");
413 let path = Path::new("/nonexistent_codesynapse_base_dir_test/file.json");
414 let result = validate_path(path, base);
415 assert!(result.is_err());
416 assert!(result.unwrap_err().to_string().contains("does not exist"));
417 }
418
419 #[test]
420 fn test_validate_path_file_not_found() {
421 let dir = std::env::temp_dir().join("codesynapse_sec_notfound");
422 fs::create_dir_all(&dir).unwrap();
423 let missing = dir.join("missing.json");
424 let result = validate_path(&missing, &dir);
425 assert!(result.is_err());
426 let _ = fs::remove_dir_all(&dir);
427 }
428
429 #[test]
430 fn test_check_file_size_allows_small() {
431 let tmp = std::env::temp_dir().join("codesynapse_sec_size_small.txt");
432 fs::write(&tmp, b"hello").unwrap();
433 assert!(check_file_size(&tmp, 1000).is_ok());
434 let _ = fs::remove_file(&tmp);
435 }
436
437 #[test]
438 fn test_check_file_size_blocks_large() {
439 let tmp = std::env::temp_dir().join("codesynapse_sec_size_large.txt");
440 fs::write(&tmp, b"hello world").unwrap();
441 let result = check_file_size(&tmp, 5);
442 assert!(result.is_err());
443 assert!(result.unwrap_err().to_string().contains("exceeds"));
444 let _ = fs::remove_file(&tmp);
445 }
446
447 #[test]
448 fn test_check_file_size_missing_file_ok() {
449 let path = Path::new("/nonexistent_codesynapse_file_size.txt");
450 assert!(check_file_size(path, 100).is_ok());
451 }
452
453 #[test]
454 fn test_sanitize_label_none() {
455 assert_eq!(sanitize_label(None), "");
456 }
457
458 #[test]
459 fn test_sanitize_label_preserves_normal() {
460 assert_eq!(sanitize_label(Some("hello world")), "hello world");
461 }
462
463 #[test]
464 fn test_sanitize_label_strips_nul() {
465 assert_eq!(sanitize_label(Some("foo\x00bar")), "foobar");
466 }
467
468 #[test]
469 fn test_sanitize_label_strips_control_chars() {
470 assert_eq!(sanitize_label(Some("foo\x01\x1fbar\x7f")), "foobar");
471 }
472
473 #[test]
474 fn test_sanitize_label_caps_length() {
475 let long = "a".repeat(300);
476 let result = sanitize_label(Some(&long));
477 assert_eq!(result.chars().count(), MAX_LABEL_LEN);
478 }
479
480 #[test]
481 fn test_sanitize_label_empty() {
482 assert_eq!(sanitize_label(Some("")), "");
483 }
484
485 #[test]
486 fn test_sanitize_metadata_none() {
487 assert!(sanitize_metadata(None).is_empty());
488 }
489
490 #[test]
491 fn test_sanitize_metadata_preserves_simple_strings() {
492 let mut m = HashMap::new();
493 m.insert("key".to_string(), Value::String("value".to_string()));
494 let result = sanitize_metadata(Some(&m));
495 assert_eq!(result["key"], Value::String("value".to_string()));
496 }
497
498 #[test]
499 fn test_sanitize_metadata_strips_control_chars_in_values() {
500 let mut m = HashMap::new();
501 m.insert("k".to_string(), Value::String("foo\x00bar".to_string()));
502 let result = sanitize_metadata(Some(&m));
503 assert_eq!(result["k"], Value::String("foobar".to_string()));
504 }
505
506 #[test]
507 fn test_sanitize_metadata_html_escapes_strings() {
508 let mut m = HashMap::new();
509 m.insert(
510 "k".to_string(),
511 Value::String("<script>alert(1)</script>".to_string()),
512 );
513 let result = sanitize_metadata(Some(&m));
514 let s = result["k"].as_str().unwrap();
515 assert!(!s.contains('<'));
516 assert!(s.contains("<"));
517 }
518
519 #[test]
520 fn test_sanitize_metadata_caps_value_length() {
521 let mut m = HashMap::new();
522 m.insert("k".to_string(), Value::String("x".repeat(600)));
523 let result = sanitize_metadata(Some(&m));
524 let s = result["k"].as_str().unwrap();
525 assert!(s.chars().count() <= METADATA_MAX_VALUE_LEN);
526 }
527
528 #[test]
529 fn test_sanitize_metadata_preserves_bool() {
530 let mut m = HashMap::new();
531 m.insert("k".to_string(), Value::Bool(true));
532 let result = sanitize_metadata(Some(&m));
533 assert_eq!(result["k"], Value::Bool(true));
534 }
535
536 #[test]
537 fn test_sanitize_metadata_preserves_null() {
538 let mut m = HashMap::new();
539 m.insert("k".to_string(), Value::Null);
540 let result = sanitize_metadata(Some(&m));
541 assert_eq!(result["k"], Value::Null);
542 }
543
544 #[test]
545 fn test_sanitize_metadata_preserves_number() {
546 let mut m = HashMap::new();
547 m.insert("k".to_string(), serde_json::json!(42));
548 let result = sanitize_metadata(Some(&m));
549 assert_eq!(result["k"], serde_json::json!(42));
550 }
551
552 #[test]
553 fn test_sanitize_metadata_caps_list_items() {
554 let mut m = HashMap::new();
555 let arr: Vec<Value> = (0..100).map(|i| serde_json::json!(i)).collect();
556 m.insert("k".to_string(), Value::Array(arr));
557 let result = sanitize_metadata(Some(&m));
558 assert_eq!(
559 result["k"].as_array().unwrap().len(),
560 METADATA_MAX_LIST_ITEMS
561 );
562 }
563
564 #[test]
565 fn test_sanitize_metadata_drops_empty_key() {
566 let mut m = HashMap::new();
567 m.insert("\x00\x01".to_string(), Value::String("val".to_string()));
568 let result = sanitize_metadata(Some(&m));
569 assert!(result.is_empty());
570 }
571
572 #[test]
573 fn test_sanitize_metadata_nested_dict() {
574 let mut inner = serde_json::Map::new();
575 inner.insert("nested".to_string(), Value::String("<bad>".to_string()));
576 let mut m = HashMap::new();
577 m.insert("outer".to_string(), Value::Object(inner));
578 let result = sanitize_metadata(Some(&m));
579 let outer = result["outer"].as_object().unwrap();
580 let s = outer["nested"].as_str().unwrap();
581 assert!(!s.contains('<'));
582 assert!(s.contains("<"));
583 }
584}