Prologue: 보안은 마지막에 하는 게 아니다
"기능 먼저, 보안은 나중에." 개발하다 보면 자연스럽게 이렇게 생각하게 된다. 근데 보안 취약점이 발견되는 타이밍은 대부분 두 가지 중 하나다: 코드 리뷰 때, 아니면 실제로 뚫렸을 때.
OWASP(Open Worldwide Application Security Project)는 웹 애플리케이션에서 가장 자주 발생하는 취약점 10가지를 정기적으로 발표한다. 이걸 알면 "어디를 조심해야 하는가"에 대한 체크리스트가 생긴다.
2025년 기준으로 최신 OWASP Top 10을 하나씩 코드와 함께 살펴보자.
A01: Broken Access Control (접근 제어 실패)
2021년부터 1위. 제일 흔하고, 제일 치명적이다.
취약한 코드
// 취약한 예시 - 사용자 ID를 URL 파라미터로 받아 그대로 쿼리
app.get("/api/users/:id/profile", async (req, res) => {
const { id } = req.params;
// 로그인한 사용자가 자기 자신의 프로필인지 확인 안 함!
const user = await db.users.findById(id);
res.json(user);
});
// 공격자가 이렇게 날리면 다른 사람 프로필을 볼 수 있음
// GET /api/users/9999/profile
이게 IDOR(Insecure Direct Object Reference) 취약점이다.
// 취약한 예시 2 - 관리자 체크 미흡
app.delete("/api/posts/:id", async (req, res) => {
const post = await db.posts.findById(req.params.id);
// role 체크가 없다!
await post.delete();
res.json({ success: true });
});
안전한 코드
// 수정: 항상 요청자의 권한과 소유권 확인
app.get("/api/users/:id/profile", authenticate, async (req, res) => {
const requestedId = req.params.id;
const currentUserId = req.user.id;
// 본인 프로필이거나 관리자만 접근 가능
if (requestedId !== currentUserId && req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
const user = await db.users.findById(requestedId);
res.json(user);
});
// 수정: 권한 미들웨어 분리
const requireRole = (role: string) => (req: Request, res: Response, next: NextFunction) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
app.delete("/api/posts/:id", authenticate, async (req, res) => {
const post = await db.posts.findById(req.params.id);
// 본인 글이거나 관리자만 삭제 가능
if (post.authorId !== req.user.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
await post.delete();
res.json({ success: true });
});
핵심 원칙: 서버에서는 항상 "이 사람이 이 자원에 접근할 권한이 있는가?"를 확인해라.
A02: Cryptographic Failures (암호화 실패)
예전엔 "민감한 데이터 노출"이라 불렸다. 암호화가 없거나, 약하거나, 잘못 구현된 경우.
취약한 코드
// 1. 비밀번호를 평문으로 저장
await db.users.create({
email: "user@example.com",
password: "mypassword123", // 절대 안 됨
});
// 2. MD5/SHA1 같은 약한 해시 사용
import crypto from "crypto";
const hash = crypto.createHash("md5").update(password).digest("hex");
// 3. HTTP로 민감한 데이터 전송
fetch("http://api.example.com/payment", { // HTTPS 아님!
method: "POST",
body: JSON.stringify({ cardNumber: "1234-5678-9012-3456" }),
});
// 4. 민감한 데이터를 로그에 출력
console.log(`사용자 로그인: ${user.email}, 비밀번호: ${password}`);
안전한 코드
import bcrypt from "bcrypt";
// 비밀번호 저장
const SALT_ROUNDS = 12;
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
await db.users.create({ email, password: hashedPassword });
// 비밀번호 검증
const isValid = await bcrypt.compare(inputPassword, storedHash);
// 민감한 필드는 응답에서 제거
const user = await db.users.findById(id);
const { password: _, ...safeUser } = user;
res.json(safeUser);
// 환경변수로 비밀 관리 (절대 코드에 하드코딩 금지)
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) throw new Error("JWT_SECRET not set");
// 데이터베이스 컬럼 레벨 암호화 (개인정보)
const encryptedPhone = encrypt(user.phone, process.env.ENCRYPTION_KEY!);
await db.users.update({ encryptedPhone });
function encrypt(text: string, key: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-gcm", Buffer.from(key, "hex"), iv);
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
}
A03: Injection (인젝션)
SQL 인젝션이 대표적이지만, OS 커맨드 인젝션, LDAP 인젝션, NoSQL 인젝션도 포함된다.
SQL 인젝션
// 취약한 코드 - 문자열 직접 연결
const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
db.query(query);
// 공격: email = "admin' --"
// 생성되는 쿼리: SELECT * FROM users WHERE email = 'admin' --' AND password = '...'
// 비밀번호 체크가 주석 처리되어 우회!
// 안전한 코드 - Prepared Statements
const result = await db.query(
"SELECT * FROM users WHERE email = $1 AND password = $2",
[email, password] // 파라미터 바인딩
);
// ORM 사용 (기본적으로 안전)
const user = await User.findOne({
where: { email, password },
});
// Prisma
const user = await prisma.user.findFirst({
where: { email },
});
NoSQL 인젝션
// MongoDB 취약한 예시
app.post("/login", async (req, res) => {
const { email, password } = req.body;
// 공격자가 { "$ne": null }을 보내면 모든 사용자 매칭
const user = await User.findOne({ email, password });
});
// 요청: { "email": "admin@example.com", "password": { "$ne": null } }
// → 비밀번호 없이 로그인 가능!
// 안전한 코드 - 타입 검증
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(100),
// 문자열만 허용하므로 객체/연산자 인젝션 불가
});
app.post("/login", async (req, res) => {
const { email, password } = loginSchema.parse(req.body);
const user = await User.findOne({ email });
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: "Invalid credentials" });
}
});
A04: Insecure Design (안전하지 않은 설계)
코드 레벨 버그가 아니라, 설계 단계에서 보안을 고려하지 않은 경우.
예시: 취약한 비밀번호 재설정 플로우
취약한 설계:
1. 이메일 입력
2. 이메일로 "임시 비밀번호 123456" 전송
→ 임시 비밀번호가 예측 가능하거나 재사용됨
→ 이메일이 탈취되면 계정도 탈취
// 안전한 설계: 토큰 기반 재설정
import crypto from "crypto";
// 1. 재설정 요청
app.post("/forgot-password", async (req, res) => {
const { email } = req.body;
const user = await User.findOne({ email });
if (!user) {
// 이메일 존재 여부를 노출하지 않음 (user enumeration 방지)
return res.json({ message: "If this email exists, a reset link was sent." });
}
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 1000 * 60 * 15); // 15분
await PasswordResetToken.create({ userId: user.id, token, expiresAt });
await sendEmail(email, `Reset: https://app.com/reset?token=${token}`);
res.json({ message: "If this email exists, a reset link was sent." });
});
// 2. 재설정 실행
app.post("/reset-password", async (req, res) => {
const { token, newPassword } = req.body;
const resetToken = await PasswordResetToken.findOne({
token,
expiresAt: { $gt: new Date() }, // 만료 체크
used: false, // 재사용 방지
});
if (!resetToken) {
return res.status(400).json({ error: "Invalid or expired token" });
}
await User.update(resetToken.userId, {
password: await bcrypt.hash(newPassword, 12),
});
// 토큰 사용 처리 (재사용 불가)
await resetToken.update({ used: true });
res.json({ message: "Password updated" });
});
A05: Security Misconfiguration (보안 설정 오류)
기본 설정 그대로 두거나, 불필요한 기능을 켜두거나, 에러 메시지가 너무 상세한 경우.
// 취약한 설정들
// 1. 상세한 에러 메시지
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({
error: err.message,
stack: err.stack, // 스택 트레이스 노출 금지!
query: req.query, // 요청 정보 노출 금지!
});
});
// 2. 기본 포트/경로에 관리자 페이지
app.get("/admin", (req, res) => {
// 인증 없는 관리자 페이지
});
// 3. CORS 와일드카드
app.use(cors({ origin: "*" })); // 모든 출처 허용
// 안전한 설정들
// 1. 환경별 에러 처리
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
const isDev = process.env.NODE_ENV === "development";
console.error(err); // 서버 로그에만 기록
res.status(500).json({
error: isDev ? err.message : "Internal Server Error",
// 프로덕션에서 스택 트레이스 숨김
});
});
// 2. CORS 화이트리스트
const allowedOrigins = ["https://myapp.com", "https://app.myapp.com"];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
}));
// 3. Helmet으로 보안 헤더 설정
import helmet from "helmet";
app.use(helmet());
// X-Frame-Options, X-Content-Type-Options, HSTS 등 자동 설정
A06: Vulnerable and Outdated Components (취약하고 구식인 컴포넌트)
# 취약점 확인
npm audit
# 출력 예시
found 3 vulnerabilities (1 moderate, 2 high)
- lodash < 4.17.21 : Prototype Pollution (high)
- axios < 0.21.2 : SSRF vulnerability (high)
# 수정
npm audit fix
npm update axios
# GitHub Dependabot 설정 (.github/dependabot.yml)
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
# 보안 패치 자동 PR 생성
open-pull-requests-limit: 10
A07: Identification and Authentication Failures (식별 및 인증 실패)
취약한 인증 패턴
// 1. 브루트 포스 보호 없음
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (user && user.password === password) { // 평문 비교도 문제
res.json({ token: generateToken(user) });
}
});
// 2. 약한 JWT 설정
const token = jwt.sign({ userId: user.id }, "secret"); // 약한 시크릿
// 만료 시간 없음!
// 안전한 인증
import rateLimit from "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) => {
const { email, password } = loginSchema.parse(req.body);
const user = await User.findOne({ email });
// 이메일 존재 여부를 분리하지 않음 (타이밍 공격 방지)
const isValid = user && await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: "Invalid credentials" });
}
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: "15m", algorithm: "HS256" }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: "7d" }
);
// Refresh Token을 httpOnly 쿠키로 (XSS 방지)
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
});
A08: Software and Data Integrity Failures (소프트웨어 및 데이터 무결성 실패)
CI/CD 파이프라인 보안, 패키지 무결성 검증 실패.
# package-lock.json 사용 (의존성 고정)
npm ci # npm install 대신 npm ci 사용
# Subresource Integrity (SRI) - CDN 스크립트 무결성 검증
<script
src="https://cdn.jsdelivr.net/npm/react@19.0.0/umd/react.production.min.js"
integrity="sha384-abc123..."
crossorigin="anonymous"
></script>
# GitHub Actions - 공식 액션만 사용, 특정 SHA 고정
- uses: actions/checkout@v4 # 태그보다 SHA가 더 안전
# - uses: actions/checkout@abc123def456 # SHA 고정
A09: Security Logging and Monitoring Failures (보안 로깅 및 모니터링 실패)
// 취약한 로깅 - 아무것도 기록하지 않음
app.post("/login", async (req, res) => {
// 로그인 시도를 기록하지 않으면 브루트 포스를 감지 불가
});
// 안전한 로깅
import winston from "winston";
const securityLogger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "security.log" }),
],
});
app.post("/login", loginLimiter, async (req, res) => {
const { email } = req.body;
const ip = req.ip;
try {
const user = await authenticateUser(email, req.body.password);
// 성공 로그
securityLogger.info("login_success", {
userId: user.id,
email: user.email,
ip,
timestamp: new Date().toISOString(),
});
res.json({ token: generateToken(user) });
} catch (err) {
// 실패 로그 - 브루트 포스 탐지에 사용
securityLogger.warn("login_failed", {
email,
ip,
reason: "invalid_credentials",
timestamp: new Date().toISOString(),
});
res.status(401).json({ error: "Invalid credentials" });
}
});
// 알림 임계치
const FAILED_LOGINS_THRESHOLD = 10;
// 같은 IP에서 10회 실패 → Slack 알림, IP 차단 등
A10: Server-Side Request Forgery (SSRF)
서버가 사용자가 제공한 URL로 요청을 보낼 때 발생한다.
// 취약한 코드
app.post("/api/fetch-url", async (req, res) => {
const { url } = req.body;
// 사용자가 내부 서비스 URL을 보내면?
// http://169.254.169.254/latest/meta-data/ (AWS 메타데이터!)
// http://localhost:6379 (Redis!)
const response = await fetch(url);
res.json(await response.json());
});
// 안전한 코드
import { URL } from "url";
const ALLOWED_HOSTS = ["api.trusted-service.com", "cdn.example.com"];
const BLOCKED_RANGES = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./, // AWS 메타데이터
];
function isUrlSafe(urlString: string): boolean {
try {
const url = new URL(urlString);
// HTTPS만 허용
if (url.protocol !== "https:") return false;
// 화이트리스트 확인
if (!ALLOWED_HOSTS.includes(url.hostname)) return false;
// 내부 IP 차단 (DNS 리바인딩 공격 대비는 별도 필요)
if (BLOCKED_RANGES.some((re) => re.test(url.hostname))) return false;
return true;
} catch {
return false;
}
}
app.post("/api/fetch-url", async (req, res) => {
const { url } = req.body;
if (!isUrlSafe(url)) {
return res.status(400).json({ error: "URL not allowed" });
}
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
res.json(await response.json());
});
OWASP Top 10 요약 체크리스트
| # | 취약점 | 핵심 대응 |
|---|---|---|
| A01 | Broken Access Control | 서버에서 항상 권한 검증 |
| A02 | Cryptographic Failures | bcrypt, AES-256, HTTPS |
| A03 | Injection | Prepared statements, 입력 유효성 검사 |
| A04 | Insecure Design | 위협 모델링, 보안 설계 패턴 |
| A05 | Security Misconfiguration | 최소 권한, Helmet, 에러 숨김 |
| A06 | Vulnerable Components | npm audit, Dependabot |
| A07 | Auth Failures | Rate limiting, 강한 JWT, MFA |
| A08 | Integrity Failures | npm ci, SRI, CI/CD 보안 |
| A09 | Logging Failures | 보안 이벤트 로깅, 알림 설정 |
| A10 | SSRF | URL 화이트리스트, 내부 IP 차단 |
결론
OWASP Top 10은 "이것만 막으면 된다"가 아니라 "이것만큼은 반드시 막아야 한다"는 최소 기준이다. 하나씩 코드 리뷰 체크리스트에 넣어라. 특히 A01(접근 제어), A03(인젝션), A07(인증)은 가장 빈번하고 치명적이다.
보안은 기능이 아니라 습관이다.