FlawOpen

flawopen.com/Teardowns/cve-2026-71416-headroom-websocket-origin-hijacking

● CVE-2026-71416 · CVSS 8.8 · 높음
보안 연구 · FlawOpen

CVE-2026-71416: Headroom WebSocket Origin 하이재킹

Headroom은 WebSocket Origin을 확인하지 않아 방문한 사이트가 프록시로 LLM 요청을 보낼 수 있었습니다. 0.35.0 이상으로 업데이트하세요.

💡 알기 쉬운 설명 (ELI5)

접수 담당자가 방문자 신분증을 확인하지 않아 누구나 전용 창구에 들어갈 수 있는 상황입니다. Origin은 그 신분증과 같습니다. 저장된 API 키를 쓸 수 있는 연결을 열기 전에 프록시가 이를 확인해야 합니다.

핵심 개념 및 용어

WebSocket
HTTP Upgrade 요청으로 시작해 양방향으로 메시지를 주고받는 지속 연결입니다.
Origin 헤더
요청을 시작한 페이지를 식별하기 위해 브라우저가 보내는 스킴, 호스트, 포트입니다. WebSocket 핸드셰이크에서는 서버가 자체 신뢰 정책을 적용해야 하며 CORS 헤더가 이 검사를 대신하지 않습니다.
CSWSH
Cross-Site WebSocket Hijacking: 신뢰할 수 없는 사이트가 접근 가능한 서비스에 WebSocket을 열고, 서비스가 연결 시작 Origin을 검증하지 않는 문제입니다.
암묵적 자격 증명
환경 변수의 API 키처럼 프로세스가 이미 사용할 수 있어 클라이언트 자체 자격 증명이 없는 요청에도 이용될 수 있는 비밀입니다.
CWE-1385
WebSocket Origin 검증 누락: 엔드포인트가 통신 출처가 허용되는지 확인하지 않습니다.

프레임워크 매핑

근본 원인 분석 (Root Cause)

Headroom의 /v1/responses WebSocket 핸들러는 핸드셰이크의 Origin을 확인하기 전에 브라우저 연결을 수락했습니다. 이후 프록시는 자체 환경의 OPENAI_API_KEY를 업스트림 요청에 추가할 수 있었습니다. 브라우저의 동일 출처 정책만으로 JavaScript가 다른 접근 가능한 호스트에 WebSocket 연결을 시작하는 것을 막을 수는 없습니다. 브라우저는 일반적으로 Origin을 보내므로 서버가 정책에 맞지 않는 Origin을 거부해야 합니다. 따라서 피해자 브라우저가 취약한 Headroom 프록시에 접근할 수 있고, 프록시에 유용한 자격 증명이나 통합이 있어야 악용할 수 있습니다. 권고에 따르면 수락된 연결은 임의의 LLM 요청을 보낼 수 있습니다. 호스트 명령 실행은 요청이 명령 실행 기능을 가진 구성 도구를 사용할 수 있을 때만 가능하며, 헤더 검사 누락만으로 자동 발생하지 않습니다. CNA는 0.35.0 이전 버전을 영향 범위로 제시하지만 GitHub 권고 패키지 메타데이터에는 0.23.0이 표시됩니다. 0.35.0 이상으로 업데이트하고 설치된 버전을 확인하세요.

단계별 공격 실행 흐름

단계 1

프록시가 업스트림 권한을 보유

Headroom 프로세스는 OPENAI_API_KEY에 OpenAI 키를 보유할 수 있으며 브라우저 클라이언트가 키를 직접 보낼 필요는 없습니다.

단계 2

브라우저가 신뢰할 수 없는 페이지를 엶

브라우저가 Headroom WebSocket 엔드포인트에 접근할 수 있으면 페이지 스크립트가 연결을 시도할 수 있습니다. 브라우저는 핸드셰이크에 페이지 Origin을 포함합니다.

단계 3

취약한 핸들러가 Origin 정책 없이 연결을 수락

영향받는 핸들러는 신뢰할 수 없는 Origin을 먼저 거부하지 않고 websocket.accept()를 실행합니다. 동일 출처 정책은 서버 측 WebSocket 인가 검사가 아닙니다.

단계 4

프록시가 인증된 요청을 전달

Headroom은 누락된 업스트림 Authorization 헤더를 환경 값으로 채울 수 있습니다. 공격자가 제어하는 페이지가 프록시 권한으로 요청을 발생시킬 수 있으며 영향은 도구, 자격 증명, 네트워크 접근성, 사용자 동작에 따라 달라집니다.

단계 5

수정은 라우팅 전에 허용되지 않은 Origin을 거부

업스트림 패치는 세션을 수락하고 전달하기 전에 Origin을 검사합니다. 브라우저 Origin은 기본적으로 loopback으로 제한되고 명시적 Origin을 설정할 수 있으며 거부된 연결은 1008 코드로 닫힙니다.

소스 코드 비교: 취약한 구현 vs 보안 패치

권고는 handle_openai_responses_ws()에서 websocket.accept() 전에 검사가 누락된 점을 밝힙니다. 업스트림 commit c632023은 _is_allowed_websocket_origin()을 추가해 라우팅 전에 허용되지 않은 Origin을 거부합니다. Origin이 없는 네이티브 클라이언트는 계속 지원하고, 브라우저 Origin은 기본적으로 loopback만 허용하며 HEADROOM_WS_ORIGINS 또는 HEADROOM_CORS_ORIGINS로 명시 설정할 수 있습니다. 이 축약 코드는 바뀐 신뢰 경계를 보여주며 보조 함수를 모두 포함하지는 않습니다. 업스트림 회귀 테스트는 Origin 누락, loopback, 원격, 잘못된 형식, 명시 설정을 다룹니다.

소스 코드 발췌: headroom/proxy/handlers/openai.py

취약한 구현
async def handle_openai_responses_ws(self, websocket: WebSocket) -> None:
    # 취약한 형태: origin을 검증하기 전에 브라우저 세션을 수락합니다.
    await websocket.accept()
    await self._relay_responses_session(websocket)
보안 강화 패치
async def handle_openai_responses_ws(self, websocket: WebSocket) -> None:
    ws_headers = dict(websocket.headers)
    if not _is_allowed_websocket_origin(ws_headers):
        logger.warning(
            "event=websocket_origin_not_allowed origin=%r",
            _header_get(ws_headers, "origin"),
        )
        await websocket.close(code=1008, reason="origin not allowed")
        return

    # 정책에서 승인한 브라우저 세션만 업스트림 라우팅에 도달합니다.
    await websocket.accept()
    await self._relay_responses_session(websocket)

엔지니어링 및 시스템 보안 강화 체크리스트

출처