Files
minecraft_launcher/src/server/app.ts
claude-bot face70b3e9 security: env-gate trust proxy, 0600 account file, seed+template for account.json untrack
리뷰 지적 추가 반영(서버 전용).
- trust proxy 를 항상 켜던 것을 TRUST_PROXY=true 일 때만 켜도록 변경. 직접 노출
  시 X-Forwarded-For 조작으로 로그인 rate limit 을 우회하던 문제 차단(프록시 뒤면
  TRUST_PROXY=true 설정).
- account.local.json 을 0o600(소유자 전용)으로 저장.
- 서버 시작 시 account.local.json 이 없으면 account.json 에서 시드(0o600). 이렇게
  하면 재배포 직후(로그인 전에도) 로컬 계정 파일이 항상 존재해, 이후 account.json
  을 안전하게 추적 해제할 수 있다.
- account.example.json 템플릿 추가.

account.json 자체의 git 추적 해제는 서버가 한 번 재배포되어 account.local.json 이
생성된 뒤 후속 커밋에서 처리(그 전에 지우면 pull 시 삭제되어 로그인이 막힘).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-11 13:27:32 +09:00

228 lines
8.2 KiB
TypeScript

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, projectRoot,
accountFilePath, accountLocalFilePath
} from '../shared/paths.js'
import {
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
} from '../shared/store.js'
import { loadEnv } from '../shared/env.js'
import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js'
import { opRouter } from './routes/op.js'
loadEnv()
const PORT = Number(process.env.PORT ?? 3000)
// 터미널에서 Ctrl+클릭으로 바로 열 수 있도록 기본값은 127.0.0.1.
// 외부 노출이 필요할 때만 HOST=0.0.0.0 환경변수로 덮어씀.
const HOST = process.env.HOST ?? '127.0.0.1'
const app = express()
app.set('view engine', 'ejs')
app.set('views', viewsDirPath)
// 리버스 프록시 뒤일 때만 켠다. 항상 켜두면 직접 노출 시 X-Forwarded-For 조작으로
// req.ip 를 위조해 로그인 rate limit 을 우회할 수 있다. 프록시 뒤라면 TRUST_PROXY=true.
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : false)
// 추적되는 account.json(과거 평문 노출)을 gitignore 된 account.local.json 으로 시드한다.
// 로컬 파일이 이미 있으면 건드리지 않음. 이후 계정 쓰기/자동 해시 업그레이드는 로컬
// 파일에만 반영되어, 재배포로 account.json 을 추적 해제해도 로그인이 유지된다.
function seedLocalAccounts(): void {
try {
if (fs.existsSync(accountLocalFilePath)) return
if (!fs.existsSync(accountFilePath)) return
fs.copyFileSync(accountFilePath, accountLocalFilePath)
fs.chmodSync(accountLocalFilePath, 0o600)
} catch {
// 실패해도 readAccounts 가 account.json 으로 폴백하므로 치명적이지 않음.
}
}
seedLocalAccounts()
app.use(express.urlencoded({ extended: true }))
app.use(express.json())
// 모든 EJS 뷰에서 t('key') 로 ko-kr.json 의 문구를 가져올 수 있도록 노출.
// localeDict 는 클라이언트 측 JS 로 사전을 통째로 전달할 때 사용(listEditor 등).
app.use((_req, res, next) => {
res.locals.t = t
res.locals.localeDict = localeDict
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: resolveSessionSecret(),
resave: false,
saveUninitialized: false,
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
}
}))
// account.json은 외부 노출 절대 금지.
app.use((req, res, next) => {
if (/^\/account\.json/i.test(req.path)) {
res.status(404).send('Not Found')
return
}
next()
})
app.use('/static', express.static(publicDirPath))
// 외부 노출이 필요한 정적 자원만 화이트리스트로 라우팅한다.
app.get('/manifest.json', (_req, res) => {
res.sendFile(manifestRootPath)
})
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
//
// fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을
// 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1
// 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가
// 생성되는 것은 loadPackDefinition 으로 차단.
// 설치기가 자기에게 표시할 약관 목록을 받아갈 수 있도록 packKey 별 index.json.
// 응답: [{ kind, label, showInInstaller, showInInstallerRp }]. v0.3.4~ builtin 개념이
// 없어졌으므로 인스톨러는 이 목록을 받아 자기 인스톨러용(`showInInstaller` / `showInInstallerRp`)
// 으로 필터링해서 탭을 만든다.
app.get('/manifest/terms/:packKey/index.json', async (req, res, next) => {
try {
const { packKey } = req.params
if (!/^[a-zA-Z0-9_\-]+$/.test(packKey)) {
res.status(404).json({ terms: [] })
return
}
const pack = await loadPackDefinition(packKey)
if (!pack) {
res.status(404).json({ terms: [] })
return
}
const terms = await listTermsWithLabels(packKey)
res.json({ terms })
} catch (error) {
next(error)
}
})
app.get('/manifest/terms/:packKey/:fileName', async (req, res, next) => {
try {
const { packKey, fileName } = req.params
if (!isPublicTermsFile(packKey, fileName)) {
res.status(404).send('Not Found')
return
}
const pack = await loadPackDefinition(packKey)
if (!pack) {
res.status(404).send('Not Found')
return
}
await ensurePackTermsDir(packKey)
res.type('text/markdown; charset=utf-8')
res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
if (!err || res.headersSent) return
res.status(404).send('Not Found')
})
} catch (error) {
next(error)
}
})
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
// 디렉토리 리스팅, 다른 확장자, 경로 탈출은 차단.
app.get('/manifest/:fileName', (req, res) => {
const fileName = req.params.fileName
if (!/^[a-zA-Z0-9_\-]+\.json$/.test(fileName)) {
res.status(404).send('Not Found')
return
}
res.sendFile(path.join(manifestDirPath, fileName), (err) => {
if (!err || res.headersSent) return
res.status(404).send('Not Found')
})
})
// 그 외 /manifest/ 하위 모든 요청 차단 (디렉토리 인덱스 포함).
app.use((req, res, next) => {
if (/^\/manifest\//i.test(req.path)) {
res.status(404).send('Not Found')
return
}
next()
})
// 모드 폴더 안의 .jar 파일 목록을 JSON으로 반환. 설치기가 자동 다운로드용으로 사용.
app.get('/file/mods/:folder/index.json', async (req, res, next) => {
const folder = req.params.folder
if (!/^[a-zA-Z0-9_\-]+$/.test(folder)) {
res.status(404).json({ files: [] })
return
}
const dir = path.join(fileDirPath, 'mods', folder)
try {
const entries = await fsp.readdir(dir)
const files = entries
.filter((name) => /\.jar$/i.test(name))
.filter((name) => !name.includes('/') && !name.includes('\\'))
.sort()
res.json({ files })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
res.status(404).json({ files: [] })
return
}
// async 핸들러의 throw 는 Express4 가 잡지 못해 unhandledRejection 이 되므로 next 로 위임.
next(error)
}
})
app.use('/file', express.static(fileDirPath, { fallthrough: true, index: false }))
app.use('/', indexRouter)
app.use('/', opRouter)
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err)
const message = err instanceof Error ? err.message : t('errors.unknown')
res.status(500).send(t('errors.serverError', { message }))
})
app.listen(PORT, HOST, () => {
console.log(`[server] http://${HOST}:${PORT}`)
console.log(`[server] views: ${path.relative(process.cwd(), viewsDirPath)}`)
})