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:
parent
9c6d2dfef9
commit
0ebc340f37
8 changed files with 89 additions and 28 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
13
src/lib/validation.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue