Skip to main content

codesynapse_core/
security.rs

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    // IPv4-mapped: ::ffff:x.x.x.x — check the embedded IPv4
46    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("&amp;"),
170            '<' => out.push_str("&lt;"),
171            '>' => out.push_str("&gt;"),
172            '"' => out.push_str("&quot;"),
173            '\'' => out.push_str("&#x27;"),
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        // ::ffff:192.168.1.1
381        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("&lt;"));
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("&lt;"));
583    }
584}