Skip to main content

nv_core/
security.rs

1//! Security utilities: URL credential redaction, error sanitization, and RTSP
2//! transport security policy.
3//!
4//! # Credential redaction
5//!
6//! [`redact_url`] strips `user:password@` from URLs while preserving the
7//! host, port, and path for diagnostic purposes. This prevents credentials
8//! from leaking into logs, health events, or error messages.
9//!
10//! # Error sanitization
11//!
12//! [`sanitize_error_string`] cleans untrusted backend error/debug strings by:
13//! - Stripping control characters and bare newlines.
14//! - Capping to a configurable maximum length.
15//! - Redacting patterns that resemble secrets (e.g., `password=...`,
16//!   `token=...`, `key=...`).
17//!
18//! # RTSP security policy
19//!
20//! [`RtspSecurityPolicy`] controls whether `rtsps://` (TLS) is preferred,
21//! required, or explicitly opted-out for RTSP sources.
22//!
23//! ## Threat model
24//!
25//! RTSP streams carry both video data and sometimes credentials in the URL.
26//! Without TLS:
27//! - Credentials may be visible to network observers (man-in-the-middle).
28//! - Video data is transmitted in the clear.
29//! - An attacker on the network can spoof or tamper with the stream.
30//!
31//! `PreferTls` (the default) upgrades bare `rtsp://` URLs to `rtsps://` so
32//! that production deployments default to encrypted transport without
33//! requiring code changes. Field deployments behind firewalls or with
34//! cameras that don't support TLS can opt out with `AllowInsecure`.
35//!
36//! ## Migration path
37//!
38//! 1. Existing code that passes explicit `rtsp://` URLs will continue to
39//!    work — the URL is promoted to `rtsps://` unless `AllowInsecure` is
40//!    set or the URL already uses `rtsps://`.
41//! 2. If a camera does not support TLS, set `AllowInsecure` on the
42//!    source spec. A health warning will be emitted.
43//! 3. For high-security deployments, set `RequireTls` to reject any
44//!    unencrypted RTSP source at config validation time.
45
46/// RTSP transport security policy.
47///
48/// Controls whether `rtsps://` (TLS) is preferred, required, or
49/// explicitly opted-out for RTSP sources.
50///
51/// The default is [`PreferTls`](Self::PreferTls).
52///
53/// # Examples
54///
55/// ```
56/// use nv_core::security::RtspSecurityPolicy;
57///
58/// // Default: prefer TLS — bare rtsp:// URLs are promoted to rtsps://
59/// let policy = RtspSecurityPolicy::default();
60/// assert_eq!(policy, RtspSecurityPolicy::PreferTls);
61///
62/// // Explicit opt-out for cameras that don't support TLS
63/// let policy = RtspSecurityPolicy::AllowInsecure;
64/// ```
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
66pub enum RtspSecurityPolicy {
67    /// Default: promote bare `rtsp://` to `rtsps://` when scheme is absent
68    /// or `rtsp`. Logs a warning if the final URL is still insecure
69    /// (e.g., camera doesn't support TLS and caller forces `AllowInsecure`).
70    #[default]
71    PreferTls,
72
73    /// Allow insecure `rtsp://` without promotion. A health warning is
74    /// emitted when an insecure source is used. Use this for cameras that
75    /// do not support TLS behind trusted networks.
76    AllowInsecure,
77
78    /// Reject any RTSP source that is not `rtsps://`. Returns a config
79    /// error at feed creation time if the URL scheme is `rtsp://`.
80    RequireTls,
81}
82impl std::fmt::Display for RtspSecurityPolicy {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::PreferTls => f.write_str("PreferTls"),
86            Self::AllowInsecure => f.write_str("AllowInsecure"),
87            Self::RequireTls => f.write_str("RequireTls"),
88        }
89    }
90}
91
92/// Whether `SourceSpec::Custom` pipeline fragments are trusted.
93///
94/// Custom pipeline fragments are raw GStreamer launch-line strings. In
95/// production, accepting arbitrary pipeline strings from untrusted config
96/// is a security risk. This policy gates custom pipelines behind an
97/// explicit opt-in.
98///
99/// The default is [`Reject`](Self::Reject).
100///
101/// # Examples
102///
103/// ```
104/// use nv_core::security::CustomPipelinePolicy;
105///
106/// // Default: reject custom pipelines
107/// let policy = CustomPipelinePolicy::default();
108/// assert_eq!(policy, CustomPipelinePolicy::Reject);
109///
110/// // Explicit opt-in for development/trusted config
111/// let policy = CustomPipelinePolicy::AllowTrusted;
112/// ```
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
114pub enum CustomPipelinePolicy {
115    /// Reject `SourceSpec::Custom` at config validation time with a
116    /// clear error message explaining how to opt in.
117    #[default]
118    Reject,
119
120    /// Allow custom pipeline fragments. Use only when the pipeline
121    /// string originates from a trusted source (e.g., hard-coded in
122    /// application code, not from user input or config files).
123    AllowTrusted,
124}
125impl std::fmt::Display for CustomPipelinePolicy {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        match self {
128            Self::Reject => f.write_str("Reject"),
129            Self::AllowTrusted => f.write_str("AllowTrusted"),
130        }
131    }
132}
133
134// ---------------------------------------------------------------------------
135// URL redaction
136// ---------------------------------------------------------------------------
137
138/// Redact credentials from a URL string.
139///
140/// Replaces `user:password@` with `***@` while preserving the scheme,
141/// host, port, and path for diagnostic purposes. If the URL has no
142/// credentials, it is returned unchanged.
143///
144/// This is a best-effort parser that handles common URL formats without
145/// requiring a full URL parser dependency. It works on:
146/// - `rtsp://user:pass@host:port/path`
147/// - `rtsps://user:pass@host/path`
148/// - `http://user:pass@host/path`
149/// - URLs without credentials (returned as-is)
150///
151/// # Examples
152///
153/// ```
154/// use nv_core::security::redact_url;
155///
156/// assert_eq!(
157///     redact_url("rtsp://admin:secret@192.168.1.1:554/stream"),
158///     "rtsp://***@192.168.1.1:554/stream"
159/// );
160/// assert_eq!(
161///     redact_url("rtsp://192.168.1.1/stream"),
162///     "rtsp://192.168.1.1/stream"
163/// );
164/// ```
165pub fn redact_url(url: &str) -> String {
166    // Find "://" to locate the authority section.
167    let Some(scheme_end) = url.find("://") else {
168        // No scheme — might still have credentials (unlikely but safe).
169        return redact_authority(url);
170    };
171    let authority_start = scheme_end + 3;
172    let rest = &url[authority_start..];
173
174    // Find the '@' that separates userinfo from host.
175    // Only look before the first '/' (path start) to avoid matching '@'
176    // in path/query components.
177    let path_start = rest.find('/').unwrap_or(rest.len());
178    let authority_section = &rest[..path_start];
179
180    if let Some(at_pos) = authority_section.rfind('@') {
181        // Has credentials — redact everything before '@'.
182        let after_at = &rest[at_pos..]; // includes '@' and the rest
183        format!("{}://***{}", &url[..scheme_end], after_at)
184    } else {
185        // No credentials — return as-is.
186        url.to_string()
187    }
188}
189
190/// Redact credentials in a string that has no scheme prefix.
191fn redact_authority(s: &str) -> String {
192    let path_start = s.find('/').unwrap_or(s.len());
193    let authority = &s[..path_start];
194    if let Some(at_pos) = authority.rfind('@') {
195        format!("***{}", &s[at_pos..])
196    } else {
197        s.to_string()
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Error string sanitization
203// ---------------------------------------------------------------------------
204
205/// Maximum length for sanitized error strings.
206const MAX_ERROR_LEN: usize = 512;
207
208/// Sanitize an untrusted error/debug string from a backend.
209///
210/// - Strips control characters (except space) and bare newlines.
211/// - Caps length at 512 characters.
212/// - Redacts patterns resembling secrets (`password=...`, `token=...`,
213///   `key=...`, `secret=...`, `auth=...`).
214///
215/// # Examples
216///
217/// ```
218/// use nv_core::security::sanitize_error_string;
219///
220/// let dirty = "error: connection failed\n\tat rtspsrc password=hunter2";
221/// let clean = sanitize_error_string(dirty);
222/// assert!(!clean.contains("hunter2"));
223/// assert!(!clean.contains('\n'));
224/// ```
225pub fn sanitize_error_string(s: &str) -> String {
226    let mut out = String::with_capacity(s.len().min(MAX_ERROR_LEN));
227
228    for ch in s.chars() {
229        if out.len() >= MAX_ERROR_LEN {
230            out.push_str("...[truncated]");
231            break;
232        }
233        // Allow printable characters and space; strip control chars.
234        if ch == ' ' || (!ch.is_control() && !ch.is_ascii_control()) {
235            out.push(ch);
236        } else {
237            out.push(' ');
238        }
239    }
240
241    redact_secret_patterns(&mut out);
242    out
243}
244
245/// Redact common secret-like patterns: `key=value` where key is a
246/// known sensitive token name. Replaces the value with `***`.
247fn redact_secret_patterns(s: &mut String) {
248    let patterns = [
249        "password=",
250        "passwd=",
251        "token=",
252        "secret=",
253        "key=",
254        "auth=",
255        "authorization:",
256        "bearer ",
257    ];
258
259    for pat in &patterns {
260        let mut search_from = 0;
261        loop {
262            // Recompute lowercase on every iteration so indexes are always
263            // consistent with the current contents of `s`.
264            let lower = s.to_lowercase();
265            if search_from >= lower.len() {
266                break;
267            }
268            let Some(rel_idx) = lower[search_from..].find(pat) else {
269                break;
270            };
271            let abs_idx = search_from + rel_idx;
272            let value_start = abs_idx + pat.len();
273            // Find end of value: next space, '&', ';', ',', or end of string.
274            let value_end = s[value_start..]
275                .find([' ', '&', ';', ',', '\'', '"'])
276                .map(|p| value_start + p)
277                .unwrap_or(s.len());
278
279            if value_end > value_start {
280                s.replace_range(value_start..value_end, "***");
281                search_from = value_start + 3;
282            } else {
283                search_from = value_start;
284            }
285        }
286    }
287}
288
289/// Apply [`redact_url`] to all URL-like substrings in a string.
290///
291/// Scans for `scheme://...` patterns and redacts credentials in each.
292/// Useful for sanitizing error messages that may embed URLs.
293pub fn redact_urls_in_string(s: &str) -> String {
294    let mut result = s.to_string();
295    // Find URL-like patterns and redact them inline.
296    for scheme in &["rtsp://", "rtsps://", "http://", "https://"] {
297        let mut search_from = 0;
298        while let Some(offset) = result[search_from..].find(scheme) {
299            let start = search_from + offset;
300            // Find end of URL: next space or end of string.
301            let url_end = result[start..]
302                .find(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '>' || c == ')')
303                .map(|p| start + p)
304                .unwrap_or(result.len());
305            let url = &result[start..url_end];
306            let redacted = redact_url(url);
307            let redacted_len = redacted.len();
308            result.replace_range(start..url_end, &redacted);
309            // Advance past the replacement to avoid re-matching the same scheme.
310            search_from = start + redacted_len;
311        }
312    }
313    result
314}
315
316/// Apply URL scheme promotion for RTSP sources under [`RtspSecurityPolicy::PreferTls`].
317///
318/// If the URL starts with `rtsp://`, returns a copy with `rtsps://`.
319/// If the URL already starts with `rtsps://`, returns it unchanged.
320/// If the URL has no recognized scheme, prepends `rtsps://`.
321///
322/// # Examples
323///
324/// ```
325/// use nv_core::security::promote_rtsp_to_tls;
326///
327/// assert_eq!(promote_rtsp_to_tls("rtsp://cam/stream"), "rtsps://cam/stream");
328/// assert_eq!(promote_rtsp_to_tls("rtsps://cam/stream"), "rtsps://cam/stream");
329/// ```
330pub fn promote_rtsp_to_tls(url: &str) -> String {
331    if url.starts_with("rtsps://") {
332        url.to_string()
333    } else if let Some(rest) = url.strip_prefix("rtsp://") {
334        format!("rtsps://{rest}")
335    } else {
336        // No recognized scheme — assume RTSP and add TLS scheme.
337        format!("rtsps://{url}")
338    }
339}
340
341/// Check whether an RTSP URL uses insecure (non-TLS) transport.
342pub fn is_insecure_rtsp(url: &str) -> bool {
343    url.starts_with("rtsp://")
344}
345
346// ---------------------------------------------------------------------------
347// Tests
348// ---------------------------------------------------------------------------
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    // -- RtspSecurityPolicy --
355
356    #[test]
357    fn security_policy_default_is_prefer_tls() {
358        assert_eq!(RtspSecurityPolicy::default(), RtspSecurityPolicy::PreferTls);
359    }
360
361    #[test]
362    fn security_policy_display() {
363        assert_eq!(RtspSecurityPolicy::PreferTls.to_string(), "PreferTls");
364        assert_eq!(
365            RtspSecurityPolicy::AllowInsecure.to_string(),
366            "AllowInsecure"
367        );
368        assert_eq!(RtspSecurityPolicy::RequireTls.to_string(), "RequireTls");
369    }
370
371    // -- CustomPipelinePolicy --
372
373    #[test]
374    fn custom_pipeline_policy_default_is_reject() {
375        assert_eq!(
376            CustomPipelinePolicy::default(),
377            CustomPipelinePolicy::Reject
378        );
379    }
380
381    // -- URL redaction --
382
383    #[test]
384    fn redact_url_with_credentials() {
385        assert_eq!(
386            redact_url("rtsp://admin:secret@192.168.1.1:554/stream"),
387            "rtsp://***@192.168.1.1:554/stream"
388        );
389    }
390
391    #[test]
392    fn redact_url_without_credentials() {
393        assert_eq!(
394            redact_url("rtsp://192.168.1.1:554/stream"),
395            "rtsp://192.168.1.1:554/stream"
396        );
397    }
398
399    #[test]
400    fn redact_url_rtsps_with_credentials() {
401        assert_eq!(
402            redact_url("rtsps://user:p%40ss@cam.example.com/live"),
403            "rtsps://***@cam.example.com/live"
404        );
405    }
406
407    #[test]
408    fn redact_url_no_scheme() {
409        assert_eq!(redact_url("user:pass@host/path"), "***@host/path");
410    }
411
412    #[test]
413    fn redact_url_empty() {
414        assert_eq!(redact_url(""), "");
415    }
416
417    #[test]
418    fn redact_url_user_only_no_password() {
419        // user@ without colon — still redacted (could be a token).
420        assert_eq!(
421            redact_url("rtsp://tokenuser@host/path"),
422            "rtsp://***@host/path"
423        );
424    }
425
426    #[test]
427    fn redact_url_at_in_path_ignored() {
428        // '@' in the path (after first '/') should not trigger redaction.
429        assert_eq!(
430            redact_url("rtsp://host/path@weird"),
431            "rtsp://host/path@weird"
432        );
433    }
434
435    // -- Error sanitization --
436
437    #[test]
438    fn sanitize_strips_control_chars() {
439        let dirty = "error\x00\x07\ndetail\r\ntab\there";
440        let clean = sanitize_error_string(dirty);
441        assert!(!clean.contains('\x00'));
442        assert!(!clean.contains('\x07'));
443        assert!(!clean.contains('\n'));
444        assert!(!clean.contains('\r'));
445    }
446
447    #[test]
448    fn sanitize_truncates_long_strings() {
449        let long = "a".repeat(1000);
450        let clean = sanitize_error_string(&long);
451        assert!(clean.len() < 600); // 512 + "[truncated]"
452    }
453
454    #[test]
455    fn sanitize_redacts_password_pattern() {
456        let s = "connection failed password=hunter2 at host";
457        let clean = sanitize_error_string(s);
458        assert!(!clean.contains("hunter2"));
459        assert!(clean.contains("password=***"));
460    }
461
462    #[test]
463    fn sanitize_redacts_token_pattern() {
464        let s = "error token=abc123secret detail";
465        let clean = sanitize_error_string(s);
466        assert!(!clean.contains("abc123secret"));
467        assert!(clean.contains("token=***"));
468    }
469
470    #[test]
471    fn sanitize_preserves_useful_context() {
472        let s = "connection refused: host 192.168.1.1 port 554";
473        let clean = sanitize_error_string(s);
474        assert_eq!(clean, s);
475    }
476
477    // -- promote_rtsp_to_tls --
478
479    #[test]
480    fn promote_rtsp_upgrades_to_rtsps() {
481        assert_eq!(
482            promote_rtsp_to_tls("rtsp://cam/stream"),
483            "rtsps://cam/stream"
484        );
485    }
486
487    #[test]
488    fn promote_rtsp_keeps_rtsps() {
489        assert_eq!(
490            promote_rtsp_to_tls("rtsps://cam/stream"),
491            "rtsps://cam/stream"
492        );
493    }
494
495    #[test]
496    fn promote_rtsp_no_scheme() {
497        assert_eq!(promote_rtsp_to_tls("cam/stream"), "rtsps://cam/stream");
498    }
499
500    // -- is_insecure_rtsp --
501
502    #[test]
503    fn insecure_rtsp_detection() {
504        assert!(is_insecure_rtsp("rtsp://host/path"));
505        assert!(!is_insecure_rtsp("rtsps://host/path"));
506        assert!(!is_insecure_rtsp("http://host/path"));
507    }
508
509    // -- redact_urls_in_string --
510
511    #[test]
512    fn redact_urls_in_error_string() {
513        let s = "failed to connect to rtsp://admin:pass@cam/stream reason timeout";
514        let clean = redact_urls_in_string(s);
515        assert!(!clean.contains("admin:pass"));
516        assert!(clean.contains("rtsp://***@cam/stream"));
517    }
518
519    #[test]
520    fn redact_urls_no_urls() {
521        let s = "plain error message";
522        assert_eq!(redact_urls_in_string(s), s);
523    }
524
525    // -- redact_secret_patterns: multiple/repeated/mixed --
526
527    #[test]
528    fn redact_multiple_secrets_in_one_string() {
529        let s = "password=abc token=xyz secret=qqq";
530        let clean = sanitize_error_string(s);
531        assert!(!clean.contains("abc"));
532        assert!(!clean.contains("xyz"));
533        assert!(!clean.contains("qqq"));
534        assert!(clean.contains("password=***"));
535        assert!(clean.contains("token=***"));
536        assert!(clean.contains("secret=***"));
537    }
538
539    #[test]
540    fn redact_repeated_same_key() {
541        let s = "token=first&token=second&token=third";
542        let clean = sanitize_error_string(s);
543        assert!(!clean.contains("first"));
544        assert!(!clean.contains("second"));
545        assert!(!clean.contains("third"));
546        // All three occurrences redacted.
547        assert_eq!(clean.matches("token=***").count(), 3);
548    }
549
550    #[test]
551    fn redact_mixed_delimiters() {
552        let s = "password=a1 token=b2&secret=c3;auth=d4,key=e5'passwd=f6\"bearer g7";
553        let clean = sanitize_error_string(s);
554        for secret in &["a1", "b2", "c3", "d4", "e5", "f6", "g7"] {
555            assert!(!clean.contains(secret), "secret {secret} leaked");
556        }
557    }
558
559    #[test]
560    fn redact_no_panic_on_adversarial_strings() {
561        // Empty value
562        let _ = sanitize_error_string("password= next");
563        // Pattern at end of string with no value
564        let _ = sanitize_error_string("password=");
565        // Overlapping pattern-like text
566        let _ = sanitize_error_string("password=password=nested");
567        // Only delimiters after key
568        let _ = sanitize_error_string("token=&&&");
569        // Very long value
570        let long_val = format!("secret={}", "x".repeat(2000));
571        let clean = sanitize_error_string(&long_val);
572        assert!(!clean.contains(&"x".repeat(100)));
573        // Unicode content
574        let _ = sanitize_error_string("token=日本語テスト done");
575        // Repeated pattern with no value between
576        let _ = sanitize_error_string("key=key=key=");
577    }
578
579    #[test]
580    fn redact_case_insensitive() {
581        let s = "PASSWORD=upper Token=Mixed SECRET=LOUD";
582        let clean = sanitize_error_string(s);
583        assert!(!clean.contains("upper"));
584        assert!(!clean.contains("Mixed"));
585        assert!(!clean.contains("LOUD"));
586    }
587}