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}