실시간 기능을 붙여달라고 하면 나오는 코드
"메시지가 오면 새로고침 없이 화면에 뜨게 해줘." Claude Code한테 이렇게 시키면 Node 쪽에서는 대개 이 모양이 나온다.
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => { /* ... */ });
Go로 짜면 이런 줄이 딸려 나온다.
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
둘 다 잘 돈다. 브라우저에서 연결하면 붙고, 메시지가 실시간으로 오가고, 배포해도 똑같다.
그런데 이 서버에는 CORS 설정이 걸리지 않는다. REST API 쪽에 허용 도메인을 아무리 정교하게 맞춰놨어도 이 포트는 그 규칙 바깥에 있다. 지구 반대편에 있는 아무 웹페이지나, 사용자가 열어둔 탭 하나로 이 서버에 연결할 수 있다는 뜻이다. 에러도 안 나고 콘솔에 경고도 없다.
전화를 거는 절차는 편지 한 통으로 시작한다
웹소켓이 뭘 하는 물건인지부터 정리하자.
HTTP는 편지다. 한 통 보내면 답장 한 통이 오고 관계가 끝난다. 서버가 먼저 말을 걸 방법이 없어서, 새 메시지가 왔는지 알려면 클라이언트가 계속 물어봐야 한다. 웹소켓은 전화다. 한 번 연결해두면 끊을 때까지 양쪽이 아무 때나 말할 수 있다.
그런데 전화를 거는 절차 자체는 편지 한 통이다. 브라우저가 서버로 평범한 HTTP GET 요청을 하나 보낸다.
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
MDN이 정리해둔 그대로다. 메서드는 반드시 GET이어야 하고 HTTP 버전은 1.1 이상이어야 한다. 서버가 101 Switching Protocols로 답하는 순간 이 TCP 연결은 더 이상 HTTP가 아니게 된다. 그때부터 오가는 데이터는 요청과 응답이 아니라 웹소켓 프레임이다.
여기서 한 줄만 짚어두자. Origin: https://example.com. RFC 6455는 브라우저에서 출발한 요청이라면 이 헤더를 반드시 포함해야 한다고 적었다. 그리고 서버 쪽에 대해서는 연결을 받을지 판단하는 데 이 정보를 쓸 수 있다고만 적었다. 브라우저에는 의무를 지우고 서버에는 선택권을 준 것이다. 이 글에 나오는 사고는 전부 그 선택권을 어떻게 썼느냐에서 갈린다.
CORS는 여기에 걸 자리가 없다
"다른 도메인에서 연결하면 CORS 에러 나는 거 아니에요?" 여기서 다들 걸린다.
CORS가 실제로 하는 일을 정확히 보자. 브라우저는 fetch를 막을 때 요청을 안 보내는 게 아니다. 요청은 나가고, 응답도 받고, 그 응답에 붙은 Access-Control-Allow-Origin을 보고 나서 "이 응답은 너한테 안 보여줄게"라며 자바스크립트에게 결과를 가린다. CORS는 응답을 읽을 권리를 통제하는 장치다.
웹소켓은 응답을 읽어서 완성되는 연결이 아니다. 101이 돌아오면 그걸로 연결이 열리고, 그다음부터는 HTTP 응답이라는 게 존재하지 않는다. 읽을 응답이 없으니 CORS가 개입할 지점도 없다. 사전 확인 요청(preflight)도 안 나가고 Access-Control-Allow-Origin도 안 본다. 서버가 그 헤더를 안 줘서 막히는 게 아니라, 브라우저가 애초에 찾지 않는다.
그래서 이 한 줄은 어느 페이지에서 실행되든 연결된다.
const ws = new WebSocket("ws://localhost:8080");
evil.example에 심어놔도 연결된다.

이번엔 응답까지 읽힌다
이 성질을 이용하는 공격에 이름이 붙어 있다. Cross-Site WebSocket Hijacking, 줄여서 CSWSH다.
공격자가 만든 페이지를 피해자가 연다. 그 페이지의 자바스크립트가 new WebSocket("wss://myapp.com/ws")를 실행한다. 브라우저는 이 요청을 myapp.com으로 보내면서 평소처럼 그 도메인의 쿠키를 붙인다. 서버 입장에서는 로그인한 사용자가 연결을 요청한 것과 구별되지 않는다. 연결이 열리고, 그때부터 공격자 페이지는 그 사용자로서 메시지를 보내고 받는다.
CSRF를 아는 사람이라면 여기서 차이 하나가 눈에 들어와야 한다. CSRF는 요청을 보낼 수는 있어도 응답을 읽지 못한다. 그래서 뭔가를 바꾸는 데는 쓸 수 있어도 훔쳐보는 데는 못 썼다. 웹소켓은 연결이 열리고 나면 양방향이다. 보내는 것도 되고 받는 것도 된다. 실시간 알림, 채팅 로그, 대시보드 지표가 그대로 공격자 화면으로 흘러간다.
이 조합이 실제로 터진 곳이 nginx-ui다. 웹 화면에서 nginx 설정을 고치고 서버 상태를 실시간으로 보는 관리 도구인데, 요즘 에이전트한테 "관리자 대시보드 만들어줘"라고 시켰을 때 나오는 결과물과 구조가 같다. 쿠키로 로그인 상태를 유지하고, 로그와 지표는 웹소켓으로 밀어준다.
CVE-2026-34403 설명이 이렇다. 모든 웹소켓 엔드포인트가 Origin 검사를 무조건 통과시켰고, 인증 토큰은 HttpOnly도 SameSite도 없는 쿠키에 들어 있었다. 관리자가 로그인한 채로 공격자 페이지를 열면 그 순간 인증된 연결이 열렸다. NIST 기준 점수는 8.1이다.
로그인이 아예 없는 서버가 더 쉽다
여기까지는 쿠키 이야기였다. 요즘 더 자주 터지는 쪽은 인증이라는 게 아예 없는 서버다.
AI 에이전트 도구를 쓰다 보면 로컬에 뜨는 서버가 하나둘 늘어난다. 에이전트 브리지, 로컬 대시보드, 개발용 메일 서버. 이런 것들은 대개 "내 노트북에서만 도는 거니까"라는 전제로 만들어지고, 그 전제 위에서 인증이 생략된다.
나노봇(nanobot)의 왓츠앱 브리지가 그랬다. ws://127.0.0.1:3001에 웹소켓 서버를 띄우는데, 토큰 인증이 기본으로 꺼져 있고 핸드셰이크에서 Origin을 검사하지 않았다. 공격자 페이지를 한 번 열면 그 브리지에 연결이 붙는다. 왓츠앱 세션을 가로채고, 들어오는 메시지를 읽고, 인증 QR 코드를 빼내고, 사용자 이름으로 메시지를 보낼 수 있었다. CVE-2026-35589, 점수 8.0이다.
개발용 메일 서버인 Mailpit에서도 같은 구조로 CVE-2026-22689가 나왔다. 1.28.1 이하 버전은 웹소켓 서버가 모든 출처를 받아들여서, 악성 페이지가 로컬 Mailpit에 연결해 메일 본문과 헤더를 실시간으로 가져갈 수 있었다.
두 사례 모두 서버는 127.0.0.1에만 묶여 있었다. 외부에서 포트를 훑어도 안 보인다. 그런데도 뚫렸다. 공격이 네트워크를 타고 들어오는 게 아니라 그 노트북의 브라우저에서 출발하기 때문이다. 브라우저는 이미 그 컴퓨터 안에 있고, 로컬 주소로 연결을 거는 데 아무 제약이 없었다.
gorilla는 기본이 닫혀 있고, ws는 기본이 열려 있다
앞의 두 CVE에는 공통점이 하나 더 있다. 둘 다 Go의 gorilla/websocket을 썼고, 둘 다 라이브러리가 원래 갖고 있던 방어를 손으로 껐다.
gorilla/websocket 문서를 보면 CheckOrigin을 지정하지 않았을 때의 동작이 이렇게 적혀 있다. Origin 헤더가 있는데 그 호스트가 Host 헤더와 다르면 false를 반환한다. 아무것도 안 하면 같은 출처만 통과시킨다는 뜻이다. Mailpit의 수정 내역이 이 사실을 잘 보여준다. 뭘 추가한 게 아니라 CheckOrigin: func(r *http.Request) bool { return true } 한 줄을 지웠다. 지우니까 라이브러리 기본값이 돌아왔다.
그러면 왜 켜져 있는 방어를 끄게 되나. 개발할 때 프론트엔드는 5173번 포트, 백엔드는 8080번 포트에서 돈다. 이러면 Origin과 Host가 다르니 핸드셰이크가 403으로 떨어진다. 검색하면 return true가 나온다. 에이전트한테 물어봐도 같은 답이 나온다. 그 줄에는 "개발용 임시"라고 적혀 있지 않고, 배포 전에 지워야 한다는 사실을 아무도 기억하지 못한다.
Node의 ws는 반대편이다. 문서에 이렇게 적혀 있다. verifyClient를 지정하지 않으면 핸드셰이크는 자동으로 수락된다. 끌 방어가 없는 대신 켜야 할 방어가 있는데, 요구하지 않으면 에이전트도 사람도 그 자리를 비워둔다. 게다가 ws 문서는 verifyClient를 쓰지 말고 HTTP 서버의 upgrade 이벤트에서 인증을 처리하라고 안내한다. 더 나은 조언이지만 손이 한 겹 더 들어가고, 그만큼 생략될 확률도 올라간다.
에이전트가 잘못 짠 게 아니다. Go 쪽은 널리 퍼진 관용구를 따라갔고, Node 쪽은 기본값을 그대로 썼다. 둘 다 결과가 열려 있는 서버다.

브라우저가 막아주는 절반
브라우저 쪽 방어가 아주 없지는 않다. 다만 어디까지 막아주는지 정확히 알아야 한다.
쿠키로 인증하는 경우부터 보자. 크롬은 SameSite가 없는 쿠키를 Lax로 취급하고, Lax는 주소창이 바뀌는 이동에만 쿠키를 붙인다. 웹소켓 핸드셰이크는 스크립트가 만드는 요청이라 그 조건에 해당하지 않는다. 그래서 쿠키가 안 붙고, 쿠키 인증을 노리는 CSWSH는 예전보다 까다로워졌다. 다만 SameSite=None을 명시한 쿠키는 그대로 따라붙는다. 앞의 nginx-ui가 정확히 그 경우였다.
이 시리즈의 지난 글을 읽었다면 여기서 걸릴 것이다. 파이어폭스는 SameSite를 안 적은 쿠키의 기본값이 여전히 None이라고 했는데, 그럼 파이어폭스에서는 핸드셰이크에 쿠키가 그냥 붙는 것 아니냐고. 쿠키 속성만 보면 붙는 게 맞다. 그런데 파이어폭스는 서드파티 맥락의 저장소를 사이트별로 쪼개서 보관한다(Total Cookie Protection). 공격자 페이지 쪽 칸에는 myapp.com 쿠키가 애초에 없으니 붙일 게 없다. SameSite=None이어도 결과가 같다.
지난 글의 CSRF와 결과가 갈리는 이유가 여기 있다. 그때의 공격은 브라우저를 통째로 내 사이트로 이동시키는 방식이라, 도착한 순간 서드파티가 아니라 그냥 방문이었다. 그래서 원래 쓰던 쿠키 칸이 열렸다. 웹소켓 핸드셰이크는 공격자 페이지 안에서 나가는 요청이고 끝까지 서드파티다. 같은 격리 장치가 한쪽은 못 막고 한쪽은 막는다.
로컬 주소 쪽에는 별도의 장치가 최근에 들어왔다. 크롬의 로컬 네트워크 접근(Local Network Access) 제한이다. 공개된 사이트가 127.0.0.1이나 사설 IP로 연결을 시도하면 사용자에게 권한을 묻는다. 이 제한이 웹소켓까지 확대된 건 크롬 147부터이고, 크로미움 팀이 올린 도입 문서의 표현이 이랬다. "로컬 웹소켓 연결은 원래 이 제안이 해결하려던 공격의 상당수에 똑같이 노출돼 있다."
정리하면 브라우저는 쿠키 쪽과 로컬 주소 쪽을 각각 절반쯤 막아준다. 크로미움 계열 밖에서는 로컬 제한이 아직 적용되지 않았고, 사용자가 허용 버튼을 한 번 누르면 그걸로 끝이다. 브라우저 쪽 방어는 몇 년에 걸쳐 오고 마지막에 사용자 판단을 거치지만, 서버 쪽 검사는 오늘 넣을 수 있다.
내 서버가 열려 있는지 확인하는 법
개발자도구 Network 탭에서 필터를 WS로 놓고 페이지를 새로고침하면 핸드셰이크 요청이 하나 잡힌다. 요청 헤더에 Origin이 그대로 보이고, 응답이 101이면 연결이 열린 것이다.
여기까지는 내 사이트에서 보낸 정상 요청이니 당연히 열린다. 진짜 확인은 그 Origin을 다른 값으로 바꿔서 붙여보는 것이다. curl로 핸드셰이크를 흉내 낼 수 있다.
curl -i -N \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Origin: https://evil.example" \
http://localhost:8080/ws
403이 오면 서버가 출처를 보고 있는 것이다. 101 Switching Protocols가 오면 아무나 연결할 수 있는 상태다. 로컬에 띄워둔 도구들에도 같은 걸 해볼 만하다.
출처 검사, 인증, 암호화
고치는 순서는 이렇다.
출처를 허용목록으로 검사한다. OWASP 권고는 모든 핸드셰이크에서 Origin을 확인하되 반드시 명시적인 허용목록을 쓰라는 것이다. 와일드카드나 부분 문자열 비교는 쓰지 말라고 따로 못 박아뒀는데, 이유는 짐작하는 그대로다. origin.endsWith("myapp.com") 같은 코드를 넣으면 evil-myapp.com이 통과한다. 문자열을 통째로 비교해야 한다.
Origin 헤더가 없는 요청을 어떻게 할지도 미리 정해두는 게 좋다. 브라우저는 항상 이 헤더를 붙인다. 그러니 헤더가 없다는 건 브라우저가 아니라는 뜻이고, 브라우저에서만 쓰라고 만든 서버라면 거절하는 쪽이 맞다.
다만 이 검사가 무엇을 막는 장치인지는 분명히 해두자. MDN도 같은 자리에 경고를 달아뒀다. 브라우저가 아닌 클라이언트는 Origin 값을 얼마든지 지어낼 수 있다. 방금 쓴 curl 명령이 그 증거다. 이 검사가 막는 건 "남의 웹페이지가 피해자의 브라우저를 빌려 연결하는 것"이지 "누가 이 서버에 접속하는 것"이 아니다. 인증을 대신하지 못한다.
인증을 따로 건다. 그런데 여기에 웹소켓 특유의 불편이 있다. 브라우저의 WebSocket 생성자는 주소와 서브프로토콜만 받는다. Authorization 헤더를 붙일 방법이 없다. fetch에서 하던 걸 여기서는 못 하니 자연스럽게 쿠키로 흐르게 되고, 그 쿠키가 앞에서 본 CSWSH의 재료가 된다.
세 갈래가 있다. 가장 깔끔한 건 연결은 열어두되 아무 데이터도 주지 않다가, 첫 메시지로 토큰을 받아 검증한 다음에야 흘려보내는 방식이다. 두 번째는 HTTP로 수명이 짧은 일회용 티켓을 발급받아 접속 주소에 실어 보내는 것인데, 주소는 서버 로그와 프록시 로그에 남으니 수명을 짧게 잡아야 한다.
세 번째는 생성자의 두 번째 인자인 서브프로토콜을 통로로 쓰는 방식이다. 브라우저에서 헤더 비슷한 값을 실어 보낼 수 있는 유일한 자리라, 쿠버네티스 API와 AWS AppSync가 실제로 여기에 베어러 토큰을 넣는다. 원래 용도가 아닌 만큼 제약이 따른다. 서버는 클라이언트가 제시한 값 중 하나를 골라 되돌려줘야 하므로, 토큰과 별개로 진짜 서브프로토콜 이름을 하나 더 같이 보내야 한다. 쿠버네티스가 토큰 뒤에 base64.binary.k8s.io를 붙여 보내는 게 그 때문이다.
어느 쪽이든 핵심은 하나다. 브라우저가 자동으로 붙여주지 않는 값을 하나 요구하는 것.
주소는 wss://로 쓴다. ws://는 평문이다. http://와 https://의 차이와 정확히 같은 이야기다.
에이전트한테 실시간 기능을 시킬 때는 이 세 줄을 요청에 같이 적어주면 대체로 맞게 나온다. "웹소켓 서버는 Origin을 허용목록으로 완전 일치 비교하고, Origin이 없으면 거절하고, 연결 후 첫 메시지에서 토큰을 검증해줘."
배포하면 연결이 안 되는 문제
보안과는 별개로, 이 시리즈 독자가 거의 반드시 밟는 지점이 하나 있다. 로컬에서는 웹소켓이 잘 되는데 nginx 뒤에 올리는 순간 연결이 안 된다.
nginx 문서가 이유를 정확히 설명한다. Upgrade는 홉 바이 홉(hop-by-hop) 헤더다. 이 구간에서만 쓰고 다음 구간으로 넘기지 않는 헤더라는 뜻이라, 프록시가 뒤쪽 서버로 그대로 전달하지 않는다. 백엔드는 클라이언트가 프로토콜을 바꾸자고 했다는 사실 자체를 모르는 상태가 된다. 그래서 손으로 넘겨줘야 한다.
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
이걸 굳이 보안 글에 넣는 이유가 있다. 배포가 안 되면 마음이 급해지고, 급해지면 손에 잡히는 것부터 푼다. 출처 검사가 대개 그 첫 번째다. 원인이 프록시 설정에 있다는 걸 알면 애먼 방어를 풀지 않는다.
정리
웹 보안 장치는 대부분 요청 하나에 응답 하나라는 HTTP의 모델 위에 얹혀 있다. CORS도 그렇고, 응답 헤더로 거는 규칙은 전부 그렇다. 웹소켓은 101이 돌아온 뒤로 그 모델을 떠난다. 떠나는 순간 그 위에 얹혀 있던 것들이 따라오지 않는다. 상속받는 게 없는 셈이다.
그래서 아무 웹페이지나 내 웹소켓 서버로 연결을 걸 수 있고, 브라우저는 그 요청에 도메인 쿠키를 붙여준다. CSRF와 달리 이번에는 응답까지 읽힌다. 인증이 없는 로컬 도구라면 쿠키조차 필요 없다. 127.0.0.1 바인딩은 여기서 아무것도 막지 못한다.
검문소는 딱 한 곳이다. 연결이 열리기 전에 딱 한 번 오가는 그 GET 요청. 그 시점을 놓치면 다음 기회가 없고, 그 요청에는 브라우저가 자기 출신지를 적어서 보낸다. 규격이 그렇게 하라고 정해뒀다. 읽을지 말지만 정하면 된다.