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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-21 10:13:05 -05:00
parent 9c6d2dfef9
commit 0ebc340f37
8 changed files with 89 additions and 28 deletions

View file

@ -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,
});

View file

@ -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<string[]>([]);
useEffect(() => {
if (!id) return;
if (!isValidUUID(id)) {
router.back();
return;
}
loadTask();
loadSubtasks();
getAllTags().then(setAvailableTags);

View file

@ -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<Date | null>(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<string | null>(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) => {

View file

@ -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));
}

View file

@ -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) {

View file

@ -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<string, unknown> = { ...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<string, unknown> = { ...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));

13
src/lib/validation.ts Normal file
View file

@ -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;
}

View file

@ -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<string, unknown>;
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<WidgetTask[]> {
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();