정합성·보안·인덱스 개선

- health/시작 로그 버전을 package.json 기준으로 동적화
- express-rate-limit으로 로그인·이메일 쓰기 요청 IP 기준 제한, trust proxy 설정
- nginx 보안 헤더(nosniff·X-Frame-Options·Referrer-Policy) 추가
- 공개 덱 목록용 복합 인덱스 추가
- 서버 페이지네이션은 후속 과제로 todo에 기록

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-03 16:49:49 +09:00
parent af721d327d
commit 97215f155d
11 changed files with 94 additions and 11 deletions

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import path from 'path';
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
@@ -18,6 +19,24 @@ const EMAIL_LIMIT_MESSAGE =
'이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.';
const EMAIL_LIMIT_CODE = 'MONTHLY_EMAIL_LIMIT_EXCEEDED';
/** 로그인 무차별 대입 방어 (IP 기준) */
const loginLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.' },
});
/** 계정·이메일 관련 쓰기 요청 남용 방어 (IP 기준) */
const authWriteLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.' },
});
/**
* 한국 시간 기준 월 키
* @returns {string}
@@ -186,7 +205,7 @@ export function createAuthRouter(db) {
});
});
router.post('/register', async (req, res, next) => {
router.post('/register', authWriteLimiter, async (req, res, next) => {
const { email, username, password } = req.body || {};
if (!email || !username || !password) {
@@ -256,7 +275,7 @@ export function createAuthRouter(db) {
}
});
router.post('/resend-verification', async (req, res, next) => {
router.post('/resend-verification', authWriteLimiter, async (req, res, next) => {
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
if (!isValidEmail(normalizedEmail)) {
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
@@ -306,7 +325,7 @@ export function createAuthRouter(db) {
}
});
router.post('/verify-email', (req, res) => {
router.post('/verify-email', authWriteLimiter, (req, res) => {
const token = String(req.body?.token || '');
if (!token) {
return res.status(400).json({
@@ -341,7 +360,7 @@ export function createAuthRouter(db) {
res.json({ ok: true, message: '이메일 인증이 완료되었습니다. 로그인해 주세요.' });
});
router.post('/login', (req, res) => {
router.post('/login', loginLimiter, (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) {
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
@@ -379,7 +398,7 @@ export function createAuthRouter(db) {
res.json({ token: signToken(safeUser), user: safeUser });
});
router.post('/forgot-password', async (req, res, next) => {
router.post('/forgot-password', authWriteLimiter, async (req, res, next) => {
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
if (!isValidEmail(normalizedEmail)) {
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
@@ -426,7 +445,7 @@ export function createAuthRouter(db) {
}
});
router.post('/reset-password', (req, res) => {
router.post('/reset-password', authWriteLimiter, (req, res) => {
const token = String(req.body?.token || '');
const newPassword = String(req.body?.newPassword || '');
if (!token) {