
웹 보안: 해커로부터 애플리케이션 지키기
웹 애플리케이션은 항상 공격의 대상입니다. 단 하나의 취약점이 전체 시스템을 무너뜨릴 수 있습니다. 이 가이드에서는 가장 흔한 웹 취약점과 그 방어법을 알아봅니다.
OWASP Top 10
OWASP(Open Web Application Security Project)는 가장 위험한 웹 취약점 10가지를 매년 발표합니다.
1. SQL Injection (SQL 인젝션)
공격 원리: 사용자 입력을 검증 없이 SQL 쿼리에 직접 삽입하면 발생합니다.
// 취약한 코드 (절대 사용 금지!)
app.get('/user', (req, res) => {
const userId = req.query.id;
const query = `SELECT * FROM users WHERE id = ${userId}`;
db.query(query, (err, results) => {
res.json(results);
});
});
// 공격 예시
// GET /user?id=1 OR 1=1 → 모든 사용자 정보 유출
// GET /user?id=1; DROP TABLE users; → 테이블 삭제
방어 방법:
1) Prepared Statements (매개변수화된 쿼리)
// 안전한 코드
app.get('/user', (req, res) => {
const userId = req.query.id;
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [userId], (err, results) => {
res.json(results);
});
});
2) ORM 사용
// Sequelize (ORM)
const user = await User.findOne({
where: { id: userId }
});
// SQL Injection 자동 방지
3) 입력 검증
const validateUserId = (id) => {
const numericId = parseInt(id, 10);
if (isNaN(numericId) || numericId < 1) {
throw new Error("유효하지 않은 ID");
}
return numericId;
};
app.get('/user', (req, res) => {
try {
const userId = validateUserId(req.query.id);
// ... 안전한 쿼리 실행
} catch (error) {
res.status(400).json({ error: error.message });
}
});
2. XSS (Cross-Site Scripting)
공격 원리: 악의적인 JavaScript 코드를 웹 페이지에 삽입하여 실행시킵니다.
// 취약한 코드
app.get('/search', (req, res) => {
const keyword = req.query.q;
res.send(`<h1>검색 결과: ${keyword}</h1>`);
});
// 공격 예시
// GET /search?q=<script>alert(document.cookie)</script>
// → 쿠키 탈취 가능
방어 방법:
1) HTML 이스케이핑
const escapeHtml = (unsafe) => {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
};
app.get('/search', (req, res) => {
const keyword = escapeHtml(req.query.q);
res.send(`<h1>검색 결과: ${keyword}</h1>`);
});
2) React 등 프레임워크 사용
// React는 자동으로 이스케이핑
function SearchResults({ keyword }) {
return <h1>검색 결과: {keyword}</h1>;
// <script> 태그도 텍스트로 표시됨
}
3) Content Security Policy (CSP)
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' https://trusted-cdn.com"
);
next();
});
4) DOMPurify 사용
import DOMPurify from 'dompurify';
const dirty = '<img src=x onerror=alert(1)>';
const clean = DOMPurify.sanitize(dirty);
// <img src="x"> (onerror 제거됨)
3. CSRF (Cross-Site Request Forgery)
공격 원리: 사용자가 의도하지 않은 요청을 강제로 실행시킵니다.
<!-- 악의적인 사이트 -->
<img src="https://bank.com/transfer?to=hacker&amount=1000000">
<!-- 사용자가 bank.com에 로그인되어 있으면 자동 실행 -->
방어 방법:
1) CSRF 토큰
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/transfer', csrfProtection, (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
app.post('/transfer', csrfProtection, (req, res) => {
// CSRF 토큰 검증 후 실행
processTransfer(req.body);
});
<!-- 폼에 CSRF 토큰 포함 -->
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="to" placeholder="받는 사람">
<input name="amount" placeholder="금액">
<button type="submit">송금</button>
</form>
2) SameSite 쿠키
res.cookie('sessionId', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict' // 다른 사이트에서 쿠키 전송 차단
});
3) Referer 검증
app.post('/transfer', (req, res) => {
const referer = req.get('Referer');
if (!referer || !referer.startsWith('https://bank.com')) {
return res.status(403).send('Forbidden');
}
// ... 송금 처리
});
4. 인증 및 세션 관리 취약점
취약한 예:
// 나쁜 예 1: 평문 비밀번호 저장
db.query('INSERT INTO users (username, password) VALUES (?, ?)',
[username, password]); // 절대 금지!
// 나쁜 예 2: 약한 세션 ID
const sessionId = Math.random().toString(); // 예측 가능
// 나쁜 예 3: 세션 타임아웃 없음
// 영구적인 세션은 위험
안전한 방법:
1) 비밀번호 해싱
const bcrypt = require('bcrypt');
// 회원가입
app.post('/signup', async (req, res) => {
const { username, password } = req.body;
const hashedPassword = await bcrypt.hash(password, 10);
await db.query(
'INSERT INTO users (username, password) VALUES (?, ?)',
[username, hashedPassword]
);
});
// 로그인
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await db.query(
'SELECT * FROM users WHERE username = ?',
[username]
);
const isValid = await bcrypt.compare(password, user.password);
if (isValid) {
// 세션 생성
req.session.userId = user.id;
res.json({ success: true });
} else {
res.status(401).json({ error: '인증 실패' });
}
});
2) 안전한 세션 관리
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET, // 환경 변수로 관리
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // JavaScript 접근 차단
maxAge: 3600000, // 1시간 후 만료
sameSite: 'strict'
}
}));
3) 2단계 인증 (2FA)
const speakeasy = require('speakeasy');
// 2FA 설정
app.post('/setup-2fa', (req, res) => {
const secret = speakeasy.generateSecret();
// QR 코드 생성하여 사용자에게 제공
res.json({ secret: secret.base32 });
});
// 로그인 시 2FA 검증
app.post('/verify-2fa', (req, res) => {
const { token } = req.body;
const verified = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: 'base32',
token: token
});
if (verified) {
req.session.userId = user.id;
res.json({ success: true });
}
});
5. 민감한 데이터 노출
취약한 예:
// 나쁜 예 1: API 응답에 민감 정보 포함
app.get('/user/:id', (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // password, ssn 등 모두 노출!
});
// 나쁜 예 2: 에러 메시지에 민감 정보
try {
await db.query(query);
} catch (error) {
res.status(500).json({ error: error.message });
// "Duplicate entry 'admin@example.com' for key 'email'"
// → 이메일 존재 여부 노출
}
안전한 방법:
1) 필요한 필드만 반환
app.get('/user/:id', async (req, res) => {
const user = await User.findById(req.params.id);
// 민감한 정보 제외
const safeUser = {
id: user.id,
username: user.username,
email: user.email,
// password, ssn 등 제외
};
res.json(safeUser);
});
2) 환경 변수로 비밀 정보 관리
// .env 파일
DATABASE_URL=postgresql://user:pass@localhost/db
JWT_SECRET=your-secret-key
API_KEY=your-api-key
// 코드에서 사용
require('dotenv').config();
const dbUrl = process.env.DATABASE_URL;
// .gitignore에 .env 추가
3) HTTPS 사용
const https = require('https');
const fs = require('fs');
const options = {
key: fs.readFileSync('private-key.pem'),
cert: fs.readFileSync('certificate.pem')
};
https.createServer(options, app).listen(443);
6. 파일 업로드 취약점
취약한 예:
// 나쁜 예: 파일 검증 없음
app.post('/upload', upload.single('file'), (req, res) => {
// 악의적인 파일 (shell.php, virus.exe) 업로드 가능
res.json({ filename: req.file.filename });
});
안전한 방법:
const multer = require('multer');
const path = require('path');
const storage = multer.diskStorage({
destination: './uploads/',
filename: (req, file, cb) => {
// 1. 파일명 무작위화
const uniqueName = `${Date.now()}-${Math.random().toString(36)}${path.extname(file.originalname)}`;
cb(null, uniqueName);
}
});
const fileFilter = (req, file, cb) => {
// 2. 파일 타입 검증
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('허용되지 않는 파일 형식'), false);
}
};
const upload = multer({
storage: storage,
fileFilter: fileFilter,
limits: {
fileSize: 5 * 1024 * 1024 // 3. 파일 크기 제한 (5MB)
}
});
app.post('/upload', upload.single('file'), (req, res) => {
// 4. 파일 스캔 (바이러스 검사)
scanFile(req.file.path)
.then(() => res.json({ success: true }))
.catch(() => {
fs.unlinkSync(req.file.path); // 위험 파일 삭제
res.status(400).json({ error: '악성 파일 감지' });
});
});
보안 헤더 설정
const helmet = require('helmet');
app.use(helmet()); // 기본 보안 헤더 자동 설정
// 또는 수동 설정
app.use((req, res, next) => {
// XSS 방지
res.setHeader('X-XSS-Protection', '1; mode=block');
// 클릭재킹 방지
res.setHeader('X-Frame-Options', 'DENY');
// MIME 타입 스니핑 방지
res.setHeader('X-Content-Type-Options', 'nosniff');
// HTTPS 강제
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
Rate Limiting (속도 제한)
const rateLimit = require('express-rate-limit');
// 일반 API
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100, // 최대 100회 요청
message: '너무 많은 요청입니다. 나중에 다시 시도하세요.'
});
app.use('/api/', apiLimiter);
// 로그인 API (더 엄격)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 15분에 5회만 허용
skipSuccessfulRequests: true
});
app.post('/login', loginLimiter, (req, res) => {
// 로그인 처리
});
입력 검증 및 Sanitization
const validator = require('validator');
app.post('/user', (req, res) => {
const { email, username, age } = req.body;
// 이메일 검증
if (!validator.isEmail(email)) {
return res.status(400).json({ error: '유효하지 않은 이메일' });
}
// 사용자명 검증 (영문, 숫자만)
if (!validator.isAlphanumeric(username)) {
return res.status(400).json({ error: '사용자명은 영문과 숫자만 가능' });
}
// 나이 검증
if (!validator.isInt(age.toString(), { min: 1, max: 120 })) {
return res.status(400).json({ error: '유효하지 않은 나이' });
}
// Sanitization
const cleanEmail = validator.normalizeEmail(email);
const cleanUsername = validator.escape(username);
// 저장
saveUser({ email: cleanEmail, username: cleanUsername, age });
});
보안 체크리스트
개발 단계
- 모든 입력 검증 및 Sanitization
- SQL Injection 방지 (Prepared Statements)
- XSS 방지 (HTML 이스케이핑)
- CSRF 토큰 사용
- 비밀번호 해싱 (bcrypt, argon2)
- HTTPS 사용
- 보안 헤더 설정 (Helmet.js)
- Rate Limiting 적용
- 파일 업로드 검증
- 환경 변수로 비밀 정보 관리
배포 단계
- 의존성 취약점 검사 (
npm audit) - 최신 보안 패치 적용
- 방화벽 설정
- 로그 모니터링
- 정기적인 백업
- 침투 테스트 (Penetration Testing)
보안 도구
# 의존성 취약점 검사
npm audit
npm audit fix
# 정적 분석
npm install -g eslint-plugin-security
eslint --plugin security .
# 런타임 보안
npm install helmet express-rate-limit
핵심 원칙:
- 모든 입력을 의심하라
- 최소 권한 원칙을 따르라
- 심층 방어(Defense in Depth) 전략 사용
- 정기적인 보안 감사 실시
작은 실수 하나가 큰 피해로 이어질 수 있습니다. 보안은 선택이 아닌 필수입니다.
