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}