Skip to main content

perl_dap/security/
mod.rs

1//! Security validation module for DAP Phase 3 (AC16)
2//!
3//! This crate provides enterprise-grade security features:
4//! - Path traversal prevention
5//! - Input validation for expressions and conditions
6//! - Resource limits enforcement
7//! - Secure defaults
8//!
9//! # Safety Guarantees
10//!
11//! - All file paths are validated against workspace boundaries
12//! - Expressions cannot contain newlines (protocol injection prevention)
13//! - Timeouts are capped at reasonable limits
14//! - Dangerous operations are blocked in safe evaluation mode
15
16use perl_parser_core::path_security::{WorkspacePathError, validate_workspace_path};
17use std::path::{Path, PathBuf};
18
19/// Security validation errors
20#[derive(Debug, PartialEq, thiserror::Error)]
21pub enum SecurityError {
22    /// Path traversal attempt detected
23    #[error("Path traversal attempt detected: {0}")]
24    PathTraversalAttempt(String),
25
26    /// Path outside workspace boundary
27    #[error("Path outside workspace: {0}")]
28    PathOutsideWorkspace(String),
29
30    /// Symlink resolves outside workspace
31    #[error("Symlink resolves outside workspace: {0}")]
32    SymlinkOutsideWorkspace(String),
33
34    /// Invalid path characters (null bytes, control characters)
35    #[error("Invalid path characters detected")]
36    InvalidPathCharacters,
37
38    /// Expression contains newlines (protocol injection risk)
39    #[error("Expression cannot contain newlines")]
40    InvalidExpression,
41
42    /// Timeout exceeds maximum allowed value
43    #[error("Timeout exceeds maximum allowed value: {0}ms")]
44    ExcessiveTimeout(u32),
45}
46
47/// Maximum allowed timeout in milliseconds (5 minutes)
48pub const MAX_TIMEOUT_MS: u32 = 300_000;
49
50/// Default timeout in milliseconds (5 seconds)
51pub const DEFAULT_TIMEOUT_MS: u32 = 5_000;
52
53impl From<WorkspacePathError> for SecurityError {
54    fn from(error: WorkspacePathError) -> Self {
55        match error {
56            WorkspacePathError::PathTraversalAttempt(message) => {
57                Self::PathTraversalAttempt(message)
58            }
59            WorkspacePathError::PathOutsideWorkspace(message) => {
60                Self::PathOutsideWorkspace(message)
61            }
62            WorkspacePathError::SymlinkOutsideWorkspace(message) => {
63                Self::SymlinkOutsideWorkspace(message)
64            }
65            WorkspacePathError::InvalidPathCharacters => Self::InvalidPathCharacters,
66        }
67    }
68}
69
70/// Validate that a path is within the workspace boundary
71pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, SecurityError> {
72    validate_workspace_path(path, workspace_root).map_err(SecurityError::from)
73}
74
75/// Validate an expression for safe evaluation
76pub fn validate_expression(expression: &str) -> Result<(), SecurityError> {
77    if expression.contains('\n') || expression.contains('\r') {
78        return Err(SecurityError::InvalidExpression);
79    }
80
81    Ok(())
82}
83
84/// Validate a timeout value, returning an error if it exceeds the maximum allowed.
85pub fn validate_timeout(timeout_ms: u32) -> Result<u32, SecurityError> {
86    if timeout_ms > MAX_TIMEOUT_MS {
87        return Err(SecurityError::ExcessiveTimeout(timeout_ms));
88    }
89    Ok(timeout_ms.max(1))
90}
91
92/// Validate a breakpoint condition for security issues
93pub fn validate_condition(condition: &str) -> Result<(), SecurityError> {
94    validate_expression(condition)
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use anyhow::Result;
101    use std::fs;
102
103    #[test]
104    fn test_validate_path_within_workspace() -> Result<()> {
105        let tempdir = tempfile::tempdir()?;
106        let workspace = tempdir.path();
107
108        let safe_path = PathBuf::from("src/main.pl");
109        let result = validate_path(&safe_path, workspace);
110
111        assert!(result.is_ok(), "Path within workspace should be valid");
112        Ok(())
113    }
114
115    #[test]
116    fn test_validate_path_parent_traversal() -> Result<()> {
117        use perl_tdd_support::must;
118        let tempdir = must(tempfile::tempdir());
119        let workspace = tempdir.path();
120
121        let unsafe_path = PathBuf::from("../../../etc/passwd");
122        let result = validate_path(&unsafe_path, workspace);
123
124        assert!(result.is_err(), "Parent traversal should be rejected");
125
126        match result {
127            Err(SecurityError::PathTraversalAttempt(_))
128            | Err(SecurityError::PathOutsideWorkspace(_)) => {}
129            Err(e) => {
130                return Err(anyhow::anyhow!(
131                    "Expected PathTraversalAttempt or PathOutsideWorkspace error, got: {:?}",
132                    e
133                ));
134            }
135            Ok(_) => return Err(anyhow::anyhow!("Expected error, got Ok")),
136        }
137        Ok(())
138    }
139
140    #[test]
141    fn test_validate_path_absolute_outside() -> Result<()> {
142        use perl_tdd_support::{must, must_some};
143        let tempdir = must(tempfile::tempdir());
144        let workspace = tempdir.path();
145
146        let tempdir2 = must(tempfile::tempdir());
147        let outside_file = tempdir2.path().join("outside.pl");
148        must(fs::write(&outside_file, "print 'outside';"));
149
150        let result = validate_path(&outside_file, workspace);
151
152        assert!(result.is_err(), "Absolute path outside workspace should be rejected");
153
154        match result {
155            Err(SecurityError::PathOutsideWorkspace(_))
156            | Err(SecurityError::PathTraversalAttempt(_)) => {}
157            Err(e) => {
158                return Err(anyhow::anyhow!(
159                    "Expected PathOutsideWorkspace or PathTraversalAttempt error, got: {:?}",
160                    e
161                ));
162            }
163            Ok(_) => return Err(anyhow::anyhow!("Expected error, got Ok")),
164        }
165
166        let _ = must_some(outside_file.to_str());
167        Ok(())
168    }
169
170    #[test]
171    fn test_validate_path_with_null_byte() -> Result<()> {
172        let tempdir = tempfile::tempdir()?;
173        let workspace = tempdir.path();
174
175        let invalid_path = PathBuf::from("file\0name.pl");
176        let result = validate_path(&invalid_path, workspace);
177
178        assert!(result.is_err(), "Path with null byte should be rejected");
179        assert!(
180            matches!(result, Err(SecurityError::InvalidPathCharacters)),
181            "Expected InvalidPathCharacters error"
182        );
183        Ok(())
184    }
185
186    #[test]
187    fn test_validate_path_with_control_character() -> Result<()> {
188        let tempdir = tempfile::tempdir()?;
189        let workspace = tempdir.path();
190
191        let invalid_path = PathBuf::from("file\x1fname.pl");
192        let result = validate_path(&invalid_path, workspace);
193
194        assert!(result.is_err(), "Path with control character should be rejected");
195        assert!(
196            matches!(result, Err(SecurityError::InvalidPathCharacters)),
197            "Expected InvalidPathCharacters error"
198        );
199        Ok(())
200    }
201
202    #[test]
203    fn test_validate_path_relative_dotdot_within_workspace() -> Result<()> {
204        let tempdir = tempfile::tempdir()?;
205        let workspace = tempdir.path();
206
207        fs::create_dir_all(workspace.join("subdir"))?;
208        let safe_path = PathBuf::from("subdir/../main.pl");
209        let result = validate_path(&safe_path, workspace);
210
211        assert!(result.is_ok(), "Normalized path within workspace should be valid");
212        Ok(())
213    }
214
215    #[test]
216    fn test_validate_expression_valid() -> Result<()> {
217        validate_expression("$x + 1")?;
218        validate_expression("my_function()")?;
219        validate_expression("$hash{key}")?;
220        Ok(())
221    }
222
223    #[test]
224    fn test_validate_expression_newline() -> Result<()> {
225        let result = validate_expression("1\nprint 'hacked'");
226
227        assert!(result.is_err(), "Expression with newline should be rejected");
228        assert!(
229            matches!(result, Err(SecurityError::InvalidExpression)),
230            "Expected InvalidExpression error"
231        );
232        Ok(())
233    }
234
235    #[test]
236    fn test_validate_expression_carriage_return() {
237        let result = validate_expression("1\rprint 'hacked'");
238        assert!(result.is_err(), "Expression with carriage return should be rejected");
239        assert!(matches!(result, Err(SecurityError::InvalidExpression)));
240    }
241
242    #[test]
243    fn test_validate_timeout_within_bounds() -> Result<()> {
244        assert_eq!(validate_timeout(1000)?, 1000);
245        assert_eq!(validate_timeout(5000)?, 5000);
246        assert_eq!(validate_timeout(100_000)?, 100_000);
247        Ok(())
248    }
249
250    #[test]
251    fn test_validate_timeout_zero() -> Result<()> {
252        assert_eq!(validate_timeout(0)?, 1, "Zero timeout should be clamped to 1ms");
253        Ok(())
254    }
255
256    #[test]
257    fn test_validate_timeout_excessive() {
258        use perl_tdd_support::must_err;
259        let result = validate_timeout(500_000);
260        assert!(result.is_err(), "Excessive timeout should be an error");
261        assert_eq!(must_err(result), SecurityError::ExcessiveTimeout(500_000));
262        assert!(validate_timeout(1_000_000).is_err());
263    }
264
265    #[test]
266    fn test_validate_timeout_boundary_at_max_is_ok() -> Result<()> {
267        assert!(validate_timeout(MAX_TIMEOUT_MS).is_ok());
268        assert_eq!(validate_timeout(MAX_TIMEOUT_MS)?, MAX_TIMEOUT_MS);
269        Ok(())
270    }
271
272    #[test]
273    fn test_validate_timeout_one_over_max_is_error() {
274        use perl_tdd_support::must_err;
275        assert!(validate_timeout(MAX_TIMEOUT_MS + 1).is_err());
276        assert_eq!(
277            must_err(validate_timeout(MAX_TIMEOUT_MS + 1)),
278            SecurityError::ExcessiveTimeout(MAX_TIMEOUT_MS + 1)
279        );
280    }
281
282    #[test]
283    fn test_validate_condition_valid() -> Result<()> {
284        validate_condition("$x > 10")?;
285        validate_condition("defined($var)")?;
286        Ok(())
287    }
288
289    #[test]
290    fn test_validate_condition_newline() -> Result<()> {
291        let result = validate_condition("1\nprint 'pwned'");
292
293        assert!(result.is_err(), "Condition with newline should be rejected");
294        assert!(
295            matches!(result, Err(SecurityError::InvalidExpression)),
296            "Expected InvalidExpression error"
297        );
298        Ok(())
299    }
300
301    #[test]
302    fn test_validate_path_empty_string() -> Result<()> {
303        let tempdir = tempfile::tempdir()?;
304        let workspace = tempdir.path();
305
306        let empty_path = PathBuf::from("");
307        let result = validate_path(&empty_path, workspace);
308
309        assert!(result.is_ok(), "Empty path should resolve to workspace root");
310        Ok(())
311    }
312
313    #[test]
314    fn test_validate_expression_empty_string() -> Result<()> {
315        validate_expression("")?;
316        Ok(())
317    }
318
319    #[test]
320    fn test_validate_timeout_boundary_values() -> Result<()> {
321        assert_eq!(validate_timeout(1)?, 1);
322        assert_eq!(validate_timeout(MAX_TIMEOUT_MS)?, MAX_TIMEOUT_MS);
323        assert!(validate_timeout(MAX_TIMEOUT_MS + 1).is_err());
324        Ok(())
325    }
326
327    #[test]
328    fn test_security_error_display_messages() {
329        let path_error = SecurityError::PathTraversalAttempt("../../../etc/passwd".to_string());
330        assert!(format!("{}", path_error).contains("Path traversal attempt detected"));
331
332        let expr_error = SecurityError::InvalidExpression;
333        assert_eq!(format!("{}", expr_error), "Expression cannot contain newlines");
334
335        let timeout_error = SecurityError::ExcessiveTimeout(500_000);
336        assert!(format!("{}", timeout_error).contains("500000ms"));
337    }
338}