TLS 종료 지점과 Reverse Proxy 신뢰 경계 설계

· 약 12분 읽기 · 조회 1 #TLS#Reverse Proxy#보안#아키텍처#nginx#Spring Boot

TLS를 어디서 끝내느냐는 인증서 위치만 정하는 일이 아니다. 그 지점이 곧 "여기부터는 평문을 본다"와 "여기부터는 앞 구간이 알려 준 클라이언트 IP와 프로토콜을 믿는다"는 신뢰 경계가 되고, 경계를 잘못 그으면 위조한 X-Forwarded-For 한 줄로 관리자 IP 제한이 풀린다. 이 글은 TLS 종료 방식 세 가지를 비교하고, 구간마다 무엇을 믿어야 하는지, 프록시와 WAS 설정을 어떻게 맞추는지 정리한 것이다.

해 질 무렵 성문에서 문지기가 봉인된 편지를 열어 확인하고 도장을 찍어 안쪽 성벽 너머 전령에게 넘기는 장면

한눈에 보기

구간 암호화 이 구간의 받는 쪽이 믿는 것 확인 방법
클라이언트 → L4·WAF TLS (공개 인증서) 아무것도 믿지 않는다 들어온 X-Forwarded-*는 버리거나 덮어쓴다
L4·WAF → Reverse Proxy TLS 그대로 통과 또는 종료 L4의 출발지 IP 보존 방식 PROXY protocol 또는 원본 IP 유지 설정
Reverse Proxy → WAS 재암호화 또는 mTLS 프록시가 쓴 헤더만 프록시 IP 목록 + 백엔드 인증서 검증
WAS → DB DB 드라이버 TLS WAS 계정 계정·접속 IP 제한

인터넷의 클라이언트가 DMZ의 L4·WAF와 TLS를 종료하는 Reverse Proxy를 거쳐 내부 영역의 WAS와 DB에 닿고, WAS로 직접 가는 경로는 경계에서 막히는 구성

그림의 핵심은 두 가지다. TLS를 끝내는 Reverse Proxy가 클라이언트 정보를 "다시 쓰는" 유일한 곳이고, WAS는 그 프록시에서 온 요청만 받는다.

TLS를 어디서 끝낼지: 세 가지 방식

통과(passthrough)

  • L4 장비나 프록시가 TLS를 풀지 않고 바이트만 넘긴다. 인증서와 개인키는 WAS에만 있다.
  • 장점은 프록시가 평문을 보지 않는다는 것이다. 단점은 프록시가 경로·헤더를 볼 수 없어 WAF 검사, 경로별 라우팅, 헤더 정리를 할 수 없다는 것이다.
  • 클라이언트 IP는 TCP 출발지로만 전달된다. L4가 출발지를 바꾸면(SNAT) PROXY protocol 같은 별도 수단이 필요하다.

종료(termination)

  • 프록시에서 TLS를 풀고 뒤로는 평문 HTTP로 보낸다. 설정이 가장 단순하고 흔하다.
  • 프록시와 WAS 사이 구간이 평문이 된다. 같은 망의 다른 서버가 이 구간을 엿보거나 WAS 포트에 직접 요청을 넣을 수 있다면, 그 구간은 신뢰 경계 안이 아니다.

재암호화(re-encryption)

  • 프록시에서 풀어 검사·라우팅을 한 뒤, 뒤 구간을 다시 TLS로 감싼다.
  • 프록시가 WAS 인증서를 검증하지 않으면 암호화만 있고 상대 확인은 없다. 여기에 WAS가 프록시의 클라이언트 인증서를 요구하면(mTLS) 양쪽이 서로를 확인한다.

선택 기준은 "프록시 뒤 구간을 누가 만질 수 있는가"다. 프록시와 WAS가 전용 구간에 있고 WAS 포트가 프록시 IP에만 열려 있으면 종료 방식으로 충분할 수 있다. 여러 팀의 서버가 같은 망을 쓰거나 개인정보가 오가면 재암호화와 mTLS를 기본으로 둔다.

신뢰 경계는 "누가 헤더를 쓸 수 있나"로 정한다

TLS를 프록시에서 끝내면 WAS는 진짜 클라이언트를 직접 보지 못한다. WAS가 보는 TCP 상대는 프록시이고, 원래 클라이언트 IP와 https 여부는 헤더로 전달받는다.

  • X-Forwarded-For: 거쳐 온 클라이언트·프록시 IP 목록
  • X-Forwarded-Proto: 클라이언트가 쓴 프로토콜(https)
  • X-Forwarded-Host: 클라이언트가 요청한 호스트
  • Forwarded: 위 세 가지를 한 헤더로 표준화한 형식(RFC 7239). 예: Forwarded: for=192.0.2.43;proto=https

문제는 이 헤더를 클라이언트도 보낼 수 있다는 점이다. 그래서 규칙은 하나다. 헤더는 내가 아는 프록시가 쓴 부분만 믿는다. 신뢰 경계 바깥에서 온 값은 덮어쓰거나 버린다.

X-Forwarded-For는 각 프록시가 받은 요청의 출발지를 오른쪽에 덧붙인다. 클라이언트가 X-Forwarded-For: 10.0.0.1을 미리 넣어 보내면 프록시를 지난 뒤 값은 10.0.0.1, 203.0.113.7이 된다. 맨 왼쪽 값을 클라이언트 IP로 쓰면 공격자가 고른 IP가 그대로 들어간다. 올바른 해석은 오른쪽부터 읽으면서 신뢰하는 프록시 IP를 하나씩 걷어내고, 처음 나오는 신뢰하지 않는 IP를 클라이언트로 본다는 것이다.

앞단 프록시: 들어온 헤더를 덮어쓴다

인터넷에서 처음 요청을 받는 프록시는 클라이언트가 보낸 X-Forwarded-*를 믿을 이유가 없다. 이어 붙이지 말고 덮어쓴다. nginx 예시는 이렇다.

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/nginx/tls/app.crt;
    ssl_certificate_key /etc/nginx/tls/app.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location / {
        proxy_pass https://was_backend;

        # 첫 프록시는 이어 붙이지 않고 덮어쓴다 ($proxy_add_x_forwarded_for 금지)
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header Forwarded         "";
        proxy_set_header Host              $host;
    }
}
  • $proxy_add_x_forwarded_for는 들어온 값 뒤에 덧붙이므로, 앞에 다른 신뢰 프록시가 없는 첫 프록시에서는 쓰지 않는다.
  • 쓰지 않는 Forwarded 헤더도 빈 값으로 지운다. WAS가 그 헤더를 우선해서 읽는 경우가 있기 때문이다.
  • 앞에 L4나 CDN이 하나 더 있으면, 그 장비 IP를 nginx realip 모듈에 등록하고(set_real_ip_from, real_ip_header, real_ip_recursive on) 거기서 얻은 $remote_addr를 쓴다.

WAS: 아는 프록시만 믿는다

WAS 쪽 설정은 "어느 IP에서 온 헤더를 믿을지"를 좁히는 일이다. Spring Boot 내장 Tomcat이면 이렇게 둔다.

server:
  forward-headers-strategy: native
  tomcat:
    remoteip:
      # 정규식. 실제 Reverse Proxy 두 대만 적는다
      internal-proxies: "10\\.0\\.1\\.11|10\\.0\\.1\\.12"
      remote-ip-header: x-forwarded-for
      protocol-header: x-forwarded-proto
  • native는 Tomcat의 RemoteIpValve를 켠다. 요청이 internal-proxies에 맞는 IP에서 왔을 때만 헤더로 getRemoteAddr(), isSecure(), 스킴을 바꾼다.
  • internal-proxies 기본값은 사설 대역 전체를 포함한다. 그대로 두면 내부망의 아무 서버나 헤더를 위조해 클라이언트 IP를 바꿀 수 있으므로, 프록시 IP만 남긴다.
  • framework 값은 Spring의 ForwardedHeaderFilter로 헤더를 반영한다. 이 방식은 출발지 IP로 거르지 않으므로 앞단 프록시가 헤더를 반드시 덮어쓰고, WAS 포트가 프록시에만 열려 있을 때만 쓴다.
  • 외장 Tomcat이면 server.xml의 RemoteIpValve에 internalProxies를, Apache HTTP Server가 앞단이면 mod_remoteip의 RemoteIPHeader, RemoteIPInternalProxy를 같은 원칙으로 맞춘다.

헤더 설정만으로는 부족하다. 방화벽이나 보안 그룹에서 WAS 포트를 프록시 IP에만 연다. 그래야 "프록시를 건너뛰고 WAS에 직접 헤더를 넣는" 경로가 사라진다.

프록시 뒤 구간: 재암호화와 mTLS

재암호화를 켰다면 프록시가 WAS 인증서를 검증하게 해야 한다. nginx는 기본값이 검증하지 않음이다.

location / {
    proxy_pass https://was_backend;

    proxy_ssl_verify              on;
    proxy_ssl_trusted_certificate /etc/nginx/tls/internal-ca.crt;
    proxy_ssl_name                was.internal;
    proxy_ssl_server_name         on;

    # mTLS: WAS가 프록시를 확인할 수 있도록 클라이언트 인증서 제시
    proxy_ssl_certificate         /etc/nginx/tls/proxy-client.crt;
    proxy_ssl_certificate_key     /etc/nginx/tls/proxy-client.key;
}
  • 내부 구간 인증서는 내부 CA로 발급한다. 공개 인증서를 내부 호스트 이름에 쓰려다 이름 검증을 끄는 일이 생기지 않게 한다.
  • WAS는 내부 CA가 서명한 클라이언트 인증서만 받도록 설정한다. 이러면 WAS 포트에 닿더라도 프록시의 키가 없는 서버는 TLS 연결부터 실패한다.
  • mTLS를 쓰면 "프록시 IP 목록"과 "프록시 인증서" 두 가지로 프록시를 확인하게 된다. 둘 중 하나만 뚫려서는 헤더 위조가 통하지 않는다.

HSTS와 인증서 관리

HSTS

  • Strict-Transport-Security는 TLS를 끝내는 곳, 즉 클라이언트가 실제로 보는 응답을 만드는 프록시에서 붙인다. WAS가 평문으로 응답을 받는 구조에서는 WAS가 붙이는 것을 잊기 쉽다.
  • includeSubDomains는 하위 도메인 전체가 HTTPS를 지원할 때만 넣는다. 아직 HTTP만 되는 하위 서비스가 있으면 접속이 끊긴다.
  • 브라우저 사전 등록(preload)은 되돌리기 어렵다. 도메인 전체 HTTPS가 확정된 뒤에 검토한다.

인증서

  • TLS 종료 지점이 늘어날수록 개인키 사본도 늘어난다. 공개 인증서는 종료 지점에만 두고, 서버마다 복사해 두지 않는다.
  • 만료일을 모니터링 대상에 넣는다. 만료 30일 전과 7일 전에 알림을 받게 하고, 갱신 절차(발급, 배포, 재시작 없는 리로드)를 문서로 남긴다.
  • 내부 CA 인증서도 만료된다. 내부 구간의 만료가 더 늦게 발견되므로 같은 목록으로 관리한다.

무엇을 어디서 막는가

통제 두는 곳 없으면 생기는 일
들어온 X-Forwarded-* 덮어쓰기 첫 Reverse Proxy 위조한 IP로 관리자 IP 제한·접속 로그를 속임
신뢰 프록시 IP 목록 WAS (internal-proxies) 내부망 아무 서버나 클라이언트 IP를 바꿈
WAS 포트 접근 제한 방화벽·보안 그룹 프록시를 건너뛰고 WAS에 직접 요청
X-Forwarded-Proto 반영 프록시 + WAS 리다이렉트가 http://로 나가고 쿠키 Secure가 빠짐
X-Forwarded-Host 덮어쓰기 첫 Reverse Proxy 비밀번호 재설정 메일 링크가 공격자 도메인으로 만들어짐
백엔드 인증서 검증·mTLS 프록시 ↔ WAS 내부 구간에서 가짜 WAS나 가짜 프록시가 끼어듦
HSTS TLS 종료 지점 첫 접속을 HTTP로 유도하는 공격에 노출

자주 하는 실수

  • X-Forwarded-For의 맨 왼쪽 값을 클라이언트 IP로 쓴다. 맨 왼쪽은 클라이언트가 마음대로 넣을 수 있는 자리다.
  • 첫 프록시에서 $proxy_add_x_forwarded_for를 쓴다. 위조된 값이 그대로 남아 뒤에서 오른쪽부터 읽어도 신뢰 목록 계산이 꼬인다.
  • internal-proxies 기본값을 그대로 둔다. 사설 대역 전체가 프록시로 취급되어, 내부망의 다른 서버가 헤더를 위조할 수 있다.
  • 재암호화하면서 백엔드 인증서 검증을 끈다. 암호화는 되지만 상대가 누구인지는 확인하지 않는다.
  • X-Forwarded-Proto를 넘기지 않는다. WAS가 요청을 HTTP로 알고 http:// 리다이렉트를 만들어 루프가 생기거나, 세션 쿠키에 Secure가 빠진다.

점검표

  • TLS 종료 지점이 구성도에 표시되어 있고, 구간마다 평문·TLS·mTLS 여부가 적혀 있다
  • 첫 Reverse Proxy가 X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host를 덮어쓴다
  • 클라이언트가 보낸 Forwarded 헤더를 지우거나 덮어쓴다
  • WAS의 신뢰 프록시 목록에 실제 프록시 IP만 있다
  • WAS 포트는 방화벽에서 프록시 IP에만 열려 있다
  • 위조한 X-Forwarded-For를 붙여 요청했을 때 접속 로그에 실제 IP가 찍히는 것을 확인했다
  • 재암호화 구간에서 프록시가 백엔드 인증서를 검증한다
  • HSTS 헤더가 클라이언트 응답에 붙고, includeSubDomains 적용 범위를 확인했다
  • 공개·내부 인증서의 만료일이 모니터링되고 알림이 온다
  • 인증서 갱신 절차가 문서로 있고 한 번 이상 실제로 해 봤다

TLS 종료 지점은 암호를 푸는 곳이자 클라이언트 정보를 새로 쓰는 곳이므로, 그 한 곳에서 헤더를 덮어쓰고 뒤의 모든 구간은 그 지점만 믿도록 설정과 네트워크를 함께 묶어야 한다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기