diff --git a/next.config.ts b/next.config.ts index 51ef58b3..62a9106d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,8 +2,53 @@ import type { NextConfig } from 'next'; import type { RemotePattern } from 'next/dist/shared/lib/image-config'; const isProd = process.env.NODE_ENV === 'production'; +const isDev = process.env.NODE_ENV === 'development'; const nextConfig: NextConfig = { + // [보안] 보안 HTTP 헤더 설정. + // CSP는 스테이징 검증 중이므로 Content-Security-Policy-Report-Only 모드로 먼저 배포. + // 결제·로그인 E2E 검증 후 위반 없으면 Content-Security-Policy로 전환할 것. + async headers() { + const cspDirectives = [ + "default-src 'self'", + // GTM, Clarity, 토스페이먼츠 스크립트 허용. 개발 환경에서는 'unsafe-eval' 추가(Next.js HMR 필요). + `script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.clarity.ms https://js.tosspayments.com${isDev ? " 'unsafe-eval'" : ''}`, + "style-src 'self' 'unsafe-inline'", + // 카카오·구글 프로필 이미지, 자사 API/CMS 이미지 도메인 허용 + "img-src 'self' data: blob: https://img1.kakaocdn.net https://lh3.googleusercontent.com https://api.zeroone.it.kr https://test-api.zeroone.it.kr https://www.zeroone.it.kr https://test-blog.zeroone.it.kr", + // API, GA, Clarity, 토스, 카카오/구글 OAuth 연결 허용. 개발에서는 HMR WebSocket 추가. + `connect-src 'self' https://api.zeroone.it.kr https://test-api.zeroone.it.kr https://www.google-analytics.com https://www.clarity.ms https://api.tosspayments.com https://kauth.kakao.com https://accounts.google.com${isDev ? ' ws://localhost:*' : ''}`, + // 토스 결제창 iframe 허용 + 'frame-src https://pay.toss.im https://cert.tosspayments.com', + "font-src 'self' data:", + ].join('; '); + + return [ + { + source: '/(.*)', + headers: [ + // Report-Only 모드: 위반을 차단하지 않고 콘솔에 경고만 출력. 검증 완료 후 CSP로 전환 예정. + { key: 'Content-Security-Policy-Report-Only', value: cspDirectives }, + // [클릭재킹 방지] 동일 출처의 iframe만 허용 + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + // [MIME 스니핑 방지] 브라우저가 Content-Type을 임의로 변경하는 행위 차단 + { key: 'X-Content-Type-Options', value: 'nosniff' }, + // [정보 유출 방지] 크로스 오리진 요청 시 origin만 전송 + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + // [HTTPS 강제] 1년간 HTTPS만 허용 (프로덕션에서 유효) + { + key: 'Strict-Transport-Security', + value: 'max-age=31536000; includeSubDomains', + }, + // [브라우저 API 제한] 불필요한 카메라·마이크·위치정보 API 비활성화 + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + ], + }, + ]; + }, // output: 'standalone', /* config options here */ // 외부 이미지 도메인 허용 설정 추가 diff --git a/src/app/api/auth/clear-session/route.ts b/src/app/api/auth/clear-session/route.ts index 7a612835..ed4aa1cb 100644 --- a/src/app/api/auth/clear-session/route.ts +++ b/src/app/api/auth/clear-session/route.ts @@ -1,9 +1,20 @@ import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; +// new URL(path, base)는 path가 절대 URL이면 base를 무시하므로 반드시 사전 검증 필요. +function isSafeRedirectPath(path: string) { + if (!path) return false; + if (!path.startsWith('/')) return false; // 절대 URL(https://evil.com) 차단 + if (path.startsWith('//')) return false; // 프로토콜-상대 URL(//evil.com) 차단 + if (path.startsWith('\\')) return false; // 역슬래시로 시작하는 경로 차단 (예: \evil.com) + + return true; +} + export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const redirectTo = searchParams.get('redirect') || '/login'; + const rawRedirect = searchParams.get('redirect') || '/login'; + const redirectTo = isSafeRedirectPath(rawRedirect) ? rawRedirect : '/login'; // 쿠키 삭제 const cookieStore = await cookies(); diff --git a/src/components/ui/image-upload-input.tsx b/src/components/ui/image-upload-input.tsx index 234e9cd7..59dda120 100644 --- a/src/components/ui/image-upload-input.tsx +++ b/src/components/ui/image-upload-input.tsx @@ -4,6 +4,9 @@ import Image from 'next/image'; import { useState, DragEvent, ChangeEvent, useRef } from 'react'; import Button from '@/components/ui/button'; +// 클라이언트에서 선제 차단하여 불필요한 413 에러 및 대용량 업로드 요청을 방지. +const DEFAULT_MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB + const inputStyles = { base: 'rounded-100 flex w-full flex-col items-center justify-center border-2 p-500', dragging: 'border-border-brand bg-fill-brand-subtle-hover', @@ -13,12 +16,31 @@ const inputStyles = { export default function ImageUploadInput({ image, onChangeImage, + maxSizeBytes = DEFAULT_MAX_SIZE_BYTES, }: { image?: string; onChangeImage: (file: File | undefined) => void; + maxSizeBytes?: number; }) { const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); + const [sizeError, setSizeError] = useState(null); + + const validateAndChange = (file: File) => { + if (!file.type.startsWith('image/')) { + setSizeError(null); + + return; + } + if (file.size > maxSizeBytes) { + const maxMb = (maxSizeBytes / 1024 / 1024).toFixed(0); + setSizeError(`이미지 파일 크기는 ${maxMb}MB 이하만 업로드할 수 있어요.`); + + return; + } + setSizeError(null); + onChangeImage(file); + }; const handleOpenFileDialog = () => { fileInputRef.current?.click(); @@ -49,77 +71,80 @@ export default function ImageUploadInput({ e.stopPropagation(); setIsDragging(false); const file = e.dataTransfer.files[0]; - if (file && file.type.startsWith('image/')) { - onChangeImage(file); - } + if (file) validateAndChange(file); }; const handleFileChange = (e: ChangeEvent) => { const file = e.target.files?.[0]; - if (file && file.type.startsWith('image/')) { - onChangeImage(file); - } + if (file) validateAndChange(file); + e.target.value = ''; // 같은 파일 재업로드 허용을 위해 입력값 초기화 }; const handleRemove = () => { + setSizeError(null); + if (fileInputRef.current) fileInputRef.current.value = ''; onChangeImage(undefined); }; return ( -
- {!image ? ( -
-
+
+
+ {!image ? ( +
+
+ 파일 업로드 + + 드래그하여 파일 업로드 + +
+ + +
+ ) : ( +
파일 업로드 - - 드래그하여 파일 업로드 - +
- - -
- ) : ( -
- preview - -
- )} + )} +
+ {sizeError &&

{sizeError}

}
); } diff --git a/src/features/study/group/model/group-study-form.schema.ts b/src/features/study/group/model/group-study-form.schema.ts index 0d80eded..2cb13368 100644 --- a/src/features/study/group/model/group-study-form.schema.ts +++ b/src/features/study/group/model/group-study-form.schema.ts @@ -18,6 +18,14 @@ import { const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +// [보안/데이터 무결성] 백엔드 DTO의 @Size 어노테이션과 동기화. +// 출처: GroupStudyDetailInfoRequestDto.java, GroupStudyInterviewPostRequestDto.java +export const GROUP_STUDY_TITLE_MAX_LENGTH = 100; +export const GROUP_STUDY_SUMMARY_MAX_LENGTH = 200; +export const GROUP_STUDY_DESCRIPTION_MAX_LENGTH = 1000; +export const GROUP_STUDY_LOCATION_MAX_LENGTH = 255; +export const GROUP_STUDY_INTERVIEW_Q_MAX_LENGTH = 500; + export const STUDY_CLASSIFICATION = ['GROUP_STUDY', 'PREMIUM_STUDY'] as const; export type StudyClassification = (typeof STUDY_CLASSIFICATION)[number]; @@ -44,7 +52,13 @@ export const GroupStudyFormSchema = z // 진행 방식(1) method: z.enum(STUDY_METHODS), // 진행 방식 (위치) optional (1) - location: z.string().trim(), + location: z + .string() + .trim() + .max( + GROUP_STUDY_LOCATION_MAX_LENGTH, + `위치는 ${GROUP_STUDY_LOCATION_MAX_LENGTH}자 이하로 입력해주세요.`, + ), // 정기 모임(1) regularMeeting: z.enum(REGULAR_MEETINGS), // 진행 기간 (시작일) (1) @@ -59,11 +73,32 @@ export const GroupStudyFormSchema = z .regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 종료일을 입력해 주세요.'), price: z.string().trim().optional(), // 스터디 제목(2) - title: z.string().trim().min(1, '스터디 제목을 입력해주세요.'), + title: z + .string() + .trim() + .min(1, '스터디 제목을 입력해주세요.') + .max( + GROUP_STUDY_TITLE_MAX_LENGTH, + `제목은 ${GROUP_STUDY_TITLE_MAX_LENGTH}자 이하로 입력해주세요.`, + ), // 스터디 한 줄 소개(2) - summary: z.string().trim().min(1, '한 줄 소개를 입력해주세요.'), + summary: z + .string() + .trim() + .min(1, '한 줄 소개를 입력해주세요.') + .max( + GROUP_STUDY_SUMMARY_MAX_LENGTH, + `한 줄 소개는 ${GROUP_STUDY_SUMMARY_MAX_LENGTH}자 이하로 입력해주세요.`, + ), // 스터디 소개(2) - description: z.string().trim().min(1, '스터디 소개를 입력해주세요.'), + description: z + .string() + .trim() + .min(1, '스터디 소개를 입력해주세요.') + .max( + GROUP_STUDY_DESCRIPTION_MAX_LENGTH, + `스터디 소개는 ${GROUP_STUDY_DESCRIPTION_MAX_LENGTH}자 이하로 입력해주세요.`, + ), // 썸네일 START(2) thumbnailExtension: z .enum(THUMBNAIL_EXTENSION) @@ -73,12 +108,31 @@ export const GroupStudyFormSchema = z // 썸네일 END(2) // 스터디원에게 보여줄 질문을 입력하세요(3) interviewPost: z - .array(z.string()) - .refine((arr) => arr.length > 0 && arr.every((v) => v.trim() !== ''), { - message: '모든 질문을 입력해야 합니다.', - }) - .refine((arr) => arr.length <= 10, { - message: '질문은 최대 10개까지만 입력할 수 있습니다.', + .array( + z + .string() + .trim() + .max( + GROUP_STUDY_INTERVIEW_Q_MAX_LENGTH, + `질문은 ${GROUP_STUDY_INTERVIEW_Q_MAX_LENGTH}자 이하로 입력해주세요.`, + ), + ) + .superRefine((arr, ctx) => { + if (arr.length > 10) { + ctx.addIssue({ + code: "custom", + message: '질문은 최대 10개까지만 입력할 수 있습니다.', + }); + } + arr.forEach((item, idx) => { + if (!item || item.trim() === '') { + ctx.addIssue({ + code: "custom", + message: '질문을 입력해주세요.', + path: [idx], + }); + } + }); }), }) .superRefine((data, ctx) => { @@ -87,7 +141,7 @@ export const GroupStudyFormSchema = z if (data.classification === 'PREMIUM_STUDY') { if (!data.price || priceNum < 10000) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: '참가비는 10,000원 이상이어야 합니다.', path: ['price'], }); @@ -96,7 +150,7 @@ export const GroupStudyFormSchema = z // 그룹 스터디: 0원만 허용 else if (data.price && priceNum !== 0) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: '그룹 스터디는 무료만 가능합니다.', path: ['price'], }); @@ -120,7 +174,7 @@ export const GroupStudyFormSchema = z if (data.startDate < tomorrowYmd) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: '스터디 시작일은 내일부터 설정할 수 있습니다.', path: ['startDate'], }); @@ -136,7 +190,7 @@ export const GroupStudyFormSchema = z const end = parseDate(data.endDate); if (end < start) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: '종료일은 시작일과 같거나 이후여야 합니다.', path: ['endDate'], }); diff --git a/src/features/study/group/ui/step/step2-group.tsx b/src/features/study/group/ui/step/step2-group.tsx index 5746c2e8..c15abfdb 100644 --- a/src/features/study/group/ui/step/step2-group.tsx +++ b/src/features/study/group/ui/step/step2-group.tsx @@ -33,7 +33,7 @@ export default function Step2OpenGroupStudy() { } }, [thumbnailFile, thumbnailExtension]); - const handleImageChange = (file: File | null) => { + const handleImageChange = (file: File | undefined) => { if (!file) { setValue('thumbnailExtension', 'DEFAULT', { shouldValidate: true }); setValue('thumbnailFile', null); @@ -42,13 +42,6 @@ export default function Step2OpenGroupStudy() { return; } - const MAX_SIZE = 5 * 1024 * 1024; // 5MB - if (file.size > MAX_SIZE) { - alert('이미지 용량은 5MB 이하만 업로드할 수 있어요.'); - - return; - } - const ext = file.name.split('.').pop()?.toUpperCase(); const validExt = ext && THUMBNAIL_EXTENSION.includes(ext as any) diff --git a/src/features/study/group/ui/step/step3-group.tsx b/src/features/study/group/ui/step/step3-group.tsx index 37d894b6..d14bb6d6 100644 --- a/src/features/study/group/ui/step/step3-group.tsx +++ b/src/features/study/group/ui/step/step3-group.tsx @@ -9,7 +9,7 @@ import { BaseInput } from '@/components/ui/input'; import { GroupStudyFormValues } from '../../model/group-study-form.schema'; export default function Step3OpenGroupStudy() { - const { setValue, getValues } = useFormContext(); + const { setValue, getValues, formState } = useFormContext(); const initQuestions = getValues('interviewPost'); @@ -44,21 +44,28 @@ export default function Step3OpenGroupStudy() { >
{questions.map((q, index) => ( -
- handleChange(index, e.target.value)} - /> - {index > 0 && ( - +
+
+ handleChange(index, e.target.value)} + /> + {index > 0 && ( + + )} +
+ {formState.errors.interviewPost?.[index]?.message && ( +

+ {formState.errors.interviewPost[index]?.message as string} +

)}
))} diff --git a/src/providers/index.tsx b/src/providers/index.tsx index 3968e5a6..bf38191b 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -16,6 +16,12 @@ function UserInitializer({ children }: ProviderProps) { const { memberId: authMemberId, isAuthReady } = useAuthReady(); const { memberId, fetchAndSetUser } = useUserStore(); + useEffect(() => { + // [보안 마이그레이션] sessionStorage 전환 이후 localStorage의 잔여 민감 데이터 정리. + // 기존 사용자 브라우저에 남아있는 user-info-storage를 삭제하여 XSS 노출 경로 제거. + localStorage.removeItem('user-info-storage'); + }, []); + useEffect(() => { if (isAuthReady && authMemberId && !memberId) { fetchAndSetUser(authMemberId).catch(console.error); diff --git a/src/stores/useUserStore.ts b/src/stores/useUserStore.ts index 6720ce35..b028bf77 100644 --- a/src/stores/useUserStore.ts +++ b/src/stores/useUserStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { createJSONStorage, persist } from 'zustand/middleware'; import { getUserProfile } from '@/entities/user/api/get-user-profile'; interface UserInfo { @@ -44,6 +44,9 @@ export const useUserStore = create()( }), { name: 'user-info-storage', + + // 새 탭에서 열면 providers/index.tsx의 UserInitializer가 fetchAndSetUser()로 재취득하므로 UX 영향 없음. + storage: createJSONStorage(() => sessionStorage), }, ), );