서비스를 배포하고 보안팀으로부터 메일을 받습니다. "취약점 스캔 결과 — TLS 1.0 활성화, 보안 헤더 미설정, 관리자 페이지 외부 접근 허용." 이게 얼마나 위험한 것인지, 어떻게 고쳐야 하는지 막막합니다.
보안은 한 번에 완벽하게 만드는 것이 아닙니다. 실무에서는 "지금 당장 꼭 해야 하는 것"부터 순서대로 적용합니다. 이 모듈은 인프라 엔지니어가 직접 Nginx 설정으로 적용할 수 있는 보안 강화 항목들을 다룹니다.
- 1Nginx에서 관리자 경로를 IP 기반으로 제한하고 숨김 파일 접근을 차단할 수 있다
- 2보안 헤더(X-Frame-Options, HSTS, CSP 등) 5개를 Nginx에 설정할 수 있다
- 3CORS 설정에서 와일드카드 대신 특정 출처만 허용하는 방법을 적용할 수 있다
- 4쿠키에 Secure, HttpOnly, SameSite 속성을 설정하는 방법을 설명할 수 있다
- 5TLS 1.0/1.1을 비활성화하고 강화된 cipher suite를 적용할 수 있다
계정 권한 관리
root 직접 접속 차단과 불필요 계정 관리
보안 감사 결과서를 받았습니다. 첫 번째 항목이 "root 직접 로그인 허용"입니다. 서버를 처음 세팅했을 때 편의를 위해 놔뒀던 설정인데, 공격자가 SSH 브루트포스를 시도할 때 root 계정이 존재하면 하나의 계정만 크래킹하면 시스템 전체를 장악할 수 있습니다. 감사에서 지적받기 전에, 서버 세팅 시 기본적으로 처리해야 하는 계정 보안 항목들이 있습니다.
서버 보안의 기본은 공격 표면을 줄이는 것입니다. 필요 없는 계정을 없애고, root 직접 접속을 막고, 필요한 권한만 부여하는 것이 출발점입니다.

# root 직접 SSH 로그인 차단 (/etc/ssh/sshd_config)
grep "PermitRootLogin" /etc/ssh/sshd_config
# 변경: PermitRootLogin no
sudo sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl reload sshd
# 활성 계정 목록 확인
awk -F: '$3 >= 1000 {print $1, $3, $7}' /etc/passwd
# UID 1000 이상 = 일반 사용자 계정
# 불필요 계정 잠금 (삭제보다 잠금 권장 — 파일 소유자 관계 유지)
sudo usermod -L testuser # 계정 잠금
sudo passwd -S testuser # 상태 확인 (L = Locked)
# 로그인 시도 실패 기록 확인
sudo last -F | grep "failed"
sudo lastb | head -20 # 로그인 실패 기록
관리자 경로 제한
Nginx에서 IP 기반 접근 제어
관리자 경로(/admin, /management, /actuator 등)를 인터넷에 공개하면 브루트포스 공격의 대상이 됩니다. 내부 IP에서만 접근하도록 제한하는 것이 기본 보안 조치입니다.
# /etc/nginx/conf.d/app.conf
server {
listen 80;
server_name example.com;
# 관리자 경로 — 내부 IP만 허용
location /admin {
allow 10.0.0.0/8; # 내부 사설 IP 대역
allow 192.168.0.0/16; # 사무실 IP 대역
deny all; # 나머지 전부 차단
}
# Spring Boot Actuator — 내부만 허용 (민감 정보 노출 방지)
location /actuator {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
}
# 숨김 파일/디렉터리 접근 차단 (.git, .env 등)
location ~ /\. {
deny all;
return 404;
}
# 특정 파일 확장자 차단
location ~* \.(sql|bak|log|conf|env)$ {
deny all;
return 404;
}
# 일반 요청
location / {
proxy_pass http://localhost:8080;
}
}
deny all만 쓰면 403을 반환해 경로 존재 여부가 노출됩니다. return 404를 함께 쓰면 경로가 아예 없는 것처럼 보입니다.
보안 헤더 설정
응답 헤더로 브라우저 보안 정책 강제하기
보안 점검 결과 "X-Frame-Options 헤더 미설정"이 취약점으로 나왔습니다. 개발팀에 전달했더니 "그게 뭔데요?"라는 답이 왔습니다. 이 헤더들은 코드 레벨 취약점이 아니라 브라우저에게 보내는 정책 지시입니다. 설정하지 않으면 클릭재킹, XSS, MIME 스니핑 같은 공격에 그대로 노출됩니다. Nginx 설정에 몇 줄만 추가하면 애플리케이션 코드를 건드리지 않고도 이 취약점들을 한번에 처리할 수 있습니다.
보안 헤더는 서버가 브라우저에게 "이 사이트에서는 이런 보안 정책을 따르라"고 지시하는 방법입니다. 코드 변경 없이 Nginx 설정만으로 주요 클라이언트 사이드 공격을 방어할 수 있습니다.
# /etc/nginx/conf.d/security-headers.conf
# 또는 server 블록 내부에 추가
# Clickjacking 방지 — iframe에서 이 페이지를 로드 차단
add_header X-Frame-Options "SAMEORIGIN" always;
# MIME 스니핑 방지 — 브라우저가 Content-Type을 변경하지 못하게 함
add_header X-Content-Type-Options "nosniff" always;
# 구형 브라우저용 XSS 필터 (최신 브라우저는 CSP로 대체)
add_header X-XSS-Protection "1; mode=block" always;
# HSTS — HTTPS 연결 강제 (1년)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Content Security Policy — 리소스 출처 제한
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
# Referrer Policy — 외부 링크 클릭 시 Referer 헤더 제어
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions Policy — 브라우저 기능 접근 제한 (카메라, 마이크 등)
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
always 키워드를 붙여야 4xx, 5xx 에러 응답에도 헤더가 포함됩니다.
헤더별 목적 요약:
| 헤더 | 방어 대상 |
|---|---|
X-Frame-Options | Clickjacking (악성 iframe으로 클릭 유도) |
X-Content-Type-Options | MIME 스니핑 기반 공격 |
Strict-Transport-Security | SSL Stripping (HTTP 다운그레이드) |
Content-Security-Policy | XSS (인라인 스크립트 실행) |
Referrer-Policy | 민감한 URL 정보 외부 노출 |
# 보안 헤더 적용 확인
curl -I https://example.com \
| grep -E "X-Frame|X-Content|Strict|Content-Security|Referrer"

CORS 설정
안전한 CORS 정책 구성
CORS(Cross-Origin Resource Sharing)는 API를 어떤 도메인에서 호출할 수 있는지를 제어합니다. 개발 편의를 위해 *로 열어두고 운영에 그대로 배포하는 경우가 흔합니다.
# 특정 출처만 허용하는 CORS 설정
map $http_origin $cors_origin {
default "";
"https://www.example.com" $http_origin;
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
location /api/ {
if ($cors_origin) {
add_header "Access-Control-Allow-Origin" $cors_origin always;
add_header "Access-Control-Allow-Methods" "GET, POST, PUT, DELETE, OPTIONS" always;
add_header "Access-Control-Allow-Headers" "Authorization, Content-Type" always;
add_header "Access-Control-Allow-Credentials" "true" always;
}
# Preflight 요청 처리
if ($request_method = "OPTIONS") {
add_header "Access-Control-Max-Age" 1728000;
add_header "Content-Length" 0;
return 204;
}
proxy_pass http://localhost:8080;
}
}
map 지시어로 허용 출처 목록을 관리하면, 새 도메인 추가가 목록에 한 줄 추가로 끝납니다.
Cookie 보안 설정
세션 쿠키 보안 강화
쿠키에 보안 속성을 설정하지 않으면 세션 하이재킹, XSS를 통한 쿠키 탈취 위험이 있습니다. 세 가지 속성을 반드시 설정해야 합니다.
# Nginx에서 Proxy를 통해 쿠키에 보안 속성 추가
# (애플리케이션이 Set-Cookie를 발급할 때 속성이 없으면 Nginx에서 추가)
proxy_cookie_flags ~ secure httponly samesite=strict;
애플리케이션 레벨(Spring Boot 예시):
# application.yml
server:
servlet:
session:
cookie:
secure: true # HTTPS에서만 전송
http-only: true # JavaScript에서 접근 불가 (XSS 방어)
same-site: strict # 외부 사이트 요청에 쿠키 미포함 (CSRF 방어)
쿠키 보안 속성 의미:
| 속성 | 방어 대상 | 효과 |
|---|---|---|
Secure | 평문 전송 | HTTP 요청에서 쿠키 미전송 |
HttpOnly | XSS 탈취 | JavaScript document.cookie 접근 차단 |
SameSite=Strict | CSRF | 외부 사이트에서 발생한 요청에 쿠키 미포함 |
TLS Hardening
TLS 버전과 암호화 스위트 강화
TLS 설정이 취약하면 구버전 프로토콜과 약한 암호화 알고리즘을 이용한 공격에 노출됩니다. 현재 권장 설정은 TLS 1.2 + 1.3만 허용하고 구버전과 약한 cipher를 제거하는 것입니다.
# /etc/nginx/conf.d/ssl.conf (또는 server 블록 내)
# TLS 버전 — 1.2와 1.3만 허용 (1.0, 1.1 제거)
ssl_protocols TLSv1.2 TLSv1.3;
# 강화된 Cipher Suite (ECDHE 기반, 순방향 암호화)
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
# 서버가 cipher 선택권을 가짐 (클라이언트가 약한 것 선택하지 못하도록)
ssl_prefer_server_ciphers on;
# ECDH 파라미터 최적화
ssl_ecdh_curve secp384r1;
# OCSP Stapling (인증서 유효성 검증 빠르게)
ssl_stapling on;
ssl_stapling_verify on;
# DH 파라미터 (Diffie-Hellman 키 교환)
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
# DH 파라미터 생성 (최초 한 번만)
openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048
# 설정 적용 후 TLS 버전 확인
openssl s_client -connect example.com:443 -tls1 2>&1 | grep "handshake failure"
# TLS 1.0이 비활성화됐으면 handshake failure 출력됨
openssl s_client -connect example.com:443 -tls1_2 2>&1 | grep "Cipher"
# TLS 1.2는 정상 접속됨
실습
운영 서버 또는 테스트 서버에서 보안 헤더 적용 여부를 확인합니다. 헤더가 없으면 출력이 비어있습니다. 적용 후 다시 실행해 각 헤더가 응답에 포함됐는지 확인합니다.
curl -s -I https://example.com | grep -E 'X-Frame-Options|Strict-Transport|Content-Security|X-Content-Type'- Strict-Transport-Security 헤더가 있고 max-age 값이 충분히 긴가 (31536000 이상 권장)
- X-Frame-Options가 SAMEORIGIN 또는 DENY로 설정됐는가
- X-Content-Type-Options: nosniff 가 포함됐는가
- Content-Security-Policy 헤더가 있는가 (default-src 'self' 이상)
- Server 헤더에 Nginx 버전이 노출되지 않는가 (server_tokens off 설정 확인)
OpenSSL 클라이언트로 실제 TLS 연결을 맺어 사용 중인 프로토콜 버전과 Cipher Suite를 확인합니다. TLSv1.3 또는 TLSv1.2가 출력되어야 정상이며, TLSv1이 출력되면 구버전이 활성화된 것입니다.
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | grep -E 'Protocol|Cipher|subject'- Protocol 버전이 TLSv1.2 또는 TLSv1.3인가
- Cipher Suite에 ECDHE가 포함됐는가 (순방향 암호화 지원)
- RC4, DES, 3DES 같은 취약한 cipher가 없는가
- openssl s_client -tls1 (TLS 1.0)로 접속 시도 시 handshake failure가 나는가
트러블슈팅
원인: includeSubDomains와 긴 max-age를 한 번에 설정하면, 브라우저 캐시에 저장된 HSTS 정책 때문에 HTTP로 접속을 시도하는 모든 서브도메인이 즉시 차단됩니다. HSTS는 한번 브라우저에 저장되면 만료까지 수정이 어렵습니다.
# 현재 HSTS 설정 확인
curl -I https://example.com | grep Strict
# 안전한 HSTS 적용 순서:
# 1단계: 짧은 max-age로 테스트 시작 (300초 = 5분)
add_header Strict-Transport-Security "max-age=300" always;
# 2단계: 모든 서브도메인이 HTTPS로 전환됐는지 확인
# 내부 서비스 포함 전수 점검
# 3단계: 문제 없으면 max-age 늘리기 (86400 = 1일)
add_header Strict-Transport-Security "max-age=86400" always;
# 4단계: 최종 적용 (31536000 = 1년, includeSubDomains 추가)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 브라우저에서 HSTS 캐시 삭제 방법 (Chrome):
# chrome://net-internals/#hsts → Delete domain security policies
원인: CSP의 default-src 'self' 또는 script-src 'self' 정책은 인라인 스크립트(<script> 태그 내 코드)와 eval()을 기본으로 차단합니다.
# 브라우저 개발자 도구에서 CSP 위반 확인
# Console 탭에서:
# "Refused to execute inline script because it violates the following Content Security Policy directive: 'script-src 'self''"
# 해결 방법 1: unsafe-inline 일시 허용 (보안 수준 낮음, 임시 방편)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'";
# 해결 방법 2: nonce 방식 (권장)
# Nginx에서 매 요청마다 랜덤 nonce 생성 (lua-nginx-module 필요)
# 또는 애플리케이션에서 nonce를 생성해 헤더와 스크립트 태그에 동일하게 삽입
# 해결 방법 3: 인라인 스크립트를 외부 파일로 분리 (가장 안전)
# <script>...</script> → <script src="/js/app.js"></script>
# CSP Report-Only 모드로 먼저 테스트 (차단 없이 위반 로그만)
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'" always;
# 위반 사항이 콘솔에만 기록되고 실제로 차단하지 않음 — 프로덕션 영향 없이 테스트 가능
실제 업무에서 이 지식이 쓰이는 상황:
보안팀의 취약점 스캔 결과를 받았을 때, 또는 PCI-DSS/ISO27001 감사를 준비할 때 이 설정들이 체크리스트에 들어갑니다. 코드 변경 없이 Nginx 설정 몇 줄로 보안 점수를 크게 올릴 수 있습니다.
보안 설정 우선순위 (긴급→일반):
즉시 적용 (P0):
□ TLS 1.0/1.1 비활성화
□ 관리자 경로 IP 제한
□ 불필요한 HTTP 메서드(TRACE, DELETE) 차단
빠른 시일 내 (P1):
□ HSTS 설정 (짧은 max-age로 테스트 후 확장)
□ X-Frame-Options, X-Content-Type-Options
□ HttpOnly, Secure 쿠키 속성
품질 개선 (P2):
□ Content-Security-Policy (Report-Only로 시작)
□ CORS 정책 구체화
□ Nginx server_tokens off (버전 정보 숨기기)
# Nginx 버전 정보 숨기기 (http 블록에 추가)
# server_tokens off;
# 설정 후 확인
curl -I https://example.com | grep Server
# "Server: nginx" 만 나오고 버전 번호 없어야 함
다음 모듈에서는 WAF/WAAP로 웹 공격을 차단하고, CDN 캐시를 관리하는 실무를 다룹니다.