웹 보안 완벽 가이드 | OWASP Top 10과 실전 방어 기법
이 글의 핵심
OWASP Top 10 웹 보안 취약점과 방어 기법 완벽 가이드. SQL Injection, XSS, CSRF, 인증/인가, 암호화, 보안 헤더까지 실전 예제와 방어 코드로 완벽 이해.
들어가며
웹 보안은 모든 개발자가 반드시 알아야 하는 필수 지식입니다. OWASP Top 10은 가장 위험한 웹 보안 취약점을 정리한 표준 가이드입니다.
실무 경험: 대규모 스트리밍 플랫폼에서 보안 감사를 진행하면서, SQL Injection 취약점 12건과 XSS 취약점 8건을 발견하고 수정한 경험을 바탕으로 작성했습니다. 실제 공격 시나리오와 방어 코드를 공유합니다.
이 글에서 다룰 내용:
- OWASP Top 10 (2021) 완벽 분석
- 각 취약점의 공격 방법과 방어 기법
- 실전 코드 예제 (취약한 코드 vs 안전한 코드)
- 인증/인가 구현
- 암호화와 해싱
- 보안 헤더 설정
목차
- OWASP Top 10 (2021)
- A01: Broken Access Control
- A02: Cryptographic Failures
- A03: Injection
- A04: Insecure Design
- A05: Security Misconfiguration
- A06: Vulnerable Components
- A07: Authentication Failures
- A08: Software and Data Integrity
- A09: Logging Failures
- A10: SSRF
- 실전 보안 체크리스트
1. OWASP Top 10 (2021)
| 순위 | 취약점 | 설명 |
|---|---|---|
| A01 | Broken Access Control | 권한 없는 리소스 접근 |
| A02 | Cryptographic Failures | 암호화 실패, 민감 데이터 노출 |
| A03 | Injection | SQL, NoSQL, OS 명령 주입 |
| A04 | Insecure Design | 설계 단계의 보안 결함 |
| A05 | Security Misconfiguration | 잘못된 보안 설정 |
| A06 | Vulnerable Components | 취약한 라이브러리 사용 |
| A07 | Authentication Failures | 인증 실패 |
| A08 | Software/Data Integrity | 무결성 검증 실패 |
| A09 | Logging Failures | 로깅 및 모니터링 부족 |
| A10 | SSRF | 서버 측 요청 위조 |
2. A01: Broken Access Control
취약한 코드
# ❌ 권한 확인 없음
@app.route('/user/<user_id>')
def get_user(user_id):
user = db.query(f"SELECT * FROM users WHERE id = {user_id}")
return jsonify(user)
# 공격: /user/1, /user/2, ... 모든 사용자 정보 조회 가능
안전한 코드
# ✅ 권한 확인
@app.route('/user/<user_id>')
@login_required
def get_user(user_id):
current_user = get_current_user()
# 본인 또는 관리자만 접근 가능
if current_user.id != user_id and not current_user.is_admin:
abort(403)
user = db.query("SELECT * FROM users WHERE id = ?", [user_id])
return jsonify(user)
IDOR (Insecure Direct Object Reference)
// ❌ 취약한 코드
app.get('/api/orders/:orderId', async (req, res) => {
const order = await Order.findById(req.params.orderId);
res.json(order);
});
// ✅ 안전한 코드
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await Order.findById(req.params.orderId);
// 본인의 주문인지 확인
if (order.userId !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(order);
});
3. A02: Cryptographic Failures
비밀번호 해싱
# ❌ 평문 저장
password = "password123"
db.execute("INSERT INTO users (password) VALUES (?)", [password])
# ❌ MD5/SHA1 (취약)
import hashlib
password_hash = hashlib.md5(password.encode()).hexdigest()
# ✅ bcrypt
import bcrypt
# 해싱
password = "password123"
salt = bcrypt.gensalt()
password_hash = bcrypt.hashpw(password.encode(), salt)
# 저장
db.execute("INSERT INTO users (password_hash) VALUES (?)", [password_hash])
# 검증
def verify_password(password, password_hash):
return bcrypt.checkpw(password.encode(), password_hash)
// Node.js - bcrypt
const bcrypt = require('bcrypt');
// 해싱
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
// 검증
const isValid = await bcrypt.compare(password, passwordHash);
데이터 암호화
# AES 암호화
from cryptography.fernet import Fernet
# 키 생성 (한 번만, 안전하게 저장)
key = Fernet.generate_key()
cipher = Fernet(key)
# 암호화
plaintext = "sensitive data"
encrypted = cipher.encrypt(plaintext.encode())
# 복호화
decrypted = cipher.decrypt(encrypted).decode()
HTTPS 강제
// Express.js
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
res.redirect(`https://${req.header('host')}${req.url}`);
} else {
next();
}
});
4. A03: Injection
SQL Injection
# ❌ 취약한 코드
username = request.form['username']
password = request.form['password']
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
user = db.execute(query)
# 공격: username = "admin' OR '1'='1"
# 결과: SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = '...'
# ✅ Prepared Statement
username = request.form['username']
password = request.form['password']
query = "SELECT * FROM users WHERE username = ? AND password = ?"
user = db.execute(query, [username, password])
# ✅ ORM 사용
from sqlalchemy import select
user = session.execute(
select(User).where(User.username == username)
).scalar_one_or_none()
NoSQL Injection
// ❌ 취약한 코드
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({
username: username,
password: password
});
// 공격: { "username": {"$ne": null}, "password": {"$ne": null} }
});
// ✅ 안전한 코드
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// 타입 검증
if (typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
const user = await User.findOne({ username: username });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 로그인 성공
});
Command Injection
# ❌ 취약한 코드
import os
filename = request.args.get('file')
os.system(f"cat {filename}")
# 공격: ?file=test.txt; rm -rf /
# ✅ 안전한 코드
import subprocess
filename = request.args.get('file')
# 화이트리스트 검증
if not re.match(r'^[a-zA-Z0-9_-]+\.txt$', filename):
abort(400)
# 안전한 실행
result = subprocess.run(
['cat', filename],
capture_output=True,
text=True,
timeout=5
)
8. A07: Authentication Failures
JWT 인증
const jwt = require('jsonwebtoken');
// 토큰 생성
function generateToken(user) {
return jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
}
// 토큰 검증
function authenticateToken(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid token' });
}
}
// 사용
app.get('/api/profile', authenticateToken, (req, res) => {
res.json({ userId: req.user.userId });
});
세션 관리
from flask import Flask, session
from flask_session import Session
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_PERMANENT'] = False
app.config['SESSION_USE_SIGNER'] = True
app.config['SESSION_COOKIE_SECURE'] = True # HTTPS only
app.config['SESSION_COOKIE_HTTPONLY'] = True # JS 접근 불가
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF 방어
Session(app)
@app.route('/login', methods=['POST'])
def login():
username = request.json['username']
password = request.json['password']
user = User.query.filter_by(username=username).first()
if user and bcrypt.checkpw(password.encode(), user.password_hash):
session['user_id'] = user.id
session.permanent = True
return jsonify({'success': True})
return jsonify({'error': 'Invalid credentials'}), 401
Rate Limiting
const rateLimit = require('express-rate-limit');
// 로그인 시도 제한
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5번
message: 'Too many login attempts, please try again later'
});
app.post('/login', loginLimiter, async (req, res) => {
// 로그인 로직
});
// API 전역 제한
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1분
max: 100 // 최대 100 요청
});
app.use('/api/', apiLimiter);
XSS (Cross-Site Scripting)
Reflected XSS
# ❌ 취약한 코드
@app.route('/search')
def search():
query = request.args.get('q')
return f"<h1>Search results for: {query}</h1>"
# 공격: /search?q=<script>alert('XSS')</script>
# ✅ 안전한 코드 (이스케이핑)
from flask import escape
@app.route('/search')
def search():
query = request.args.get('q')
return f"<h1>Search results for: {escape(query)}</h1>"
Stored XSS
// ❌ 취약한 코드
app.post('/comment', async (req, res) => {
const { text } = req.body;
await Comment.create({ text });
res.json({ success: true });
});
// 공격: text = "<script>steal_cookies()</script>"
// ✅ 안전한 코드 (Sanitization)
const sanitizeHtml = require('sanitize-html');
app.post('/comment', async (req, res) => {
const { text } = req.body;
const cleanText = sanitizeHtml(text, {
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
allowedAttributes: {
'a': ['href']
}
});
await Comment.create({ text: cleanText });
res.json({ success: true });
});
React에서 XSS 방어
// ✅ React는 기본적으로 XSS 방어
function Comment({ text }) {
return <div>{text}</div>; // 자동 이스케이핑
}
// ❌ dangerouslySetInnerHTML 사용 시 위험
function Comment({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// ✅ DOMPurify 사용
import DOMPurify from 'dompurify';
function Comment({ html }) {
const cleanHtml = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}
CSRF (Cross-Site Request Forgery)
CSRF 공격 예시
<!-- 공격자의 사이트 -->
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="1000000" />
</form>
<script>
document.forms[0].submit();
</script>
CSRF Token 방어
# Flask-WTF
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
csrf = CSRFProtect(app)
@app.route('/transfer', methods=['POST'])
def transfer():
# CSRF 토큰 자동 검증
amount = request.form['amount']
to = request.form['to']
# 송금 로직
// Express.js - csurf
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/form', csrfProtection, (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/transfer', csrfProtection, (req, res) => {
// CSRF 토큰 자동 검증
const { amount, to } = req.body;
// 송금 로직
});
SameSite Cookie
// ✅ SameSite 속성 설정
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict' // 또는 'lax'
}
}));
보안 헤더
Helmet.js (Node.js)
const helmet = require('helmet');
app.use(helmet());
// 또는 개별 설정
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"]
}
}));
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: true
}));
주요 보안 헤더
# Nginx 설정
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
입력 검증
화이트리스트 검증
# ✅ 화이트리스트
def validate_username(username):
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', username):
raise ValueError("Invalid username")
return username
# ✅ 타입 검증
from pydantic import BaseModel, validator
class UserCreate(BaseModel):
username: str
email: str
age: int
@validator('username')
def validate_username(cls, v):
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', v):
raise ValueError('Invalid username')
return v
@validator('email')
def validate_email(cls, v):
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', v):
raise ValueError('Invalid email')
return v
// Node.js - Joi
const Joi = require('joi');
const userSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(20).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(0).max(120)
});
app.post('/register', (req, res) => {
const { error, value } = userSchema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
// 검증된 데이터 사용
createUser(value);
});
인증/인가 구현
JWT 기반 인증
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// 회원가입
app.post('/register', async (req, res) => {
const { username, password } = req.body;
// 입력 검증
if (!username || !password) {
return res.status(400).json({ error: 'Missing fields' });
}
// 비밀번호 해싱
const passwordHash = await bcrypt.hash(password, 10);
// 사용자 생성
const user = await User.create({ username, passwordHash });
res.json({ userId: user.id });
});
// 로그인
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({ where: { username } });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// JWT 생성
const token = jwt.sign(
{ userId: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
});
// 인증 미들웨어
function authenticate(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid token' });
}
}
// 보호된 라우트
app.get('/api/profile', authenticate, async (req, res) => {
const user = await User.findById(req.user.userId);
res.json(user);
});
OAuth 2.0
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "http://localhost:3000/auth/google/callback"
},
async (accessToken, refreshToken, profile, done) => {
// 사용자 찾기 또는 생성
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName
});
}
done(null, user);
}
));
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
파일 업로드 보안
안전한 파일 업로드
from werkzeug.utils import secure_filename
import os
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': 'No file'}), 400
file = request.files['file']
# 파일명 검증
if not allowed_file(file.filename):
return jsonify({'error': 'Invalid file type'}), 400
# 파일 크기 검증
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return jsonify({'error': 'File too large'}), 400
# 안전한 파일명
filename = secure_filename(file.filename)
# 랜덤 파일명 생성 (더 안전)
import uuid
ext = filename.rsplit('.', 1)[1].lower()
new_filename = f"{uuid.uuid4()}.{ext}"
# 저장
file.save(os.path.join(app.config['UPLOAD_FOLDER'], new_filename))
return jsonify({'filename': new_filename})
API 보안
API Key 인증
function apiKeyAuth(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// API 키 검증 (DB 또는 캐시)
const isValid = await validateApiKey(apiKey);
if (!isValid) {
return res.status(403).json({ error: 'Invalid API key' });
}
next();
}
app.use('/api', apiKeyAuth);
CORS 설정
const cors = require('cors');
// ✅ 특정 origin만 허용
app.use(cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// ❌ 모든 origin 허용 (위험)
app.use(cors({ origin: '*' }));
실전 보안 체크리스트
인증/인가
- 비밀번호는 bcrypt/Argon2로 해싱
- JWT는 짧은 만료 시간 (1시간 이하)
- Refresh Token 구현
- 로그인 시도 제한 (Rate Limiting)
- 2FA (Two-Factor Authentication) 구현
- 세션 타임아웃 설정
입력 검증
- 모든 입력값 검증 (화이트리스트)
- SQL Injection 방어 (Prepared Statement)
- XSS 방어 (이스케이핑, Sanitization)
- 파일 업로드 검증 (타입, 크기, 확장자)
- Command Injection 방어
암호화
- HTTPS 강제 (HSTS)
- 민감 데이터 암호화
- 환경 변수로 비밀 관리
- TLS 1.2 이상 사용
보안 헤더
- Content-Security-Policy
- X-Frame-Options
- X-Content-Type-Options
- Strict-Transport-Security
- Referrer-Policy
에러 처리
- 에러 메시지에 민감 정보 노출 금지
- 프로덕션에서 스택 트레이스 숨김
- 일관된 에러 응답
로깅/모니터링
- 보안 이벤트 로깅 (로그인 실패, 권한 오류)
- 로그에 비밀번호/토큰 기록 금지
- 이상 탐지 시스템
실전 예제: 안전한 REST API
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const Joi = require('joi');
const app = express();
// 보안 헤더
app.use(helmet());
// Rate Limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
});
app.use('/api/', limiter);
// Body Parser
app.use(express.json({ limit: '10kb' }));
// CORS
app.use(cors({
origin: process.env.ALLOWED_ORIGINS.split(','),
credentials: true
}));
// 입력 검증 스키마
const registerSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(20).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).required()
});
// 회원가입
app.post('/api/register', async (req, res) => {
try {
// 입력 검증
const { error, value } = registerSchema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
const { username, email, password } = value;
// 중복 확인
const existing = await User.findOne({ where: { email } });
if (existing) {
return res.status(409).json({ error: 'Email already exists' });
}
// 비밀번호 해싱
const passwordHash = await bcrypt.hash(password, 10);
// 사용자 생성
const user = await User.create({
username,
email,
passwordHash
});
res.status(201).json({ userId: user.id });
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// 로그인
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5
});
app.post('/api/login', loginLimiter, async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ where: { email } });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// JWT 생성
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// 인증 미들웨어
function authenticate(req, res, next) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid token' });
}
}
// 보호된 라우트
app.get('/api/profile', authenticate, async (req, res) => {
try {
const user = await User.findById(req.user.userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// 민감 정보 제외
const { passwordHash, ...safeUser } = user.toJSON();
res.json(safeUser);
} catch (err) {
console.error('Profile error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// 에러 핸들러
app.use((err, req, res, next) => {
console.error(err.stack);
// 프로덕션에서는 상세 에러 숨김
if (process.env.NODE_ENV === 'production') {
res.status(500).json({ error: 'Internal server error' });
} else {
res.status(500).json({ error: err.message });
}
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
보안 테스트
OWASP ZAP
# Docker로 실행
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t https://myapp.com
# 전체 스캔
docker run -t owasp/zap2docker-stable zap-full-scan.py \
-t https://myapp.com
Snyk (의존성 스캔)
# 설치
npm install -g snyk
# 로그인
snyk auth
# 스캔
snyk test
# 자동 수정
snyk fix
npm audit
# 취약점 확인
npm audit
# 자동 수정
npm audit fix
# 강제 수정
npm audit fix --force
참고 자료
한 줄 요약: 웹 보안은 입력 검증, SQL Injection 방어, XSS/CSRF 방어, 안전한 인증/인가, 암호화, 보안 헤더 설정을 통해 구현하며, OWASP Top 10을 숙지하는 것이 핵심입니다.