XSS 방어 · CSRF 방어와 인증 · SQL 인젝션 방어 · HTTPS와 환경변수 관리
XSS의 3가지 유형, innerHTML의 위험성과 textContent로의 안전한 대안, 서버측 이스케이프, DOMPurify로 부분 HTML 허용, 그리고 CSP 헤더로 추가 보호층을 익힙니다. CSRF 공격 시나리오, SameSite 쿠키와 CSRF 토큰 두 가지 방어법, 세션과 JWT의 동작 차이, 그리고 httpOnly·secure·sameSite·maxAge 보안 쿠키 속성을 익힙니다. SQL 인젝션 공격 원리와 대표 페이로드, Prepared Statement(파라미터화 쿼리)가 안전한 이유, ORM 방어 구조, 동적 컬럼명용 화이트리스트, 그리고 에러 메시지 노출 방지를 익힙니다. HTTP vs HTTPS와 TLS 인증서, Let's Encrypt 무료 발급, helmet으로 보안 헤더 일괄 설정, .env로 시크릿 안전 관리, .env.example 패턴, 그리고 시크릿 유출 시 즉시 대응법까지.
이 강의에서 배우는 것
- 1Stored / Reflected / DOM XSS 의 차이를 안다
- 2innerHTML 대신 textContent를 사용한다
- 3DOMPurify로 마크다운/리치텍스트를 안전하게 처리한다
- 4CSP 헤더와 helmet으로 추가 보호층을 만든다
- 5CSRF 공격 시나리오를 설명한다
- 6SameSite=Strict/Lax/None 의 동작을 안다
- 7CSRF 토큰 발급·검증 미들웨어를 작성한다
- 8세션과 JWT의 장단점을 비교한다
- 9httpOnly/secure/sameSite/maxAge 4종 세트를 적용한다
- 10SQL 인젝션의 동작 원리를 안다
- 11Prepared Statement (?, $1, @name)을 사용한다
- 12ORM이 자동으로 파라미터화하는 이유를 안다
- 13동적 컬럼명/정렬 방향에 화이트리스트를 적용한다
- 14에러 메시지를 사용자에게 노출하지 않는다
- 15HTTP/HTTPS와 TLS Handshake의 흐름을 안다
- 16Let's Encrypt + Certbot 으로 무료 인증서를 발급한다
- 17HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy / CSP를 설정한다
- 18helmet 미들웨어로 한 번에 적용한다
- 19.env로 시크릿을 관리하고 .gitignore + .env.example 패턴을 따른다
- 20시크릿이 유출됐을 때 즉시 무효화·히스토리 정리·예방을 한다
XSS 방어
XSS의 3가지 유형, innerHTML의 위험성과 textContent로의 안전한 대안, 서버측 이스케이프, DOMPurify로 부분 HTML 허용, 그리고 CSP 헤더로 추가 보호층을 익힙니다.
1. XSS란
공격자가 웹 페이지에 악성 스크립트를 삽입해 다른 사용자 브라우저에서 실행시키는 공격. 쿠키/세션 탈취, 키로거, 피싱 리다이렉트 등.
2. XSS 3가지 유형
Stored XSS — DB에 저장
[공격자] 댓글에 <script>document.cookie를 전송</script>
[서버] DB에 저장
[피해자] 페이지 방문 시 자동 실행Reflected XSS — URL에 반사
악성 URL: https://bank.com/search?q=<script>...</script>
공격자가 피해자에게 이메일로 전송 → 서버 응답에 그대로 반사 → 실행DOM XSS — 클라이언트 JS
// 취약: URL 해시를 그대로 innerHTML
const hash = location.hash.substring(1);
document.getElementById('output').innerHTML = hash;
// 공격: https://site.com/#<img src=x onerror=악성코드>3. innerHTML의 위험성
// ✗ 절대 금지
element.innerHTML = userInput;
// 공격자: '<img src=x onerror="document.cookie 전송">' → XSS!
// ✓ textContent (HTML 태그를 텍스트로)
element.textContent = userInput;
// '<script>...' 가 그대로 화면에 텍스트로 표시
// ✓ createElement + textContent
const p = document.createElement('p');
p.textContent = userInput;
container.appendChild(p);4. 서버측 이스케이프
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// <script>alert(1)</script>
// → <script>alert(1)</script>
// → 화면에 텍스트로만 표시5. DOMPurify
마크다운 에디터처럼 일부 HTML 태그는 허용해야 할 때.
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>const dirty = '<img src=x onerror=alert(1)> <b>안전한 텍스트</b>';
const clean = DOMPurify.sanitize(dirty);
// 결과: " <b>안전한 텍스트</b>" ← onerror 제거, 안전한 태그 유지
element.innerHTML = DOMPurify.sanitize(userInput);6. CSP (Content-Security-Policy)
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline';"
);
next();
});
// 권장: helmet
const helmet = require('helmet');
app.use(helmet());CSP가 있으면 <script>alert(1)</script> 같은 인라인 스크립트가 차단됩니다.
CSRF 방어와 인증
CSRF 공격 시나리오, SameSite 쿠키와 CSRF 토큰 두 가지 방어법, 세션과 JWT의 동작 차이, 그리고 httpOnly·secure·sameSite·maxAge 보안 쿠키 속성을 익힙니다.
1. CSRF란
인증된 사용자를 속여 의도하지 않은 요청을 서버에 보내게 하는 공격.
1. 피해자가 bank.com 로그인 (세션 쿠키)
2. 공격자가 evil.com에 악성 폼 심어둠:
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="공격자계좌">
<input name="amount" value="1000000">
</form>
<script>document.forms[0].submit();</script>
3. 피해자가 evil.com 방문
4. 브라우저가 bank.com에 자동으로 쿠키 포함하여 POST
5. bank.com은 유효한 세션으로 판단 → 송금 실행!2. SameSite 쿠키 (현대적 방어)
// Strict: 같은 사이트에서의 요청에만
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
});
// Lax: GET은 허용, POST는 차단 (권장)
res.cookie('sessionId', token, { sameSite: 'Lax' });
// None: 크로스사이트 허용 (Secure 필수)
res.cookie('sessionId', token, { sameSite: 'None', secure: true });| 값 | 동작 |
|---|---|
| Strict | 외부 사이트 링크로 온 GET도 쿠키 없음 (가장 엄격) |
| Lax | 외부 GET은 허용, POST 등은 차단 (권장) |
| None | 모두 허용 (Secure 필수) |
3. CSRF 토큰
// 1. 토큰 생성 (서버)
const crypto = require('crypto');
const csrfToken = crypto.randomBytes(32).toString('hex');
req.session.csrfToken = csrfToken;
res.render('form', { csrfToken });<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="amount" type="number">
<button type="submit">송금</button>
</form>function csrfProtect(req, res, next) {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const submitted = req.body._csrf || req.headers['x-csrf-token'];
const stored = req.session.csrfToken;
if (!submitted || submitted !== stored) {
return res.status(403).json({ error: 'CSRF 토큰 검증 실패' });
}
}
next();
}4. 세션 vs JWT
세션
[클라이언트] ── POST /login ──> [서버]
세션 생성 { userId: 1 }
<── Set-Cookie: sid=abc ── 저장소(Redis/DB)에 저장
[클라이언트] ── GET /profile ──> [서버] (Cookie: sid=abc)
저장소에서 세션 조회 → userId 확인장점: 즉시 무효화 가능 / 단점: 저장소 필요, 수평 확장 시 세션 공유
JWT
[클라이언트] ── POST /login ──> [서버]
JWT 생성 { userId: 1, exp }
<── JWT 토큰 ────── (서버에 저장 안 함)
[클라이언트] ── GET /profile ──> [서버] (Authorization: Bearer eyJ...)
JWT 서명 검증 → userId 확인장점: 저장소 불필요, 수평 확장 / 단점: 만료 전 무효화 어려움
5. httpOnly / Secure 쿠키
res.cookie('token', value, {
httpOnly: true, // JS에서 document.cookie 접근 불가 → XSS로 쿠키 탈취 방지
secure: true, // HTTPS에서만 전송 → 도청 방지
sameSite: 'Lax', // CSRF 방어
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
path: '/',
});| 속성 | 목적 |
|---|---|
| httpOnly | XSS를 통한 쿠키 탈취 방지 |
| secure | 네트워크 도청 방지 |
| sameSite | CSRF 방지 |
| maxAge | 세션 하이재킹 최소화 (짧을수록 안전) |
SQL 인젝션 방어
SQL 인젝션 공격 원리와 대표 페이로드, Prepared Statement(파라미터화 쿼리)가 안전한 이유, ORM 방어 구조, 동적 컬럼명용 화이트리스트, 그리고 에러 메시지 노출 방지를 익힙니다.
1. SQL 인젝션이란
// 취약: 사용자 입력을 문자열로 직접 삽입
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;공격자 입력: username = admin' --, password = 아무거나
SELECT * FROM users WHERE username = 'admin' --' AND password = '아무거나'
-- 이후가 주석 처리 → 비밀번호 확인 없이 admin 로그인!2. 대표 페이로드
-- 항상 참
' OR '1'='1
' OR 1=1 --
-- 주석으로 무력화
admin' --
admin'/*
-- UNION으로 다른 쿼리
' UNION SELECT username, password FROM users --
-- 데이터 삭제
'; DROP TABLE users; --3. Prepared Statement (파라미터화 쿼리)
// ✗ 취약
const query = `SELECT * FROM users WHERE username = '${username}'`;
db.query(query);
// ✓ 안전
const stmt = db.prepare('SELECT * FROM users WHERE username = ? AND password = ?');
const user = stmt.get(username, password);
// username에 ' OR 1=1 -- 를 넣어도 SQL로 해석되지 않고 그냥 문자열로 처리원리: 일반 쿼리는 SQL 파싱 → 데이터 삽입 → 실행. Prepared는 SQL 파싱 → 실행 → 데이터 바인딩. 데이터가 SQL 파싱에 개입할 수 없음.
4. ORM은 자동 방어
// Prisma — 자동으로 Prepared Statement
const user = await prisma.user.findFirst({
where: { username: username }
});
// 내부: SELECT * FROM users WHERE username = $1
// username 값은 항상 데이터로 처리됨
// Mongoose — NoSQL이라 SQL 인젝션 없음 (NoSQL 인젝션 별도 주의)
const user = await User.findOne({ username: username });5. 동적 컬럼명 — 화이트리스트
// 컬럼명/테이블명은 Prepared Statement로 해결 불가
const ALLOWED_SORT_COLUMNS = ['name', 'price', 'created_at'];
const ALLOWED_ORDERS = ['ASC', 'DESC'];
function buildSafeQuery(sortBy, order) {
const safeColumn = ALLOWED_SORT_COLUMNS.includes(sortBy) ? sortBy : 'created_at';
const safeOrder = ALLOWED_ORDERS.includes(order?.toUpperCase()) ? order.toUpperCase() : 'DESC';
return `SELECT * FROM products ORDER BY ${safeColumn} ${safeOrder}`;
}6. 에러 메시지 노출 금지
// ✗ 나쁜 예 — DB 구조 노출
catch (err) {
res.status(500).json({ error: err.message });
// "SQLITE_ERROR: no such table: users" → 테이블 이름 노출!
}
// ✓ 좋은 예
catch (err) {
console.error('[에러]', err); // 서버 로그
res.status(500).json({ error: '검색 중 오류가 발생했습니다.' });
}HTTPS와 환경변수 관리
HTTP vs HTTPS와 TLS 인증서, Let's Encrypt 무료 발급, helmet으로 보안 헤더 일괄 설정, .env로 시크릿 안전 관리, .env.example 패턴, 그리고 시크릿 유출 시 즉시 대응법까지.
1. HTTP vs HTTPS
HTTP (평문): 클라이언트 ── 비밀번호:abc123 ─> 서버
↑ 중간자가 읽을 수 있음
HTTPS (암호화): 클라이언트 ── 5h#@x9!K3m... ──> 서버
↑ 암호화 — 해독 불가TLS Handshake: 서버가 인증서(공개키) 전송 → CA 검증 → 대칭키 안전하게 교환 → 이후 통신은 대칭키로 암호화.
Let's Encrypt — 무료 인증서
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com
# 자동 갱신 (90일 만료)
sudo certbot renew --dry-run2. 보안 HTTP 헤더
app.use((req, res, next) => {
// HTTPS만 허용 (1년)
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// 클릭재킹 방지 (iframe 삽입 차단)
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
// MIME 스니핑 방지
res.setHeader('X-Content-Type-Options', 'nosniff');
// 참조 URL 정보 제한
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// 리소스 출처 제한 (XSS 방어 보조)
res.setHeader('Content-Security-Policy', "default-src 'self'");
next();
});3. helmet (권장)
npm install helmetconst helmet = require('helmet');
// 모든 보안 헤더 한 번에
app.use(helmet());
// 또는 개별 설정
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true },
}));4. 환경변수로 시크릿 관리
절대 하지 말 것
// ✗ 하드코딩
const DB_PASSWORD = 'my_secret_password';
const JWT_SECRET = 'jwt-secret-key-123';
const API_KEY = 'sk-abcdefghijk12345';
// ✗ package.json에도 금지
{
"scripts": {
"start": "DB_PASSWORD=secret node app.js"
}
}.env
DATABASE_URL=mongodb+srv://user:pass@cluster.mongodb.net/db
JWT_SECRET=매우긴랜덤문자열여기에
API_KEY=sk-abcdefghijk12345
SESSION_SECRET=또다른랜덤문자열
PORT=3000require('dotenv').config();
mongoose.connect(process.env.DATABASE_URL);
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
console.error('JWT_SECRET이 설정되지 않았습니다!');
process.exit(1); // 시크릿 없으면 서버 시작 거부
}5. .gitignore + .env.example
# .gitignore
.env
.env.local
.env.production
*.pem # SSL 인증서
node_modules/# .env.example (Git에 포함 — 실제 값 없이 키만)
DATABASE_URL=your_mongodb_connection_string
JWT_SECRET=generate_a_random_string_here
API_KEY=your_api_key_here
SESSION_SECRET=another_random_string
PORT=30006. 시크릿 유출 대응
즉시 해야 할 일
- 즉시 키 무효화 — API 키, 비밀번호 즉시 변경
- Git 히스토리 정리 — 과거 커밋에서 시크릿 제거
- 피해 평가 — 유출된 키로 무엇을 할 수 있었는지
# 히스토리에서 시크릿 파일 제거
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch .env' \
--prune-empty --tag-name-filter cat -- --all
# 또는 BFG Repo Cleaner (더 안전)
bfg --delete-files .env예방
# pre-commit hook으로 커밋 전 검사
npm install -g git-secrets
git secrets --install
git secrets --register-aws댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 주세요.
댓글은 회원만 쓸 수 있습니다.
광고·욕설·비방·개인정보·불법 내용이 담긴 댓글은 숨기거나 지웁니다. 댓글은 닉네임과 함께 공개됩니다. 개인정보처리방침