Skip to main content

fraiseql_core/security/
error_formatter.rs

1//! Error Formatter
2//!
3//! This module provides error sanitization and formatting for different environments.
4//! It controls what error details are exposed to clients based on deployment context.
5//!
6//! # Architecture
7//!
8//! The Error Formatter acts as the fifth and final layer in the security middleware:
9//! ```text
10//! GraphQL Error
11//!     ↓
12//! ErrorFormatter::format_error()
13//!     ├─ Check 1: Determine detail level based on environment
14//!     ├─ Check 2: Sanitize error message
15//!     ├─ Check 3: Remove sensitive information
16//!     └─ Check 4: Return formatted error
17//!     ↓
18//! Safe Error Message (suitable for client)
19//! ```
20//!
21//! # Examples
22//!
23//! ```no_run
24//! use fraiseql_core::security::{ErrorFormatter, DetailLevel};
25//!
26//! // Create formatter for production (minimal details)
27//! let formatter = ErrorFormatter::new(DetailLevel::Production);
28//!
29//! // Format an error
30//! let error_msg = "Database error: connection refused to postgresql://user:pass@db.local";
31//! let formatted = formatter.format_error(error_msg);
32//! println!("{}", formatted); // Shows only: "Internal server error"
33//! ```
34
35use std::fmt;
36
37use serde::{Deserialize, Serialize};
38
39use crate::security::errors::SecurityError;
40
41/// Detail level for error responses
42///
43/// Controls how much information is exposed to clients.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[non_exhaustive]
46pub enum DetailLevel {
47    /// Development: Full error details, stack traces, database info
48    Development,
49
50    /// Staging: Limited error details, no sensitive information
51    Staging,
52
53    /// Production: Minimal error details, generic messages
54    Production,
55}
56
57impl fmt::Display for DetailLevel {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Self::Development => write!(f, "Development"),
61            Self::Staging => write!(f, "Staging"),
62            Self::Production => write!(f, "Production"),
63        }
64    }
65}
66
67/// Sanitization configuration
68///
69/// Configures which sensitive patterns to hide in error messages.
70#[derive(Debug, Clone)]
71#[allow(clippy::struct_excessive_bools)] // Reason: each bool controls an independent sanitization rule; bitflags would reduce readability
72pub struct SanitizationConfig {
73    /// Hide database connection strings
74    pub hide_database_urls: bool,
75
76    /// Hide SQL statements
77    pub hide_sql: bool,
78
79    /// Hide file system paths
80    pub hide_paths: bool,
81
82    /// Hide IP addresses
83    pub hide_ips: bool,
84
85    /// Hide email addresses
86    pub hide_emails: bool,
87
88    /// Hide API keys and credentials
89    pub hide_credentials: bool,
90}
91
92impl SanitizationConfig {
93    /// Create a permissive configuration (minimal sanitization)
94    ///
95    /// Used in development environments.
96    #[must_use]
97    pub const fn permissive() -> Self {
98        Self {
99            hide_database_urls: false,
100            hide_sql:           false,
101            hide_paths:         false,
102            hide_ips:           false,
103            hide_emails:        false,
104            hide_credentials:   false,
105        }
106    }
107
108    /// Create a standard configuration (moderate sanitization)
109    ///
110    /// Used in staging environments.
111    #[must_use]
112    pub const fn standard() -> Self {
113        Self {
114            hide_database_urls: true,
115            hide_sql:           true,
116            hide_paths:         false,
117            hide_ips:           true,
118            hide_emails:        true,
119            hide_credentials:   true,
120        }
121    }
122
123    /// Create a strict configuration (aggressive sanitization)
124    ///
125    /// Used in production environments.
126    #[must_use]
127    pub const fn strict() -> Self {
128        Self {
129            hide_database_urls: true,
130            hide_sql:           true,
131            hide_paths:         true,
132            hide_ips:           true,
133            hide_emails:        true,
134            hide_credentials:   true,
135        }
136    }
137}
138
139/// Error Formatter
140///
141/// Sanitizes and formats errors based on environment detail level.
142/// Acts as the fifth layer in the security middleware pipeline.
143#[derive(Debug, Clone)]
144pub struct ErrorFormatter {
145    detail_level: DetailLevel,
146    config:       SanitizationConfig,
147}
148
149impl ErrorFormatter {
150    /// Create a new error formatter with a specific detail level
151    #[must_use]
152    pub const fn new(detail_level: DetailLevel) -> Self {
153        let config = Self::config_for_level(detail_level);
154        Self {
155            detail_level,
156            config,
157        }
158    }
159
160    /// Create formatter with custom sanitization configuration
161    #[must_use]
162    pub const fn with_config(detail_level: DetailLevel, config: SanitizationConfig) -> Self {
163        Self {
164            detail_level,
165            config,
166        }
167    }
168
169    /// Create formatter for development (full details)
170    #[must_use]
171    pub const fn development() -> Self {
172        Self::new(DetailLevel::Development)
173    }
174
175    /// Create formatter for staging (moderate details)
176    #[must_use]
177    pub const fn staging() -> Self {
178        Self::new(DetailLevel::Staging)
179    }
180
181    /// Create formatter for production (minimal details)
182    #[must_use]
183    pub const fn production() -> Self {
184        Self::new(DetailLevel::Production)
185    }
186
187    /// Get the sanitization configuration for a detail level
188    const fn config_for_level(level: DetailLevel) -> SanitizationConfig {
189        match level {
190            DetailLevel::Development => SanitizationConfig::permissive(),
191            DetailLevel::Staging => SanitizationConfig::standard(),
192            DetailLevel::Production => SanitizationConfig::strict(),
193        }
194    }
195
196    /// Format an error message for client consumption
197    ///
198    /// Performs 4-step sanitization:
199    /// 1. Determine detail level
200    /// 2. Sanitize message content
201    /// 3. Remove sensitive information
202    /// 4. Return formatted error
203    #[must_use]
204    pub fn format_error(&self, error_msg: &str) -> String {
205        match self.detail_level {
206            DetailLevel::Development => {
207                // Development: return full error
208                error_msg.to_string()
209            },
210            DetailLevel::Staging => {
211                // Staging: sanitize but keep error type
212                self.sanitize_error(error_msg)
213            },
214            DetailLevel::Production => {
215                // Production: return generic error
216                if Self::is_security_related(error_msg) {
217                    "Security validation failed".to_string()
218                } else {
219                    "An error occurred while processing your request".to_string()
220                }
221            },
222        }
223    }
224
225    /// Format a `SecurityError` for client consumption
226    #[must_use]
227    pub fn format_security_error(&self, error: &SecurityError) -> String {
228        let error_msg = error.to_string();
229
230        match self.detail_level {
231            DetailLevel::Development => {
232                // Development: full error message
233                error_msg
234            },
235            DetailLevel::Staging => {
236                // Staging: keep the error type but sanitize details
237                self.extract_error_type_and_sanitize(&error_msg)
238            },
239            DetailLevel::Production => {
240                // Production: generic message with error category
241                match error {
242                    SecurityError::AuthRequired => "Authentication required".to_string(),
243                    SecurityError::InvalidToken
244                    | SecurityError::TokenExpired { .. }
245                    | SecurityError::TokenMissingClaim { .. }
246                    | SecurityError::InvalidTokenAlgorithm { .. } => {
247                        "Invalid authentication".to_string()
248                    },
249                    SecurityError::TlsRequired { .. }
250                    | SecurityError::TlsVersionTooOld { .. }
251                    | SecurityError::MtlsRequired { .. }
252                    | SecurityError::InvalidClientCert { .. } => {
253                        "Connection security validation failed".to_string()
254                    },
255                    SecurityError::QueryTooDeep { .. }
256                    | SecurityError::QueryTooComplex { .. }
257                    | SecurityError::QueryTooLarge { .. } => "Query validation failed".to_string(),
258                    SecurityError::IntrospectionDisabled { .. } => {
259                        "Schema introspection is not available".to_string()
260                    },
261                    _ => "An error occurred while processing your request".to_string(),
262                }
263            },
264        }
265    }
266
267    /// Sanitize an error message by removing sensitive information
268    fn sanitize_error(&self, error_msg: &str) -> String {
269        let mut result = error_msg.to_string();
270
271        // Sanitize database URLs (postgresql://user:pass@host)
272        if self.config.hide_database_urls {
273            result = Self::hide_pattern(&result, "postgresql://", "**hidden**");
274            result = Self::hide_pattern(&result, "mysql://", "**hidden**");
275            result = Self::hide_pattern(&result, "mongodb://", "**hidden**");
276        }
277
278        // Sanitize SQL statements
279        if self.config.hide_sql {
280            result = Self::hide_pattern(&result, "SELECT ", "[SQL hidden]");
281            result = Self::hide_pattern(&result, "INSERT ", "[SQL hidden]");
282            result = Self::hide_pattern(&result, "UPDATE ", "[SQL hidden]");
283            result = Self::hide_pattern(&result, "DELETE ", "[SQL hidden]");
284        }
285
286        // Sanitize file paths
287        if self.config.hide_paths {
288            result = Self::redact_paths(&result);
289        }
290
291        // Sanitize IP addresses
292        if self.config.hide_ips {
293            result = Self::redact_ips(&result);
294        }
295
296        // Sanitize email addresses
297        if self.config.hide_emails {
298            result = Self::redact_emails(&result);
299        }
300
301        // Sanitize credentials
302        if self.config.hide_credentials {
303            result = Self::hide_pattern(&result, "@", "[credentials redacted]");
304        }
305
306        result
307    }
308
309    /// Check if an error is security-related
310    fn is_security_related(error_msg: &str) -> bool {
311        let lower = error_msg.to_lowercase();
312        lower.contains("auth")
313            || lower.contains("permission")
314            || lower.contains("forbidden")
315            || lower.contains("security")
316            || lower.contains("tls")
317            || lower.contains("https")
318    }
319
320    /// Extract error type and sanitize details
321    fn extract_error_type_and_sanitize(&self, error_msg: &str) -> String {
322        let sanitized = self.sanitize_error(error_msg);
323
324        // Keep the first 100 characters if error is short, or first meaningful part
325        if sanitized.len() > 100 {
326            format!("{}...", &sanitized[..100])
327        } else {
328            sanitized
329        }
330    }
331
332    /// Hide a pattern in a string by replacing it
333    fn hide_pattern(text: &str, pattern: &str, replacement: &str) -> String {
334        if text.contains(pattern) {
335            text.replace(pattern, replacement)
336        } else {
337            text.to_string()
338        }
339    }
340
341    /// Redact file paths from error messages
342    fn redact_paths(text: &str) -> String {
343        // Simple pattern: /path/to/file or C:\path\to\file
344        let mut result = text.to_string();
345
346        // Match paths with / (Unix-style)
347        if result.contains('/') && result.contains(".rs") {
348            result = result.replace('/', "*");
349        }
350
351        // Match paths with \ (Windows-style)
352        if result.contains('\\') {
353            result = result.replace('\\', "*");
354        }
355
356        result
357    }
358
359    /// Redact IP addresses from error messages
360    fn redact_ips(text: &str) -> String {
361        // Simple pattern detection for IPv4 addresses (x.x.x.x)
362        let mut result = String::new();
363        let mut current_word = String::new();
364
365        for c in text.chars() {
366            if c.is_numeric() || c == '.' {
367                current_word.push(c);
368            } else {
369                // Check if accumulated word looks like an IP
370                if Self::looks_like_ip(&current_word) {
371                    result.push_str("[IP]");
372                } else {
373                    result.push_str(&current_word);
374                }
375                current_word.clear();
376                result.push(c);
377            }
378        }
379
380        // Handle last word
381        if Self::looks_like_ip(&current_word) {
382            result.push_str("[IP]");
383        } else {
384            result.push_str(&current_word);
385        }
386
387        result
388    }
389
390    /// Redact email addresses from error messages
391    fn redact_emails(text: &str) -> String {
392        // Simple pattern: anything@domain.com
393        let mut result = String::new();
394        let mut in_email = false;
395        let mut email = String::new();
396
397        for c in text.chars() {
398            if c == '@' {
399                in_email = true;
400                email.clear();
401                email.push(c);
402            } else if in_email {
403                email.push(c);
404                if c == ' ' || c == '\n' {
405                    result.push_str("[email]");
406                    result.push(c);
407                    in_email = false;
408                    email.clear();
409                }
410            } else {
411                result.push(c);
412            }
413        }
414
415        // Handle email at end of string
416        if in_email && email.contains('@') {
417            result.push_str("[email]");
418        } else {
419            result.push_str(&email);
420        }
421
422        result
423    }
424
425    /// Check if a string looks like an `IPv4` address
426    fn looks_like_ip(s: &str) -> bool {
427        if !s.contains('.') {
428            return false;
429        }
430
431        let parts: Vec<&str> = s.split('.').collect();
432        if parts.len() != 4 {
433            return false;
434        }
435
436        parts.iter().all(|p| {
437            !p.is_empty()
438                && p.chars().all(|c| c.is_ascii_digit())
439                && p.parse::<u32>().unwrap_or(256) <= 255
440        })
441    }
442
443    /// Get the current detail level
444    #[must_use]
445    pub const fn detail_level(&self) -> DetailLevel {
446        self.detail_level
447    }
448
449    /// Get the sanitization configuration
450    #[must_use]
451    pub const fn config(&self) -> &SanitizationConfig {
452        &self.config
453    }
454}