Skip to main content

fraiseql_server/config/
error_sanitization.rs

1//! Error sanitization configuration and service.
2//!
3//! When `enabled = true`, strips internal error details (SQL fragments, stack
4//! traces, raw DB error messages) from GraphQL responses before they reach
5//! the client.
6
7use serde::Deserialize;
8
9use crate::error::{ErrorCode, GraphQLError};
10
11/// Configuration for error sanitization (mirrors `ErrorSanitizationConfig` from
12/// `fraiseql-cli`, deserialized from `compiled.security.error_sanitization`).
13#[derive(Debug, Clone, Deserialize)]
14#[serde(default)]
15pub struct ErrorSanitizationConfig {
16    /// Enable error sanitization (default: false — opt-in for backwards compat).
17    pub enabled:                     bool,
18    /// Strip stack traces, SQL fragments, file paths (default: true).
19    pub hide_implementation_details: bool,
20    /// Replace raw database error messages with a generic message (default: true).
21    pub sanitize_database_errors:    bool,
22    /// Replacement message shown to clients when an internal error is sanitized.
23    pub custom_error_message:        Option<String>,
24}
25
26impl Default for ErrorSanitizationConfig {
27    fn default() -> Self {
28        Self {
29            enabled:                     false,
30            hide_implementation_details: true,
31            sanitize_database_errors:    true,
32            custom_error_message:        None,
33        }
34    }
35}
36
37/// Sanitizes GraphQL errors before they reach the client.
38///
39/// When configured with `enabled = true`, strips internal details from
40/// `DatabaseError` and `InternalServerError` responses. Client-facing error
41/// codes (validation, auth, not-found, etc.) are always passed through
42/// unchanged so the client can act on them.
43pub struct ErrorSanitizer {
44    config: ErrorSanitizationConfig,
45}
46
47impl ErrorSanitizer {
48    /// Create a new sanitizer with the given configuration.
49    #[must_use]
50    pub const fn new(config: ErrorSanitizationConfig) -> Self {
51        Self { config }
52    }
53
54    /// Create a disabled sanitizer — current behaviour unchanged.
55    #[must_use]
56    pub fn disabled() -> Self {
57        Self::new(ErrorSanitizationConfig::default())
58    }
59
60    /// Sanitize a single GraphQL error.
61    ///
62    /// Returns the error unchanged when:
63    /// - sanitization is disabled, or
64    /// - the error code is client-facing (validation, auth, not-found, etc.)
65    #[must_use]
66    pub fn sanitize(&self, mut error: GraphQLError) -> GraphQLError {
67        if !self.config.enabled {
68            return error;
69        }
70
71        let is_internal =
72            matches!(error.code, ErrorCode::InternalServerError | ErrorCode::DatabaseError);
73
74        if is_internal && self.config.sanitize_database_errors {
75            error.message = self
76                .config
77                .custom_error_message
78                .clone()
79                .unwrap_or_else(|| "An internal error occurred".to_string());
80        }
81
82        if self.config.hide_implementation_details {
83            if let Some(ext) = error.extensions.as_mut() {
84                ext.detail = None;
85            }
86        }
87
88        error
89    }
90
91    /// Sanitize a batch of errors (the GraphQL `errors` response array).
92    #[must_use]
93    pub fn sanitize_all(&self, errors: Vec<GraphQLError>) -> Vec<GraphQLError> {
94        errors.into_iter().map(|e| self.sanitize(e)).collect()
95    }
96
97    /// Whether sanitization is enabled.
98    #[must_use]
99    pub const fn is_enabled(&self) -> bool {
100        self.config.enabled
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::error::ErrorExtensions;
108
109    fn enabled_sanitizer() -> ErrorSanitizer {
110        ErrorSanitizer::new(ErrorSanitizationConfig {
111            enabled:                     true,
112            hide_implementation_details: true,
113            sanitize_database_errors:    true,
114            custom_error_message:        None,
115        })
116    }
117
118    fn disabled_sanitizer() -> ErrorSanitizer {
119        ErrorSanitizer::new(ErrorSanitizationConfig {
120            enabled: false,
121            ..ErrorSanitizationConfig::default()
122        })
123    }
124
125    #[test]
126    fn test_sanitizer_strips_db_error_when_enabled() {
127        let s = enabled_sanitizer();
128        let err = GraphQLError::database(r#"ERROR: relation "tb_users" does not exist"#);
129        let out = s.sanitize(err);
130        assert_eq!(out.message, "An internal error occurred");
131    }
132
133    #[test]
134    fn test_sanitizer_passes_through_when_disabled() {
135        let s = disabled_sanitizer();
136        let original = r#"ERROR: relation "tb_users" does not exist"#;
137        let err = GraphQLError::database(original);
138        let out = s.sanitize(err);
139        assert_eq!(out.message, original);
140    }
141
142    #[test]
143    fn test_sanitizer_preserves_user_facing_errors() {
144        let s = enabled_sanitizer();
145        let cases = [
146            (ErrorCode::ValidationError, "field is required"),
147            (ErrorCode::Unauthenticated, "Authentication required"),
148            (ErrorCode::Forbidden, "Access denied"),
149            (ErrorCode::NotFound, "Resource not found"),
150        ];
151        for (code, msg) in cases {
152            let err = GraphQLError::new(msg, code);
153            let out = s.sanitize(err);
154            assert_eq!(out.message, msg, "code {code:?} should not be sanitized");
155        }
156    }
157
158    #[test]
159    fn test_sanitizer_custom_message() {
160        let s = ErrorSanitizer::new(ErrorSanitizationConfig {
161            enabled: true,
162            custom_error_message: Some("Contact support".to_string()),
163            ..ErrorSanitizationConfig::default()
164        });
165        let err = GraphQLError::database("pg error detail");
166        assert_eq!(s.sanitize(err).message, "Contact support");
167    }
168
169    #[test]
170    fn test_sanitizer_strips_extensions_detail_when_hide_impl() {
171        let s = enabled_sanitizer();
172        let mut err = GraphQLError::internal("internal");
173        err.extensions = Some(ErrorExtensions {
174            category:         None,
175            status:           None,
176            request_id:       None,
177            retry_after_secs: None,
178            detail:           Some("panic at line 42".to_string()),
179        });
180        let out = s.sanitize(err);
181        assert!(
182            out.extensions.as_ref().and_then(|e| e.detail.as_ref()).is_none(),
183            "detail should be stripped when hide_implementation_details = true"
184        );
185    }
186
187    #[test]
188    fn test_sanitize_database_errors_false_allows_db_message_through() {
189        let s = ErrorSanitizer::new(ErrorSanitizationConfig {
190            enabled: true,
191            sanitize_database_errors: false,
192            ..ErrorSanitizationConfig::default()
193        });
194        let err = GraphQLError::database("duplicate key value");
195        assert_eq!(s.sanitize(err).message, "duplicate key value");
196    }
197}