보안 미들웨어가 진짜 에이전트를 403으로 막았다

· bejoyfuuul

에이전트가 연결을 시도하자 403이 떨어졌다. 공격자가 아니었다. 공격을 막으려고 넣은 방어막이 정상 사용자를 막고 있었다.

RelayRoom MCP 서버에는 Host 허용목록 검사가 있다. 의도는 맞았다. self-host 서버, 특히 로컬이나 사설망에 붙은 서버는 DNS rebinding 류 공격에 노출될 수 있다. 하지만 보안 경계가 설정 경계와 어긋나면, 방어 코드는 정상 경로를 공격처럼 보게 만든다. 이번 문제가 그랬다.

DNS rebinding이 뭔가

DNS rebinding은 브라우저의 same-origin policy를 우회해 사설망 서비스를 때리는 오래된 공격 기법이다. 공격자는 attacker.example 같은 도메인을 준비하고, 처음에는 자기 웹 서버 IP로 응답한다. 피해자 브라우저가 그 페이지를 열면 악성 JavaScript가 같은 origin인 attacker.example로 요청을 보낼 수 있다. 그 다음 공격자는 DNS 응답을 내부 IP나 로컬 서비스 IP로 바꾼다. 브라우저 입장에서는 여전히 같은 origin으로 요청하는 것처럼 보이지만, 실제 네트워크 목적지는 피해자의 내부 서비스가 될 수 있다.

서버가 Host 헤더를 검사하는 이유가 여기에 있다. 요청이 어떤 IP로 들어왔든, HTTP Host에는 클라이언트가 접근하려고 한 이름이 남는다. 서버가 localhost, hub.example.com처럼 신뢰한 이름만 받으면, 공격자가 만든 임의 도메인으로 들어온 요청을 거부할 수 있다.

RelayRoom도 이 원칙을 따랐다. MCP 서버가 로컬 개발 환경이나 self-host 환경에서 동작하므로, 예상하지 않은 Host를 403으로 막았다.

정상 도메인이 막혔다

self-host 허브를 커스텀 도메인 뒤에 올린 뒤 에이전트를 연결하자 다음과 같은 실패가 나왔다.

error: could not fetch RELAYROOM.md ... server responded 403

직접 요청을 보내 보니 서버 응답은 더 노골적이었다.

{"error":"host not allowed"}

프록시가 막은 것이 아니었다. RelayRoom 서버가 정상 커스텀 도메인을 허용목록 밖으로 판단했다.

이 지점이 중요하다. 보안 미들웨어는 실패할 때 대체로 "보안이 잘 작동한다"처럼 보인다. 403은 의도된 결과이기 때문이다. 그러나 허용목록형 방어의 품질은 공격자를 막는 것만으로 평가할 수 없다. 정상 운영 경로가 자동으로 허용되는지도 같이 봐야 한다.

원인은 두 겹이었다

첫 번째 원인은 compose env 전달 누락이었다.

서버의 Host 허용목록은 RELAYROOM_SERVER_BASE_URLRELAYROOM_ALLOWED_HOSTS 같은 서버 환경변수에서 만들어진다. 그런데 compose 파일은 이 값을 web 컨테이너에만 전달하고 server 컨테이너에는 전달하지 않았다. 웹 UI는 공개 URL을 알고 있었지만, 실제 Host 검사를 수행하는 서버는 몰랐다. 서버 입장에서 허용 목록은 사실상 localhost 계열뿐이었다.

두 번째 원인은 더 구조적이었다.

RelayRoom에는 서버 BASE URL을 대시보드 UI에서 설정하는 기능이 있었다. self-host 사용자는 환경변수를 직접 편집하지 않고 화면에서 공개 URL을 바꿀 수 있다. 그런데 Host 허용목록은 그 DB 설정값을 읽지 않았다. 그 결과 UI에는 https://hub.example.com 기준 connect 가이드가 표시되는데, 정작 MCP 서버는 hub.example.com 요청을 403으로 막는 모순이 생겼다.

이것이 self-host footgun이다. 사용자는 제품이 안내한 URL을 그대로 썼는데 제품의 다른 레이어가 그 URL을 거부한다.

env와 DB 설정은 서로 다른 시간에 산다

이 문제의 핵심은 "어디에 설정을 둘 것인가"다.

환경변수는 운영자가 배포 시점에 주입한다. 컨테이너가 뜰 때 읽고, 대개 재시작 전까지 바뀌지 않는다. 보안 미들웨어에는 좋다. 부팅 때 확정된 값을 기준으로 빠르게 검사할 수 있고, 운영자가 통제한다.

DB 런타임 설정은 사용자가 제품 안에서 바꾼다. self-host 제품에는 좋다. .env를 열고 compose를 재시작하지 않아도 도메인, SMTP, 공개 URL 같은 값을 바꿀 수 있다.

문제는 두 설정 표면이 같은 의미를 가질 때다. RELAYROOM_SERVER_BASE_URL과 대시보드의 서버 URL은 둘 다 "공개 MCP 서버 URL"을 뜻한다. connect 가이드는 DB 값을 보고, Host 미들웨어는 env만 보면 단일 진실원천이 깨진다.

수정: 정상 공개 URL은 자동으로 허용한다

수정은 두 방향이었다.

첫째, compose가 server 컨테이너에도 공개 BASE URL을 전달하도록 했다. 처음 설치한 사용자는 .env에 넣은 공개 URL이 web과 server 양쪽에 들어간다.

둘째, 서버 허용목록이 DB의 대시보드 설정값도 읽도록 했다. UI로 공개 URL을 바꾸면 connect 가이드만 바뀌는 것이 아니라 Host 허용목록도 갱신된다. 성능과 안정성을 위해 매 요청마다 무거운 조회를 하는 대신 캐시를 둘 수 있다. 중요한 것은 보안 미들웨어가 실제 제품 설정 표면을 인정한다는 점이다.

개념적으로는 다음 순서다.

const allowedHosts = new Set<string>();
 
allowedHosts.add("localhost");
allowedHosts.add("127.0.0.1");
allowedHosts.add(hostFromEnv("RELAYROOM_SERVER_BASE_URL"));
allowedHosts.addAll(hostsFromEnv("RELAYROOM_ALLOWED_HOSTS"));
allowedHosts.add(hostFromDatabaseSetting("serverBaseUrl"));
 
if (!allowedHosts.has(requestHost)) {
  return json({ error: "host not allowed" }, 403);
}

실제 구현은 캐시, URL 파싱, 포트 처리, IPv6 localhost 등을 더 신중히 다뤄야 한다. 하지만 설계 원칙은 단순하다. 제품이 사용자에게 "이 URL로 연결하세요"라고 말한다면, 서버의 보안 경계도 그 URL을 정상 경로로 인정해야 한다.

허용목록을 넓힐 때 조심할 점

해결책은 *를 허용하는 것이 아니다. Host 검사를 꺼 버리면 DNS rebinding 방어가 사라진다. self-host 제품에서 문제를 빨리 피하려고 RELAYROOM_ALLOWED_HOSTS=* 같은 우회로를 만들면, 나중에 문서와 예제가 그 우회로를 정답처럼 퍼뜨린다.

대신 허용목록은 다음 속성을 가져야 한다.

  • 기본 로컬 개발 경로(localhost, 127.0.0.1)는 허용한다.
  • 설치 시 입력한 공개 URL의 host를 허용한다.
  • 대시보드에서 바꾼 공개 URL의 host를 허용한다.
  • 운영자가 명시한 추가 host만 허용한다.
  • Host 파싱은 URL parser를 사용하고, 대소문자와 포트를 일관되게 정규화한다.
  • 거부 응답은 원인을 알 수 있을 만큼 명확하되, 내부 설정 전체를 노출하지 않는다.

403과 SSE 0바이트는 다른 문제였다

같은 커스텀 도메인에서 두 문제가 겹쳐 처음에는 헷갈렸다. 하나는 Host 허용목록 403이고, 다른 하나는 SSE 버퍼링으로 wake 스트림이 0바이트처럼 보이는 문제였다.

두 문제를 가르는 테스트는 간단하다.

  • 403이면 일반 API나 RELAYROOM.md fetch도 서버에서 명시적으로 거부된다.
  • SSE 버퍼링이면 인증과 일반 API는 통과하고, /api/sse 스트림만 실시간으로 흐르지 않는다.
  • 오리진 직결과 프록시 경유를 비교하면 둘이 분리된다.

이 구분을 문서화하는 것이 중요하다. 사용자는 "도메인 뒤에서 안 된다"라고만 말한다. 제품은 그 한 문장을 Host, TLS, OAuth redirect, SSE buffering, idle timeout 같은 별개의 failure mode로 쪼개야 한다.

가져갈 것

보안 미들웨어는 설정의 단일 진실원천에 특히 민감하다. env는 운영자가 통제하고, DB 설정은 사용자가 런타임에 바꾼다. 둘 중 하나만 보면 다른 쪽에서 만들어진 정상 경로를 막을 수 있다.

허용목록형 방어는 공격자를 막는 동시에 정상 공개 URL을 자동으로 포함해야 한다. 제품 UI가 안내한 URL을 제품 서버가 거부하면, 사용자는 보안을 "깨진 설정"으로 경험한다.

RelayRoom은 env와 DB 설정을 둘 다 인정하는 절충을 택했다. 운영자는 RELAYROOM_ALLOWED_HOSTS로 엄격히 제어할 수 있고, self-host 사용자는 대시보드에서 공개 URL을 바꿔도 MCP 연결이 막히지 않는다.

참고 자료