보안 최우선 개발 가이드 - 해킹당하지 않는 코드 작성하기
"모든 입력은 악의적이고, 모든 사용자는 해커다"
// 1. SQL Injection
// ❌ 절대 금지
const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
// ✅ Prepared Statement 사용
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [req.params.id]);
// 2. XSS (Cross-Site Scripting)
// ❌ 절대 금지
div.innerHTML = userInput;
// ✅ 이스케이프 처리
div.textContent = userInput;
// 또는 DOMPurify 사용
div.innerHTML = DOMPurify.sanitize(userInput);
// 3. 인증 토큰 노출
// ❌ 절대 금지
localStorage.setItem('token', jwt); // XSS에 취약
// ✅ HttpOnly 쿠키 사용
res.cookie('token', jwt, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
// 4. CSRF 방어
// ✅ CSRF 토큰 검증
app.use(csrf());
// 5. Rate Limiting
// ✅ 무차별 대입 공격 방지
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100 // 최대 100번
});
app.use('/api/', limiter);
// 6. HTTPS 강제
// ✅ 프로덕션에서 필수
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
res.redirect(`https://${req.header('host')}${req.url}`);
}
next();
});
// SQL Injection
// ❌ 위험한 코드
const username = req.body.username;
db.query(`SELECT * FROM users WHERE username = '${username}'`);
// ✅ 안전한 코드 (ORM 사용)
const user = await prisma.user.findUnique({
where: { username: req.body.username }
});
// NoSQL Injection
// ❌ 위험한 코드
User.findOne({ username: req.body.username });
// 공격: { "username": { "$ne": null } }
// ✅ 안전한 코드 (타입 검증)
const username = String(req.body.username);
User.findOne({ username });
// ❌ 약한 인증
// - 비밀번호 평문 저장
// - 세션 타임아웃 없음
// - 약한 비밀번호 허용
// ✅ 강한 인증
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
// 비밀번호 해싱
const hashedPassword = await bcrypt.hash(password, 10);
// JWT 생성 (짧은 만료 시간)
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET!,
{ expiresIn: '1h' }
);
// 비밀번호 복잡도 검증
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
if (!passwordRegex.test(password)) {
throw new Error('비밀번호는 8자 이상, 대소문자, 숫자, 특수문자 포함');
}
// ❌ 민감 정보 노출
res.json({
user: {
id: 1,
email: 'user@example.com',
password: hashedPassword, // ❌
ssn: '123456-1234567', // ❌
creditCard: '1234-5678' // ❌
}
});
// ✅ 필요한 정보만 전송
res.json({
user: {
id: 1,
email: 'user@example.com',
name: '홍길동'
}
});
// ✅ 민감 정보 마스킹
const maskedSSN = ssn.replace(/^(\d{6})-(\d)(\d{6})$/, '$1-$2******');
const maskedCard = cardNumber.replace(/^(\d{4})-(\d{4})-(\d{4})-(\d{4})$/, '$1-****-****-$4');
// ❌ 위험한 XML 파싱
const xml2js = require('xml2js');
xml2js.parseString(userInput, callback);
// ✅ 안전한 설정
const xml2js = require('xml2js');
const parser = new xml2js.Parser({
explicitRoot: false,
explicitArray: false,
ignoreAttrs: true
});
// ❌ 권한 검증 없음
app.get('/api/users/:id/orders', async (req, res) => {
const orders = await getOrders(req.params.id);
res.json(orders);
// 다른 사용자의 주문도 볼 수 있음!
});
// ✅ 권한 검증
app.get('/api/users/:id/orders', requireAuth, async (req, res) => {
// 본인 또는 관리자만 조회 가능
if (req.user.id !== req.params.id && !req.user.isAdmin) {
return res.status(403).json({ error: '권한이 없습니다' });
}
const orders = await getOrders(req.params.id);
res.json(orders);
});
// ❌ 위험한 설정
const express = require('express');
const app = express();
// ✅ 안전한 설정
import helmet from 'helmet';
import cors from 'cors';
app.use(helmet()); // 보안 헤더 자동 설정
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true
}));
// 에러 메시지에서 민감 정보 제거
app.use((err, req, res, next) => {
console.error(err.stack); // 서버 로그만
res.status(500).json({
error: process.env.NODE_ENV === 'production'
? '서버 오류가 발생했습니다'
: err.message // 개발 환경에서만 상세 메시지
});
});
// ❌ 위험한 코드
// React
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// Vanilla JS
element.innerHTML = userInput;
// ✅ 안전한 코드
// React (자동 이스케이프)
<div>{userInput}</div>
// DOMPurify 사용 (HTML 필요 시)
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(userInput)
}} />
// Content Security Policy 설정
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
}
}));
// ❌ 위험한 역직렬화
const userData = JSON.parse(req.body.data);
eval(userData.code); // 절대 금지!
// ✅ 안전한 처리
// 1. JSON만 허용
const userData = JSON.parse(req.body.data);
// 2. 스키마 검증
import { z } from 'zod';
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().min(0).max(150)
});
const userData = userSchema.parse(JSON.parse(req.body.data));
# ✅ 정기적으로 업데이트 확인
npm audit
npm audit fix
# package.json에 정확한 버전 명시
{
"dependencies": {
"express": "4.18.2", # ✅ 정확한 버전
"lodash": "^4.17.21" # 😐 마이너 업데이트 허용
}
}
# Dependabot 활성화 (GitHub)
# 자동으로 취약점 알림 + PR 생성
// ✅ 중요한 이벤트 로깅
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// 로그인 시도
logger.info('Login attempt', {
email: req.body.email,
ip: req.ip,
userAgent: req.headers['user-agent'],
success: false
});
// 민감한 작업
logger.warn('Password change', {
userId: req.user.id,
ip: req.ip,
timestamp: new Date()
});
// 의심스러운 활동
if (failedAttempts > 5) {
logger.error('Possible brute force attack', {
email: req.body.email,
ip: req.ip,
attempts: failedAttempts
});
}
// ❌ 절대 금지
// - 평문 저장
// - 로그에 기록
// - URL 파라미터로 전송
// - 클라이언트 저장
// ✅ 안전한 처리
import crypto from 'crypto';
// 암호화 저장
function encryptSSN(ssn: string): string {
const algorithm = 'aes-256-gcm';
const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(ssn, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
// 마스킹 표시
function maskSSN(ssn: string): string {
return ssn.replace(/^(\d{6})-(\d)(\d{6})$/, '$1-$2******');
}
// API 응답
res.json({
user: {
name: '홍길동',
ssn: maskSSN(user.ssn), // 900101-1******
// 원본은 절대 전송하지 않음
}
});
// ❌ 불필요한 정보 수집
interface User {
name: string;
ssn: string; // 정말 필요한가?
address: string; // 정말 필요한가?
phone: string;
email: string;
birthdate: Date; // SSN에서 추출 가능
}
// ✅ 필요한 정보만
interface User {
name: string;
phone: string; // 본인 인증용
email: string;
// 주민번호는 본인 인증 시에만 일회성으로 사용
}
// ✅ NICE/PASS 본인인증 사용
// 주민번호를 직접 수집하지 않고 인증 결과만 받기
interface VerificationResult {
name: string;
birthdate: string; // 생년월일만
gender: 'M' | 'F';
ci: string; // 연계정보 (개인 식별용, 암호화됨)
di: string; // 중복가입확인정보
}
// CI/DI는 암호화된 고유값이므로 안전
// 주민번호 원본은 받지 않음
### 인증 & 인가
- [ ] 비밀번호 해싱 (bcrypt, scrypt)
- [ ] JWT 만료 시간 설정 (< 24시간)
- [ ] Refresh Token 구현
- [ ] Rate Limiting 적용
- [ ] HTTPS 강제
- [ ] 쿠키 보안 플래그 (HttpOnly, Secure, SameSite)
### 입력 검증
- [ ] 모든 입력에 타입 검증
- [ ] SQL Injection 방어 (Prepared Statement)
- [ ] XSS 방어 (이스케이프 처리)
- [ ] CSRF 토큰 검증
- [ ] 파일 업로드 검증 (타입, 크기, 확장자)
### 데이터 보호
- [ ] 민감 정보 암호화
- [ ] 민감 정보 로그 제외
- [ ] 민감 정보 URL 제외
- [ ] 에러 메시지에 내부 정보 제외
- [ ] 개인정보 수집 최소화
### 설정
- [ ] 보안 헤더 설정 (Helmet.js)
- [ ] CORS 설정
- [ ] 환경 변수로 비밀키 관리
- [ ] 불필요한 서비스 비활성화
- [ ] 최신 보안 패치 적용
### 모니터링
- [ ] 로그인 시도 로깅
- [ ] 민감한 작업 로깅
- [ ] 에러 로깅
- [ ] 의심스러운 활동 알림
# 1. 서비스 점검 모드 전환
# 2. 모든 세션 무효화
# 3. 비밀번호 강제 재설정
# 4. 로그 분석
# 5. 취약점 패치
# 6. 사용자 공지
1. 유출 범위 확인
2. 즉시 보안 조치
3. 한국인터넷진흥원(KISA) 신고
4. 개인정보보호위원회 신고
5. 사용자 개별 통지
6. 홈페이지 공지
7. 재발 방지 대책 수립
# 정적 분석
npm install -D eslint-plugin-security
# 의존성 검사
npm audit
snyk test
# 헤더 보안
npm install helmet
# Rate Limiting
npm install express-rate-limit
# CSRF 방어
npm install csurf
# 입력 검증
npm install zod
npm install validator
# 암호화
npm install bcrypt
"보안은 선택이 아닌 필수다"