From dd01a8ec69b4af2cb686a3a30d54d9bcf82a1a07 Mon Sep 17 00:00:00 2001 From: Wonhee Han <47270724+neruhan01@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:58:47 +0900 Subject: [PATCH] Security+UX: Add DOMPurify XSS protection, security headers, admin auth gate, import file size limit, due date color coding, swipe/scroll conflict prevention --- next.config.ts | 19 ++++++++++- package-lock.json | 25 ++++++++++++++ package.json | 2 ++ src/app/admin/page.tsx | 26 ++++++++++++++- src/app/api/import/route.ts | 14 ++++++-- src/components/tasks/MarkdownNoteEditor.tsx | 11 +++++-- src/components/tasks/TaskDetail.tsx | 25 ++++++++++++-- src/components/tasks/TaskList.tsx | 36 +++++++++++++-------- 8 files changed, 136 insertions(+), 22 deletions(-) diff --git a/next.config.ts b/next.config.ts index b9aabe5..33e2987 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,13 +2,30 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", - // PWA service worker (manual - no next-pwa needed) async headers() { return [ { source: "/manifest.json", headers: [{ key: "Content-Type", value: "application/manifest+json" }], }, + { + // 모든 라우트에 보안 헤더 적용 + source: "/(.*)", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-XSS-Protection", value: "1; mode=block" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + ], + }, ]; }, }; diff --git a/package-lock.json b/package-lock.json index efb97fb..2f17e10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@prisma/client": "^6.19.3", + "@types/dompurify": "^3.2.0", "bcryptjs": "^3.0.3", "csv-parse": "^7.0.2", + "dompurify": "^3.4.14", "marked": "^18.0.10", "next": "16.3.1", "next-auth": "^4.24.15", @@ -1686,6 +1688,15 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", + "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", + "dependencies": { + "dompurify": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1758,6 +1769,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3172,6 +3189,14 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", diff --git a/package.json b/package.json index 68685b4..8c8fbf9 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@prisma/client": "^6.19.3", + "@types/dompurify": "^3.2.0", "bcryptjs": "^3.0.3", "csv-parse": "^7.0.2", + "dompurify": "^3.4.14", "marked": "^18.0.10", "next": "16.3.1", "next-auth": "^4.24.15", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 001f008..054ba9b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,6 +1,8 @@ -"use client"; +"use client"; import React, { useState, useEffect } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; import { useI18n } from "@/lib/i18n"; import { getDemoStore } from "@/lib/mockData"; @@ -46,17 +48,39 @@ const INITIAL_ADMIN_USERS: AdminUser[] = [ export default function AdminPage() { const { t } = useI18n(); + const router = useRouter(); + const { data: session, status } = useSession(); + const [users, setUsers] = useState(INITIAL_ADMIN_USERS); const [search, setSearch] = useState(""); const [totalTasks, setTotalTasks] = useState(67); const [totalLists, setTotalLists] = useState(9); + // 인증 게이트: 비로그인 시 /login으로 리디렉션 + useEffect(() => { + if (status === "unauthenticated") { + router.replace("/login"); + } + }, [status, router]); + useEffect(() => { const store = getDemoStore(); if (store.tasks) setTotalTasks(store.tasks.length); if (store.lists) setTotalLists(store.lists.length); }, []); + // 로딩 중 스피너 + if (status === "loading" || status === "unauthenticated") { + return ( +
+
+
🔒
+

Checking access...

+
+
+ ); + } + const toggleRole = (id: string) => { setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u)) diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index d3c31b3..081b711 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -16,12 +16,22 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "file and listId required" }, { status: 400 }); } + // 파일 크기 제한: 5MB + const MAX_SIZE = 5 * 1024 * 1024; + if (file.size > MAX_SIZE) { + return NextResponse.json({ error: "File too large. Maximum size is 5MB." }, { status: 413 }); + } + + // 허용 확장자 검증 + const ext = file.name.split(".").pop()?.toLowerCase(); + if (!["csv", "ics"].includes(ext ?? "")) { + return NextResponse.json({ error: "Unsupported file format. Use CSV or ICS." }, { status: 400 }); + } + const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } }); if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 }); const text = await file.text(); - const ext = file.name.split(".").pop()?.toLowerCase(); - let imported = 0; if (ext === "csv") { diff --git a/src/components/tasks/MarkdownNoteEditor.tsx b/src/components/tasks/MarkdownNoteEditor.tsx index 4ff6436..f6c1203 100644 --- a/src/components/tasks/MarkdownNoteEditor.tsx +++ b/src/components/tasks/MarkdownNoteEditor.tsx @@ -1,6 +1,7 @@ -"use client"; +"use client"; import React, { useState, useRef, useCallback, useEffect } from "react"; import { marked } from "marked"; +import DOMPurify from "dompurify"; import { useI18n } from "@/lib/i18n"; interface MarkdownNoteEditorProps { @@ -114,7 +115,13 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit return ``; }); - return rawHtml; + // XSS 방어: DOMPurify sanitize (링크 target=_blank, class, data-idx 허용) + const sanitized = DOMPurify.sanitize(rawHtml, { + ADD_ATTR: ["target", "rel", "data-idx", "class"], + ALLOW_DATA_ATTR: true, + }); + + return sanitized; } catch { return value; } diff --git a/src/components/tasks/TaskDetail.tsx b/src/components/tasks/TaskDetail.tsx index 69074aa..119ac9f 100644 --- a/src/components/tasks/TaskDetail.tsx +++ b/src/components/tasks/TaskDetail.tsx @@ -46,6 +46,8 @@ export function TaskDetail({ // Mobile Bottom-Sheet: swipe down to close const touchStartY = useRef(null); + const touchStartX = useRef(null); + const gestureDirection = useRef<"vertical" | "horizontal" | null>(null); const [panelTranslateY, setPanelTranslateY] = useState(0); const [isMobile, setIsMobile] = useState(false); @@ -306,15 +308,30 @@ export function TaskDetail({ [isDemo, onDemoUpdateTask, onUpdate, subtasks, task] ); - // Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기 + // Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기 (스크롤과 충돌 방지) const handleTouchStart = (e: React.TouchEvent) => { + // 스와이프 핸들 또는 detail-header 영역에서만 드래그 닫기 허용 + const target = e.target as HTMLElement; + const isHandle = target.closest(".mobile-swipe-handle") || target.closest(".detail-header"); + if (!isHandle || !isMobile) return; touchStartY.current = e.touches[0].clientY; + touchStartX.current = e.touches[0].clientX; + gestureDirection.current = null; }; const handleTouchMove = (e: React.TouchEvent) => { - if (touchStartY.current === null) return; + if (touchStartY.current === null || touchStartX.current === null) return; const deltaY = e.touches[0].clientY - touchStartY.current; - if (deltaY > 0) { + const deltaX = e.touches[0].clientX - touchStartX.current; + + // 초기 제스처 방향 결정 (첫 10px 이동 기준) + if (gestureDirection.current === null && (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10)) { + gestureDirection.current = Math.abs(deltaY) > Math.abs(deltaX) ? "vertical" : "horizontal"; + } + + // 수직 드래그일 때만 패널 이동 + if (gestureDirection.current === "vertical" && deltaY > 0) { + e.preventDefault(); setPanelTranslateY(deltaY); } }; @@ -325,6 +342,8 @@ export function TaskDetail({ } setPanelTranslateY(0); touchStartY.current = null; + touchStartX.current = null; + gestureDirection.current = null; }; const completedCount = subtasks.filter((s) => s.completed).length; diff --git a/src/components/tasks/TaskList.tsx b/src/components/tasks/TaskList.tsx index a037f5f..69319bf 100644 --- a/src/components/tasks/TaskList.tsx +++ b/src/components/tasks/TaskList.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { useI18n } from "@/lib/i18n"; import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu"; @@ -97,15 +97,18 @@ function RecursiveTaskItem({ } }, [addingSubtask]); - const formatDate = (d: string | null) => { + // 마감일 상태 반환: label + 색상 + const getDueDateInfo = (d: string | null): { label: string; color: string } | null => { if (!d) return null; const date = new Date(d); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString(); - if (isToday) return t("today"); - if (isTomorrow) return t("tomorrow"); - return date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" }); + const isPast = date < now && !isToday; + if (isToday) return { label: t("today"), color: "#EF4444" }; + if (isTomorrow) return { label: t("tomorrow"), color: "#F97316" }; + if (isPast) return { label: date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" }), color: "#DC2626" }; + return { label: date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" }), color: "var(--text-tertiary)" }; }; const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")]; @@ -261,14 +264,21 @@ function RecursiveTaskItem({ title={priorityLabels[task.priority]} /> )} - {task.dueDate && !isTrashMode && ( - - - - - {formatDate(task.dueDate)} - - )} + {task.dueDate && !isTrashMode && (() => { + const dueDateInfo = getDueDateInfo(task.dueDate); + if (!dueDateInfo) return null; + return ( + + + + + {dueDateInfo.label} + + ); + })()} {totalChildren > 0 && !isTrashMode && (