From f392842d7f7ca77e9034786a33fad8634d9c02d8 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 11 Jul 2026 12:31:34 +0900 Subject: [PATCH] security: login rate-limit, gitignored account store, secure cookie opt, upgrade-fail log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 지적 4건 반영(서버 전용, exe 영향 없음). - 로그인 IP 기준 실패 제한(15분 창 10회 → 15분 차단). 브루트포스 + scrypt CPU 남용 방지. 인메모리, 무한 성장 가드 포함. - 운영 계정을 gitignore 된 account.local.json 으로 이전. readAccounts 는 local 우선, 없으면 추적되는 account.json 을 시드로 읽음. writeAccounts 는 local 에만 기록 → 첫 로그인 자동 해시 업그레이드부터는 추적 평문 파일을 더 쓰지 않음. (account.json 을 git rm --cached 하면 서버 pull 시 삭제되는 위험이 있어 추적 자체는 건드리지 않고, 실질 사용 파일만 분리.) - 세션 쿠키 secure 를 SESSION_COOKIE_SECURE=true 로 켤 수 있게(HTTPS 배포용). - 평문→해시 자동 업그레이드 저장 실패를 조용히 무시하지 않고 console.error 로 기록. Co-Authored-By: Claude Opus 4 --- .gitignore | 2 ++ locales/server/ko-kr.json | 3 ++- src/server/app.ts | 3 +++ src/server/routes/op.ts | 49 +++++++++++++++++++++++++++++++++++++-- src/shared/paths.ts | 3 +++ src/shared/store.ts | 27 ++++++++++++--------- 6 files changed, 73 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 48246a4..5caa44a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ conversations/ .env.*.local # 세션 서명용 자동 생성 시크릿. 절대 커밋 금지. .session-secret +# 운영 계정(해시 비밀번호) 파일. 추적되는 account.json 대신 이 파일을 사용. 커밋 금지. +account.local.json diff --git a/locales/server/ko-kr.json b/locales/server/ko-kr.json index b8adf14..297f64f 100644 --- a/locales/server/ko-kr.json +++ b/locales/server/ko-kr.json @@ -30,7 +30,8 @@ "title": "관리자 로그인", "password": "비밀번호", "submit": "로그인", - "wrongPassword": "비밀번호가 올바르지 않습니다." + "wrongPassword": "비밀번호가 올바르지 않습니다.", + "tooManyAttempts": "로그인 시도가 너무 많습니다. 약 {{minutes}}분 후 다시 시도해 주세요." }, "dashboard": { "title": "음악퀴즈 목록", diff --git a/src/server/app.ts b/src/server/app.ts index a47b367..01ed42c 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -67,6 +67,9 @@ app.use(session({ cookie: { httpOnly: true, sameSite: 'lax', + // HTTPS 전용 배포면 SESSION_COOKIE_SECURE=true 로 secure 쿠키 활성화. + // HTTP 접근이 섞이면 로그인 쿠키가 안 실리므로 기본값은 false. + secure: process.env.SESSION_COOKIE_SECURE === 'true', maxAge: 1000 * 60 * 60 * 8 } })) diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index 48b450a..ce94e08 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -35,6 +35,40 @@ import { buildSongsMcfunction } from '../datapack.js' export const opRouter = Router() +// 로그인 브루트포스 + scrypt CPU 남용 방지용 IP 기준 인메모리 실패 제한. +const LOGIN_WINDOW_MS = 15 * 60 * 1000 +const LOGIN_MAX_FAILS = 10 +const loginFails = new Map() + +function loginClientKey(req: { ip?: string; socket?: { remoteAddress?: string } }): string { + return req.ip || req.socket?.remoteAddress || 'unknown' +} + +/** 차단 중이면 남은 ms, 아니면 0. 접근 시 만료된 항목은 정리. */ +function loginBlockedMs(key: string): number { + const now = Date.now() + const entry = loginFails.get(key) + if (!entry) return 0 + if (entry.blockedUntil > now) return entry.blockedUntil - now + if (now - entry.first > LOGIN_WINDOW_MS) loginFails.delete(key) + return 0 +} + +function recordLoginFail(key: string): void { + const now = Date.now() + let entry = loginFails.get(key) + if (!entry || now - entry.first > LOGIN_WINDOW_MS) entry = { count: 0, first: now, blockedUntil: 0 } + entry.count += 1 + if (entry.count >= LOGIN_MAX_FAILS) entry.blockedUntil = now + LOGIN_WINDOW_MS + loginFails.set(key, entry) + // 맵 무한 성장 방지(분산 시도 대비): 상한 초과 시 만료 항목 정리. + if (loginFails.size > 5000) { + for (const [k, v] of loginFails) { + if (v.blockedUntil <= now && now - v.first > LOGIN_WINDOW_MS) loginFails.delete(k) + } + } +} + function pickFirstValue(value: unknown): string { if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : '' return typeof value === 'string' ? value : '' @@ -58,20 +92,31 @@ opRouter.get('/op', (req, res) => { opRouter.post('/op', async (req, res, next) => { try { + const clientKey = loginClientKey(req) + const blockedMs = loginBlockedMs(clientKey) + if (blockedMs > 0) { + res.status(429).render('op/login', { + error: t('login.tooManyAttempts', { minutes: Math.ceil(blockedMs / 60000) }) + }) + return + } const password = pickFirstValue(req.body.password) const accounts = await readAccounts() const matched = accounts.find((entry) => verifyPassword(password, entry.password)) if (!matched) { + recordLoginFail(clientKey) res.status(401).render('op/login', { error: t('login.wrongPassword') }) return } + loginFails.delete(clientKey) // 평문으로 저장돼 있던 비밀번호는 로그인 성공 시 scrypt 해시로 자동 업그레이드. if (!isHashed(matched.password)) { try { matched.password = hashPassword(password) await writeAccounts(accounts) - } catch { - // 업그레이드 실패는 로그인 자체에 영향 주지 않음. + } catch (err) { + // 로그인 자체는 진행하되, 평문이 계속 남는 상황이므로 반드시 로그로 알린다. + console.error('[auth] 비밀번호 해시 자동 업그레이드 저장 실패(평문 유지됨):', (err as Error).message) } } req.session.userId = matched.id diff --git a/src/shared/paths.ts b/src/shared/paths.ts index 9586c0d..eecf0a5 100644 --- a/src/shared/paths.ts +++ b/src/shared/paths.ts @@ -6,7 +6,10 @@ export const projectRoot = path.resolve(__dirname, '..', '..') export const manifestRootPath = path.join(projectRoot, 'manifest.json') export const manifestDirPath = path.join(projectRoot, 'manifest') export const manifestTermsDirPath = path.join(manifestDirPath, 'terms') +// 추적되는 account.json(과거 평문 노출)을 대체할, gitignore 된 운영 계정 파일. +// readAccounts 는 이 파일을 우선 사용하고, 없을 때만 account.json 을 시드로 읽는다. export const accountFilePath = path.join(projectRoot, 'account.json') +export const accountLocalFilePath = path.join(projectRoot, 'account.local.json') export const fileDirPath = path.join(projectRoot, 'file') export const fileListDirPath = path.join(fileDirPath, 'list') export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks') diff --git a/src/shared/store.ts b/src/shared/store.ts index a795400..8c4c8bf 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -3,7 +3,7 @@ import fsp from 'node:fs/promises' import path from 'node:path' import { manifestRootPath, manifestDirPath, manifestTermsDirPath, - accountFilePath, fileListDirPath + accountFilePath, accountLocalFilePath, fileListDirPath } from './paths.js' import type { Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType, @@ -778,18 +778,23 @@ export function isPublicTermsFile(packKey: string, fileName: string): boolean { } export async function readAccounts(): Promise { - try { - const raw = await fsp.readFile(accountFilePath, 'utf8') - const parsed = JSON.parse(raw) - if (!Array.isArray(parsed)) return [] - return parsed.filter((entry): entry is AccountEntry => - typeof entry?.id === 'string' && typeof entry?.password === 'string') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] - throw error + // gitignore 된 account.local.json 우선. 없으면(ENOENT) 추적되는 account.json 을 시드로. + for (const filePath of [accountLocalFilePath, accountFilePath]) { + try { + const raw = await fsp.readFile(filePath, 'utf8') + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter((entry): entry is AccountEntry => + typeof entry?.id === 'string' && typeof entry?.password === 'string') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } } + return [] } +// 운영 계정 저장은 항상 gitignore 된 account.local.json 에만 한다(추적 파일 오염 방지). export async function writeAccounts(accounts: AccountEntry[]): Promise { - await fsp.writeFile(accountFilePath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf8') + await fsp.writeFile(accountLocalFilePath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf8') }