Skip to content

fix(session): callback.voice 가 메시지의 세션 소유를 검증하도록 (보안) - #254

Merged
i3months merged 1 commit into
devfrom
fix/voice-callback-session-ownership
Sep 27, 2026
Merged

i3months merged 1 commit into
devfrom
fix/voice-callback-session-ownership

Conversation

@i3months

Copy link
Copy Markdown
Member

🔴 인증된 사용자가 타인의 면접 답변을 덮어쓸 수 있었습니다

RealTime Go 코드를 정독하다 발견했습니다.

취약점 체인

스트리밍 음성(RT3)은 messageId 를 클라이언트가 쿼리로 보냅니다:

wss://stack-up.shop/realtime/sessions/{id}/audio?messageId=N

체인 어디에도 messageId 소유 검증이 없었습니다:

계층 검증
RealTime router.go 토큰의 SESSION 범위가 URL 세션 id 와 맞는지만 확인
RealTime ws_audio.go messageId 를 그대로 AI 로 전달 (buildAIStreamURL)
AI voice_stream.py 쿼리에서 받아 콜백에 그대로 실음. DB 를 모름(설계상 내부 통신 전용)
Core VoiceCallbackService findById(interviewMessageId) — 세션을 안 봄 ← 막아야 할 곳

공격 경로

  1. 계정을 만들고 자기 세션 A 를 시작한다 (정상)
  2. stream-begin 없이 wss://…/realtime/sessions/A/audio?messageId=<피해자_메시지_id> 로 연결
  3. 라우터 통과 — 토큰은 세션 A 용이고 URL 도 A 다
  4. 아무 음성이나 스트리밍
  5. AI 가 전사 후 callback.voice{sessionId: A, interviewMessageId: 피해자_메시지} 발행
  6. Core 가 id 로만 찾아 피해자 답변에 공격자 전사를 덮어쓴다

결과: 피해자의 면접 답변 내용 교체, message_voice_analyses 행 삽입, 피해자 세션 채널로 SSE 발행. 메시지 id 는 연속 정수라 대상 지목도 쉽습니다.

수정

Core 가 마지막 방어선이므로 여기서 막습니다 — 콜백의 sessionId 와 메시지의 실제 세션이 다르면 드롭.

배치 업로드 경로는 서버가 placeholder 를 만들어 영향이 없었습니다. 스트리밍 경로 전용 문제입니다.

덤 — 늦은 콜백 가드

같은 지점에서 별개 문제도 막았습니다. StaleTranscriptionSweeper 가 FAILED 로 확정해 턴을 푼 뒤 늦은 콜백이 도착하면, 옛 답변이 COMPLETED 로 되살아나 같은 턴에 답변이 둘이 됩니다. 꼬리질문 applyFollowup 이 같은 이유로 막아 둔 것과 동일한 사유라, status != CREATED 면 드롭합니다.

테스트

  • apply_dropsCallbackWhenMessageBelongsToAnotherSession — 타 세션 메시지 지목 시 내용·상태 불변, 분석 행 미저장, 이벤트 미발행
  • apply_dropsLateCallbackForAlreadySettledMessage — 확정된 메시지가 되살아나지 않음
  • ./gradlew test 308건 — 실패는 Testcontainers 5개 클래스뿐(로컬 Docker 없음, CI 검증)

영향 범위

  • DB 마이그레이션: 없음
  • API contract: 없음
  • 정상 흐름 무변경 — stream-begin 이 만든 placeholder 는 항상 같은 세션·CREATED 상태입니다

리뷰어 체크포인트

  • RealTime 쪽에도 방어를 둘지 — 지금은 Core 한 곳입니다. RealTime 은 DB 가 없어 Core 에 물어봐야 하는데(왕복 추가), 최종 쓰기를 Core 가 하므로 여기 한 곳으로 충분하다고 판단했습니다. 이견 있으시면 말씀해 주세요.
  • 늦은 콜백 가드가 정상 흐름을 막지 않는지 — placeholder 는 생성 시 CREATED 이고 배치·스트리밍 모두 콜백 전까지 그 상태를 유지합니다.
  • RT3 는 아직 프론트 소비자가 없지만 엔드포인트는 라이브입니다. 그래서 미래 기능이 아니라 현재 취약점으로 다뤘습니다.

🤖 Generated with Claude Code

RealTime Go 코드를 정독하다 발견했다. 스트리밍 음성(RT3)은 messageId 를
**클라이언트가 쿼리로 보낸다**:

    wss://stack-up.shop/realtime/sessions/{id}/audio?messageId=N

체인 어디에도 messageId 소유 검증이 없었다:
  · RealTime router — 토큰의 SESSION 범위가 URL 세션 id 와 맞는지만 확인
  · ws_audio        — messageId 를 그대로 AI 로 전달
  · AI              — DB 를 모른다(설계상 내부 통신 전용)
  · Core            — findById 만 하고 세션을 안 봄  ← 여기서 막아야 했다

따라서 계정만 있으면 자기 세션 토큰으로 **남의 면접 답변을 덮어쓸 수 있었다.**
피해자 메시지의 전사가 공격자 음성으로 교체되고, 음성 분석 행이 삽입되며,
피해자 세션 채널로 SSE 까지 나간다. 메시지 id 는 연속 정수라 대상 지목도 쉽다.

Core 가 마지막 방어선이므로 여기서 세션 불일치 콜백을 드롭한다. 배치 업로드
경로는 placeholder 를 서버가 만들어 영향이 없었다 — 스트리밍 경로 전용 문제다.

같은 지점에 늦은 콜백 가드도 넣었다. StaleTranscriptionSweeper 가 FAILED 로
확정해 턴을 푼 뒤 늦은 콜백이 도착하면 옛 답변이 COMPLETED 로 되살아나 같은
턴에 답변이 둘이 된다(꼬리질문 applyFollowup 이 같은 이유로 막아 둔 것과 동일).
status != CREATED 면 드롭한다.

테스트 2건 추가 — 타 세션 메시지 지목 시 내용·상태 불변 + 이벤트 미발행,
확정된 메시지에 늦은 콜백이 와도 되살아나지 않음.
@i3months
i3months merged commit d0b986d into dev Sep 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant