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>
This commit is contained in:
2026-07-11 13:27:32 +09:00
parent 651c63faa6
commit face70b3e9
3 changed files with 25 additions and 3 deletions

3
account.example.json Normal file
View File

@@ -0,0 +1,3 @@
[
{ "id": "admin", "password": "여기에-비밀번호를-넣으세요" }
]

View File

@@ -6,7 +6,8 @@ import fsp from 'node:fs/promises'
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { import {
manifestRootPath, manifestDirPath, manifestTermsDirPath, manifestRootPath, manifestDirPath, manifestTermsDirPath,
fileDirPath, viewsDirPath, publicDirPath, projectRoot fileDirPath, viewsDirPath, publicDirPath, projectRoot,
accountFilePath, accountLocalFilePath
} from '../shared/paths.js' } from '../shared/paths.js'
import { import {
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
@@ -27,7 +28,24 @@ const app = express()
app.set('view engine', 'ejs') app.set('view engine', 'ejs')
app.set('views', viewsDirPath) app.set('views', viewsDirPath)
app.set('trust proxy', 1) // 리버스 프록시 뒤일 때만 켠다. 항상 켜두면 직접 노출 시 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.urlencoded({ extended: true }))
app.use(express.json()) app.use(express.json())

View File

@@ -795,6 +795,7 @@ export async function readAccounts(): Promise<AccountEntry[]> {
} }
// 운영 계정 저장은 항상 gitignore 된 account.local.json 에만 한다(추적 파일 오염 방지). // 운영 계정 저장은 항상 gitignore 된 account.local.json 에만 한다(추적 파일 오염 방지).
// 비밀번호(해시) 파일이므로 소유자만 읽기/쓰기(0o600).
export async function writeAccounts(accounts: AccountEntry[]): Promise<void> { export async function writeAccounts(accounts: AccountEntry[]): Promise<void> {
await fsp.writeFile(accountLocalFilePath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf8') await fsp.writeFile(accountLocalFilePath, `${JSON.stringify(accounts, null, 2)}\n`, { mode: 0o600 })
} }