Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 레코드 또는 인메모리로.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,73 @@ void apply_skipsDuplicateMessageId() {
verify(messageRepository, never()).findById(any());
}


/**
* 콜백의 sessionId 와 메시지의 실제 세션이 다르면 버린다.
*
* <p>스트리밍 음성(RT3)은 messageId 를 클라이언트가 쿼리로 보낸다
* (wss://…/realtime/sessions/{id}/audio?messageId=N). RealTime 은 토큰의 SESSION 범위가
* URL 세션 id 와 맞는지만 보고 messageId 는 검사하지 않으며, AI 는 DB 를 모른다.
* 이 검사가 없으면 자기 세션 토큰을 가진 사용자가 남의 messageId 를 실어 보내
* <b>타인의 답변을 덮어쓸 수 있다</b>.
*/
@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));
}

/**
* 이미 확정된 메시지는 늦은 콜백으로 되살리지 않는다.
*
* <p>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);
Expand Down
Loading