import 'dotenv/config' import express from 'express' import cors from 'cors' import helmet from 'helmet' import rateLimit from 'express-rate-limit' import cookieParser from 'cookie-parser' import { testWingDbConnection } from './db/wingDb.js' import layersRouter from './routes/layers.js' import simulationRouter from './routes/simulation.js' import authRouter from './auth/authRouter.js' import userRouter from './users/userRouter.js' import roleRouter from './roles/roleRouter.js' import settingsRouter from './settings/settingsRouter.js' import menuRouter from './menus/menuRouter.js' import auditRouter from './audit/auditRouter.js' import boardRouter from './board/boardRouter.js' import hnsRouter from './hns/hnsRouter.js' import reportsRouter from './reports/reportsRouter.js' import assetsRouter from './assets/assetsRouter.js' import incidentsRouter from './incidents/incidentsRouter.js' import scatRouter from './scat/scatRouter.js' import predictionRouter from './prediction/predictionRouter.js' import aerialRouter from './aerial/aerialRouter.js' import rescueRouter from './rescue/rescueRouter.js' import { sanitizeBody, sanitizeQuery, removeServerInfo, BODY_SIZE_LIMIT } from './middleware/security.js' const app = express() const PORT = process.env.PORT || 3001 // ============================================================ // 보안 미들웨어 // ============================================================ // 1. Helmet: HTTP 보안 헤더 설정 // - X-Content-Type-Options: nosniff (MIME 스니핑 방지) // - X-Frame-Options: DENY (클릭재킹 방지) // - X-XSS-Protection: 1 (브라우저 XSS 필터) // - Strict-Transport-Security (HTTPS 강제) // - Content-Security-Policy (컨텐츠 보안 정책) app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "blob:"], connectSrc: [ "'self'", ...(process.env.NODE_ENV !== 'production' ? ['http://localhost:*'] : []), 'https://*.gc-si.dev', 'https://*.data.go.kr', 'https://*.khoa.go.kr', ], fontSrc: ["'self'"], objectSrc: ["'none'"], frameSrc: ["'none'"], } }, crossOriginEmbedderPolicy: false, // API 서버이므로 비활성 crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용 })) // 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지) app.use(removeServerInfo) app.disable('x-powered-by') // 3. CORS: 허용된 출처만 접근 가능 const allowedOrigins = [ process.env.FRONTEND_URL || 'https://wing-demo.gc-si.dev', ...(process.env.NODE_ENV !== 'production' ? [ 'http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000', ] : []), ].filter(Boolean) as string[] app.use(cors({ origin: (origin, callback) => { // 서버-to-서버 요청 (origin 없음) 또는 허용된 출처 if (!origin || allowedOrigins.includes(origin)) { callback(null, true) } else { callback(new Error('CORS 정책에 의해 차단되었습니다.')) } }, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 86400, // preflight 캐시 24시간 })) // 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지 const generalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15분 max: 500, // IP당 최대 500요청 (HLS 스트리밍 고려) standardHeaders: true, legacyHeaders: false, skip: (req) => { // HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외 return req.path.startsWith('/api/aerial/cctv/stream-proxy'); }, message: { error: '요청 횟수 초과', message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.' } }) const simulationLimiter = rateLimit({ windowMs: 60 * 1000, // 1분 max: 10, // IP당 최대 10요청 (시뮬레이션은 비용이 큰 작업) message: { error: '시뮬레이션 요청 횟수 초과', message: '시뮬레이션 요청은 1분당 10회로 제한됩니다.' } }) app.use(generalLimiter) // 5. 쿠키 파서 (JWT 인증 쿠키 처리) app.use(cookieParser()) // 6. JSON 본문 파서 (크기 제한 적용) app.use(express.json({ limit: BODY_SIZE_LIMIT })) app.use(express.text({ limit: BODY_SIZE_LIMIT })) app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT })) // 7. 입력값 살균 미들웨어 app.use(sanitizeBody) app.use(sanitizeQuery) // ============================================================ // 라우트 // ============================================================ // 루트 경로 app.get('/', (_req, res) => { res.json({ name: 'WING Backend API', version: '1.0.0', status: 'running', }) }) // API 라우트 — 인증 app.use('/api/auth', authRouter) app.use('/api/users', userRouter) app.use('/api/roles', roleRouter) app.use('/api/settings', settingsRouter) app.use('/api/menus', menuRouter) app.use('/api/audit', auditRouter) // API 라우트 — 업무 app.use('/api/board', boardRouter) app.use('/api/layers', layersRouter) app.use('/api/simulation', simulationLimiter, simulationRouter) app.use('/api/hns', hnsRouter) app.use('/api/reports', reportsRouter) app.use('/api/assets', assetsRouter) app.use('/api/incidents', incidentsRouter) app.use('/api/scat', scatRouter) app.use('/api/prediction', predictionRouter) app.use('/api/aerial', aerialRouter) app.use('/api/rescue', rescueRouter) // 헬스 체크 app.get('/health', (_req, res) => { res.json({ status: 'ok' }) }) // ============================================================ // 404 처리 // ============================================================ app.use((_req, res) => { res.status(404).json({ error: '요청한 리소스를 찾을 수 없습니다.' }) }) // ============================================================ // 전역 에러 핸들러 (내부 오류 메시지 노출 방지) // ============================================================ app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { // 운영 환경에서는 내부 오류 메시지를 노출하지 않음 const isDev = process.env.NODE_ENV !== 'production' if (isDev) { console.error('서버 오류:', err.message) } res.status(500).json({ error: '서버 내부 오류가 발생했습니다.', ...(isDev && { detail: err.message }) }) }) // ============================================================ // 서버 시작 // ============================================================ app.listen(PORT, async () => { console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) // wing DB 연결 확인 (wing + auth 스키마 통합) const connected = await testWingDbConnection() if (connected) { // SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응) try { const { wingPool } = await import('./db/wingDb.js') await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) } catch { // 이미 TEXT이거나 권한 없으면 무시 } } })