방송 A를 볼 수 있는 사용자가 A의 URL을 통해 B의 녹화를 요청한다면, 앞단의 접근 허용만으로 충분할까요. PokeClip의 MediaMTX 앞에 nginx를 둘 때 확인해야 했던 것은 요청이 전달된다는 사실보다 허용한 방송과 실제 읽는 방송이 같은가였습니다.

저는 3인 팀에서 미디어·인프라의 구조와 서비스 계약을 맡았습니다. nginx를 유지할 이유를 재검토하면서 playback 요청 대조를 필수 책임으로 남겼고, 원문 경로·쿼리·내부 포트까지 검증 범위에 넣었습니다. 구현·시험 실행·리뷰에는 AI를 활용했습니다. 이 글은 PR #311에 병합된 구성과 2026년 10월 시험 기록을 설명합니다.

경로에서 허용한 대상과 쿼리가 고른 대상

playback은 녹화 목록이나 구간을 읽는 MediaMTX 재생 서버입니다. 프록시는 /{id}/list와 /{id}/get을 받아, 내부 서버의 /list와 /get으로 전달합니다. 실제 읽을 대상은 쿼리의 path로 지정됩니다.

그러므로 /A/list?path=B를 그대로 넘기면 URL 경로와 서버가 읽는 대상이 달라집니다. 저는 쿼리의 path가 정확히 하나이고, 그 값이 경로의 ID와 같을 때만 전달하는 조건을 택했습니다. nginx가 계정 권한을 새로 계산하는 것은 아닙니다. 앞단의 접근 제어가 대상으로 삼은 경로와 뒤의 재생 요청을 연결하는 검사입니다.

요청 예시 프록시에서 지킬 조건
/A/list?path=A 허용한 대상과 읽을 대상이 일치하면 전달
/A/list?path=B 다른 방송을 지정하므로 403, 상류 호출 없음
/A/list 또는 ?path= 대상이 없거나 비어 있으므로 403
?path=A&path=B 중복 파라미터를 서로 다르게 해석할 수 있어 403
?pa%74h=B&path=A 디코딩하면 중복 키가 되므로 403

값 없이 이름만 있는 path 키도 중복 개수에 포함했습니다. 쿼리 키 이름의 %를 허용하지 않는 반면, 시각 값에 필요한 %3A와 %2B는 유지합니다. 모든 인코딩을 막는 대신 키 해석과 값 전달을 구분한 것입니다. 실제 nginx 템플릿에 원문 검사와 쿼리 대조를 나누어 두었습니다.

location이 본 경로가 받은 원문과 같지는 않았습니다

nginx 공식 문서는 location이 정규화한 URI를 대상으로 선택된다고 설명합니다. %XX를 디코딩하고, 점 경로와 연속 슬래시를 정리한 뒤의 경로입니다.

이 차이를 무시하면 원문의 /A/..%2fB/list를 검사하는 계층과 정리된 /B/list를 보는 계층이 다른 판단을 내릴 수 있습니다. 그래서 playback은 원문부터 허용한 /{id}/list·/{id}/get 꼴이어야 통과하게 했습니다. 경로 ID에 허용할 문자도 풀어 읽었을 때 의미가 바뀌지 않는 범위로 제한했습니다.

라이브와 제어 API는 원문을 상류에 그대로 전달합니다. 이쪽은 인코딩한 점·슬래시·역슬래시·퍼센트, 빈 경로 조각, 점 경로를 거절합니다. 판단은 정규화된 경로로 하고 전달은 다른 원문으로 하는 틈을 줄이기 위한 선택입니다. 모든 서버에 그대로 적용할 규칙은 아닙니다. 상류가 Go HTTP 서버라는 전제에 기대는 허용 문자도 있으므로, 상류가 바뀌면 해석 규칙을 다시 확인해야 합니다.

전달 경로와 관리 경로를 구분했습니다

nginx와 MediaMTX는 같은 네트워크 네임스페이스를 공유하고, 내부 HTTP 포트는 루프백으로 접근합니다. 네임스페이스는 컨테이너들이 같은 네트워크 공간을 쓰도록 묶는 단위입니다. 외부에는 필요한 앞단 포트만 열고 MediaMTX 내부 포트를 직접 노출하지 않는 구조입니다.

경로 맡긴 책임
라이브 HTTP 원문 검사 후 LL-HLS 서버로 전달
playback HTTP 방송 ID·쿼리 대조 후 재생 서버로 전달
제어 API 허용한 /api/ingest/·/api/streams/만 전달
관리 API nginx 전달 경로를 만들지 않음
SRT 송출 nginx HTTP 프록시를 지나지 않음

관리 API에는 송출 종료나 설정 변경 같은 작업이 있습니다. 자격증명이 있다는 이유로 이 경로까지 프록시하지 않았습니다. 제어 API와 MediaMTX 관리 API는 서로 다른 창구입니다. 제어 서버 주소가 아직 비어 있으면 허용 접두사도 404로 닫도록 구성했습니다.

성공 응답뿐 아니라 상류에 가지 않았는지도 확인했습니다

프록시 계약 시험은 응답 코드와 상류가 받은 요청을 함께 확인합니다. 403을 반환했어도 요청이 이미 상류에 도달했다면 필요한 차단이 아니기 때문입니다. 잘못된 ID, 중복 쿼리, 경로 정규화 우회에 대해 거절과 상류 호출 0을 검사했습니다.

10월 22일 PR 전 시험에서 proxy_contract는 5회 반복 모두 통과했습니다. 공식 nginx 이미지의 다이제스트를 고정하고, 운영과 같은 Terraform 템플릿 렌더 경로를 사용한 격리 실험입니다. 이 결과는 해당 요청 계약의 검증이며 운영 공격 차단율이나 대규모 처리량 수치는 아닙니다.

로그도 따로 검토했습니다. 접근 로그에서는 Authorization과 쿼리를 빼고, 거절한 원문 경로를 남깁니다. 다만 nginx 오류 로그에는 요청 줄과 일부 헤더가 포함될 수 있습니다. 접근 로그를 줄였다는 이유로 모든 로그에서 비밀이 사라졌다고 판단하지 않았습니다.

남은 비용과 적용 범위

프록시가 하나 늘면 설정·업그레이드·장애 대응 대상도 늘어납니다. 여기서는 재생 요청의 대상 대조와 내부 포트 경계를 맡길 필요가 그 비용을 정당화했습니다. 별도로 수행한 CDN 캐시 실험의 전송량 절감은 이 nginx 검증의 성과로 세지 않습니다.

PR #311은 2026년 10월 23일 한국 시각에 병합됐습니다. 10월 27일 저장 기록에서는 운영 Media 배포가 후속 단계로 남아 있습니다. 코드와 격리 시험의 완료를 운영 전 구간 검증으로 확대하지 않습니다. 이 구성에서 얻은 것은 “프록시를 붙였다”는 사실보다 허용한 경로와 실제 처리 대상이 같다는 조건을 명시하고 시험한 것입니다.

Media 호스트의 재시작과 비밀 파일 수명 · 전체 기술 기록

직접 바꿔보기 · 설명용 모형

허용된 요청은 어디까지 도달할까?

아래 결과는 선택한 입력으로 계산합니다. 실제 서버에 요청하거나 본문의 실험을 재실행하지 않습니다.

방송 A의 경로로 들어온 요청입니다. 앞단의 접근 허용 여부와 nginx의 대상 대조를 따로 확인합니다.

/A/list?path=A
  1. ① 앞단 접근 제어 허용
  2. ② 원문·대상 대조 통과
  3. ③ 재생 서버 호출 1회

path가 하나이고 경로의 A와 일치하면 전달합니다.

본문의 요청 사례를 고정한 모형입니다. 완전한 URI 파서나 보안 검사기가 아니며, 통과 표시는 실제 서버 응답을 뜻하지 않습니다.