Security+UX: Add DOMPurify XSS protection, security headers, admin auth gate, import file size limit, due date color coding, swipe/scroll conflict prevention

This commit is contained in:
2026-08-20 22:58:47 +09:00
parent 36c8d690d9
commit 8212f16d28
8 changed files with 136 additions and 22 deletions
+18 -1
View File
@@ -2,13 +2,30 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
// PWA service worker (manual - no next-pwa needed)
async headers() { async headers() {
return [ return [
{ {
source: "/manifest.json", source: "/manifest.json",
headers: [{ key: "Content-Type", value: "application/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",
},
],
},
]; ];
}, },
}; };
+25
View File
@@ -12,8 +12,10 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"@types/dompurify": "^3.2.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"csv-parse": "^7.0.2", "csv-parse": "^7.0.2",
"dompurify": "^3.4.14",
"marked": "^18.0.10", "marked": "^18.0.10",
"next": "16.3.1", "next": "16.3.1",
"next-auth": "^4.24.15", "next-auth": "^4.24.15",
@@ -1686,6 +1688,15 @@
"@types/ms": "*" "@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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1758,6 +1769,12 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -3172,6 +3189,14 @@
"node": ">=0.10.0" "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": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+2
View File
@@ -14,8 +14,10 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"@types/dompurify": "^3.2.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"csv-parse": "^7.0.2", "csv-parse": "^7.0.2",
"dompurify": "^3.4.14",
"marked": "^18.0.10", "marked": "^18.0.10",
"next": "16.3.1", "next": "16.3.1",
"next-auth": "^4.24.15", "next-auth": "^4.24.15",
+25 -1
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { getDemoStore } from "@/lib/mockData"; import { getDemoStore } from "@/lib/mockData";
@@ -46,17 +48,39 @@ const INITIAL_ADMIN_USERS: AdminUser[] = [
export default function AdminPage() { export default function AdminPage() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const { data: session, status } = useSession();
const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS); const [users, setUsers] = useState<AdminUser[]>(INITIAL_ADMIN_USERS);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [totalTasks, setTotalTasks] = useState(67); const [totalTasks, setTotalTasks] = useState(67);
const [totalLists, setTotalLists] = useState(9); const [totalLists, setTotalLists] = useState(9);
// 인증 게이트: 비로그인 시 /login으로 리디렉션
useEffect(() => {
if (status === "unauthenticated") {
router.replace("/login");
}
}, [status, router]);
useEffect(() => { useEffect(() => {
const store = getDemoStore(); const store = getDemoStore();
if (store.tasks) setTotalTasks(store.tasks.length); if (store.tasks) setTotalTasks(store.tasks.length);
if (store.lists) setTotalLists(store.lists.length); if (store.lists) setTotalLists(store.lists.length);
}, []); }, []);
// 로딩 중 스피너
if (status === "loading" || status === "unauthenticated") {
return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg-primary)" }}>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
<p style={{ color: "var(--text-secondary)" }}>Checking access...</p>
</div>
</div>
);
}
const toggleRole = (id: string) => { const toggleRole = (id: string) => {
setUsers((prev) => setUsers((prev) =>
prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u)) prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u))
+12 -2
View File
@@ -16,12 +16,22 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "file and listId required" }, { status: 400 }); 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 } }); const list = await prisma.list.findFirst({ where: { id: listId, userId: session.user.id } });
if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 }); if (!list) return NextResponse.json({ error: "List not found" }, { status: 404 });
const text = await file.text(); const text = await file.text();
const ext = file.name.split(".").pop()?.toLowerCase();
let imported = 0; let imported = 0;
if (ext === "csv") { if (ext === "csv") {
+9 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useState, useRef, useCallback, useEffect } from "react"; import React, { useState, useRef, useCallback, useEffect } from "react";
import { marked } from "marked"; import { marked } from "marked";
import DOMPurify from "dompurify";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
interface MarkdownNoteEditorProps { interface MarkdownNoteEditorProps {
@@ -114,7 +115,13 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
return `<span class="markdown-checkbox-box" data-idx="${id}"></span>`; return `<span class="markdown-checkbox-box" data-idx="${id}"></span>`;
}); });
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 { } catch {
return value; return value;
} }
+22 -3
View File
@@ -46,6 +46,8 @@ export function TaskDetail({
// Mobile Bottom-Sheet: swipe down to close // Mobile Bottom-Sheet: swipe down to close
const touchStartY = useRef<number | null>(null); const touchStartY = useRef<number | null>(null);
const touchStartX = useRef<number | null>(null);
const gestureDirection = useRef<"vertical" | "horizontal" | null>(null);
const [panelTranslateY, setPanelTranslateY] = useState(0); const [panelTranslateY, setPanelTranslateY] = useState(0);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
@@ -306,15 +308,30 @@ export function TaskDetail({
[isDemo, onDemoUpdateTask, onUpdate, subtasks, task] [isDemo, onDemoUpdateTask, onUpdate, subtasks, task]
); );
// Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기 // Touch handlers: 모바일에서 아래로 스와이프 → 패널 닫기 (스크롤과 충돌 방지)
const handleTouchStart = (e: React.TouchEvent) => { 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; touchStartY.current = e.touches[0].clientY;
touchStartX.current = e.touches[0].clientX;
gestureDirection.current = null;
}; };
const handleTouchMove = (e: React.TouchEvent) => { 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; 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); setPanelTranslateY(deltaY);
} }
}; };
@@ -325,6 +342,8 @@ export function TaskDetail({
} }
setPanelTranslateY(0); setPanelTranslateY(0);
touchStartY.current = null; touchStartY.current = null;
touchStartX.current = null;
gestureDirection.current = null;
}; };
const completedCount = subtasks.filter((s) => s.completed).length; const completedCount = subtasks.filter((s) => s.completed).length;
+19 -9
View File
@@ -1,4 +1,4 @@
"use client"; "use client";
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu"; import { ContextMenu, MenuItem } from "@/components/ui/ContextMenu";
@@ -97,15 +97,18 @@ function RecursiveTaskItem({
} }
}, [addingSubtask]); }, [addingSubtask]);
const formatDate = (d: string | null) => { // 마감일 상태 반환: label + 색상
const getDueDateInfo = (d: string | null): { label: string; color: string } | null => {
if (!d) return null; if (!d) return null;
const date = new Date(d); const date = new Date(d);
const now = new Date(); const now = new Date();
const isToday = date.toDateString() === now.toDateString(); const isToday = date.toDateString() === now.toDateString();
const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString(); const isTomorrow = date.toDateString() === new Date(now.getTime() + 86400000).toDateString();
if (isToday) return t("today"); const isPast = date < now && !isToday;
if (isTomorrow) return t("tomorrow"); if (isToday) return { label: t("today"), color: "#EF4444" };
return date.toLocaleDateString(lang === "ko" ? "ko-KR" : lang === "ja" ? "ja-JP" : "en-US", { month: "short", day: "numeric" }); 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")]; const priorityLabels = [t("priorityNone"), t("priorityLow"), t("priorityMedium"), t("priorityHigh")];
@@ -261,14 +264,21 @@ function RecursiveTaskItem({
title={priorityLabels[task.priority]} title={priorityLabels[task.priority]}
/> />
)} )}
{task.dueDate && !isTrashMode && ( {task.dueDate && !isTrashMode && (() => {
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}> const dueDateInfo = getDueDateInfo(task.dueDate);
if (!dueDateInfo) return null;
return (
<span
className="task-due"
style={{ color: task.completed ? undefined : dueDateInfo.color, fontWeight: (dueDateInfo.color !== "var(--text-tertiary)" && !task.completed) ? 600 : undefined }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" /> <rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
{formatDate(task.dueDate)} {dueDateInfo.label}
</span> </span>
)} );
})()}
{totalChildren > 0 && !isTrashMode && ( {totalChildren > 0 && !isTrashMode && (
<span <span
className="task-sub-count" className="task-sub-count"