OWASP Top 10으로 보는 웹 보안: 접근 제어, 암호화 실패, 인젝션, XSS·CSRF 방어, 보안 헤더

이 글의 핵심

OWASP Top 10의 주요 항목을 실제 공격 예시와 함께 살펴보고, 접근 제어와 인젝션·XSS·CSRF를 막는 코드, 보안 헤더와 입력 검증 설정을 정리합니다.

들어가며

웹 서비스 사고에서 흔히 보는 패턴은 화려한 제로데이가 아니라 사소한 설정 실수입니다. 스테이징용 .env가 그대로 운영에 올라가 디버그 모드와 테스트용 API 키가 열려 있거나, 로그가 7일치만 남아 있어서 공격이 언제 시작됐는지조차 확인할 수 없는 식입니다. 이런 사고의 사후 분석을 읽어 보면 “체크리스트에는 다 있었다”는 말이 반복됩니다. 문제는 항목을 몰랐던 것이 아니라, 그 항목이 이 배포, 이 환경에서 지켜졌는지 확인하는 절차가 없었다는 점입니다.

그래서 먼저 분명히 해 두겠습니다. 보안은 체크리스트가 아닙니다. OWASP Top 10을 외우는 것은 시험 범위를 잡는 것과 비슷하지만, 시험지에는 늘 “그 외” 문제가 한두 개 더 나옵니다. 체크리스트는 누락을 줄이는 도구일 뿐이며, “지금 이 요청, 이 사용자, 이 경로에서 정말 맞는가?”를 의심하는 습관이 훨씬 값집니다.

개인적으로 정리해 둔 교훈 (다른 팀에는 맞을 수도, 맞지 않을 수도 있습니다):

  • “나중에 고칠게”는 거의 늪입니다. 민감 설정·권한 경로는 티켓을 미루는 순간 단순한 취약점이 아니라 사고가 됩니다.
  • 로그·알람은 장식이 아니라 사후 입증과 탐지를 위한 것입니다. 사고가 터진 뒤 “왜 미리…”를 줄이는 것이 목표라면, 배포마다 같은 질문이 대시보드에 뜨게 만드는 것이 좋습니다.
  • 코드 리뷰에서 “이게 보안 문제냐?”라는 말이 나오기 전에, PR 설명에 “누가·무엇을·왜”가 한 줄이라도 있으면 IDOR·접근 통제 쪽 실수가 확실히 줄었습니다.

이 글에서 다룰 내용 (형식은 가이드지만, “내 서비스에 무엇이 해당될지”를 기준으로 골라 읽어도 됩니다):

  • OWASP Top 10 (2021)을 표가 아니라 한 번에 짚는 지도처럼
  • 취약·안전한 코드 쌍
  • 인증/인가, 암호화, 보안 헤더
  • 맨 뒤 부록에 운영·트러블슈팅용으로 짧게 정리

OWASP Top 10 (2021)

아래는 표로 묶지 않고, 2021판 기준으로 A01~A10이 각각 왜 위험한지만 빠르게 훑어보는 용도입니다.

  • A01 · Broken Access Control — “로그인만 했지, 이 데이터까지 볼 권한이 있나?”를 안 물어본 상태로 리소스가 열릴 때.
  • A02 · Cryptographic Failures — 암호를 잘못 쓰거나(혹은 안 써서) 민감한 게 그냥 노출될 때.
  • A03 · Injection — SQL·NoSQL·OS 명령·템플릿에 사용자 입력이 그대로 끼어들 때.
  • A04 · Insecure Design — 코드 품질 문제가 아니라, “이 플로우가 원래 약하다”는 설계 단계부터.
  • A05 · Security Misconfiguration — 디버그 켜둔 채로 prod, 기본 비번, CORS * 같은 “설정만”의 실수.
  • A06 · Vulnerable and Outdated Components — npm audit이 울릴 때 안 듣는 구간.
  • A07 · Identification and Authentication Failures — 세션, 비번 정책, MFA 빼먹기 등 인증/세션 쪽.
  • A08 · Software and Data Integrity Failures — 파이프라인·업데이트·서명 없이 “신뢰”를 건너뛸 때.
  • A09 · Security Logging and Monitoring Failures — 무언가 터졌는지 모르는 상태로 지나갈 때.
  • A10 · Server-Side Request Forgery (SSRF) — 서버가 공격자가 시키는 URL로 요청을 보낼 때(내부망 스캔·메타데이터 털기 루트).

A01: Broken Access Control

2021판에서 접근 제어가 1위로 올라온 이유는 자동화 도구로 찾기 가장 어려운 취약점이기 때문입니다. SQL 인젝션은 스캐너가 페이로드를 던져 보면 드러나지만, “사용자 A가 사용자 B의 주문을 볼 수 있다”는 사실은 비즈니스 규칙을 알아야 판별할 수 있습니다. 그래서 방어도 코드 한 줄이 아니라 “모든 리소스 조회에 소유자 조건이 붙는가”라는 구조의 문제가 됩니다.

취약한 코드

# ❌ 권한 확인 없음
@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, ... 모든 사용자 정보 조회 가능

위 코드는 권한 검사가 없는 것과 동시에 id = {user_id}로 SQL 인젝션까지 열려 있습니다. 실제 서비스에서도 두 문제가 같이 나오는 경우가 많습니다.

안전한 코드

# ✅ 권한 확인
@app.route('/user/<int:user_id>')  # int 변환기: 문자열 '1'과 정수 1 비교 실수를 막음
@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 || String(order.userId) !== String(req.user.id)) {
    return res.status(404).json({ error: 'Not found' });
  }
  
  res.json(order);
});

여기서 자주 틀리는 부분이 두 가지 있습니다. 첫째, order가 null이면 order.userId에서 TypeError: Cannot read properties of null이 나고 500 응답과 스택 트레이스가 새어 나갑니다. 둘째, Mongoose에서 order.userId는 ObjectId 객체이고 JWT에서 꺼낸 req.user.id는 문자열이라 !== 비교가 항상 참이 됩니다. 처음 이 패턴을 넣었을 때 본인 주문도 전부 403이 나서 원인을 찾느라 시간을 쓰는 경우가 흔한데, 반대로 비교를 ==로 느슨하게 바꾸는 식의 “수정”이 들어가면 다른 구멍이 생기기 쉽습니다. 문자열로 명시 변환하거나 order.userId.equals(req.user.id)를 쓰는 편이 안전합니다.

403 대신 404를 돌려주는 것은 선택입니다. 403은 “그 ID의 주문이 존재한다”는 정보를 흘리므로, 순차 ID를 쓰는 서비스라면 404로 통일하는 편이 열거 공격에 덜 노출됩니다. 더 근본적으로는 Order.findOne({ _id: orderId, userId: req.user.id })처럼 쿼리 자체에 소유자 조건을 넣으면 검사 누락이 구조적으로 불가능해집니다. 엔드포인트마다 if 문을 붙이는 방식은 새 라우트를 추가하는 사람이 잊어버리는 순간 무너집니다.


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);

MD5·SHA-1이 위험한 이유는 “깨졌기” 때문만이 아니라 너무 빠르기 때문입니다. 범용 해시는 GPU에서 초당 수십억 번 계산되도록 설계되어 있어, 유출된 해시 목록을 사전 대입으로 되돌리는 비용이 매우 낮습니다. bcrypt·scrypt·Argon2는 의도적으로 느리고(cost 인자), scrypt와 Argon2는 메모리까지 많이 쓰게 만들어 병렬 크래킹을 어렵게 합니다. 새로 설계한다면 OWASP Password Storage Cheat Sheet가 1순위로 꼽는 Argon2id를, 기존 bcrypt 시스템이라면 cost 10 이상을 유지하면 됩니다.

bcrypt에는 알아 둘 함정이 하나 있습니다. 입력의 앞 72바이트만 사용하므로, 긴 패스프레이즈나 “pepper + 비밀번호”처럼 앞부분이 고정된 문자열을 넣으면 뒤쪽이 무시됩니다. 또 cost를 올리면 로그인 한 번에 수백 ms가 걸려 CPU가 병목이 되므로, 로그인 엔드포인트에 rate limit이 없으면 해시 계산 자체가 DoS 벡터가 됩니다.

데이터 암호화

# 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()

Fernet은 AES-128-CBC와 HMAC-SHA256을 묶은 인증 암호화 포맷이라, 직접 AES 모드와 IV를 고르다 실수할 여지를 없애 줍니다. 실무에서 어려운 쪽은 암호화 자체보다 키 관리입니다. 키를 소스 코드나 같은 DB에 두면 DB 덤프 한 번에 암호문과 키가 함께 유출되므로, KMS나 시크릿 매니저에 두고 MultiFernet으로 키 교체 경로를 미리 만들어 두는 것이 좋습니다.

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();
  }
});

x-forwarded-proto는 로드 밸런서나 리버스 프록시가 붙여 주는 헤더라, 프록시 없이 앱이 직접 노출된 환경에서는 클라이언트가 임의로 넣을 수 있습니다. Express라면 app.set('trust proxy', 1) 후 req.secure를 쓰는 편이 의도가 분명하고, 리다이렉트는 가능하면 Nginx나 CDN 단계에서 처리하고 HSTS 헤더로 브라우저가 처음부터 HTTPS로만 접속하게 만드는 것이 정석입니다. 또 host 헤더를 그대로 리다이렉트 URL에 넣으면 오픈 리다이렉트로 악용될 수 있으므로, 운영에서는 고정 도메인을 쓰는 편이 안전합니다.


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 = '...'

공격 문자열의 핵심은 '로 문자열 리터럴을 닫아 버리는 것입니다. 그 뒤부터는 사용자 입력이 “값”이 아니라 SQL 문법으로 해석되고, AND가 OR보다 우선순위가 높기 때문에 username = 'admin' OR ('1'='1' AND password = '...')처럼 묶여 비밀번호 검사가 사실상 무력화됩니다. 작은따옴표를 이스케이프하는 식의 방어는 인코딩 차이나 숫자형 컬럼(따옴표가 없는 id = {x})에서 쉽게 우회되므로, 해법은 쿼리 구조와 값을 아예 분리해 보내는 것입니다.

# ✅ Prepared Statement (구조와 값을 분리)
# 참고: 실제로는 비밀번호를 SQL에서 비교하지 말고, 사용자만 조회한 뒤 bcrypt.checkpw로 검증합니다
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()

파라미터 바인딩으로 막을 수 없는 자리도 있습니다. ORDER BY {sort}의 컬럼명, 테이블명, LIMIT 절 일부 드라이버처럼 식별자 위치에는 플레이스홀더를 쓸 수 없어서, 개발자가 여기만 문자열로 끼워 넣는 경우가 많습니다. 이 자리는 {'name': User.username, 'date': User.created_at} 같은 허용 목록으로 매핑하고 목록에 없는 값은 거부해야 합니다.

NoSQL Injection

MongoDB 쿼리는 문자열이 아니라 객체라서 “인젝션과 무관하다”고 생각하기 쉽지만, express.json()이 요청 본문을 그대로 객체로 파싱하기 때문에 공격자는 문자열 대신 {"$ne": null} 같은 연산자 객체를 보낼 수 있습니다. 아래 코드의 방어 핵심은 타입 검사입니다. express-mongo-sanitize 같은 미들웨어로 $로 시작하는 키를 제거하는 방법도 있지만, 스키마 검증(Joi, zod)으로 “문자열이어야 한다”를 강제하는 쪽이 의도가 더 명확합니다.

// ❌ 취약한 코드
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
)

os.system과 subprocess.run(..., shell=True)는 문자열을 /bin/sh -c에 넘기므로 ;, &&, $( ), 백틱이 모두 명령 구분자로 해석됩니다. 인자를 리스트로 넘기면 셸을 거치지 않고 execve로 바로 실행되어 메타문자가 의미를 잃습니다. 다만 리스트 방식도 만능은 아닙니다. 파일명이 -로 시작하면 옵션으로 해석되는 인자 인젝션이 가능하므로(--로 옵션 종료를 표시), 위처럼 정규식으로 형식을 제한하는 이중 방어가 필요합니다. 가장 좋은 방법은 파일 읽기처럼 파이썬으로 직접 할 수 있는 일에 외부 명령을 쓰지 않는 것입니다.


A07: Authentication Failures

JWT 인증

JWT는 서버가 세션 저장소를 두지 않아도 된다는 장점이 있지만, 그 대가로 발급한 토큰을 만료 전에 취소할 수 없습니다. 비밀번호를 바꾸거나 계정을 정지해도 이미 나간 토큰은 expiresIn까지 유효하므로, 액세스 토큰은 짧게 두고 취소가 필요한 쪽은 서버에 저장되는 리프레시 토큰으로 관리하는 구성이 일반적입니다. 아래 코드에서 눈여겨볼 점은 jwt.verify가 서명과 exp를 함께 검사한다는 것입니다. jwt.decode는 서명을 검증하지 않으므로 인증에 쓰면 안 됩니다. 또 algorithms: ['HS256']처럼 허용 알고리즘을 명시해 두면, 공개키를 HMAC 비밀로 오용하게 만드는 알고리즘 혼동 공격을 차단할 수 있습니다.

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 });
});

토큰이 만료되거나 서명이 틀린 경우 이 코드는 403을 돌려주는데, HTTP 의미상으로는 “인증 정보가 유효하지 않다”는 401이 맞습니다. 프런트엔드가 401을 받으면 리프레시를 시도하도록 짜 두는 경우가 많아서, 여기서 403을 쓰면 만료된 사용자가 재로그인 흐름으로 넘어가지 못하고 “권한 없음” 화면에 갇히는 문제가 생깁니다.

세션 관리

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

세션 방식에서 빠뜨리기 쉬운 것은 로그인 성공 시 세션 ID 재발급입니다. 로그인 전 세션을 그대로 이어 쓰면 공격자가 미리 심어 둔 세션 ID로 피해자가 로그인하는 세션 고정(session fixation) 공격이 가능합니다. Flask-Session에서는 로그인 직후 session.clear()로 기존 데이터를 비우고, 버전에 따라 제공되는 세션 재생성 API(app.session_interface.regenerate(session) 등)를 쓰는 편이 안전합니다. 참고로 위 코드는 설정에서 SESSION_PERMANENT = False로 두고 로그인에서 session.permanent = True로 덮어쓰므로, 실제 만료 시간은 PERMANENT_SESSION_LIFETIME(기본 31일)을 따릅니다. 의도한 값인지 확인해야 합니다.

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);

express-rate-limit의 기본 저장소는 프로세스 메모리라서, 인스턴스를 여러 개 띄우면 인스턴스 수만큼 한도가 늘어나고 재시작하면 카운트가 초기화됩니다. 운영에서는 Redis 저장소를 붙이는 것이 보통입니다. 또 프록시 뒤에서 trust proxy를 설정하지 않으면 모든 요청이 프록시 IP 하나로 집계되어 정상 사용자 전체가 동시에 차단됩니다. 최근 버전은 이 상황을 감지하면 ERR_ERL_UNEXPECTED_X_FORWARDED_FOR 경고를 로그에 남기므로, 배포 후 로그를 한 번 확인해 보는 것이 좋습니다.


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 markupsafe import escape  # Flask 2.3+에서 flask.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 }} />;
}

XSS 방어의 원칙은 “입력 시 정화”보다 출력 위치에 맞는 인코딩입니다. 같은 문자열이라도 HTML 본문, 속성값, <script> 안의 JS 문자열, URL에 들어갈 때 필요한 이스케이프가 모두 다르기 때문입니다. React의 자동 이스케이핑도 HTML 본문과 속성값까지만 보호합니다. <a href={userUrl}>에 javascript:alert(1)이 들어오면 React 18까지는 콘솔 경고만 출력하고 렌더링은 그대로 하므로, 링크 URL은 https:/http: 스킴만 허용하는 검사가 따로 필요합니다.

저장 시점에 sanitize-html로 정화하는 방식도 트레이드오프가 있습니다. 정화 규칙을 나중에 바꾸면 이미 저장된 데이터에는 적용되지 않고, 원본이 사라져 다른 출력 형식(이메일, 모바일 앱)으로 재가공하기 어렵습니다. 그래서 원본을 저장하고 렌더링 직전에 DOMPurify로 정화하는 구성을 택하는 팀이 많습니다.


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']
    # 송금 로직

CSRF가 성립하는 이유는 브라우저가 다른 사이트에서 시작된 요청에도 대상 도메인의 쿠키를 자동으로 붙이기 때문입니다. 따라서 쿠키로 인증하는 API만 대상이 되고, Authorization: Bearer 헤더를 JavaScript로 직접 붙이는 API는 공격자 페이지가 그 토큰을 읽을 수 없으므로 CSRF에는 해당하지 않습니다(대신 토큰을 localStorage에 두면 XSS 한 번에 탈취됩니다).

아래 csurf 패키지는 2022년에 유지보수가 중단되어 deprecated 상태입니다. 기존 코드를 이해하는 용도로만 참고하고, 새 프로젝트에서는 csrf-csrf(double submit cookie 방식) 같은 대안이나 프레임워크 내장 기능을 쓰는 편이 좋습니다.

// Express.js - csurf (deprecated, 참고용)
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 속성 설정
app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    httpOnly: true,
    secure: true,  // HTTPS only
    sameSite: 'strict'  // 또는 'lax'
  }
}));

strict는 외부 사이트의 링크를 눌러 들어올 때도 쿠키를 보내지 않으므로, 메일이나 메신저 링크로 접속한 사용자가 로그인이 풀린 것처럼 보이는 부작용이 있습니다. 그래서 대부분의 서비스는 lax를 기본으로 씁니다. lax는 최상위 GET 이동에는 쿠키를 보내므로 GET 요청으로 상태를 바꾸는 엔드포인트(GET /delete?id=3 같은)가 있으면 여전히 뚫립니다. SameSite는 CSRF 토큰을 대체한다기보다 한 겹 더 쌓는 방어로 보는 것이 맞습니다.


보안 헤더

Helmet.js (Node.js)

const helmet = require('helmet');
app.use(helmet());
// 또는 개별 설정
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],  // 작은따옴표 포함 필수: 'self' 없이 self만 쓰면 "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 "0" always;  # 구형 XSS 필터 비활성화 (OWASP 권고)
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;

헤더 설정에서 실제로 문제가 되는 지점은 세 가지입니다. 첫째, script-src 'unsafe-inline'을 허용하면 CSP가 XSS를 막는 효과가 대부분 사라집니다. 인라인 스크립트를 정리하기 어렵다면 nonce나 hash 기반 CSP로 옮기고, 처음에는 Content-Security-Policy-Report-Only로 위반 보고만 받아 보며 정책을 조이는 방식이 안전합니다. 둘째, HSTS의 preload와 includeSubDomains는 되돌리기 어렵습니다. preload 목록에 등록되면 브라우저에 하드코딩되어 제거에 수개월이 걸리므로, HTTP로만 동작하는 사내 서브도메인이 하나라도 있으면 그 서비스가 접속 불가가 됩니다. 셋째, Nginx의 add_header는 하위 블록에 하나라도 add_header가 있으면 상위 블록의 설정을 상속하지 않습니다. location 블록에 캐시 헤더 하나를 추가했더니 그 경로에서만 보안 헤더가 전부 사라지는 경우가 흔하므로, 공통 헤더는 include로 묶어 각 블록에 넣는 편이 좋습니다.


입력 검증

화이트리스트 검증

입력 검증은 “나쁜 것을 걸러내는” 블랙리스트보다 “허용되는 형식만 통과시키는” 화이트리스트가 훨씬 견고합니다. <script>를 막으면 <img onerror=...>가 들어오고, 그것을 막으면 대소문자나 인코딩을 바꾼 변형이 들어오는 식으로 블랙리스트는 끝이 없기 때문입니다. 다만 입력 검증은 인젝션·XSS 방어를 대체하지 않습니다. 이름 필드에 O'Brien처럼 정상적인 작은따옴표가 들어올 수 있으므로, 검증은 형식을 좁히는 1차 방어이고 파라미터 바인딩과 출력 인코딩이 본 방어입니다. 아래 pydantic 예제의 @validator는 v1 문법이며, pydantic v2에서는 @field_validator로 바뀌었고 EmailStr 타입을 쓰면 이메일 정규식을 직접 관리할 필요가 없습니다.

# ✅ 화이트리스트
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 인증

async 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);

API 키는 DB에 평문으로 두지 말고 SHA-256 같은 해시로 저장한 뒤 들어온 키를 해시해서 비교하는 것이 좋습니다. 비밀번호와 달리 API 키는 충분히 긴 무작위 값이라 느린 해시가 필요 없고, DB가 유출돼도 키 원문은 남지 않습니다. 키 앞부분에 sk_live_ 같은 접두사를 붙여 두면 GitHub 시크릿 스캐닝 같은 도구가 유출을 감지하기도 쉬워집니다.

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: '*' }));

브라우저는 Access-Control-Allow-Origin: *와 credentials: true 조합을 거부하므로 * 자체는 쿠키 탈취로 이어지지 않습니다. 더 위험한 것은 이 오류를 없애려고 요청의 Origin을 그대로 반사하는 설정(origin: true)입니다. 그러면 어떤 사이트든 사용자의 쿠키를 실어 API를 호출하고 응답까지 읽을 수 있습니다. CORS 동작 방식은 CORS 정리 글에서 더 자세히 다룹니다.


실전 보안 체크리스트

인증/인가

  • 비밀번호는 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 cors = require('cors');
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로 실행
# (구 owasp/zap2docker-stable 이미지는 지원 종료, 현재는 GHCR 이미지 사용)
docker run -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py \
  -t https://myapp.com
# 전체 스캔 (실제 공격 페이로드를 보내므로 운영 환경 금지)
docker run -t ghcr.io/zaproxy/zaproxy:stable zap-full-scan.py \
  -t https://myapp.com

baseline 스캔은 페이지를 크롤링하며 응답 헤더와 쿠키 속성 같은 수동 점검만 하므로 CI에 넣기 좋습니다. full scan은 폼에 인젝션 페이로드를 실제로 제출하기 때문에 테스트 데이터가 대량으로 생기고 계정이 잠길 수도 있어, 스테이징 전용으로 돌리는 것이 원칙입니다. 이런 스캐너는 A01(접근 제어) 같은 비즈니스 로직 결함은 거의 찾지 못한다는 점도 기억해 둘 만합니다.

Snyk (의존성 스캔)

# 설치
npm install -g snyk
# 로그인
snyk auth
# 스캔
snyk test
# 자동 수정
snyk fix

npm audit

# 취약점 확인
npm audit
# 자동 수정
npm audit fix
# 강제 수정 (메이저 버전 업그레이드 포함, 주의)
npm audit fix --force

npm audit fix --force는 semver 범위를 무시하고 메이저 버전을 올리기 때문에, 취약점 경고는 사라져도 빌드가 깨지거나 동작이 바뀌는 일이 잦습니다. 반대로 audit 경고 상당수는 개발 의존성이나 실제로 호출되지 않는 경로에 있는 경우도 많아서, 경고 수를 0으로 만드는 것보다 런타임에 로드되는 패키지의 높은 심각도 항목부터 확인하는 편이 현실적입니다. npm audit --omit=dev로 운영 의존성만 볼 수 있습니다.


자주 묻는 질문 (FAQ)

Q. 로그인 인증만 있으면 /api/orders/:orderId 같은 API는 안전한가요?

A. 아닙니다. 인증은 누가 요청했는지만 확인할 뿐이라, 로그인한 사용자가 URL의 orderId만 바꿔 다른 사람의 주문을 조회하는 IDOR 취약점이 그대로 남습니다. 조회한 리소스의 소유자(order.userId)가 요청한 사용자와 같은지 서버에서 확인하고 다르면 403을 돌려줘야 합니다. 이런 객체 단위 권한 검사는 엔드포인트마다 빠지기 쉬우므로 공통 미들웨어나 쿼리 조건에 소유자 필터를 넣는 방식으로 일관되게 적용하는 것이 좋습니다.

참고 자료

한 줄 요약 (다시 말하지만, 보안은 체크리스트가 아닙니다): 입력·권한·세션·비밀·로그·헤더를 “한 흐름”으로 보면 구멍이 어디로 새는지가 보이며, OWASP Top 10은 그 지도에 이름을 붙이는 역할에 가깝습니다.


같이 보면 좋은 글