security: hash operator passwords (scrypt) + persistent session secret

- 운영자 로그인 비밀번호를 평문 비교(===)에서 Node 내장 scrypt 해시 + 상수시간
  비교(verifyPassword)로 전환. 새 파일 src/server/password.ts. 기존 account.json
  의 평문 비밀번호는 그대로 검증되며, 로그인 성공 시 scrypt 해시로 자동 업그레이드
  후 저장(writeAccounts 추가). 외부 의존성 없음.
- 세션 시크릿을 하드코딩 폴백('...dev-secret') 대신, 환경변수 우선 → 없으면
  .session-secret 파일에 영구 랜덤값 생성/보관하도록 변경(세션 위조 방지, 재시작
  후에도 세션 유지). .session-secret 은 .gitignore 에 추가.

서버(사이트) 전용 변경이라 설치기 exe 는 영향 없음(재빌드 불필요).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:24:42 +09:00
parent 704d58d3ac
commit 00344084c7
5 changed files with 91 additions and 4 deletions

View File

@@ -1,10 +1,12 @@
import express from 'express'
import session from 'express-session'
import path from 'node:path'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import crypto from 'node:crypto'
import {
manifestRootPath, manifestDirPath, manifestTermsDirPath,
fileDirPath, viewsDirPath, publicDirPath
fileDirPath, viewsDirPath, publicDirPath, projectRoot
} from '../shared/paths.js'
import {
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
@@ -38,8 +40,28 @@ app.use((_req, res, next) => {
next()
})
// 세션 시크릿: 환경변수 우선, 없으면 하드코딩(위조 위험) 대신 영구 랜덤 시크릿을
// 파일로 생성/보관한다(재시작해도 세션 유지). 파일 접근 불가 시엔 프로세스 수명 동안만
// 유효한 랜덤값으로 폴백(그래도 하드코딩보다 안전).
function resolveSessionSecret(): string {
const fromEnv = process.env.SESSION_SECRET
if (fromEnv && fromEnv.length >= 16) return fromEnv
const secretPath = path.join(projectRoot, '.session-secret')
try {
if (fs.existsSync(secretPath)) {
const existing = fs.readFileSync(secretPath, 'utf8').trim()
if (existing.length >= 16) return existing
}
const generated = crypto.randomBytes(32).toString('hex')
fs.writeFileSync(secretPath, generated, { mode: 0o600 })
return generated
} catch {
return crypto.randomBytes(32).toString('hex')
}
}
app.use(session({
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
secret: resolveSessionSecret(),
resave: false,
saveUninitialized: false,
cookie: {