Stan 기술블로그

리버스 프록시 뒤 Sentry에 Keycloak SSO를 붙이며 만난 CSRF 6단계

TL;DR 사내 Sentry(self-hosted)에 Keycloak OIDC를 연결하고 리버스 프록시로 HTTPS까지 붙였다. 도메인을 붙이는 순간 CSRF Validation Failed가 떨어졌고, 원인을 하나씩 벗겨내는 데 6단계가 걸렸다. 가장 큰 함정은 다섯 번째였다 — sentry.conf.py에 아무리 URL을 박아둬도 config.yml의 system.url-prefix가 이긴다.


배경 — 왜 이 작업을 하고 있었나

인프라 엔지니어로 출근한 첫날 임무는 단순했다.

“PC 서버 하나에 Sentry 깔아봐.”

세무 데이터를 다루는 회사라 외부 클라우드(SaaS Sentry)로 데이터를 못 뺀다. 그래서 self-hosted로 가고, 계정은 사내 Keycloak과 SSO로 묶고, 사내망에서 도메인으로 접근하도록 리버스 프록시(Nginx Proxy Manager) 뒤에 앉힌다 — 여기까지가 요구사항이었다.

세팅 자체는 순서대로 밟으면 어렵지 않았다. 문제는 도메인을 붙인 다음이었다.


어떤 문제였나

세팅을 다 끝내고 https://sentry.x.xxx.kr로 접속했더니 로그인 페이지 대신 이 화면이 떴다.

Forbidden (403)
CSRF verification failed. Request aborted.

Sentry 컨테이너까지는 정상, 192.168.x.x:9000으로 직접 붙으면 로그인도 됐다. 도메인 + HTTPS를 거친 요청만 CSRF에서 튕겼다.

IP 직결은 되는데, 도메인은 안 된다.

이 한 줄이 출발점이다.


설정이 두 곳에 있다

CSRF 검증은 요청의 Origin이 신뢰 목록에 있는지 보는 것이다. Sentry(Django 기반)는 그 신뢰 기준을 두 파일에서 관리한다.

파일 담당
sentry/config.yml system.url-prefix — Sentry가 자기 자신을 부르는 절대 URL
sentry/sentry.conf.py CSRF_TRUSTED_ORIGINS — CSRF 검증이 허용하는 Origin 목록

이 둘이 한쪽이라도 도메인과 안 맞으면 CSRF에서 튕긴다. 그리고 겹치는 부분에서 우선순위가 있다는 걸 나중에 알게 된다.


6단계 추적기

[1차] CSRF_TRUSTED_ORIGINS에 도메인이 없다

증상 : 도메인 접속 시 CSRF 에러.

원인 : sentry.conf.py의 CSRF_TRUSTED_ORIGINS에 sentry.x.xxx.kr이 없었다.

조치 :

CSRF_TRUSTED_ORIGINS = [
    "http://192.168.x.x:9000",
    "https://sentry.x.xxx.kr",
]

여전히 튕겼다.

[2차] Sentry가 자기를 HTTP라고 오해한다

증상 : Origin은 등록했는데도 CSRF 에러.

원인 : 요청 구조는 유저 → HTTPS → nginx → HTTP → Sentry다. Sentry 입장에선 자기가 HTTP로 서비스된다고 인식한다. 브라우저가 보낸 Origin: https://sentry.x.xxx.kr과 서버가 인식하는 http://sentry.x.xxx.kr 사이에 스킴이 어긋난다.

리버스 프록시가 X-Forwarded-Proto: https 헤더로 “원래는 HTTPS야”라고 알려줘야 하는데, 중간 nginx 설정이 이 헤더를 $scheme(즉 http)로 덮어썼다.

조치 : Sentry 내부 nginx.conf에서 값을 강제로 박고, 애플리케이션 측이 그 헤더를 신뢰하도록 설정.

proxy_set_header X-Forwarded-Proto https;
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SOCIAL_AUTH_REDIRECT_IS_HTTPS = True

여기서 CSRF는 넘어갔다. 이제 로그인은 되는데 — 곧바로 세션이 풀렸다.

[3차] Secure 쿠키가 잘린다

증상 : 로그인 성공 → 새로고침 → 다시 로그인 화면.

원인 : SESSION_COOKIE_SECURE = True, CSRF_COOKIE_SECURE = True가 켜져 있었다. 이 옵션이 켜지면 쿠키는 HTTPS 연결에서만 전송된다. 그런데 nginx 뒤 Sentry는 내부적으로 HTTP로 통신해서, 응답에 실린 Secure 쿠키가 브라우저까지 도달은 하지만 다음 요청에서 다시 붙을 때 애플리케이션 서버 앞에서 잘렸다.

조치 : 두 옵션을 주석 처리.

# SESSION_COOKIE_SECURE = True
# CSRF_COOKIE_SECURE = True

“HTTPS니까 켜두자”의 직관은 리버스 프록시 뒤에선 함정이다. 브라우저는 HTTPS로 오지만 서버 사이는 HTTP다.

[4차] sed 치환이 따옴표를 날려버렸다

증상 : CSRF_TRUSTED_ORIGINS를 고쳤는데 재기동 후에도 반영이 안 됨.

원인 : 배포 스크립트에서 sed로 값을 넣었는데, 이스케이프를 잘못 짜서 파이썬 문자열의 양쪽 따옴표가 사라졌다. 파이썬 파서가 문자열로 인식을 못 해 아예 로드에 실패했다.

조치 : sed 치환을 걷어내고 파이썬 스크립트로 정확히 다시 썼다. 로그가 흘려버릴 뻔한 실패였는데, 컨테이너 로그에서 파싱 에러 한 줄로 잡아냈다.

[5차 — 핵심] config.yml이 sentry.conf.py를 이긴다

증상 : sentry.conf.py에서 SENTRY_OPTIONS["system.url-prefix"]를 https://sentry.x.xxx.kr로 지정했는데도, 로그인 리다이렉트가 계속 IP + 포트(http://192.168.x.x:9000/...)로 나갔다. 브라우저는 도메인으로 접속하는데 응답이 IP로 튕겨내는 상황.

원인 : sentry/config.yml에 system.url-prefix 값이 하드코딩돼 있었고, 이 값이 sentry.conf.py보다 우선으로 로드됐다.

config.yml  >  sentry.conf.py

이걸 몰라서 파이썬 파일만 계속 고쳤다. SENTRY_OPTIONS를 아무리 덮어써도 원래 값이 살아 있는 이유가 이거였다.

어떻게 알아냈냐면 — 문서가 아니라 동작이었다. 파이썬 파일을 고치고 재기동해도 리다이렉트 URL이 안 바뀌길래, config.yml 쪽 값을 바꿔봤더니 그제야 바뀌었다. 두 파일이 같은 키를 갖고 있을 때 어느 쪽이 이기는지는 실행해봐야 보이는 순서였다. “왜 값이 안 먹지”에 붙잡히기 전에 “지금 어디서 이 값을 읽고 있지”를 먼저 물었어야 했다.

조치 : config.yml도 함께 수정.

system.url-prefix: 'https://sentry.x.xxx.kr'

설정을 두 곳에서 관리하는 시스템은 어디가 이기는지를 먼저 확인해야 한다. 우선순위를 모르면 아래쪽만 계속 고치게 된다.

[6차] Realm을 잘못 잡았다

증상 : Sentry에서 “Keycloak으로 로그인”을 누르면 로그인 페이지까지는 가는데, 사내 계정이 인식되지 않았다.

원인 : Keycloak 관리 콘솔에서 master realm의 sentry 클라이언트를 수정했다. 실제 사내 계정은 TEAM-<사내> realm에 있었다. 좌측 상단 realm 드롭다운을 못 봤다.

조치 : TEAM-<사내> realm의 sentry 클라이언트에서 값을 다시 세팅.

  • Redirect URI: https://sentry.x.xxx.kr/auth/sso/*
  • Web Origins: +
  • sentry.conf.py의 OIDC_DOMAIN도 https://key.x.xxx.kr/realms/TEAM-<사내>으로 정정.

왜 이렇게 많은 단계가 필요했나

한 번에 정리해 보면 여섯 개가 서로 다른 층에서 발생했다.

층 사건
애플리케이션 (신뢰 목록) 1차 — CSRF_TRUSTED_ORIGINS 누락
프록시 (헤더 전달) 2차 — X-Forwarded-Proto 덮어쓰기
브라우저 ↔ 서버 (쿠키 정책) 3차 — Secure 쿠키 충돌
배포 도구 (파일 편집) 4차 — sed 이스케이프 실패
설정 우선순위 5차 — config.yml 대 sentry.conf.py
IAM (realm 격리) 6차 — realm 오인

같은 “CSRF 에러”라는 증상이 5개 층을 걸쳐 다르게 발현됐다. 하나의 로그 메시지만 보면 여섯 개가 다 똑같이 보인다.

증상은 하나여도 원인은 층별로 다르다. 로그 메시지가 아니라 구조를 봐야 한다.


최종 설정 (기록용)

sentry/sentry.conf.py

SENTRY_OPTIONS["system.url-prefix"] = "https://sentry.x.xxx.kr"

CSRF_TRUSTED_ORIGINS = [
    "http://192.168.x.x:9000",
    "https://sentry.x.xxx.kr",
]

SOCIAL_AUTH_REDIRECT_IS_HTTPS = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

# 리버스 프록시 뒤 내부 HTTP 구간과 충돌하므로 켜지 않는다.
# SESSION_COOKIE_SECURE = True
# CSRF_COOKIE_SECURE = True

OIDC_CLIENT_ID = "sentry"
OIDC_CLIENT_SECRET = "<masked>"
OIDC_SCOPE = "openid email profile"
OIDC_DOMAIN = "https://key.x.xxx.kr/realms/TEAM-<사내>"
OIDC_ISSUER = "Keycloak"

sentry/config.yml

system.url-prefix: 'https://sentry.x.xxx.kr'

sentry/nginx.conf (Sentry 내부 프록시)

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;

만약 다시 짠다면

지금 이 세팅을 다시 한다면 두 가지를 순서에 끼워 넣을 것이다.

  • 도메인 붙이기 전에 config.yml부터 열어본다. 이번엔 IP 접근으로 세팅을 다 끝내고 나서 마지막에 도메인을 붙였는데, 그 순서가 5차 사건의 원인이었다. 애초에 system.url-prefix를 최종 도메인으로 박은 상태에서 세팅을 진행했으면 CSRF의 절반은 아예 발생하지 않았을 것이다.
  • 리버스 프록시 뒤 세팅은 로컬에서 한 번 그림을 그린 뒤 붙인다. 유저 → HTTPS → nginx → HTTP → Sentry 구조에서 스킴이 바뀌는 지점이 어디인지, 쿠키가 어디까지 실려 가는지, X-Forwarded-*가 어디서 세팅·덮어써지는지를 종이 위에 한 번 정리하면 2차·3차·5차 사건은 대부분 사전에 걸러진다.

다만 그건 “리버스 프록시 뒤 서비스”라는 구조가 익숙해진 다음의 이야기다. 이번엔 그 구조 자체가 처음이라 층별로 하나씩 부딪히면서 배운 셈이다. 명령어는 잊혀지겠지만, “이 문제가 왜 생기고 어디를 봐야 하는가”의 감각은 남을 것이다.


참고

self-hosted Sentry 카테고리의 글