Skip to main content

max / goingson

Fix SQL/DB issues: LIKE injection, week boundary, snooze TOCTOU - Add escape_like() helper to escape %, _, \ in LIKE patterns - Apply ESCAPE clause to tag search in contact_repo and search_repo - Fix "this week" filter to use weekday 1 (next Monday) with < operator - Use saturating_add for search limit+offset to prevent overflow - Make task snooze atomic: move status check into UPDATE WHERE clause - Fix saved view sort_by parsing to use serde_json::Value::String - Add tracing::warn on malformed saved view filter deserialization 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:38 UTC
Commit: d10b4b782109bd5009ca38e87557fa7456504bb5
Parent: d404fcf
5 files changed, +40 insertions, -29 deletions
@@ -13,7 +13,7 @@ use goingson_core::{
13 13 SocialHandle, SocialHandleId, UpdateContact, UserId,
14 14 };
15 15
16 - use crate::utils::{format_datetime_now, parse_datetime, parse_tags, parse_uuid};
16 + use crate::utils::{escape_like, format_datetime_now, parse_datetime, parse_tags, parse_uuid};
17 17
18 18 // ============ Row Structs ============
19 19
@@ -436,12 +436,12 @@ impl ContactRepository for SqliteContactRepository {
436 436 #[tracing::instrument(skip_all)]
437 437 async fn list_by_tag(&self, user_id: UserId, tag: &str) -> Result<Vec<Contact>> {
438 438 // Tags stored as JSON array, use LIKE for matching
439 - let pattern = format!("%\"{}\"%" , tag);
439 + let pattern = format!("%\"{}\"%" , escape_like(tag));
440 440 let rows = sqlx::query_as::<_, ContactRow>(
441 441 r#"
442 442 SELECT id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, created_at, updated_at
443 443 FROM contacts
444 - WHERE user_id = ? AND tags LIKE ?
444 + WHERE user_id = ? AND tags LIKE ? ESCAPE '\'
445 445 ORDER BY display_name ASC
446 446 "#,
447 447 )
@@ -467,8 +467,8 @@ impl ContactRepository for SqliteContactRepository {
467 467 let mut binds: Vec<String> = vec![user_id.to_string()];
468 468
469 469 if let Some(t) = tag.filter(|t| !t.is_empty()) {
470 - conditions.push("c.tags LIKE ?".to_string());
471 - binds.push(format!("%\"{}\"%" , t));
470 + conditions.push("c.tags LIKE ? ESCAPE '\\'".to_string());
471 + binds.push(format!("%\"{}\"%" , escape_like(t)));
472 472 }
473 473
474 474 if let Some(s) = search.filter(|s| !s.is_empty()) {
@@ -282,7 +282,10 @@ fn row_to_saved_view(row: ViewRow) -> Result<SavedView> {
282 282 let view_type = ViewType::from_str_or_default(&row.view_type);
283 283
284 284 let filters: ViewFilters = serde_json::from_str(&row.filters)
285 - .unwrap_or_default();
285 + .unwrap_or_else(|e| {
286 + tracing::warn!(view_id = %row.id, error = %e, "Malformed saved view filters, using defaults");
287 + ViewFilters::default()
288 + });
286 289
287 290 Ok(SavedView {
288 291 id: parse_uuid(&row.id)?.into(),
@@ -290,7 +293,10 @@ fn row_to_saved_view(row: ViewRow) -> Result<SavedView> {
290 293 name: row.name,
291 294 view_type,
292 295 filters,
293 - sort_by: row.sort_by.as_deref().and_then(|s| serde_json::from_str(&format!("\"{}\"", s)).ok()),
296 + sort_by: row.sort_by.as_deref().and_then(|s| {
297 + let json = serde_json::Value::String(s.to_string());
298 + serde_json::from_value(json).ok()
299 + }),
294 300 sort_order: SortDirection::from_str_or_default(&row.sort_order),
295 301 is_pinned: row.is_pinned == 1,
296 302 position: row.position,
@@ -13,7 +13,7 @@ use goingson_core::{
13 13 SearchRepository, SearchResultItem, SearchResultType, UserId,
14 14 };
15 15
16 - use crate::utils::parse_uuid;
16 + use crate::utils::{escape_like, parse_uuid};
17 17
18 18 /// SQLite-backed implementation of [`SearchRepository`].
19 19 ///
@@ -54,7 +54,7 @@ impl SearchRepository for SqliteSearchRepository {
54 54 let user_id_str = user_id.to_string();
55 55 let limit = query.limit.unwrap_or(50);
56 56 let offset = query.offset.unwrap_or(0);
57 - let per_type_cap = offset + limit;
57 + let per_type_cap = offset.saturating_add(limit);
58 58
59 59 // Prepare search term for FTS5 (escape special characters and add prefix matching)
60 60 let search_term = if has_text {
@@ -214,8 +214,9 @@ fn build_is_filter_clauses(is_filters: &[IsFilter]) -> Vec<String> {
214 214 IsFilter::Today => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', 'localtime'))".to_string(),
215 215 IsFilter::Tomorrow => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', '+1 day', 'localtime'))".to_string(),
216 216 IsFilter::ThisWeek => {
217 - // Due between now and end of week (Sunday)
218 - "(t.due IS NOT NULL AND datetime(t.due) <= datetime('now', 'weekday 0', '+7 days', 'start of day'))".to_string()
217 + // Due on or before end of this week (Sunday).
218 + // weekday 1 = next Monday; < Monday midnight = through Sunday 23:59:59.
219 + "(t.due IS NOT NULL AND datetime(t.due) < datetime('now', 'weekday 1'))".to_string()
219 220 }
220 221 IsFilter::Snoozed => "(t.snoozed_until IS NOT NULL AND datetime(t.snoozed_until) > datetime('now'))".to_string(),
221 222 IsFilter::Pending => "t.status = 'Pending'".to_string(),
@@ -326,15 +327,15 @@ async fn search_tasks_fts(
326 327
327 328 // Tag filters (include) — tags stored as JSON array in tasks.tags column
328 329 for tag in &query.tags_include {
329 - sql.push_str(&format!(" AND t.tags LIKE ${}", param_idx));
330 - params.push(format!("%\"{}\"%" , tag));
330 + sql.push_str(&format!(" AND t.tags LIKE ${} ESCAPE '\\'", param_idx));
331 + params.push(format!("%\"{}\"%" , escape_like(tag)));
331 332 param_idx += 1;
332 333 }
333 334
334 335 // Tag filters (exclude)
335 336 for tag in &query.tags_exclude {
336 - sql.push_str(&format!(" AND t.tags NOT LIKE ${}", param_idx));
337 - params.push(format!("%\"{}\"%" , tag));
337 + sql.push_str(&format!(" AND t.tags NOT LIKE ${} ESCAPE '\\'", param_idx));
338 + params.push(format!("%\"{}\"%" , escape_like(tag)));
338 339 param_idx += 1;
339 340 }
340 341
@@ -674,7 +675,7 @@ async fn search_events_fts(
674 675 );
675 676 }
676 677 IsFilter::ThisWeek => {
677 - sql.push_str(" AND datetime(ev.start_time) <= datetime('now', 'weekday 0', '+7 days', 'start of day')");
678 + sql.push_str(" AND datetime(ev.start_time) < datetime('now', 'weekday 1')");
678 679 }
679 680 IsFilter::Overdue => {
680 681 sql.push_str(" AND datetime(ev.start_time) < datetime('now')");
@@ -22,22 +22,11 @@ pub(crate) async fn snooze(
22 22 user_id: UserId,
23 23 until: DateTime<Utc>,
24 24 ) -> Result<Option<Task>> {
25 - // Check task status before snoozing
26 - if let Some(task) = get_task_by_id(pool, id, user_id).await? {
27 - if task.status == TaskStatus::Completed {
28 - return Err(CoreError::validation("status", "cannot snooze a completed task"));
29 - }
30 - if task.status == TaskStatus::Deleted {
31 - return Err(CoreError::validation("status", "cannot snooze a deleted task"));
32 - }
33 - } else {
34 - return Ok(None);
35 - }
36 -
37 25 let until_str = format_datetime(&until);
38 26
27 + // Atomically update only if task is not completed/deleted
39 28 let result = sqlx::query(
40 - "UPDATE tasks SET snoozed_until = ? WHERE id = ? AND user_id = ?"
29 + "UPDATE tasks SET snoozed_until = ? WHERE id = ? AND user_id = ? AND status NOT IN ('Completed', 'Deleted')"
41 30 )
42 31 .bind(&until_str)
43 32 .bind(id.to_string())
@@ -49,6 +38,15 @@ pub(crate) async fn snooze(
49 38 if result.rows_affected() > 0 {
50 39 get_task_by_id(pool, id, user_id).await
51 40 } else {
41 + // Distinguish "not found" from "wrong status"
42 + if let Some(task) = get_task_by_id(pool, id, user_id).await? {
43 + if task.status == TaskStatus::Completed {
44 + return Err(CoreError::validation("status", "cannot snooze a completed task"));
45 + }
46 + if task.status == TaskStatus::Deleted {
47 + return Err(CoreError::validation("status", "cannot snooze a deleted task"));
48 + }
49 + }
52 50 Ok(None)
53 51 }
54 52 }
@@ -66,6 +66,12 @@ pub fn parse_uuid_opt(s: Option<&str>) -> Result<Option<Uuid>, CoreError> {
66 66 s.map(parse_uuid).transpose()
67 67 }
68 68
69 + /// Escape LIKE wildcards in a value to prevent unintended pattern matching.
70 + #[inline]
71 + pub fn escape_like(value: &str) -> String {
72 + value.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
73 + }
74 +
69 75 /// Validate email address format (RFC 5321/5322 compliant).
70 76 ///
71 77 /// Validates the basic structure of an email address: