사이트 보안점검 후기

2026-04-03·1분 읽기·

사이트 보안점검 후기

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

기존 Wordpress 기반 홈페이지에서 Nextjs 기반 홈페이지로 최근 마이그래이션을 했다. 완료 후 보안 점검 진행해보았고 결과를 정리해보았다.

hero
hero


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

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

즉, 브라우저에서 잠금 화면처럼 보여도 서버 응답을 잡아보면 본문이 다 있다. 기존에 .env.local로 암호를 지정해놨는데 암호 우회가 가능한 상태였다.

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

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

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

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

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

나는 포스트 작성 시 mdx를 이용하는데, 관리자 채팅 UI에서 마크다운 → 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' } }) }
});

sanitize-html은 허용 목록에 없는 태그/속성을 전부 날린다. javascript: URL도 차단. 간단한데 효과적이다.


3. CSP 헤더가 아예 없었음

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

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

실제 curl -I 응답 — 보안 헤더 7개 확인
실제 curl -I 응답 — 보안 헤더 7개 확인

curl -I https://zino.kr/로 실제 응답 헤더를 찍어봤다. CSP 포함 7개 보안 헤더가 전부 붙어있다.

적용한 헤더 목록:

  • Content-Security-Policy — 스크립트/리소스 출처 제어
  • X-Frame-Options: DENY — clickjacking 방지
  • X-Content-Type-Options: nosniff — MIME 타입 위조 차단
  • X-XSS-Protection — 구형 브라우저 XSS 필터
  • Referrer-Policy — 참조 URL 노출 제어
  • Permissions-Policy — 카메라/마이크/위치 접근 차단
  • Strict-Transport-Security — HTTPS 강제

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

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

PHP 코드를 .png로 위장 업로드 시 차단
PHP 코드를 .png로 위장 업로드 시 차단

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

매직바이트(magic bytes)란 파일 맨 앞에 있는 고정 바이트 시퀀스다. .png 파일이라면 앞 8바이트가 89 50 4E 47 ...이어야 하는데, PHP 파일을 열어보면 3C 3F 70 68 70(= <?php)으로 시작한다. 이걸 보면 속임수가 바로 들통난다.

추가로 SVG는 내부에 <script> 태그나 on* 이벤트 속성을 심을 수 있어서 따로 텍스트 파싱으로 제거했다.


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

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

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

SSRF 차단 — 내부 IP 차단 / 외부 IP 통과
SSRF 차단 — 내부 IP 차단 / 외부 IP 통과

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


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

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

로그인 brute-force — 7번째 시도에서 RATE_LIMITED
로그인 brute-force — 7번째 시도에서 RATE_LIMITED

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

중앙화된 rate-limiter를 만들어서 로그인, 업로드, 채팅, 답변 등 주요 엔드포인트에 각각 한도를 뒀다. 지금은 인메모리라 서버 재시작하면 초기화되는데 나중에 Redis로 바꿀 예정.


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

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

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

테스트 겸 로그인 brute-force + 파일 위조 업로드를 직접 날려봤더니 이렇게 알림이 왔다.

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

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


마무리

오늘 작업 요약:

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

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

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

ShareX

이 글이 도움이 됐나요?