Skip to main content

max / goingson

Harden imports, exports, and plugin runtime - Add 50 MB file size limit on VCF/ICS import reads - Add 500 MB decompression limit on backup restore (prevent zip bombs) - Add 10 MB file size limit in plugin read_file API - Add RAII ContextGuard for plugin context cleanup on error paths - Clamp negative plugin progress values to 0 before usize cast - Sanitize CSV export fields against spreadsheet formula injection - Validate backup delete targets have .json.gz extension - Validate email archive folder names against control characters - Add UUID suffix to temp email preview filenames - Log VCF import sub-collection errors with tracing::warn - Add chrono-tz for proper IANA timezone resolution in iCal import - Fix vCard quoted-printable decoder for multi-byte UTF-8 - Fix truncate_text to respect char boundaries (prevent panic) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 19:39 UTC
Commit: 47cb825e40590bef6e713c0403f8bcc4f3f90b73
Parent: 9748845
14 files changed, +156 insertions, -37 deletions
M Cargo.lock +29
@@ -633,6 +633,16 @@ dependencies = [
633 633 ]
634 634
635 635 [[package]]
636 + name = "chrono-tz"
637 + version = "0.10.4"
638 + source = "registry+https://github.com/rust-lang/crates.io-index"
639 + checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
640 + dependencies = [
641 + "chrono",
642 + "phf 0.12.1",
643 + ]
644 +
645 + [[package]]
636 646 name = "cipher"
637 647 version = "0.4.4"
638 648 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1897,6 +1907,7 @@ dependencies = [
1897 1907 "async-trait",
1898 1908 "base64 0.22.1",
1899 1909 "chrono",
1910 + "chrono-tz",
1900 1911 "csv",
1901 1912 "dirs",
1902 1913 "docengine",
@@ -3680,6 +3691,15 @@ dependencies = [
3680 3691
3681 3692 [[package]]
3682 3693 name = "phf"
3694 + version = "0.12.1"
3695 + source = "registry+https://github.com/rust-lang/crates.io-index"
3696 + checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
3697 + dependencies = [
3698 + "phf_shared 0.12.1",
3699 + ]
3700 +
3701 + [[package]]
3702 + name = "phf"
3683 3703 version = "0.13.1"
3684 3704 source = "registry+https://github.com/rust-lang/crates.io-index"
3685 3705 checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
@@ -3828,6 +3848,15 @@ dependencies = [
3828 3848
3829 3849 [[package]]
3830 3850 name = "phf_shared"
3851 + version = "0.12.1"
3852 + source = "registry+https://github.com/rust-lang/crates.io-index"
3853 + checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
3854 + dependencies = [
3855 + "siphasher 1.0.2",
3856 + ]
3857 +
3858 + [[package]]
3859 + name = "phf_shared"
3831 3860 version = "0.13.1"
3832 3861 source = "registry+https://github.com/rust-lang/crates.io-index"
3833 3862 checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
M Cargo.toml +1
@@ -16,6 +16,7 @@ license-file = "LICENSE"
16 16 [workspace.dependencies]
17 17 # Core dependencies
18 18 chrono = { version = "0.4.43", features = ["serde"] }
19 + chrono-tz = "0.10"
19 20 uuid = { version = "1.16", features = ["v4", "v5", "serde"] }
20 21 serde = { version = "1.0.228", features = ["derive"] }
21 22 serde_json = "1.0.149"
@@ -146,6 +146,17 @@ mod goingson_api {
146 146 return Err("No import file set for this execution".into());
147 147 }
148 148
149 + // Check file size before reading to prevent OOM on large files
150 + let metadata = std::fs::metadata(path)
151 + .map_err(|e| -> Box<EvalAltResult> { format!("Failed to stat file '{}': {}", path, e).into() })?;
152 + let max_size = 10 * 1024 * 1024; // 10 MB
153 + if metadata.len() > max_size {
154 + return Err(format!(
155 + "File '{}' is {} bytes, exceeding {} byte limit",
156 + path, metadata.len(), max_size
157 + ).into());
158 + }
159 +
149 160 std::fs::read_to_string(path)
150 161 .map_err(|e| format!("Failed to read file '{}': {}", path, e).into())
151 162 })
@@ -253,7 +264,7 @@ mod goingson_api {
253 264 #[tracing::instrument(skip_all)]
254 265 pub fn report_progress(current: i64, total: i64, message: &str) {
255 266 CONTEXT.with(|c| {
256 - c.borrow_mut().progress = Some((current as usize, total as usize, message.to_string()));
267 + c.borrow_mut().progress = Some((current.max(0) as usize, total.max(0) as usize, message.to_string()));
257 268 });
258 269 }
259 270
@@ -127,6 +127,15 @@ impl PluginRegistry {
127 127 };
128 128 PluginApiContext::set(ctx);
129 129
130 + // Guard ensures context is cleared even if call_fn_2 errors
131 + struct ContextGuard;
132 + impl Drop for ContextGuard {
133 + fn drop(&mut self) {
134 + PluginApiContext::clear();
135 + }
136 + }
137 + let _guard = ContextGuard;
138 +
130 139 // Convert options to Rhai map
131 140 let options_map = options_to_rhai_map(&options);
132 141
@@ -151,9 +160,6 @@ impl PluginRegistry {
151 160 }
152 161 }
153 162
154 - // Clean up context
155 - PluginApiContext::clear();
156 -
157 163 // Convert result
158 164 dynamic_to_import_result(result, plugin_id)
159 165 }
@@ -37,6 +37,7 @@ serde_json = { workspace = true }
37 37
38 38 # Utilities
39 39 chrono = { workspace = true }
40 + chrono-tz = { workspace = true }
40 41 uuid = { workspace = true }
41 42
42 43 # Email
@@ -335,7 +335,7 @@ pub async fn open_email_in_browser(state: State<'_, Arc<AppState>>, id: EmailId)
335 335 };
336 336
337 337 let temp_dir = std::env::temp_dir();
338 - let file_name = format!("goingson_email_{}.html", id);
338 + let file_name = format!("goingson_email_{}_{}.html", id, uuid::Uuid::new_v4().simple());
339 339 let file_path = temp_dir.join(file_name);
340 340
341 341 tokio::fs::write(&file_path, html_content).await
@@ -243,6 +243,12 @@ pub async fn create_email_account(state: State<'_, Arc<AppState>>, input: EmailA
243 243 return Err(ApiError::validation("smtpServer", "SMTP server is required"));
244 244 }
245 245
246 + if let Some(ref folder) = input.archive_folder_name {
247 + if folder.contains('\r') || folder.contains('\n') || folder.chars().any(|c| c.is_control()) {
248 + return Err(ApiError::validation("archiveFolderName", "Folder name contains invalid characters"));
249 + }
250 + }
251 +
246 252 let account = state.email_accounts
247 253 .create(
248 254 DESKTOP_USER_ID,
@@ -273,6 +279,12 @@ pub async fn update_email_account(state: State<'_, Arc<AppState>>, id: EmailAcco
273 279 return Err(ApiError::validation("emailAddress", "Invalid email address"));
274 280 }
275 281
282 + if let Some(ref folder) = input.archive_folder_name {
283 + if folder.contains('\r') || folder.contains('\n') || folder.chars().any(|c| c.is_control()) {
284 + return Err(ApiError::validation("archiveFolderName", "Folder name contains invalid characters"));
285 + }
286 + }
287 +
276 288 state.email_accounts
277 289 .update(
278 290 id,
@@ -432,6 +432,15 @@ pub async fn delete_backup(
432 432 ));
433 433 }
434 434
435 + // Verify it's actually a backup file
436 + if !canonical_path.extension().is_some_and(|ext| ext == "gz")
437 + || !canonical_path.to_string_lossy().ends_with(".json.gz")
438 + {
439 + return Err(ApiError::bad_request(
440 + "Can only delete .json.gz backup files",
441 + ));
442 + }
443 +
435 444 std::fs::remove_file(&canonical_path)
436 445 .map_api_err("Failed to delete backup", ApiError::internal)?;
437 446
@@ -13,6 +13,23 @@ use goingson_core::{
13 13 NewEvent, NewSocialHandle, Recurrence,
14 14 };
15 15
16 + /// Maximum import file size (50 MB).
17 + const MAX_IMPORT_FILE_SIZE: u64 = 50 * 1024 * 1024;
18 +
19 + fn read_import_file(path: &str) -> Result<String, ApiError> {
20 + let metadata = std::fs::metadata(path)
21 + .map_err(|e| ApiError::internal(format!("Failed to stat file: {}", e)))?;
22 + if metadata.len() > MAX_IMPORT_FILE_SIZE {
23 + return Err(ApiError::validation_msg(format!(
24 + "File is too large ({} bytes, max {} bytes)",
25 + metadata.len(),
26 + MAX_IMPORT_FILE_SIZE
27 + )));
28 + }
29 + std::fs::read_to_string(path)
30 + .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))
31 + }
32 +
16 33 use crate::external_sync::{ical, vcard};
17 34 use crate::state::{AppState, DESKTOP_USER_ID};
18 35 use super::ApiError;
@@ -55,8 +72,7 @@ pub struct IcsPreview {
55 72 #[tauri::command]
56 73 #[instrument(skip_all)]
57 74 pub async fn preview_vcf(file_path: String) -> Result<Vec<VCardPreview>, ApiError> {
58 - let content = std::fs::read_to_string(&file_path)
59 - .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?;
75 + let content = read_import_file(&file_path)?;
60 76
61 77 let cards = vcard::parse_vcf(&content)
62 78 .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {}", e)))?;
@@ -76,8 +92,7 @@ pub async fn preview_vcf(file_path: String) -> Result<Vec<VCardPreview>, ApiErro
76 92 #[tauri::command]
77 93 #[instrument(skip_all)]
78 94 pub async fn preview_ics(file_path: String) -> Result<Vec<IcsPreview>, ApiError> {
79 - let content = std::fs::read_to_string(&file_path)
80 - .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?;
95 + let content = read_import_file(&file_path)?;
81 96
82 97 let events = ical::parse_ics(&content)
83 98 .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {}", e)))?;
@@ -108,8 +123,7 @@ pub async fn import_vcf(
108 123 state: State<'_, Arc<AppState>>,
109 124 file_path: String,
110 125 ) -> Result<ImportResult, ApiError> {
111 - let content = std::fs::read_to_string(&file_path)
112 - .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?;
126 + let content = read_import_file(&file_path)?;
113 127
114 128 let cards = vcard::parse_vcf(&content)
115 129 .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {}", e)))?;
@@ -155,18 +169,21 @@ pub async fn import_vcf(
155 169 match state.contacts.create(DESKTOP_USER_ID, new_contact).await {
156 170 Ok(contact) => {
157 171 // Set external source/id for dedup on re-import
158 - let _ = sqlx::query(
172 + if let Err(e) = sqlx::query(
159 173 "UPDATE contacts SET external_source = ?, external_id = ? WHERE id = ?",
160 174 )
161 175 .bind("vcf")
162 176 .bind(&ext_id)
163 177 .bind(contact.id.to_string())
164 178 .execute(&state.pool)
165 - .await;
179 + .await
180 + {
181 + tracing::warn!(contact = %card.display_name, "Failed to set external source: {}", e);
182 + }
166 183
167 - // Add sub-collections
184 + // Add sub-collections, collecting any errors
168 185 for email in card.emails {
169 - let _ = state
186 + if let Err(e) = state
170 187 .contacts
171 188 .add_email(
172 189 contact.id,
@@ -177,10 +194,13 @@ pub async fn import_vcf(
177 194 is_primary: email.is_primary,
178 195 },
179 196 )
180 - .await;
197 + .await
198 + {
199 + tracing::warn!(contact = %card.display_name, "Failed to add email: {}", e);
200 + }
181 201 }
182 202 for phone in card.phones {
183 - let _ = state
203 + if let Err(e) = state
184 204 .contacts
185 205 .add_phone(
186 206 contact.id,
@@ -191,10 +211,13 @@ pub async fn import_vcf(
191 211 is_primary: phone.is_primary,
192 212 },
193 213 )
194 - .await;
214 + .await
215 + {
216 + tracing::warn!(contact = %card.display_name, "Failed to add phone: {}", e);
217 + }
195 218 }
196 219 for social in card.social_handles {
197 - let _ = state
220 + if let Err(e) = state
198 221 .contacts
199 222 .add_social_handle(
200 223 contact.id,
@@ -205,10 +228,13 @@ pub async fn import_vcf(
205 228 url: social.url,
206 229 },
207 230 )
208 - .await;
231 + .await
232 + {
233 + tracing::warn!(contact = %card.display_name, "Failed to add social handle: {}", e);
234 + }
209 235 }
210 236 for field in card.custom_fields {
211 - let _ = state
237 + if let Err(e) = state
212 238 .contacts
213 239 .add_custom_field(
214 240 contact.id,
@@ -219,7 +245,10 @@ pub async fn import_vcf(
219 245 url: field.url,
220 246 },
221 247 )
222 - .await;
248 + .await
249 + {
250 + tracing::warn!(contact = %card.display_name, "Failed to add custom field: {}", e);
251 + }
223 252 }
224 253
225 254 imported += 1;
@@ -244,8 +273,7 @@ pub async fn import_ics(
244 273 state: State<'_, Arc<AppState>>,
245 274 file_path: String,
246 275 ) -> Result<ImportResult, ApiError> {
247 - let content = std::fs::read_to_string(&file_path)
248 - .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?;
276 + let content = read_import_file(&file_path)?;
249 277
250 278 let parsed_events = ical::parse_ics(&content)
251 279 .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {}", e)))?;
@@ -98,10 +98,12 @@ pub fn write_backup<P: AsRef<Path>>(export: &FullExport, path: P) -> Result<u64,
98 98 /// The parsed export data.
99 99 pub fn read_backup<P: AsRef<Path>>(path: P) -> Result<FullExport, BackupError> {
100 100 let file = File::open(path.as_ref())?;
101 - let mut decoder = GzDecoder::new(file);
101 + let decoder = GzDecoder::new(file);
102 + // Limit decompressed size to 500 MB to prevent decompression bombs
103 + let mut limited = decoder.take(500 * 1024 * 1024);
102 104
103 105 let mut json = String::new();
104 - decoder.read_to_string(&mut json)?;
106 + limited.read_to_string(&mut json)?;
105 107
106 108 let export: FullExport = serde_json::from_str(&json)?;
107 109
@@ -76,7 +76,7 @@ pub fn write_tasks_csv<W: Write>(
76 76 csv_writer.write_record([
77 77 task.id.to_string(),
78 78 project_name.to_string(),
79 - task.description.clone(),
79 + sanitize_csv_field(&task.description),
80 80 task.status.as_str().to_string(),
81 81 task.priority.as_str().to_string(),
82 82 due,
@@ -92,6 +92,15 @@ pub fn write_tasks_csv<W: Write>(
92 92 Ok(tasks.len())
93 93 }
94 94
95 + /// Prefix cell values that spreadsheets would interpret as formulas.
96 + fn sanitize_csv_field(value: &str) -> String {
97 + if value.starts_with('=') || value.starts_with('+') || value.starts_with('-') || value.starts_with('@') {
98 + format!("'{}", value)
99 + } else {
100 + value.to_string()
101 + }
102 + }
103 +
95 104 #[cfg(test)]
96 105 mod tests {
97 106 use super::*;
@@ -4,6 +4,7 @@
4 4 //! to GO's Event model.
5 5
6 6 use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
7 + use chrono_tz::Tz;
7 8 use goingson_core::Recurrence;
8 9 use ical::parser::ical::component::IcalEvent;
9 10 use ical::property::Property;
@@ -120,12 +121,15 @@ fn parse_datetime_property(properties: &[Property], name: &str) -> Option<DateTi
120 121 });
121 122
122 123 // Try parsing with timezone
123 - if let Some(_tz_name) = tzid {
124 - // Parse local datetime then convert (simplified: treat as UTC offset)
125 - // Full IANA timezone handling would need chrono-tz, but for import
126 - // we use a best-effort approach
124 + if let Some(tz_name) = tzid {
127 125 if let Some(ndt) = parse_ical_datetime(value) {
128 - // For now, if we can't resolve the timezone, treat as UTC
126 + // Resolve IANA timezone and convert local time to UTC
127 + if let Ok(tz) = tz_name.parse::<Tz>() {
128 + if let Some(local_dt) = tz.from_local_datetime(&ndt).earliest() {
129 + return Some(local_dt.with_timezone(&Utc));
130 + }
131 + }
132 + // Fall back to treating as UTC if timezone can't be resolved
129 133 return Some(Utc.from_utc_datetime(&ndt));
130 134 }
131 135 }
@@ -334,7 +334,7 @@ fn decode_value(value: &str, params: &[String]) -> String {
334 334
335 335 /// Decode quoted-printable encoded text.
336 336 fn decode_quoted_printable(input: &str) -> String {
337 - let mut result = String::new();
337 + let mut decoded_bytes = Vec::new();
338 338 let bytes = input.as_bytes();
339 339 let mut i = 0;
340 340 while i < bytes.len() {
@@ -343,15 +343,15 @@ fn decode_quoted_printable(input: &str) -> String {
343 343 hex_val(bytes[i + 1]),
344 344 hex_val(bytes[i + 2]),
345 345 ) {
346 - result.push((h << 4 | l) as char);
346 + decoded_bytes.push(h << 4 | l);
347 347 i += 3;
348 348 continue;
349 349 }
350 350 }
351 - result.push(bytes[i] as char);
351 + decoded_bytes.push(bytes[i]);
352 352 i += 1;
353 353 }
354 - result
354 + String::from_utf8_lossy(&decoded_bytes).into_owned()
355 355 }
356 356
357 357 fn hex_val(b: u8) -> Option<u8> {
@@ -230,7 +230,14 @@ fn truncate_text(text: &str, max_len: usize) -> String {
230 230 if text.len() <= max_len {
231 231 text.to_string()
232 232 } else {
233 - format!("{}...", &text[..max_len.saturating_sub(3)])
233 + let truncate_to = max_len.saturating_sub(3);
234 + let end = text
235 + .char_indices()
236 + .map(|(i, _)| i)
237 + .take_while(|&i| i <= truncate_to)
238 + .last()
239 + .unwrap_or(0);
240 + format!("{}...", &text[..end])
234 241 }
235 242 }
236 243