Stan 기술블로그

Sentry ingest 엔드포인트만 외부에 여는 방법

TL;DR 자체 호스팅 Sentry에 이벤트 수집(ingest) 경로만 외부로 열었다. 관리 UI는 내부망 그대로 두고, 새 서브도메인(ingest.sentry.x.xxx.kr)을 분리해서 화이트리스트 경로만 허용. 지난 글에서 “외부 노출 안 됨”이라 판단한 게 실은 부정확했다 — 실측해보니 타임아웃이 아니라 403이었고, IP ACL이 애플리케이션 층에서 차단하고 있었을 뿐이다. DSN 게이트로 열리는 write-only 엔드포인트는 인바운드 웹훅과 리스크 성격이 다르다. “내부망 전용” 원칙을 뒤집을지 여부는 리스크 등급별로 나눠서 판단해야 한다.


배경

지난 글에서 자체 호스팅 Sentry에 Slack 연동을 붙였을 때, 아웃바운드(알림 발송)까지만 하고 인바운드(슬래시 커맨드·인터랙티브 버튼)는 접었다. 그때 결론이 “우리 Sentry는 내부망에만 열려 있어서 슬랙에서 오는 콜백을 못 받는다”였다.

이번에 실기기 앱이 보내는 에러 리포트가 403으로 막힌다는 문의가 들어왔다. 앱 SDK가 호출하는 이벤트 수집 경로(/api/{project_id}/envelope/, /store/)만 외부에서 허용해달라는 요청이었다. 작업하다 보니 지난 글에서 내가 했던 “외부 노출 안 됨” 판단이 실제로는 부정확했다는 걸 알게 됐다. 노출은 되어 있었고, IP ACL로 차단되고 있었을 뿐이었다.


어떤 상황이었나

모바일팀에서 문의가 왔다.

Sentry가 내부망 IP만 허용하고 있어서, 실기기 앱이 보내는 에러 리포트가 전부 403으로 막힘. 관리 UI는 지금처럼 내부망 제한 유지하고, 아래 이벤트 수집 경로만 외부 허용 가능한지?

POST /api/{project_id}/envelope/
POST /api/{project_id}/store/

요청 자체는 명확했다. 관리 대시보드는 지금처럼 내부망에서만 접근하고, 이벤트 수집 경로만 인터넷 어디에서든 도달 가능하게 만들면 된다.


개념 정리 — DSN 게이트가 뭔지

Sentry 클라이언트는 이벤트를 보낼 때 DSN(Data Source Name) 이라는 문자열을 쓴다. 형태는 대략 이렇다.

https://<public_key>@<host>/<project_id>

여기서 <public_key>는 프로젝트별로 발급되는 공개 키다. 이 키를 요청 헤더(또는 쿼리)에 실어서 /api/{project_id}/envelope/나 /store/에 POST한다. 서버는 키가 유효하고 프로젝트에 매칭되는지만 확인하고 이벤트를 받는다.

특징이 두 가지 있다.

  • write-only: 이 경로로는 이벤트를 “보낼” 수만 있다. 데이터를 조회하거나 설정을 바꿀 수 없다.
  • 원래 인터넷에 노출되는 게 정상: SaaS Sentry(sentry.io)나 일반적인 사내 배포에서도 이 경로는 인터넷에 열려 있다. 인증은 DSN 키가 담당한다.

지난 글에서 다뤘던 슬랙 인바운드(Event Subscriptions/Interactivity) 웹훅은 성격이 다르다. 그쪽은 서명 검증만 있고 콜백 URL이 노출되면 재생 공격 여지가 상대적으로 크다. 반면 이 ingest 경로는 프로젝트별 키가 게이트를 잡고 있고, 최악의 경우에도 “가짜 이벤트가 프로젝트에 쌓인다” 수준이다.

리스크 성격이 다르다는 걸 짚어두고 시작한다.


층별 추적기

[1차] 외부 도달성 실측 — 지난 판단 정정

먼저 정말로 외부에서 도달이 안 되는지 다시 확인했다. 폰에서 와이파이를 끄고 데이터망으로 https://sentry.x.xxx.kr에 접속했다.

타임아웃이 아니라 403 응답이 왔다.

이 차이가 결정적이다. 타임아웃이면 네트워크 경로 자체가 막힌 것이고, 403이면 서버까지는 요청이 도달했는데 애플리케이션/프록시 레이어에서 반려한 것이다. 즉 라우터 포트포워딩과 DNS는 이미 열려 있고, NPM(Nginx Proxy Manager)의 Access List가 IP로 걸러내고 있는 상태였다.

지난 글에서 “외부 인터넷에 전혀 노출 안 됨”이라 적어둔 건 부정확한 서술이었다. 정확히는 “노출은 되어 있는데 IP ACL로 차단”이 맞다. 슬랙 인바운드를 접었던 결정 자체는 유효하지만(어차피 DSN 같은 게이트 없이 서명만으로 여는 건 별개 판단이 필요), 그때 이유 설명은 이 글에서 정정해둔다 — 지난 글에도 정정 박스를 달아뒀다.

응답 코드는 문제의 층을 알려준다. “안 된다”를 “타임아웃이라 안 된다”와 “403이라 안 된다”로 나눠 보는 순간, 다음 조치가 완전히 달라진다.

[2차] NPM에서 Access List 확인

192.168.x.x:81(NPM 관리 UI)에 접속해서 sentry.x.xxx.kr 프록시 호스트의 Access List 탭을 확인했다. 예상대로 “Local”이라는 사내 IP 화이트리스트가 걸려 있었다.

라우터 포트포워딩은 이미 되어 있는 상태였다. 그러니 이 단계는 손댈 게 없었다.

[3차 — 판단] 기존 호스트 수정 vs 새 서브도메인 분리

두 가지 선택지가 있었다.

  • (A) 기존 sentry.x.xxx.kr 호스트의 Advanced 탭에 커스텀 nginx를 넣어서 특정 경로만 예외 처리
  • (B) 새 서브도메인 ingest.sentry.x.xxx.kr을 파고, 이건 처음부터 Public + 경로 화이트리스트만 허용

(A)의 문제가 두 개였다.

  • NPM Access List는 호스트 단위로 걸리는 UI라 경로별 예외를 깔끔하게 표현하기 어렵다
  • 기존 관리 UI가 그대로 사는 호스트를 직접 수정하다가 실수하면 관리 UI가 통째로 인터넷에 노출되는 사고가 날 수 있다

(B)로 갔다. 새 호스트를 파면 기존 sentry.x.xxx.kr은 손대지 않아도 된다. 실수의 폭발 반경이 좁아진다.

위험한 변경은 기존 리소스를 뜯어고치지 말고, 새 리소스로 격리해서 붙이는 게 안전하다. 문제가 생겨도 원상복구 대신 새 리소스만 걷어내면 된다.

[4차] NPM 새 Proxy Host 설정 — 옵션을 최대한 껐다

새 프록시 호스트를 만들면서 옵션을 뺐다.

  • Cache Assets: 끔
  • Websockets Support: 끔

기존 관리 UI 호스트는 둘 다 켜져 있었다. 대시보드 정적 자산과 실시간 업데이트 때문에 필요한 설정이라 그건 맞다. 그런데 이 ingest 호스트는 POST API 두 개만 통과시키는 게 목적이다. 옵션을 켜면 NPM이 자동으로 추가하는 location 블록이 늘어나서, 내가 짜려는 “이 두 경로만 허용, 나머지 403” 규칙과 우선순위가 꼬일 여지가 있다.

Access List는 “Publicly Available”로 지정했다. 기존 “Local”에는 절대 연결하지 않았다(연결하는 순간 외부에서 오는 정상 요청이 다시 차단된다).

SSL 탭에서 Let’s Encrypt로 신규 발급, Force SSL을 켰다.

Advanced 탭에 넣은 커스텀 nginx는 이렇다.

location ~ ^/api/[0-9]+/(envelope|store)/ {
    proxy_pass http://192.168.x.x:9000;
    proxy_set_header Host sentry.x.xxx.kr;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Request-Id $request_id;
}

location / {
    return 403;
}

두 가지 포인트가 있다.

  • Host 헤더는 반드시 sentry.x.xxx.kr로 고정한다. 지난 글(Sentry CSRF 트러블슈팅 편)에서 배운 것과 같은 이유다 — Sentry 내부 system.url-prefix 설정과 일치시켜야 한다.
  • Rate limiting(limit_req)은 일단 뺐다. NPM에서 이건 호스트별 Advanced가 아니라 별도 글로벌 커스텀 설정 파일(/data/nginx/custom/http_top.conf 같은 것)을 컨테이너에 마운트해야 한다. 복잡도가 붙으니 기본 동작을 먼저 확인하고 2단계 작업으로 미뤘다.

[5차 — 함정] 외부망 테스트, 폰 단독은 신뢰도가 낮았다

서버 자체에서 curl로 확인해봤다.

curl -I https://ingest.sentry.x.xxx.kr/
# → HTTP/2 403                                (관리 UI 경로 차단 정상)

curl -X POST https://ingest.sentry.x.xxx.kr/api/999999/store/
# → {"detail":"missing authorization information"}  (403 아님 = 실제 백엔드까지 도달)

여기까지는 예상대로였다. 그런데 이건 지난 글에서 배운 대로 “셀프 curl 함정”이다 — 같은 서버 또는 내부망에서 도메인을 부르는 건 진짜 외부 경로 검증이 아니다. 그래서 폰에서도 테스트했다.

폰 브라우저/앱으로 두 경로를 쳐봤는데 둘 다 403이 나왔다. /는 의도된 403이지만 /api/.../store/까지 403이면 이상하다.

폰 단독 테스트를 그만두고, 맥북을 폰 핫스팟에 테더링한 뒤 curl -v로 재테스트했다. 그 결과 서버 자체 테스트와 동일했다 — /는 403, /store/는 인증 에러 JSON이 정상으로 나왔다.

원인을 명확히 특정하진 못했다. 폰 브라우저/앱의 캐시일 가능성이 크다. 다만 결론은 확실했다.

진짜 외부망 검증은 폰 단독 도구보다 노트북+핫스팟+curl -v 조합이 신뢰도가 높다. 폰 앱은 응답 헤더를 상세히 보여주지 않고, 캐시 층도 통제하기 어렵다.


원인 층위 정리

층 상태 조치
라우터 포트포워딩 이미 열려있음 손대지 않음
DNS sentry.x.xxx.kr은 이미 있음 → ingest. 서브도메인 A 레코드 추가 신규
NPM Access List 기존 호스트에 “Local” 걸림 유지, 새 호스트는 “Publicly Available”
NPM 프록시 호스트 기존 호스트는 관리 UI용 새 호스트를 별도로 팜
nginx 라우팅 화이트리스트 미적용 envelope|store만 허용, 나머지 403
Sentry 내부 Host 헤더 system.url-prefix와 일치해야 함 프록시에서 Host: sentry.x.xxx.kr 강제
DSN 클라이언트 설정 기존은 sentry.x.xxx.kr 호스트 호스트 부분만 ingest.로 교체 (키/project_id 유지)

최종 설정 스니펫

새 프록시 호스트의 Advanced 탭 커스텀 nginx.

location ~ ^/api/[0-9]+/(envelope|store)/ {
    proxy_pass http://192.168.x.x:9000;
    proxy_set_header Host sentry.x.xxx.kr;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Request-Id $request_id;
}

location / {
    return 403;
}

외부망 확인용 curl.

# 관리 UI 경로(차단되어야 정상)
curl -I https://ingest.sentry.x.xxx.kr/
# → HTTP/2 403

# 이벤트 수집 경로(403이 아니어야 정상 = 백엔드까지 도달)
curl -X POST https://ingest.sentry.x.xxx.kr/api/999999/store/
# → {"detail":"missing authorization information"}

앱 쪽 DSN 변경은 호스트 부분만 교체하면 된다. 예: https://<public_key>@sentry.x.xxx.kr/<project_id> → https://<public_key>@ingest.sentry.x.xxx.kr/<project_id>. 키/project_id는 그대로 유지한다 — DSN 재발급이 아니라 호스트 스왑이다.


다음번 순서

  1. 외부 도달성 실측 — 다른 망(데이터망/핫스팟)에서 응답 코드까지 확인. 타임아웃인지 403인지에 따라 원인이 달라진다.
  2. 위험한 변경은 새 리소스로 격리 — 기존 호스트/설정을 직접 뜯어고치지 말고, 새 서브도메인/호스트를 파서 붙인다. 실수의 폭발 반경을 좁힌다.
  3. 경로 화이트리스트 + 나머지 403 — 이 두 경로만 허용한다면, location / { return 403; }을 명시적으로 넣어서 미매칭 요청을 반려한다. Host 헤더는 원본과 일치시키는 걸 잊지 않는다.
  4. 외부망 최종 검증은 노트북+핫스팟+curl -v — 서버 자체 curl은 셀프 함정, 폰 단독은 캐시 함정. 노트북을 폰 핫스팟에 물려서 curl -v로 헤더까지 확인한다.

참고

  • 선행 글: CSRF 6단계 편 (Host 헤더·system.url-prefix), Slack 통합 편 (이 글이 정정한 인바운드 판단)
  • 환경: self-hosted Sentry (Docker Compose), Nginx Proxy Manager, 사내망 게이트웨이

self-hosted Sentry 카테고리의 글