From 7602d42a8b5a5381eeb981e21ac37dd223f61dfa Mon Sep 17 00:00:00 2001 From: jmj Date: Sun, 27 Sep 2026 13:47:37 +0900 Subject: [PATCH] =?UTF-8?q?fix(session):=20callback.voice=20=EA=B0=80=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EC=9D=98=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=EB=A5=BC=20=EA=B2=80=EC=A6=9D=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20(=EB=B3=B4=EC=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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건 추가 — 타 세션 메시지 지목 시 내용·상태 불변 + 이벤트 미발행, 확정된 메시지에 늦은 콜백이 와도 되살아나지 않음. --- backend/CLAUDE.md | 12 ++++ .../application/VoiceCallbackService.java | 29 ++++++++ .../application/VoiceCallbackServiceTest.java | 67 +++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 2aaa73c1..251f5baa 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -574,6 +574,18 @@ docker compose up -d FAILED 가 아닌 문서는 `DOC_REANALYZE_NOT_ALLOWED`. 프론트는 `errorCode` 로 고른 한국어 문구만 쓰고 (`features/analysis/lib/analysisError.ts`) 원문은 서버 로그용으로만 남기며, 재시도해도 결과가 같은 실패(빈 PDF·차단된 URL 등)에는 버튼을 내지 않는다. 음성 답변 재전사와 같은 성격의 2차 방어선. +- **callback.voice 세션 소유 검증 본 구현 (보안)**: `VoiceCallbackService.apply` 가 + `findById(interviewMessageId)` 만 하고 **그 메시지가 콜백의 sessionId 소속인지 확인하지 + 않았다.** 스트리밍 음성(RT3)은 messageId 를 **클라이언트가 쿼리로 보내고** + (`wss://…/realtime/sessions/{id}/audio?messageId=N`), RealTime 은 토큰의 SESSION 범위가 + URL 세션 id 와 맞는지만 볼 뿐 messageId 는 검사하지 않으며, AI 는 DB 를 모른다(설계상 + 내부 통신 전용). 결과적으로 **자기 세션 토큰을 가진 사용자가 남의 messageId 를 실어 보내 + 타인의 면접 답변을 덮어쓸 수 있었다**(전사 내용 교체 + 음성 분석 행 삽입 + 피해자 채널로 + SSE 발행). 체인 어디에도 검사가 없어 Core 가 마지막 방어선이다 — 세션 불일치 콜백은 + 드롭한다. 배치 업로드 경로는 placeholder 를 서버가 만들어 영향이 없었다. + 같은 지점에 **늦은 콜백 가드**도 넣었다: `StaleTranscriptionSweeper` 가 FAILED 로 확정해 + 턴을 푼 뒤 늦은 콜백이 도착하면 옛 답변이 COMPLETED 로 되살아나 같은 턴에 답변이 둘이 + 된다(꼬리질문 `applyFollowup` 이 같은 이유로 막아 둔 것과 동일). `status != CREATED` 면 드롭. - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java index cf230451..ed2734fd 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java @@ -11,6 +11,7 @@ import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; import com.stackup.stackup.session.domain.InterviewMessage; import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.MessageStatus; import com.stackup.stackup.session.domain.MessageVoiceAnalysis; import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; import lombok.RequiredArgsConstructor; @@ -61,6 +62,34 @@ public void apply(VoiceCallbackEnvelope envelope) { return; } + // 메시지가 정말 그 세션의 것인지 확인한다. + // + // 스트리밍 음성(RT3)은 messageId 를 **클라이언트가 쿼리로 보낸다** + // (wss://…/realtime/sessions/{id}/audio?messageId=N). RealTime 은 토큰의 + // SESSION 범위가 URL 의 세션 id 와 맞는지만 보고 messageId 는 검사하지 않으며, + // AI 는 DB 를 모른다(설계상). 그래서 이 검사가 없으면 자기 세션 토큰을 가진 + // 사용자가 남의 messageId 를 실어 보내 **타인의 답변 내용을 덮어쓸 수 있다** + // (findById 만 하고 세션을 안 봤다). 여기가 마지막 방어선이다. + if (message.getSession() == null || !message.getSession().getId().equals(p.sessionId())) { + log.warn("callback.voice session mismatch — drop. claimedSessionId={}, actualSessionId={}, msg={}", + p.sessionId(), + message.getSession() == null ? null : message.getSession().getId(), + message.getId()); + markProcessed(envelope.messageId()); + return; + } + + // 이미 전사가 끝났거나 실패로 확정된 메시지는 건드리지 않는다. + // StaleTranscriptionSweeper 가 FAILED 로 확정해 턴을 푼 뒤 늦은 콜백이 도착하면, + // 되살아난 답변과 사용자가 새로 쓴 답변이 동시에 살아있게 된다 + // (꼬리질문 쪽에서 같은 이유로 막아 둔 것과 동일한 사유). + if (message.getStatus() != MessageStatus.CREATED) { + log.info("callback.voice late — message already settled({}). sessionId={}, msg={}", + message.getStatus(), p.sessionId(), message.getId()); + markProcessed(envelope.messageId()); + return; + } + if (p.errorCode() != null && !p.errorCode().isBlank()) { failVoiceMessage(p.sessionId(), message, p.errorCode()); markProcessed(envelope.messageId()); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java index 8f8dafe8..b4c7c01e 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java @@ -144,6 +144,73 @@ void apply_skipsDuplicateMessageId() { verify(messageRepository, never()).findById(any()); } + + /** + * 콜백의 sessionId 와 메시지의 실제 세션이 다르면 버린다. + * + *

스트리밍 음성(RT3)은 messageId 를 클라이언트가 쿼리로 보낸다 + * (wss://…/realtime/sessions/{id}/audio?messageId=N). RealTime 은 토큰의 SESSION 범위가 + * URL 세션 id 와 맞는지만 보고 messageId 는 검사하지 않으며, AI 는 DB 를 모른다. + * 이 검사가 없으면 자기 세션 토큰을 가진 사용자가 남의 messageId 를 실어 보내 + * 타인의 답변을 덮어쓸 수 있다. + */ + @Test + void apply_dropsCallbackWhenMessageBelongsToAnotherSession() { + InterviewSession victimSession = sessionFixture(99L); + InterviewMessage victimQuestion = InterviewMessage.interviewer(victimSession, 1, "피해자 질문"); + ReflectionTestUtils.setField(victimQuestion, "id", 900L); + InterviewMessage victimAnswer = + InterviewMessage.voiceInterviewee(victimSession, 2, victimQuestion, "k-victim"); + ReflectionTestUtils.setField(victimAnswer, "id", 901L); + + // 공격자는 자기 세션(50)의 토큰으로 피해자 메시지(901) 를 지목한다. + VoiceCallbackPayload payload = new VoiceCallbackPayload(50L, 901L, + "공격자가 말한 내용", 120.0, 1.0, Map.of(), 0.9, null); + VoiceCallbackEnvelope env = new VoiceCallbackEnvelope("vc-attack", "callback.voice", "1", "t", + null, "ai", payload, null); + + when(processedMessageRepository.existsById("vc-attack")).thenReturn(false); + when(messageRepository.findById(901L)).thenReturn(Optional.of(victimAnswer)); + + service.apply(env); + + assertThat(victimAnswer.getContent()).isEqualTo(InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT); + assertThat(victimAnswer.getStatus()).isEqualTo(MessageStatus.CREATED); + verify(voiceAnalysisRepository, never()).save(any(MessageVoiceAnalysis.class)); + verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class)); + } + + /** + * 이미 확정된 메시지는 늦은 콜백으로 되살리지 않는다. + * + *

StaleTranscriptionSweeper 가 FAILED 로 확정해 턴을 푼 뒤 사용자가 다시 답했는데 + * 늦은 콜백이 옛 메시지를 COMPLETED 로 살리면 같은 턴에 답변이 둘이 된다 + * (꼬리질문 쪽에서 같은 이유로 막아 둔 것과 동일). + */ + @Test + void apply_dropsLateCallbackForAlreadySettledMessage() { + InterviewSession session = sessionFixture(50L); + InterviewMessage question = InterviewMessage.interviewer(session, 1, "질문"); + ReflectionTestUtils.setField(question, "id", 100L); + InterviewMessage swept = InterviewMessage.voiceInterviewee(session, 2, question, "k1"); + ReflectionTestUtils.setField(swept, "id", 200L); + swept.failVoiceTranscription(); // 스위퍼가 확정 + + VoiceCallbackPayload payload = new VoiceCallbackPayload(50L, 200L, + "늦게 도착한 전사", 120.0, 1.0, Map.of(), 0.9, null); + VoiceCallbackEnvelope env = new VoiceCallbackEnvelope("vc-late", "callback.voice", "1", "t", + null, "ai", payload, null); + + when(processedMessageRepository.existsById("vc-late")).thenReturn(false); + when(messageRepository.findById(200L)).thenReturn(Optional.of(swept)); + + service.apply(env); + + assertThat(swept.getStatus()).isEqualTo(MessageStatus.FAILED); + assertThat(swept.getContent()).isEqualTo(InterviewMessage.VOICE_TRANSCRIPTION_FAILED_TEXT); + verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class)); + } + private InterviewSession sessionFixture(Long id) { User user = User.createGithubUser(1L, "u", null, null, "t"); ReflectionTestUtils.setField(user, "id", 1L);