로컬 개발 서버는 같은 와이파이 전체에 열려 있다
개발 서버가 0.0.0.0에 바인드되면 localhost 주소여도 같은 네트워크 전체에 노출됩니다. 실측 사례와 확인·수정 방법을 정리합니다.
localhost라는 착각
지난 7월, 제가 만든 영상 렌더 파이프라인의 개발 환경을 점검하다가 당연하게 여기던 전제 하나가 틀렸다는 걸 알았습니다. 저는 localhost:3000 같은 주소로 개발 서버를 띄우면 그건 내 컴퓨터 안에서만 보이는 창이라고 생각하고 있었습니다. 브라우저 주소창에 localhost가 찍히니까요.
그런데 브라우저에 localhost로 접속되는 것과 서버가 실제로 어느 주소에서 요청을 받는지는 전혀 다른 이야기였습니다. 확인해 보니 당시 돌아가던 개발용 서버 3개 — 영상 편집 UI, 카드뉴스 UI, 유튜브 인증 — 가 전부 0.0.0.0에 바인드돼 있었고, 어느 것에도 인증이 없었습니다. 0.0.0.0은 “모든 네트워크 인터페이스에서 수신한다”는 뜻입니다. 즉 같은 와이파이에 붙은 아무 기기나 제 컴퓨터의 IP와 포트만 알면 그 서버에 접근할 수 있는 상태였습니다.
먼저 분명히 해두면, 실제로 누가 접근했다는 증거는 없습니다. 이건 침해 사고가 아니라 “열려 있었다”까지의 이야기입니다. 다만 열려 있었다는 사실 자체가 충분히 곱씹을 만했습니다.
무엇이 얼마나 열려 있었나
세 서버 중에서도 영상 편집 UI가 문제였습니다. 이 서버의 설정 조회 API는 유튜브 OAuth의 Client Secret을 평문 JSON으로 그대로 반환하고 있었습니다. 설정 화면에 현재 값을 채워 보여주려는, 개발할 때 흔히 짜는 편의 코드였습니다. 로컬은 나만 본다는 가정 아래에서는 아무 문제가 없어 보이는 코드입니다.
공격 경로는 두 가지로 정리됐습니다.
- (a) 직접 호출 — 같은 와이파이에 붙은 다른 기기가 제 IP와 포트만 알면 그 API를 그대로 호출할 수 있습니다.
- (b) CSRF — 제 브라우저에 열어둔 악성 페이지가 로컬 주소를 호출하는 경로입니다. 이 경우는 상대가 같은 네트워크에 있을 필요조차 없습니다. 제가 그 페이지를 여는 것만으로 요청이 로컬 서버로 나갑니다.
이 상태에서 가능했던 일은 세 가지였습니다. 시크릿 조회, 설정 변조, 그리고 렌더 작업의 임의 실행. 카페나 코워킹 스페이스의 공용 와이파이에서 이런 서버를 띄워둔 채 작업했다면, 같은 망에 있던 누군가에게 이 문이 그대로 열려 있었던 셈입니다.
프레임워크 기본값을 의심하기
이게 특별히 부주의한 설정이었냐면, 그렇지도 않습니다. 요즘 개발 서버는 상당수가 0.0.0.0을 기본으로 하거나, --host 플래그 한 번이면 쉽게 그렇게 바뀝니다. Vite는 --host를 붙이면 곧바로 0.0.0.0으로 열리고, 여러 Node 서버 예제도 app.listen(PORT)처럼 바인드 주소를 생략하는데 이 경우 기본이 모든 인터페이스입니다. 모바일 기기에서 개발 중인 화면을 확인하려고 무심코 --host를 켜둔 채 잊어버리기도 쉽습니다.
그래서 “내 개발 서버가 지금 어디에 바인드돼 있는지”를 직접 확인하는 습관이 필요합니다. 두 가지 방법을 씁니다.
먼저 다른 기기에서 열어보는, 가장 확실한 방법입니다. 같은 와이파이에 붙은 폰이나 노트북 브라우저에서 내 컴퓨터의 IP와 포트를 직접 쳐봅니다.
# 내 로컬 IP 확인 (macOS)
ipconfig getifaddr en0
# → 다른 기기 브라우저에서 http://192.168.0.x:3000 접속 시도
열린다면 그건 네트워크 전체에 노출돼 있다는 뜻입니다. 그리고 바인드 주소를 직접 확인하는 방법입니다.
lsof -i :3000
# 또는
netstat -an | grep 3000
여기서 LISTEN 상태의 주소가 127.0.0.1:3000이면 내 컴퓨터 안에서만, *:3000이나 0.0.0.0:3000이면 모든 인터페이스에서 수신하는 상태입니다.
두 줄로 닫은 문
수정은 결국 두 줄이었습니다. 하나는 서버 바인드 주소를 명시한 것입니다.
// before
app.listen(PORT)
// after
app.listen(PORT, '127.0.0.1')
127.0.0.1을 명시하면 서버는 루프백에서만 수신하므로, 같은 네트워크의 다른 기기가 직접 호출하는 (a) 경로가 막힙니다. 다른 하나는 설정 조회 응답에서 시크릿을 마스킹한 것입니다. 값이 저장돼 있는지 여부만 알려주고, 실제 Client Secret은 응답에 담지 않도록 바꿨습니다.
다만 여기엔 경계가 있습니다. 127.0.0.1 바인드는 (a)를 막지만, 브라우저를 경유하는 (b) CSRF 경로까지 막아주지는 않습니다. 그래서 시크릿을 응답으로 내보내지 않는다는 원칙이 바인드 설정과 별개로 필요합니다. 로컬 개발 서버라도 시크릿은 응답 본문에 실리는 순간 그 가정 하나로 지켜지는 상태가 되는데, 그 가정은 생각보다 쉽게 깨집니다.
정리하면 판단 기준은 단순합니다. 개발 서버를 띄울 때 바인드 주소를 생략하지 말고 의도한 값을 명시할 것, 지금 어디에 열려 있는지 lsof나 다른 기기로 직접 확인할 것, 그리고 로컬이라는 이유로 시크릿을 응답에 담지 말 것. 세 가지 모두 코드 몇 줄이지만, “로컬은 나만 본다”는 착각을 걷어내야 비로소 손이 가는 것들이었습니다.
자주 묻는 질문
localhost로 접속되는데 왜 다른 기기에서도 열리나요?
브라우저에 localhost로 접속되는 것과 서버가 어느 주소에 바인드됐는지는 별개입니다. 서버가 0.0.0.0에 바인드돼 있으면 모든 네트워크 인터페이스에서 수신하기 때문에, 같은 와이파이의 다른 기기가 내 IP:포트로 직접 접근할 수 있습니다.
내 개발 서버가 어느 주소에 바인드됐는지 어떻게 확인하나요?
macOS·리눅스에서는 lsof -i :포트 또는 netstat -an 으로 LISTEN 상태의 바인드 주소를 확인할 수 있습니다. 127.0.0.1로 나오면 내 컴퓨터에서만, 0.0.0.0(또는 *)으로 나오면 네트워크 전체에서 접근 가능합니다. 다른 기기 브라우저에서 내 IP:포트를 직접 열어보는 것도 확실한 방법입니다.
127.0.0.1에만 바인드하면 CSRF도 막히나요?
아닙니다. 127.0.0.1 바인드는 같은 네트워크의 다른 기기가 직접 호출하는 경로를 막습니다. 하지만 내 브라우저에 열어둔 악성 페이지가 로컬 주소를 호출하는 CSRF 경로는 여전히 남으므로, 인증·CSRF 토큰·시크릿 마스킹을 함께 적용해야 합니다.
개발 서버 API가 시크릿을 응답에 담아도 되나요?
개발 환경이라도 시크릿을 응답 본문으로 내보내지 않는 것이 원칙입니다. 설정 조회 API는 저장 여부만 알려주고 값 자체는 마스킹해서 반환해야 합니다. 로컬은 안전하다는 가정이 깨지는 순간 그 응답이 그대로 노출되기 때문입니다.