Engineering · Security · Governance

nginx 기본 502 페이지가 가리킨 곳: 응답에 실린 신원 헤더와 클라이언트 헤더 위조 경로

원격 조회가 불가능한 폐쇄망의 문서 다운로드 502를 응답 본문 형태로 가설을 하나씩 배제해, 게이트웨이가 서비스용 신원 헤더를 브라우저 응답에도 실어 앞단 nginx의 헤더 버퍼를 넘기는 구조로 좁혔습니다. 조사 중 클라이언트가 보낸 신원 헤더가 서비스까지 전달되던 위조 경로를 찾아, 이미 검증된 보안 수정을 이식해 막았습니다.

기간
2026.10 – 2026.10
소속
고객사 구축 · 은행
역할
원인 분석·API 게이트웨이 보안 수정

Key Results

약 3.6KB
권한 목록 응답 헤더 하나의 크기목록형 권한 141개 사용자, 외부망 실측, 앞단 프록시 헤더 버퍼 기본 4KB 대비
15/15
게이트웨이 테스트 통과운영 Dockerfile과 같은 Rust 이미지, 보안 테스트 6종 포함, release 빌드 통과

은행 폐쇄망 운영계의 채팅에서 에이전트가 만든 문서를 내려받으려 하면 502가 난다는 제보가 들어왔습니다. 브라우저 콘솔의 응답 본문은 nginx 기본 502 페이지였고, 같은 오류가 반복해서 재현됐습니다. 폐쇄망은 외부에서 로그를 볼 수 없고, 외부망 데모의 파드는 폐쇄망에 반입한 이미지와 판이 달라 그대로 참고할 수 없었습니다. 코드와 폐쇄망 환경 명세서만으로 원인을 추론해야 했습니다.

이 글은 응답 본문의 형태 하나로 가설을 배제해 가며 원인을 "게이트웨이가 서비스용 신원 헤더를 브라우저 응답에도 실어 앞단 nginx의 헤더 버퍼를 넘기는 구조"로 좁힌 과정과, 그 조사 중에 발견한 더 심각한 문제, 즉 클라이언트가 보낸 신원 헤더가 서비스까지 전달되던 위조 경로를 막은 과정을 다룹니다. 502의 최종 확정은 현장 로그 한 줄을 기다리고 있으므로, 어디까지가 근거이고 어디부터가 추정인지도 구분해 씁니다.

보이지 않는 폐쇄망: 요청이 지나는 경로

브라우저 ─▶ L4 VIP ─▶ nginx (TLS 종료, proxy_buffer_size 기본 4k)
        ─▶ Istio ─▶ API 게이트웨이 (Rust) ─▶ 문서 서비스

이 경로는 같은 날 갱신하던 폐쇄망 환경 명세서에서 가져왔습니다. 명세서는 현장에서 직접 조회한 클러스터 구성에 저장소의 배포 자료와 인수인계 자료를 대조해 만든 것이고, 외부망 자료와 현장 값이 어긋나는 지점을 따로 표기해 두었습니다. 원격으로 확인할 수 없는 환경에서는 이 명세가 사실상 유일한 지도였습니다.

채팅 답변의 다운로드 버튼은 저장소 파일 다운로드 API를 호출하고, 요청은 게이트웨이의 프록시를 거쳐 문서 서비스에 도착합니다. 버튼은 실패를 UI에 표시하지 않고 콘솔에 상태 코드와 본문만 남기는 구조라, 현장에서 받은 단서는 콘솔 캡처 한 장이었습니다. 게이트웨이는 토큰을 검증한 뒤 서비스로 가는 요청에 사용자 식별 헤더(x-user-id, 이름, 슈퍼유저 여부, 역할, 권한 목록, 감독 범위)를 실어 보내고, 서비스는 SDK를 통해 이 헤더로 사용자와 권한을 판정합니다.

502를 낼 수 있는 자리 세 곳

캡처를 받기 전, 코드 기준으로 502를 낼 수 있는 자리를 유력한 순서로 꼽았습니다.

  1. 문서 서비스가 직접 반환하는 502입니다. 다운로드 시 DRM 암호화가 켜져 있으면 파일 전체를 암호화 서비스에 왕복시키고, 실패하면 502를 반환합니다. 외부망은 암호화가 꺼져 있어 재현할 수 없었습니다.
  2. 게이트웨이의 502입니다. 업스트림 요청이 실패하면 게이트웨이가 JSON 본문으로 502를 반환합니다.
  3. 반입본이 이전 판인 경우입니다. 예전 다운로드 경로는 응답을 시작한 뒤 스트림 안에서 복호화해, 크기가 맞지 않으면 중간에 끊겼습니다. 다만 암호화가 켜진 환경에서는 이 분기를 타지 않습니다.

현장에서 한 번에 가르는 방법은 응답 본문이었습니다. 1번이면 암호화 실패 문구가 든 JSON, 2번이면 게이트웨이의 JSON, nginx HTML이면 nginx 자체의 실패입니다.

곧 현장 캡처가 왔고, 본문은 nginx 버전이 찍힌 기본 502 페이지였습니다. 문서 서비스의 JSON도 게이트웨이의 JSON도 아니었습니다. 바깥 nginx가 업스트림에서 응답 헤더를 정상적으로 받지 못해 스스로 만든 502로 판단했습니다. nginx는 헤더를 보낸 뒤에 실패하면 상태 코드를 502로 바꿀 수 없으므로, 헤더 단계에서 실패했다는 뜻이기도 합니다. 현장에서 파드를 열어 암복호화를 시험했을 때 정상이었다는 사실까지 더해 1번은 후순위로 내렸습니다. 한 가지 예외는 남겼습니다. 현장 nginx에 업스트림 오류를 가로채 자체 오류 페이지로 바꾸는 설정이 있다면 업스트림 502도 같은 모양이 되므로, access 로그의 업스트림 상태로 확인해야 합니다.

응답 헤더가 커지는 이유

헤더 단계의 실패라면 응답 헤더를 의심할 차례였습니다. 게이트웨이 프록시 코드를 읽어 보니, 서비스로 가는 요청에 싣기 위해 만든 신원 헤더 묶음을 응답 쪽에도 세 군데에서 확장해 넣고 있었습니다. 브라우저로 가는 응답에 사용자의 권한 목록이 실려 나가고 있었던 것입니다.

크기를 쟀습니다. 외부망에서 와일드카드 권한(*:*)을 가진 관리자는 권한 헤더가 9B, 응답 헤더 전체가 약 645B였습니다. 그러나 권한을 목록으로 가진 사용자는 전체 권한 키 141개 기준으로 권한 헤더 하나만 약 3.6KB였습니다(54개면 약 1.4KB). 다운로드 응답에는 여기에 한글 파일명을 인코딩한 Content-Disposition(한글 1자에 9B)과 Office 문서의 MIME 타입(71B)이 더해져, 일반 JSON 응답보다 수백 바이트 큽니다. nginx의 proxy_buffer_size가 기본값 4k라면 "일반 API는 되는데 다운로드만 502"인 구간이 생깁니다. 이 경우 nginx는 upstream sent too big header를 기록하고 502를 냅니다.

응답 신원 헤더 크기 비고
관리자, 와일드카드 권한 권한 헤더 9B, 응답 헤더 전체 약 645B 외부망 실측
목록형 권한 사용자 (키 141개) 권한 헤더 약 3.6KB 같은 방식 계산
다운로드 응답 추가분 Content-Disposition 한글 1자 9B + MIME 71B 일반 응답보다 수백 B 큼

이 동작이 의도된 기능인지도 확인했습니다. 이력을 보면 2025년 10월 게이트웨이 인증 구현 당시의 원래 주석은 "기존 서비스들이 사용하던 헤더 계약을 유지"였고, 응답 쪽 호출은 처음에는 응답 헤더(토큰이 없음)를 넘겨 실제로는 아무것도 싣지 않았습니다. 다음 날의 리팩터링으로 요청에서 만든 헤더 묶음을 넘기게 되면서 사용자 정보가 응답에 나가기 시작했습니다. 브라우저용 의도가 없는, 리팩터링의 부작용이었습니다. 권한 목록 헤더는 2026년 4월부터 추가돼 크기 문제를 키웠습니다.

다른 후보도 함께 적어 두었습니다. 문서 서비스가 파일 전체를 준비한 뒤 헤더를 보내므로 그 전에 연결이 끊기는 경우, 그리고 게이트웨이의 HTTP 클라이언트가 환경 변수의 프록시 설정을 그대로 따르므로 현장에 프록시가 주입돼 있고 예외 목록에 문서 서비스가 없으면 내부 호출이 프록시를 타는 경우입니다. 판정 순서는 nginx error.log의 해당 시각 한 줄, 개발자 도구에서 502 응답에 게이트웨이 흔적(x-request-id, x-user-*)이 있는지, 관리자 계정으로 같은 파일을 받아 보는 것으로 정했습니다.

고칠 자리 고르기

선택지 장점 단점 판단
nginx proxy_buffer_size 상향 즉시 효과 은행 nginx 설정 변경이 필요하고 권한 목록 노출은 그대로 반입 전 급할 때만 쓰는 은행 측 임시 조치
게이트웨이 응답 쪽 주입 3줄 삭제 정보 노출과 헤더 비대의 원인을 함께 제거. 은행 설정 무변경, 반입은 게이트웨이 이미지 하나 502 원인이 다르면 노출 수정으로만 남음 채택

지우기 전에 응답의 신원 헤더를 읽는 곳이 있는지 찾았습니다. 프론트엔드는 권한을 로그인과 토큰 검증 응답의 JSON에서 읽고(게이트웨이 자체 핸들러라 프록시 경로가 아님), 응답 헤더를 읽는 코드는 0건이었습니다. 서비스 SDK와 권한 판정은 요청 헤더만 읽습니다. 게이트웨이 내부(웹소켓, 로그, 텔레메트리)에서도 이 헤더 묶음은 업스트림 주입용으로만 쓰였고, SSO, 현황판, 인프라 스크립트도 0건이었습니다. CORS에도 노출 헤더 설정이 없었습니다. 배포 자료에는 nginx가 이 응답 헤더를 쓰는 설정이 없어, 바깥 nginx가 기본 버퍼일 가능성도 높았습니다. 남은 미확인 지점은 저장소에 없는 현장 nginx 설정 하나였습니다.

은행의 권한 관리가 엄격한 만큼 의도된 동작을 건드리는지도 따로 확인했습니다. 부서는 헤더가 아니라 DB 값이라 무관하고, 슈퍼유저·역할·권한·감독 범위 판정은 요청 쪽 주입 그대로라 바뀌지 않습니다. 같은 응답 쪽 주입은 제품 기본 라인과 운영 라인의 프록시 코드에도 같은 형태로 있었습니다. 제품 기본 라인용 수정도 만들었지만 이번에는 제품 기본 라인을 건드리지 않기로 해 닫았고, 수정 범위는 고객 배포 라인으로 한정했습니다.

조사 중 발견한 더 큰 문제: 클라이언트가 보낸 신원 헤더

영향을 확인하던 중 요청 쪽에서 더 심각한 구조를 발견했습니다. 게이트웨이는 업스트림으로 헤더를 전달할 때 들어온 헤더를 전부 복사하고(복사 목록에 x-user-id가 명시적으로 한 번 더 있었습니다), 그 뒤에 자기가 계산한 신원 헤더를 추가했습니다. HTTP 클라이언트의 헤더 추가는 덮어쓰기가 아니라 append입니다. 그리고 서비스 SDK는 같은 이름의 헤더 중 첫 값을 믿고, JWT나 내부 비밀값으로 검증하지 않습니다. 브라우저가 X-User-ID, X-User-Superuser: true, X-User-Permissions: *:*를 실어 보내면 서비스가 그 값으로 판정할 수 있는 구조였습니다.

수정을 직접 작성하기 전에 다른 라인을 확인했고, 제품 기본 라인과 운영 라인에는 이미 P0 보안 수정(신원·IP 헤더 제거와 .·.. 경로 세그먼트 거부, forwarded 헤더 재구성)이 들어가 있었습니다. 이 고객 배포 라인을 포함한 일부 라인에만 빠져 있었던 것입니다. 직접 짠 수정은 버리고, 운영에서 이미 동작 중인 수정을 이식했습니다. 이식하면서 고객 배포 라인에 없는 무관한 커밋(파일명 재인코딩)과 충돌한 부분은 제외하고, 고객 배포 라인의 트레이스 헤더 처리는 유지했습니다. 이식 결과 신원, 클라이언트 IP, forwarded 헤더는 게이트웨이 계산값만 보내고 클라이언트 값은 버립니다.

검증은 운영 Dockerfile과 같은 Rust 이미지에서 했습니다. 수정 전 코드에 임시 회귀 테스트를 넣자 업스트림의 x-user-id가 다음처럼 전달돼 실패했고, 이식 후에는 통과했습니다.

클라이언트가 x-user-id: 999 를 실어 보낸 요청
  수정 전  업스트림 x-user-id = ["999", "999", "42"]   클라이언트 값이 게이트웨이 값(42)보다 앞
  수정 후  회귀 테스트 통과 (게이트웨이 계산값만 전달)

영향도 확인했습니다. 코어의 접속 IP 기록은 게이트웨이와 같은 헤더 우선순위(true-client-ip, cf-connecting-ip, x-client-ip, x-real-ip 순으로 보고 마지막에 X-Forwarded-For)를 써서 이식 뒤에도 기록되는 값이 같고, SSO는 리다이렉트 주소를 설정값으로 만들어 forwarded 헤더 재계산과 무관합니다. 게이트웨이를 거치는 내부 호출은 Authorization을 함께 보내 같은 신원이 다시 계산됩니다. 토큰이 만료된 긴 대화의 중계 요청이 이제 신원 없이 거절되는 것은 의도된 동작입니다. 이식으로 함께 들어온 주석 42줄은 한 줄로 정리했고, 주석과 빈 줄을 뺀 635줄이 정리 전과 같아 코드 변경은 0입니다. 바뀐 파일은 프록시 소스 하나이고 의존성·설정 변화가 없어 폐쇄망 반입 의존성도 늘지 않으며, 롤백은 이전 이미지 태그로 복원하는 것 하나입니다. 다만 폐쇄망 게이트웨이가 예전 판이라면 고객 배포 라인에 이미 쌓여 있던 변경 4건(응답 길이 헤더 처리, 웹소켓 공개 경로, 리프레시 토큰, 관측성 계측)도 함께 들어간다는 점을 반입 안내에 적었습니다. 이 4건은 외부망에서는 이미 운영 중이었습니다.

같은 날 고친 다운로드 표시 문제

외부망 데모에서는 다운로드와 관련된 표시 문제가 두 건 더 있었습니다. 하나는 엑셀을 만들어 달라는 요청에 버튼 없이 파일 표시 텍스트만 나온 건입니다. 실행 기록을 보니 모델이 문서 생성 도구를 한 번도 부르지 않고 표시를 지어냈습니다(LLM 1회, 도구 0회, 저장소에 해당 파일 없음). 이를 지우는 가드가 이미 있었지만 콜백 경로에만 걸려 있어, 에이전트 채팅이 쓰는 스트리밍 이벤트 경로에서는 우회됐고 그 시간대 가드 로그는 0건이었습니다. 같은 가드를 스트리밍 경로에 적용하고, 도구 결과의 다운로드 마커만 진짜 신호로 쓰게 했습니다. 지어낸 표시는 "문서가 실제로 생성되지 않았다"는 안내로 바뀝니다. 모델이 도구를 부르지 않은 것 자체는 에이전트 설정(기본 시스템 프롬프트, temperature 1, 도구 37개 전부 활성)의 문제로 따로 정리했습니다.

다른 하나는 버튼은 정상으로 떴는데 같은 표시가 원문으로 남은 건입니다. 모델이 최종 답변에 마커를 접두어만 빼고 반복했고, 고객 배포 라인 프론트엔드의 정규식은 접두어를 필수로 요구해 지우지 못했습니다. 제품 기본 라인에 이미 있던 수정에서 정규식 2줄만 이식하고, 같은 커밋의 버튼 디자인 변경은 제외했습니다. 고객 배포 라인에는 최소 수정만 들인다는 원칙 때문입니다.

결과와 남은 확인

지표 이전 이후 측정 조건
응답의 신원 헤더 포함 (목록형 권한 사용자 권한 헤더 약 3.6KB) 제거 외부망 배포 후, 관리자 로그인·일반 API 200
클라이언트가 보낸 x-user-id 업스트림에 999, 999, 42 순으로 전달 게이트웨이 계산값만 임시 회귀 테스트, 운영과 같은 Rust 이미지
게이트웨이 테스트 - 15/15, release 빌드 통과 보안 테스트 6종 포함, 최종 합본
지어낸 다운로드 표시 가드 우회 (가드 로그 0건) 제거 후 안내, 다음 질문 블록 보존 실제 출력 원문을 가짜 스트리밍 이벤트로 주입
반복된 원문 표시 남음 제거, 다시 열 때 버튼 복원 3:3 문제 대화의 저장 원문으로 전후 비교

nginx를 앞단에 둔 종단 재현은 하지 않았고, 코드 판정과 외부망 헤더 실측으로 갈음했습니다. 그래서 502의 원인은 아직 확정이 아닙니다. 현장 nginx error.log에서 해당 시각의 다운로드 요청 줄이 upstream sent too big header라면 응답 헤더 수정으로 해소가 확정되고, 아니라면 이 수정은 정보 노출 결함 수정으로 남고 502는 따로 봐야 합니다. 기록 시점에 폐쇄망 게이트웨이 이미지는 반입이 보류돼 응답에 신원 헤더가 남아 있고, 반입 후 같은 문서를 한 번 내려받는 확인이 남아 있습니다. 현장 nginx 설정이 이 응답 헤더를 로그나 인증에 쓰는지 확인하는 것도 남은 미확인 지점입니다.

돌아보면 이번 조사를 이끈 것은 응답 본문의 형태였습니다. 502라는 숫자는 같아도 본문이 JSON인지 nginx HTML인지에 따라 실패 지점이 달라지고, 그 하나로 DRM과 게이트웨이 업스트림 가설을 내릴 수 있었습니다. 위조 경로는 새로 작성하지 않고 운영에서 이미 검증된 수정을 이식했습니다. 제품 기본 라인과 고객 배포 라인이 갈라진 지점에서 보안 수정이 한쪽에만 들어가 있었다는 사실은, 라인 간 드리프트를 정기적으로 대조해야 하는 이유이기도 합니다.