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:
+18
-1
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
Generated
+25
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+25
-1
@@ -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<AdminUser[]>(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 (
|
||||
<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) => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, role: u.role === "ADMIN" ? "USER" : "ADMIN" } : u))
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 `<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 {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ export function TaskDetail({
|
||||
|
||||
// Mobile Bottom-Sheet: swipe down to close
|
||||
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 [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;
|
||||
|
||||
@@ -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 && (
|
||||
<span className={`task-due${isOverdue(task.dueDate) && !task.completed ? " overdue" : ""}`}>
|
||||
<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" />
|
||||
</svg>
|
||||
{formatDate(task.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
{task.dueDate && !isTrashMode && (() => {
|
||||
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">
|
||||
<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>
|
||||
{dueDateInfo.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{totalChildren > 0 && !isTrashMode && (
|
||||
<span
|
||||
className="task-sub-count"
|
||||
|
||||
Reference in New Issue
Block a user