From 0ebc340f3765f8abf9db90da021a4faefc36e798 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 21 Feb 2026 10:13:05 -0500 Subject: [PATCH] fix: add input validation and deep link sanitization Validate UUID format on all route params to prevent arbitrary DB queries from malicious deep links. Truncate user input (titles, notes, names) to safe lengths, clamp priority to [0,3], validate recurrence values, and add schema validation on widget JSON data. Co-Authored-By: Claude Opus 4.6 --- app/list/[id].tsx | 3 ++- app/task/[id].tsx | 6 +++++- app/task/new.tsx | 32 ++++++++++++++++++++------------ src/db/repository/lists.ts | 7 +++++-- src/db/repository/tags.ts | 5 +++-- src/db/repository/tasks.ts | 30 +++++++++++++++++++++++------- src/lib/validation.ts | 13 +++++++++++++ src/widgets/widgetTaskHandler.ts | 21 ++++++++++++++++++--- 8 files changed, 89 insertions(+), 28 deletions(-) create mode 100644 src/lib/validation.ts diff --git a/app/list/[id].tsx b/app/list/[id].tsx index 6f13c70..e81c045 100644 --- a/app/list/[id].tsx +++ b/app/list/[id].tsx @@ -15,6 +15,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { isValidUUID } from '@/src/lib/validation'; import { useTaskStore } from '@/src/stores/useTaskStore'; import { getTasksByList, toggleComplete, deleteTask, reorderTasks } from '@/src/db/repository/tasks'; import { getAllLists } from '@/src/db/repository/lists'; @@ -64,7 +65,7 @@ export default function ListDetailScreen() { const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); const loadData = useCallback(async () => { - if (!id || isDraggingRef.current) return; + if (!isValidUUID(id) || isDraggingRef.current) return; const result = await getTasksByList(id, { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, }); diff --git a/app/task/[id].tsx b/app/task/[id].tsx index bc7a3f0..2a3360a 100644 --- a/app/task/[id].tsx +++ b/app/task/[id].tsx @@ -17,6 +17,7 @@ import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/dat import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { isValidUUID } from '@/src/lib/validation'; import { getPriorityOptions } from '@/src/lib/priority'; import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence'; import { @@ -65,7 +66,10 @@ export default function TaskDetailScreen() { const [selectedTagIds, setSelectedTagIds] = useState([]); useEffect(() => { - if (!id) return; + if (!isValidUUID(id)) { + router.back(); + return; + } loadTask(); loadSubtasks(); getAllTags().then(setAvailableTags); diff --git a/app/task/new.tsx b/app/task/new.tsx index 71da841..55b025d 100644 --- a/app/task/new.tsx +++ b/app/task/new.tsx @@ -21,6 +21,7 @@ import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/dat import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { isValidUUID } from '@/src/lib/validation'; import { createTask } from '@/src/db/repository/tasks'; import { getInboxId, getAllLists } from '@/src/db/repository/lists'; import { getAllTags, setTagsForTask } from '@/src/db/repository/tags'; @@ -47,7 +48,9 @@ export default function NewTaskScreen() { const [priority, setPriority] = useState(0); const [dueDate, setDueDate] = useState(null); const [showDatePicker, setShowDatePicker] = useState(false); - const [selectedListId, setSelectedListId] = useState(params.listId ?? getInboxId()); + const [selectedListId, setSelectedListId] = useState( + isValidUUID(params.listId) ? params.listId : getInboxId() + ); const [lists, setLists] = useState<{ id: string; name: string; color: string | null; icon: string | null; isInbox: boolean }[]>([]); const [recurrence, setRecurrence] = useState(null); const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]); @@ -60,18 +63,23 @@ export default function NewTaskScreen() { const handleSave = async () => { if (!title.trim()) return; - const taskId = await createTask({ - title: title.trim(), - notes: notes.trim() || undefined, - priority, - dueDate: dueDate ?? undefined, - listId: selectedListId, - recurrence: recurrence ?? undefined, - }); - if (selectedTagIds.length > 0) { - await setTagsForTask(taskId, selectedTagIds); + try { + const taskId = await createTask({ + title: title.trim(), + notes: notes.trim() || undefined, + priority, + dueDate: dueDate ?? undefined, + listId: selectedListId, + recurrence: recurrence ?? undefined, + }); + if (selectedTagIds.length > 0) { + await setTagsForTask(taskId, selectedTagIds); + } + router.back(); + } catch { + // FK constraint or other DB error — fallback to inbox + setSelectedListId(getInboxId()); } - router.back(); }; const handleDateChange = (_: DateTimePickerEvent, date?: Date) => { diff --git a/src/db/repository/lists.ts b/src/db/repository/lists.ts index 7250f1f..0f3041f 100644 --- a/src/db/repository/lists.ts +++ b/src/db/repository/lists.ts @@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm'; import { db } from '../client'; import { lists } from '../schema'; import { randomUUID } from '@/src/lib/uuid'; +import { truncate } from '@/src/lib/validation'; const INBOX_ID = '00000000-0000-0000-0000-000000000001'; @@ -37,7 +38,7 @@ export async function createList(name: string, color?: string, icon?: string) { await db.insert(lists).values({ id, - name, + name: truncate(name, 200), color: color ?? null, icon: icon ?? null, position: maxPosition + 1, @@ -49,9 +50,11 @@ export async function createList(name: string, color?: string, icon?: string) { } export async function updateList(id: string, data: { name?: string; color?: string; icon?: string | null }) { + const sanitized = { ...data }; + if (sanitized.name !== undefined) sanitized.name = truncate(sanitized.name, 200); await db .update(lists) - .set({ ...data, updatedAt: new Date() }) + .set({ ...sanitized, updatedAt: new Date() }) .where(eq(lists.id, id)); } diff --git a/src/db/repository/tags.ts b/src/db/repository/tags.ts index 348c941..71a015e 100644 --- a/src/db/repository/tags.ts +++ b/src/db/repository/tags.ts @@ -2,6 +2,7 @@ import { eq, and } from 'drizzle-orm'; import { db } from '../client'; import { tags, taskTags } from '../schema'; import { randomUUID } from '@/src/lib/uuid'; +import { truncate } from '@/src/lib/validation'; export async function getAllTags() { return db.select().from(tags).orderBy(tags.name); @@ -11,7 +12,7 @@ export async function createTag(name: string, color: string) { const id = randomUUID(); await db.insert(tags).values({ id, - name, + name: truncate(name, 100), color, createdAt: new Date(), }); @@ -19,7 +20,7 @@ export async function createTag(name: string, color: string) { } export async function updateTag(id: string, name: string, color: string) { - await db.update(tags).set({ name, color }).where(eq(tags.id, id)); + await db.update(tags).set({ name: truncate(name, 100), color }).where(eq(tags.id, id)); } export async function deleteTag(id: string) { diff --git a/src/db/repository/tasks.ts b/src/db/repository/tasks.ts index 37687ef..0d5d376 100644 --- a/src/db/repository/tasks.ts +++ b/src/db/repository/tasks.ts @@ -9,6 +9,8 @@ import { scheduleTaskReminder, cancelTaskReminder } from '@/src/services/notific import { addTaskToCalendar, updateCalendarEvent, removeCalendarEvent } from '@/src/services/calendar'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; import { syncWidgetData } from '@/src/services/widgetSync'; +import { clamp, truncate } from '@/src/lib/validation'; +import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence'; export interface TaskFilters { sortBy?: SortBy; @@ -145,17 +147,21 @@ export async function createTask(data: { } } + const sanitizedRecurrence = data.recurrence && RECURRENCE_OPTIONS.includes(data.recurrence as any) + ? data.recurrence + : null; + await db.insert(tasks).values({ id, - title: data.title, - notes: data.notes ?? null, - priority: data.priority ?? 0, + title: truncate(data.title, 500), + notes: data.notes ? truncate(data.notes, 10000) : null, + priority: clamp(data.priority ?? 0, 0, 3), dueDate: data.dueDate ?? null, listId: data.listId, parentId: data.parentId ?? null, completed: false, position: maxPosition + 1, - recurrence: data.recurrence ?? null, + recurrence: sanitizedRecurrence, calendarEventId, createdAt: now, updatedAt: now, @@ -187,10 +193,20 @@ export async function updateTask( recurrence?: string | null; } ) { - const updates: Record = { ...data, updatedAt: new Date() }; - if (data.completed === true) { + const sanitized = { ...data }; + if (sanitized.title !== undefined) sanitized.title = truncate(sanitized.title, 500); + if (sanitized.notes !== undefined) sanitized.notes = truncate(sanitized.notes, 10000); + if (sanitized.priority !== undefined) sanitized.priority = clamp(sanitized.priority, 0, 3); + if (sanitized.recurrence !== undefined && sanitized.recurrence !== null) { + sanitized.recurrence = RECURRENCE_OPTIONS.includes(sanitized.recurrence as any) + ? sanitized.recurrence + : null; + } + + const updates: Record = { ...sanitized, updatedAt: new Date() }; + if (sanitized.completed === true) { updates.completedAt = new Date(); - } else if (data.completed === false) { + } else if (sanitized.completed === false) { updates.completedAt = null; } await db.update(tasks).set(updates).where(eq(tasks.id, id)); diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..bccd278 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,13 @@ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isValidUUID(value: unknown): value is string { + return typeof value === 'string' && UUID_RE.test(value); +} + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? value.slice(0, maxLength) : value; +} diff --git a/src/widgets/widgetTaskHandler.ts b/src/widgets/widgetTaskHandler.ts index 5eaa7c3..2310cda 100644 --- a/src/widgets/widgetTaskHandler.ts +++ b/src/widgets/widgetTaskHandler.ts @@ -2,12 +2,27 @@ import type { WidgetTaskHandlerProps } from 'react-native-android-widget'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { TaskListWidget } from './TaskListWidget'; import { WIDGET_DATA_KEY, type WidgetTask } from '../services/widgetSync'; +import { isValidUUID } from '../lib/validation'; + +function isWidgetTask(item: unknown): item is WidgetTask { + if (typeof item !== 'object' || item === null) return false; + const obj = item as Record; + return ( + typeof obj.id === 'string' && + typeof obj.title === 'string' && + typeof obj.priority === 'number' && + typeof obj.completed === 'boolean' && + (obj.dueDate === null || typeof obj.dueDate === 'string') + ); +} async function getWidgetTasks(): Promise { try { const data = await AsyncStorage.getItem(WIDGET_DATA_KEY); if (!data) return []; - return JSON.parse(data) as WidgetTask[]; + const parsed: unknown = JSON.parse(data); + if (!Array.isArray(parsed)) return []; + return parsed.filter(isWidgetTask); } catch { return []; } @@ -38,8 +53,8 @@ export async function widgetTaskHandler( case 'WIDGET_CLICK': { if (props.clickAction === 'TOGGLE_COMPLETE') { - const taskId = props.clickActionData?.taskId as string; - if (!taskId) break; + const taskId = props.clickActionData?.taskId; + if (!isValidUUID(taskId)) break; // Update the cached data to remove the completed task immediately const tasks = await getWidgetTasks();