오늘 내 사이트 보안 점검해봤는데 생각보다 구멍 많더라

2026-04-03·1분 읽기·

오늘 내 사이트 보안 점검해봤는데 생각보다 구멍 많더라

오랜만에 Next.js 사이트를 직접 뒤적거리며 보안 점검을 했다. 발견한 문제들과 어떻게 고쳤는지 실제 커맨드 결과랑 같이 정리.

어느 날 갑자기 "내 사이트 괜찮나?" 싶어서 커피 들고 코드 베이스를 훑어봤다. 딱히 사고가 난 건 아니고 그냥 불안한 느낌? 결론부터 말하면 생각보다 구멍이 많았다. 하나씩 정리해봄.

hero
hero


1. 보호 글인데 본문이 서버에서 그대로 나가고 있었음

이게 제일 황당했는데, 비밀번호 걸어둔 글도 SSR(서버 사이드 렌더링) 단계에서 본문을 통째로 응답에 포함시키고 있었다.

즉, 브라우저에서 잠금 화면처럼 보여도 서버 응답을 잡아보면 본문이 다 있다. 암호 따위 의미 없는 상태.

PasswordGate 화면 — 잠금 UI만 보인다
PasswordGate 화면 — 잠금 UI만 보인다

보이는 건 잠금 화면이지만, 수정 전엔 응답에 본문이 다 들어있었다.

고친 방법: 서버는 제목·요약 같은 메타 정보만 내려보내고, 본문은 클라이언트가 비밀번호 확인 후 별도 API에서 받아오게 바꿨다.

code
// pseudo
if (post.protected) {
  return <PasswordGate slug={slug} />;  // 서버엔 본문 없음
} else {
  return <MDXRemote source={content} />;
}

2. 마크다운을 sanitize 없이 바로 출력하고 있었음

마크다운 → HTML 변환 후 바로 dangerouslySetInnerHTML에 때려 박고 있었다. 그 상태면 [클릭](javascript:alert(1)) 같은 입력으로 XSS가 터진다.

관리자 전용 UI라도 안심할 수 없다. 관리자 계정이 털리면 그게 공격자 입력이 되는 거니까.

고친 방법: marked()로 HTML 만들고 → sanitize-html로 허용 목록 기반 필터링.

code
const raw = marked(mdText);
const safe = sanitizeHtml(raw, {
  allowedTags: ['p','strong','em','a','ul','ol','li','pre','code','table'],
  allowedSchemes: ['http','https','mailto'],
  transformTags: { a: (_, attrs) => ({ tagName: 'a', attribs: { ...attrs, target: '_blank', rel: 'noopener noreferrer' } }) }
});

3. CSP 헤더가 아예 없었음

CSP(Content Security Policy)는 브라우저한테 "이 사이트에서 허용된 리소스만 불러와" 라고 강제하는 헤더다. 없으면 XSS 성공했을 때 외부 스크립트 로드 같은 2차 공격이 훨씬 쉬워진다.

next.config.tsheaders() 추가하는 거라 코드 몇 줄짜리 작업이었다.

실제 보안 헤더 응답
실제 보안 헤더 응답

curl로 실제 응답 헤더를 찍어봤다. CSP 포함 7개 보안 헤더가 전부 붙어있다.


4. 파일 업로드 — 확장자만 보고 있었음

업로드 API에서 파일명 끝이 .png면 그냥 통과시키고 있었다. 근데 malware.phpmalware.png로 이름만 바꿔서 올리면?

파일 위조 차단 테스트
파일 위조 차단 테스트

PHP 코드를 담은 파일을 .png로 위장해서 올렸더니 바로 차단된다. file-type 라이브러리로 실제 파일 내용(매직바이트)을 본다.


5. SSRF — 서버가 내부 IP로 요청하는 거 막아야 함

SSRF(Server-Side Request Forgery)는 "서버가 나 대신 요청을 보내줘" 하는 공격이다. 사용자가 IP를 입력하면 서버에서 그 IP로 fetch하는 기능이 있었는데, 여기에 192.168.1.1 같은 내부 IP를 넣으면 서버가 내부 네트워크에 접근하게 된다.

그래서 입력 IP를 검증해서 private/loopback/link-local 주소는 전부 막았다.

SSRF 차단 실제 결과
SSRF 차단 실제 결과

내부 IP는 Private or reserved IP not allowed로 차단, 외부 공개 IP는 정상 처리된다.


6. Rate Limit — 로그인만 제한하고 있었음

로그인 엔드포인트만 rate limit이 있고 업로드, 채팅, 답변 API에는 없었다. 브루트포스 공격이나 봇 요청에 무방비.

로그인 rate limit 시나리오를 직접 확인해봤다.

Rate Limit 작동 확인
Rate Limit 작동 확인

6번까진 틀린 비밀번호 응답, 7번째부터 RATE_LIMITED. 실제로 작동하는 거 확인.


7. 보안 알림 — 이상 이벤트는 바로 알아야 함

로그인 실패, rate limit 초과, 비정상 파일 업로드 같은 이벤트가 생겨도 아무 알림이 없었다. 실시간으로 알아야 대응이 빠른데.

관리 채널로 이벤트를 보내도록 추가했다. 토큰은 환경변수, 알림은 비동기 처리(실패해도 서비스에 영향 없게).

테스트 겸 아까 로그인 브루트포스 + 파일 위조 업로드를 직접 날려봤더니 이렇게 알림이 왔다.

Telegram 보안 알림 — 로그인 실패 6회 → Rate Limit → 비정상 파일 업로드
Telegram 보안 알림 — 로그인 실패 6회 → Rate Limit → 비정상 파일 업로드

🚨 Admin 로그인 실패 6번, ⚠️ Rate Limit 초과, ⚠️ 비정상 파일 업로드 시도까지 실시간으로 Telegram에 도착했다. 실제로 동작하는 거 확인.


스크린샷 캡처 가이드 (진호님이 직접 찍을 때)

아래는 진호님이 나중에 실제 캡처를 찍어서 이 글에 넣을 때 참고할 명령어와 저장 경로입니다. 로컬에서 실행한 뒤 생성된 파일을 public/images/blog/nextjs-security-hardening-guide/에 넣어주시면 제가 MDX에 바로 반영해 드릴게요.

권장 저장 경로:

  • /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_01_headers.png
  • /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_02_ssrf.png
  • /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_03_ratelimit.png
  • /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_04_upload.png
  • /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_05_protected_gate.png
  • /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_06_telegram_alert.jpg
  • (옵션) /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_vscode_1.png

주의: 아래 예제에서는 민감한 값(토큰 등)을 하드코딩하지 마세요. 업로드 테스트처럼 인증이 필요한 경우 ADMIN_AGENT_TOKEN 등은 환경변수로 넘기거나, 로컬에서 수동 로그인 후 캡처하세요.

A) 터미널 출력 → 이미지 (render_terminal.py 사용)

code
mkdir -p /tmp/caps
 
# 보안 헤더
curl -s -I https://zino.kr/ | grep -E "HTTP|content-security|x-content|x-frame|x-xss|strict-transport|referrer|permissions" > /tmp/caps/cap_01_headers.txt
python3 /home/jinho/Dev/Project/log4shell-lab/render_terminal.py /tmp/caps/cap_01_headers.txt \
  /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_01_headers.png --title "Security Headers" --width 1100
 
# SSRF 테스트
curl -s "https://zino.kr/api/ip-lookup?ip=192.168.1.1" > /tmp/caps/cap_02_ssrf.txt
curl -s "https://zino.kr/api/ip-lookup?ip=127.0.0.1" >> /tmp/caps/cap_02_ssrf.txt
curl -s "https://zino.kr/api/ip-lookup?ip=8.8.8.8" >> /tmp/caps/cap_02_ssrf.txt
python3 /home/jinho/Dev/Project/log4shell-lab/render_terminal.py /tmp/caps/cap_02_ssrf.txt \
  /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_02_ssrf.png --title "SSRF Test" --width 1100
 
# Rate limit 테스트
for i in {1..7}; do 
  curl -s -X POST https://zino.kr/api/studio/auth/login -H "Content-Type: application/json" \
    -d '{"username":"hacker","password":"wrong"}' >> /tmp/caps/cap_03_ratelimit.txt; echo >> /tmp/caps/cap_03_ratelimit.txt; 
done
python3 /home/jinho/Dev/Project/log4shell-lab/render_terminal.py /tmp/caps/cap_03_ratelimit.txt \
  /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_03_ratelimit.png --title "Rate Limit Test" --width 1100
 
# 업로드 차단 테스트 (인증 필요 시 환경변수 사용)
printf "<?php system(\$_GET['cmd']); ?>" > /tmp/caps/fake.php
curl -s -H "X-Agent-Token: $ADMIN_AGENT_TOKEN" \
  -F "file=@/tmp/caps/fake.php;filename=malware.png" \
  "https://zino.kr/api/admin/chat/upload" > /tmp/caps/cap_04_upload.txt
python3 /home/jinho/Dev/Project/log4shell-lab/render_terminal.py /tmp/caps/cap_04_upload.txt \
  /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_04_upload.png --title "Upload Block Test" --width 1100

B) 브라우저(주소창 포함) 캡처 — Playwright(headful) 예제

GUI 디스플레이나 Xvfb가 필요합니다. 보호된 글은 로그인/세션이 필요하면 Playwright에서 세션을 넣거나, 수동 로그인 후 캡처하세요.

code
node -e "const { chromium } = require('playwright'); (async()=>{ 
  const browser = await chromium.launch({ headless: false, args:['--no-sandbox'] });
  const page = await browser.newPage();
  await page.goto('https://zino.kr/blog/sample-all-features', { waitUntil: 'networkidle' });
  await page.waitForTimeout(2000);
  await page.screenshot({ path: '/home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_05_protected_gate.png' });
  await browser.close();
})();"

C) VSCode 코드 캡처

  • 추천: Polacode 확장으로 바로 이미지 생성
  • 대체(스크린샷): VSCode에서 코드 창을 포커스하고, OS 스크린샷 도구로 창을 캡처
code
# 활성 창(포커스된 창) 캡처
scrot /home/jinho/homepage/public/images/blog/nextjs-security-hardening-guide/cap_vscode_1.png -u

D) MDX에 이미지 삽입 샘플

code
<figure>
  <img src="/images/blog/nextjs-security-hardening-guide/cap_01_headers.png" alt="Security headers" />
  <figcaption>curl -I 로 확인한 보안 헤더</figcaption>
</figure>

캡처 파일을 업로드(또는 경로에 복사)해 주시면 제가 MDX에 넣어드릴게요.


마무리

오늘 작업 요약:

항목상태
Protected 글 본문 노출✅ 수정
마크다운 XSS sanitize✅ 적용
CSP + 보안 헤더 7개✅ 적용
파일 매직바이트 검증 + SVG sanitize✅ 적용
Rate limit 전 API 확장✅ 적용
SSRF — Private IP 차단✅ 적용
보안 이벤트 알림✅ 적용

쿠키 옵션(HttpOnly, Secure, SameSite)은 이미 맞게 설정돼 있었다.

다음에 할 것: CSP에서 'unsafe-inline' 없애기(nonce 기반으로 전환), AV 스캔 파이프라인 연동, Redis 레이트 리미터 전환.

ShareX

이 글이 도움이 됐나요?