fraiseql_server/config/
error_sanitization.rs1use serde::Deserialize;
8
9use crate::error::{ErrorCode, GraphQLError};
10
11#[derive(Debug, Clone, Deserialize)]
14#[serde(default)]
15pub struct ErrorSanitizationConfig {
16 pub enabled: bool,
18 pub hide_implementation_details: bool,
20 pub sanitize_database_errors: bool,
22 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
37pub struct ErrorSanitizer {
44 config: ErrorSanitizationConfig,
45}
46
47impl ErrorSanitizer {
48 #[must_use]
50 pub const fn new(config: ErrorSanitizationConfig) -> Self {
51 Self { config }
52 }
53
54 #[must_use]
56 pub fn disabled() -> Self {
57 Self::new(ErrorSanitizationConfig::default())
58 }
59
60 #[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 #[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 #[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}