서버 보안 점검 2편: Nginx Proxy Manager와 Authelia 접근 제어

서버 운영 2026년 8월 24일

리버스 프록시는 외부 요청이 가장 먼저 통과하는 지점이다. 여기서 관리자 화면이 그대로 공개되거나 Authelia 규칙 순서가 잘못되면, 각 애플리케이션을 따로 보호해도 우회 경로가 생길 수 있다.

이번 점검에서는 Nginx Proxy Manager의 관리 UI 게시 주소, Nginx 구성 검사, 보호 도메인의 인증 리디렉션, Authelia 접근 정책을 순서대로 확인했다. 결과는 대체로 의도와 맞았지만, 설정 경고와 종단 간 2FA 검증은 별도 과제로 남았다.

이 연재는 다음 관리 서비스 점검을 시작하기 직전까지의 기록만 다룬다. 실제 운영 도메인, 컨테이너명, 내부 IP와 Compose 경로는 모두 치환했다.

세 줄 요약

  • NPM 관리자 UI는 127.0.0.1에만 게시되도록 제한했고, Nginx 구성 문법 검사는 성공했다.
  • 보호 대상 도메인에서 인증 포털로 향하는 302를 확인했고, Authelia는 default_policy: deny와 공개·2FA 규칙을 분리하고 있었다.
  • CIDR host bit 경고와 Compose 폐기 예정 경고는 남아 있었으며, 모든 보호 경로의 실제 2FA 성공까지는 확인되지 않았다.

실제 점검 결과

항목 보존된 결과 판정 의미
NPM 관리자 포트 loopback 주소에만 게시 성공 공용 인터페이스 직접 노출 범위를 줄임
Nginx 구성 검사 syntax OK, test successful 성공 문법과 기본 참조 파일 검사가 통과함
보호 도메인 접근 인증 포털로 302 부분 성공 프록시의 인증 진입 동작을 확인함
Authelia 기본 정책 deny 성공 어떤 규칙에도 맞지 않는 요청을 기본 거부
공개 경로 규칙 제한된 도메인에 bypass 성공 의도한 공개 서비스만 인증을 건너뛰도록 분리
관리 경로 규칙 관리 도메인과 하위 도메인에 two_factor 부분 성공 정책은 확인됐지만 실제 MFA 완료 테스트 기록은 없음
인증 포털 GET 정책 bypass 응답 확인 부분 성공 로그인 포털 접근을 위한 결과로 보이나 다른 경로·메서드는 미확인
서비스 상태 실행 중, 재시작 증가 징후 없음 성공 점검 기록 범위에서 불안정 징후가 보이지 않음
nginx CIDR low address bits 경고 경고 host 주소와 subnet prefix가 혼용된 규칙 정리 필요
Compose 스키마 version, external.name 경고 경고 즉시 장애는 아니지만 최신 형식으로 정리 필요

이 결과에서 가장 중요한 구분은 문법 검사 성공과 보안 정책 완성은 다르다는 점이다. nginx -t가 성공해도 CIDR 경고가 남을 수 있고, 302가 발생해도 실제 사용자가 2FA를 마쳐 보호 자원에 접근하는 전체 흐름까지 검증된 것은 아니다.

1. NPM 관리자 포트를 loopback으로 제한한다

점검 전후 Compose 원문과 실제 경로는 공개하지 않는다. 공개용 예시는 다음과 같다.

services:
  npm:
    ports:
      - "80:80"
      - "443:443"
      - "127.0.0.1:<NPM_ADMIN_PORT>:<CONTAINER_ADMIN_PORT>"

Docker 공식 문서에 따르면 호스트 IP를 생략한 포트 게시 규칙은 기본적으로 모든 호스트 주소에 적용된다. 반면 127.0.0.1을 명시하면 현재 Docker의 기본 NAT 구성에서는 호스트에서만 접근할 수 있다.

적용 상태는 컨테이너 전체 설정을 덤프하지 않고 포트만 확인한다.

docker inspect \
  --format '{{json .NetworkSettings.Ports}}' \
  '<NPM_CONTAINER>'

기대 결과는 관리 포트의 HostIp127.0.0.1로 보이는 것이다. 공개 웹 포트는 서비스 목적상 외부 인터페이스에 게시할 수 있지만, 관리자 포트는 프록시와 인증을 거치거나 승인된 사설 경로에서만 접근하게 한다.

loopback 제한은 직접 노출을 줄이는 조치이지 관리자 인증, 프록시 설정, 호스트 방화벽을 대체하지 않는다.

또한 Docker 공식 문서는 28.0.0 미만 버전의 loopback 게시 동작에 같은 L2 네트워크 관련 예외가 있었음을 안내한다. 오래된 엔진이라면 버전 확인과 별도 네트워크 테스트를 생략하지 않는다.

2. Compose와 Nginx 구성을 읽기 전용으로 검사한다

아래 명령은 실제 점검 원문을 복원한 것이 아니라 동일 항목을 확인하기 위한 재현용 명령이다.

set +x
export NPM_COMPOSE_DIR='<NPM_COMPOSE_DIR>'

: "${NPM_COMPOSE_DIR:?NPM_COMPOSE_DIR을 설정하세요}"

cd "$NPM_COMPOSE_DIR"
docker compose config --quiet
docker compose ps

Nginx 구성은 컨테이너 안에서 검사한다.

docker exec '<NPM_CONTAINER>' nginx -t

점검 당시 문법 검사는 성공했지만 다음 두 종류의 경고가 남았다.

  1. Compose의 최상위 version 필드가 더 이상 필요하지 않다는 경고
  2. 외부 네트워크의 external.name 형식이 폐기 예정이라는 경고

공개용 변환 예시는 다음과 같다.

# 이전 형태의 예
version: "3.8"

networks:
  default:
    external:
      name: <EXTERNAL_NETWORK>
# 최신 Compose 형식의 예
networks:
  default:
    name: <EXTERNAL_NETWORK>
    external: true

이 경고들은 당시 서비스 실패를 뜻하지 않았다. 따라서 실패가 아니라 경고로 분류했다. 실제 수정은 docker compose config로 렌더링 결과를 확인하고 백업한 뒤 별도 배포 절차에서 진행해야 한다.

3. CIDR 경고는 /24/32의 의미부터 바로잡는다

Nginx 접근 규칙에서 다음과 같은 유형의 경고가 확인됐다.

low address bits of <INTERNAL_IP>/<PREFIX> are meaningless

예를 들어 문서용 주소 192.0.2.189/24는 “한 호스트”가 아니라 192.0.2.0/24 네트워크를 가리킨다. 뒤의 host bit는 네트워크 주소 계산에서 버려지므로 Nginx가 경고한다.

# 의도가 불명확한 예
allow 192.0.2.189/24;

# 단일 호스트만 허용하려는 경우
allow 192.0.2.189/32;

# 전체 서브넷을 허용하려는 경우
allow 192.0.2.0/24;

deny all;

allowdeny는 선언 순서대로 검사되며 첫 일치 규칙이 적용된다. 단순히 경고를 없애려고 값을 바꾸지 말고, 운영 의도가 호스트 하나인지 서브넷 전체인지 먼저 결정해야 한다.

점검 당시 nginx -t는 성공했다. 하지만 그것은 문법이 유효하다는 뜻이지 경고가 해결됐다는 뜻은 아니다. 따라서 CIDR 항목은 경고로 남겼다.

4. Authelia 규칙은 기본 거부와 순서를 함께 본다

보존된 설정에서는 기본 정책이 deny였고, 공개 사이트는 제한적으로 bypass, 관리 사이트는 two_factor로 나뉘어 있었다. 공개용 구조는 다음과 같다.

access_control:
  default_policy: deny

  rules:
    - domain:
        - "<PUBLIC_DOMAIN>"
        - "www.<PUBLIC_DOMAIN>"
        - "<BYPASS_DOMAIN>"
      policy: bypass

    - domain:
        - "<NPM_ADMIN_DOMAIN>"
        - "<PORTAINER_DOMAIN>"
        - "*.<PRIVATE_DOMAIN>"
      policy: two_factor

Authelia는 위에서 아래로 규칙을 평가하고 모든 조건이 맞는 첫 번째 규칙을 적용한다. bypass는 인증을 생략하는 정책이므로 넓은 와일드카드 규칙을 앞에 두면 뒤의 two_factor가 실행되지 않을 수 있다.

점검 당시 공개 대상과 관리 대상을 분리한 구조는 확인됐다. 다만 실제 공개 글에서는 도메인 목록과 규칙 전문을 싣지 않고 “공개 도메인 몇 개”와 “관리 도메인 그룹” 정도로 요약하는 편이 안전하다.

5. HTTP 상태 코드로 인증 진입점을 확인한다

응답 본문에는 내부 URL이나 인증 상태가 포함될 수 있으므로 상태와 리디렉션 대상의 호스트 유형만 확인한다.

set +x
export PROTECTED_URL='https://<NPM_ADMIN_DOMAIN>/'

curl --proto '=https' \
  --max-time 10 \
  --silent \
  --output /dev/null \
  --dump-header - \
  "$PROTECTED_URL" |
  sed -n '1p;/^[Ll]ocation:/p'

점검 당시 보호 대상은 인증 포털로 302를 반환했다. 이는 프록시가 인증 진입 동작을 수행한다는 근거다. 그러나 다음 항목까지 자동으로 증명하지는 않는다.

  • 올바른 사용자의 2FA 성공
  • 잘못된 사용자의 접근 거부
  • 세션 만료 이후 재인증
  • GET 이외 메서드와 모든 하위 경로의 정책
  • 프록시가 모든 보호 호스트에서 실제 인증 검사를 호출하는지 여부

따라서 302 확인은 성공적인 중간 단계지만 전체 결과는 부분 성공이다.

6. 발행 전에 해야 할 종단 간 테스트

운영자가 브라우저와 별도의 비인증 세션에서 다음 시나리오를 직접 확인해야 한다.

  1. 공개 사이트는 로그인 없이 정상 접근된다.
  2. 관리 도메인은 인증 포털로 이동한다.
  3. 1차 인증만으로는 보호 자원에 접근할 수 없다.
  4. 등록된 2차 인증을 마친 뒤에만 보호 자원에 접근한다.
  5. 로그아웃 또는 세션 만료 뒤 보호 자원에 다시 접근할 수 없다.
  6. 존재하지 않는 하위 도메인은 넓은 bypass 규칙에 걸리지 않는다.

이 시나리오의 실제 성공·실패 기록은 보존 자료에 없으므로 이 글에서 수행 완료라고 쓰지 않는다.

이번 점검의 결론

NPM 관리자 UI의 loopback 제한, Nginx 문법 검사, Authelia의 기본 거부와 정책 분리는 확인됐다. 외부 보호 대상에서 인증 포털로 이동하는 것도 관측됐다. 이 부분은 공격 표면을 줄이는 데 의미 있는 성공이다.

남은 과제는 세 가지다.

  • host bit가 섞인 CIDR 규칙을 실제 의도에 맞게 /32 또는 네트워크 주소로 정리
  • Compose의 폐기 예정 필드를 최신 형식으로 변환
  • 모든 관리 도메인에 대해 비인증·1차 인증·2차 인증·세션 만료를 포함한 종단 간 테스트 수행

경고가 있는 구성도 nginx -t를 통과할 수 있고, 302 하나도 전체 인증 성공을 대신하지 못한다. 점검 결과를 성공과 경고, 미확인으로 분리해서 기록해야 다음 변경의 위험을 줄일 수 있다.

다음 글

공식 문서

태그