Skip to main content

reinhardt_utils/utils_core/
input_validation.rs

1//! Input validation and sanitization utilities
2//!
3//! Provides helpers for validating and sanitizing user input to prevent
4//! common security vulnerabilities such as open redirects, log injection,
5//! and identifier-based attacks.
6
7/// Errors returned by [`validate_identifier`].
8#[derive(Debug, thiserror::Error)]
9pub enum IdentifierError {
10	/// The identifier is an empty string.
11	#[error("Identifier is empty")]
12	Empty,
13	/// The identifier exceeds the allowed maximum length.
14	#[error("Identifier exceeds maximum length of {max_length} characters")]
15	TooLong {
16		/// The maximum allowed length.
17		max_length: usize,
18	},
19	/// The identifier contains a character that is not alphanumeric, hyphen, or underscore.
20	#[error("Identifier contains invalid character: '{ch}'")]
21	InvalidCharacter {
22		/// The invalid character found.
23		ch: char,
24	},
25	/// The identifier starts with a character that is not alphanumeric or underscore.
26	#[error("Identifier must start with alphanumeric or underscore, got: '{ch}'")]
27	InvalidStartCharacter {
28		/// The invalid starting character.
29		ch: char,
30	},
31}
32
33/// Validates a URL for safe redirect usage.
34///
35/// Allows:
36/// - Relative paths starting with `/` (absolute paths on same origin)
37/// - Same-origin relative paths starting with `./`
38/// - Anchor links starting with `#`
39/// - `http://` and `https://` URLs
40///
41/// Rejects:
42/// - Path traversal (`../`)
43/// - Dangerous protocols (`javascript:`, `data:`, `vbscript:`)
44/// - Unknown URL schemes
45/// - URLs with embedded credentials (`http://user:pass@host`)
46///
47/// # Examples
48///
49/// ```
50/// use reinhardt_utils::utils_core::input_validation::validate_redirect_url;
51///
52/// assert!(validate_redirect_url("/dashboard"));
53/// assert!(validate_redirect_url("https://example.com/page"));
54/// assert!(!validate_redirect_url("javascript:alert(1)"));
55/// assert!(!validate_redirect_url("../secret"));
56/// ```
57pub fn validate_redirect_url(url: &str) -> bool {
58	let trimmed = url.trim();
59
60	if trimmed.is_empty() {
61		return false;
62	}
63
64	// Reject path traversal
65	if trimmed.starts_with("../") || trimmed.contains("/../") || trimmed.ends_with("/..") {
66		return false;
67	}
68
69	// Allow anchor links
70	if trimmed.starts_with('#') {
71		return true;
72	}
73
74	// Allow same-origin relative paths
75	if trimmed.starts_with("./") {
76		return true;
77	}
78
79	// Allow absolute paths on same origin (must start with single /)
80	// Reject protocol-relative URLs (//) to prevent open redirect
81	if trimmed.starts_with('/') {
82		return !trimmed.starts_with("//");
83	}
84
85	let lower = trimmed.to_lowercase();
86
87	// Reject dangerous protocols
88	let dangerous_protocols = ["javascript:", "data:", "vbscript:"];
89	for proto in &dangerous_protocols {
90		if lower.starts_with(proto) {
91			return false;
92		}
93	}
94
95	// Allow only http:// and https://
96	if lower.starts_with("http://") || lower.starts_with("https://") {
97		// Reject URLs with embedded credentials (user:pass@host)
98		let after_scheme = if lower.starts_with("https://") {
99			&trimmed[8..]
100		} else {
101			&trimmed[7..]
102		};
103
104		// Check for @ before the first / (indicates credentials)
105		if let Some(path_start) = after_scheme.find('/') {
106			let authority = &after_scheme[..path_start];
107			if authority.contains('@') {
108				return false;
109			}
110		} else if after_scheme.contains('@') {
111			return false;
112		}
113
114		return true;
115	}
116
117	// Reject all other schemes / unknown formats
118	false
119}
120
121/// Sanitizes user input for safe inclusion in log messages.
122///
123/// Replaces control characters, newlines, and other characters
124/// that could be used for log injection attacks. Truncates
125/// the result to `max_length` characters.
126///
127/// # Examples
128///
129/// ```
130/// use reinhardt_utils::utils_core::input_validation::sanitize_log_input;
131///
132/// let input = "normal text\ninjected line";
133/// let sanitized = sanitize_log_input(input, 100);
134/// assert!(!sanitized.contains('\n'));
135/// ```
136pub fn sanitize_log_input(input: &str, max_length: usize) -> String {
137	let mut result = String::with_capacity(input.len().min(max_length));
138
139	for (char_count, ch) in input.chars().enumerate() {
140		if char_count >= max_length {
141			break;
142		}
143
144		match ch {
145			// Replace newlines and carriage returns with spaces
146			'\n' | '\r' => result.push(' '),
147			// Replace tabs with spaces
148			'\t' => result.push(' '),
149			// Replace other control characters with Unicode replacement character
150			c if c.is_control() => result.push('\u{FFFD}'),
151			// Keep printable characters as-is
152			c => result.push(c),
153		}
154	}
155
156	result
157}
158
159/// Validates that a string is a safe identifier.
160///
161/// Allows: ASCII alphanumeric, hyphens, underscores.
162/// First character must be alphanumeric or underscore.
163/// Max length is enforced.
164///
165/// # Errors
166///
167/// Returns [`IdentifierError`] if the identifier is empty, too long,
168/// starts with an invalid character, or contains invalid characters.
169///
170/// # Examples
171///
172/// ```
173/// use reinhardt_utils::utils_core::input_validation::validate_identifier;
174///
175/// assert!(validate_identifier("my-plugin", 64).is_ok());
176/// assert!(validate_identifier("_internal", 64).is_ok());
177/// assert!(validate_identifier("", 64).is_err());
178/// assert!(validate_identifier("-invalid", 64).is_err());
179/// ```
180pub fn validate_identifier(input: &str, max_length: usize) -> Result<(), IdentifierError> {
181	if input.is_empty() {
182		return Err(IdentifierError::Empty);
183	}
184
185	if input.len() > max_length {
186		return Err(IdentifierError::TooLong { max_length });
187	}
188
189	// First character must be alphanumeric or underscore
190	let first = input.chars().next().expect("non-empty string");
191	if !first.is_ascii_alphanumeric() && first != '_' {
192		return Err(IdentifierError::InvalidStartCharacter { ch: first });
193	}
194
195	// Remaining characters: alphanumeric, hyphens, underscores
196	for ch in input.chars() {
197		if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
198			return Err(IdentifierError::InvalidCharacter { ch });
199		}
200	}
201
202	Ok(())
203}
204
205#[cfg(test)]
206mod tests {
207	use super::*;
208	use rstest::rstest;
209
210	// ===================================================================
211	// validate_redirect_url tests
212	// ===================================================================
213
214	#[rstest]
215	#[case("/dashboard", true)]
216	#[case("/path/to/page", true)]
217	#[case("./relative", true)]
218	#[case("#section", true)]
219	#[case("#", true)]
220	#[case("https://example.com", true)]
221	#[case("http://example.com/page", true)]
222	#[case("https://example.com/path?q=1", true)]
223	fn test_validate_redirect_url_allows_safe_urls(#[case] url: &str, #[case] expected: bool) {
224		// Act
225		let result = validate_redirect_url(url);
226
227		// Assert
228		assert_eq!(result, expected, "URL {:?} should be allowed", url);
229	}
230
231	#[rstest]
232	#[case("javascript:alert(1)", false)]
233	#[case("JAVASCRIPT:alert(1)", false)]
234	#[case("data:text/html,<script>", false)]
235	#[case("vbscript:msgbox", false)]
236	#[case("../secret", false)]
237	#[case("/path/../secret", false)]
238	#[case("/path/..", false)]
239	#[case("//evil.com", false)]
240	#[case("", false)]
241	#[case("   ", false)]
242	#[case("ftp://files.example.com", false)]
243	#[case("http://user:pass@host.com", false)]
244	#[case("https://admin:secret@host.com/path", false)]
245	fn test_validate_redirect_url_rejects_unsafe_urls(#[case] url: &str, #[case] expected: bool) {
246		// Act
247		let result = validate_redirect_url(url);
248
249		// Assert
250		assert_eq!(result, expected, "URL {:?} should be rejected", url);
251	}
252
253	#[rstest]
254	fn test_validate_redirect_url_trims_whitespace() {
255		// Arrange
256		let url = "  /dashboard  ";
257
258		// Act
259		let result = validate_redirect_url(url);
260
261		// Assert
262		assert!(result);
263	}
264
265	// ===================================================================
266	// sanitize_log_input tests
267	// ===================================================================
268
269	#[rstest]
270	fn test_sanitize_log_input_replaces_newlines() {
271		// Arrange
272		let input = "line1\nline2\rline3\r\nline4";
273
274		// Act
275		let result = sanitize_log_input(input, 100);
276
277		// Assert
278		assert_eq!(result, "line1 line2 line3  line4");
279	}
280
281	#[rstest]
282	fn test_sanitize_log_input_replaces_tabs() {
283		// Arrange
284		let input = "col1\tcol2\tcol3";
285
286		// Act
287		let result = sanitize_log_input(input, 100);
288
289		// Assert
290		assert_eq!(result, "col1 col2 col3");
291	}
292
293	#[rstest]
294	fn test_sanitize_log_input_replaces_control_characters() {
295		// Arrange
296		let input = "before\x00\x01\x07after";
297
298		// Act
299		let result = sanitize_log_input(input, 100);
300
301		// Assert
302		assert_eq!(result, "before\u{FFFD}\u{FFFD}\u{FFFD}after");
303	}
304
305	#[rstest]
306	fn test_sanitize_log_input_truncates_to_max_length() {
307		// Arrange
308		let input = "a".repeat(200);
309
310		// Act
311		let result = sanitize_log_input(&input, 50);
312
313		// Assert
314		assert_eq!(result.len(), 50);
315	}
316
317	#[rstest]
318	fn test_sanitize_log_input_preserves_normal_text() {
319		// Arrange
320		let input = "Hello, World! 123 @#$";
321
322		// Act
323		let result = sanitize_log_input(input, 100);
324
325		// Assert
326		assert_eq!(result, input);
327	}
328
329	#[rstest]
330	fn test_sanitize_log_input_empty_input() {
331		// Act
332		let result = sanitize_log_input("", 100);
333
334		// Assert
335		assert_eq!(result, "");
336	}
337
338	#[rstest]
339	fn test_sanitize_log_input_zero_max_length() {
340		// Act
341		let result = sanitize_log_input("some text", 0);
342
343		// Assert
344		assert_eq!(result, "");
345	}
346
347	// ===================================================================
348	// validate_identifier tests
349	// ===================================================================
350
351	#[rstest]
352	#[case("my-plugin", 64)]
353	#[case("MyPlugin", 64)]
354	#[case("plugin_v2", 64)]
355	#[case("_internal", 64)]
356	#[case("a", 64)]
357	#[case("A123-test_name", 64)]
358	fn test_validate_identifier_accepts_valid(#[case] input: &str, #[case] max_len: usize) {
359		// Act
360		let result = validate_identifier(input, max_len);
361
362		// Assert
363		assert!(result.is_ok(), "Identifier {:?} should be valid", input);
364	}
365
366	#[rstest]
367	fn test_validate_identifier_rejects_empty() {
368		// Act
369		let result = validate_identifier("", 64);
370
371		// Assert
372		assert!(matches!(result, Err(IdentifierError::Empty)));
373	}
374
375	#[rstest]
376	fn test_validate_identifier_rejects_too_long() {
377		// Arrange
378		let input = "a".repeat(65);
379
380		// Act
381		let result = validate_identifier(&input, 64);
382
383		// Assert
384		assert!(matches!(
385			result,
386			Err(IdentifierError::TooLong { max_length: 64 })
387		));
388	}
389
390	#[rstest]
391	#[case("-starts-with-hyphen")]
392	fn test_validate_identifier_rejects_invalid_start(#[case] input: &str) {
393		// Act
394		let result = validate_identifier(input, 64);
395
396		// Assert
397		assert!(matches!(
398			result,
399			Err(IdentifierError::InvalidStartCharacter { .. })
400		));
401	}
402
403	#[rstest]
404	#[case("has space", ' ')]
405	#[case("has.dot", '.')]
406	#[case("has/slash", '/')]
407	#[case("has@at", '@')]
408	fn test_validate_identifier_rejects_invalid_characters(
409		#[case] input: &str,
410		#[case] expected_ch: char,
411	) {
412		// Act
413		let result = validate_identifier(input, 64);
414
415		// Assert
416		match result {
417			Err(IdentifierError::InvalidCharacter { ch }) => {
418				assert_eq!(ch, expected_ch);
419			}
420			other => panic!("Expected InvalidCharacter, got {:?}", other),
421		}
422	}
423
424	// ===================================================================
425	// IdentifierError Display tests
426	// ===================================================================
427
428	#[rstest]
429	fn test_sanitize_log_input_multibyte_truncation_does_not_panic() {
430		// Fixes #762: Use character count instead of byte length for truncation
431		// to prevent cutting in the middle of multi-byte UTF-8 characters.
432		let input = "あいうえおかきくけこ"; // 10 chars, 30 bytes
433
434		// Act
435		let result = sanitize_log_input(input, 5);
436
437		// Assert
438		assert_eq!(result.chars().count(), 5);
439		assert_eq!(result, "あいうえお");
440	}
441
442	#[rstest]
443	fn test_sanitize_log_input_mixed_multibyte_truncation() {
444		// Fixes #762: Mixed ASCII and multibyte characters
445		let input = "aあbいcうdえeお";
446
447		// Act
448		let result = sanitize_log_input(input, 6);
449
450		// Assert
451		assert_eq!(result.chars().count(), 6);
452		assert_eq!(result, "aあbいcう");
453	}
454
455	#[rstest]
456	fn test_identifier_error_display_messages() {
457		// Assert
458		assert_eq!(IdentifierError::Empty.to_string(), "Identifier is empty");
459		assert_eq!(
460			IdentifierError::TooLong { max_length: 32 }.to_string(),
461			"Identifier exceeds maximum length of 32 characters"
462		);
463		assert_eq!(
464			IdentifierError::InvalidCharacter { ch: '@' }.to_string(),
465			"Identifier contains invalid character: '@'"
466		);
467		assert_eq!(
468			IdentifierError::InvalidStartCharacter { ch: '-' }.to_string(),
469			"Identifier must start with alphanumeric or underscore, got: '-'"
470		);
471	}
472}