From 6a78a2e75ec8463b52612edb0e6beb82d7a83571 Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 20 Aug 2026 16:41:05 +0900 Subject: [PATCH] =?UTF-8?q?fix(realtime):=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BC=EC=9D=98=20contentType=20=EC=9D=84?= =?UTF-8?q?=20AI=20=EC=97=90=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 의 `/internal/voice/stream` 은 `contentType` 쿼리로 STT 세션의 디코더를 고른다 (`provider.open_session(content_type=...)`). 그런데 RealTime 이 업스트림 URL 을 만들 때 `sessionId`·`messageId` 만 붙이고 있었다: aiURL := fmt.Sprintf("%s?sessionId=%d&messageId=%d", h.AIBaseURL, sid, mid) 결과적으로 브라우저가 무엇을 보내든 AI 는 기본값 `audio/webm` 으로 가정한다. MediaRecorder 는 Safari 에서 `audio/mp4` 를 만들므로 그 경우 잘못된 디코더로 세션이 열린다. 아직 프론트가 RT3 를 쓰지 않아 드러나지 않았을 뿐, 연결하는 순간 webm 이 아닌 브라우저에서 깨진다. 브라우저의 `?contentType=` 을 그대로 전달한다. 다만 사용자 제어 값이므로: - `"audio/webm;codecs=opus"` 같은 값에서 base MIME 만 뽑고 소문자로 정규화 - 화이트리스트(webm/ogg/mp4/mpeg/wav — Core 가 저장을 허용하는 것과 동일)를 통과한 값만 사용 - 그 외에는 기존 동작과 같은 `audio/webm` 으로 폴백 - URL 에 붙일 때 `url.QueryEscape` (슬래시가 이스케이프되지 않으면 AI 쪽 파싱이 어긋난다) 테스트 (10) — 코덱 파라미터 제거·대소문자·공백, 허용 목록 밖/빈 값의 기본값 폴백, 쿼리 주입 시도(`audio/webm&apiKey=...`)가 기본값으로 떨어지는지, URL 조립과 이스케이프. --- realtime/CLAUDE.md | 1 + realtime/internal/transport/ws_audio.go | 32 ++++++++++- .../transport/ws_audio_content_type_test.go | 57 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 realtime/internal/transport/ws_audio_content_type_test.go diff --git a/realtime/CLAUDE.md b/realtime/CLAUDE.md index 5344e4a3..70981c91 100644 --- a/realtime/CLAUDE.md +++ b/realtime/CLAUDE.md @@ -99,6 +99,7 @@ config는 cmd, internal/* 모두에서 import 가능 > 모든 `/realtime/stream/*` (SSE) 및 `/realtime/sessions/{id}` (WS) 는 인증 필요 — `?access_token=` 쿼리로 Core 발급 토큰 검증 (EventSource/WS 헤더 한계 우회). `internal/auth` 미들웨어가 처리. > WS는 SSE(`/realtime/stream/*`)와 **다른 path**라 Upgrade 헤더 분기가 필요 없다. WS 핸들러(`coder/websocket`)는 session 채널 fan-out을 그대로 구독(서버→클라)하고, 수신한 답변(`{type:"answer",content,idempotencyKey?}`)을 `internal/core` 클라이언트로 Core 내부 REST(`POST /api/internal/sessions/{id}/messages`)에 프록시한다. > RT3(`/realtime/sessions/{id}/audio`)는 `WSAudioHandler`(`transport/ws_audio.go`)가 브라우저 WS를 AI WS(`REALTIME_AI_WS_URL` + `?sessionId=&messageId=`, `X-Internal-API-Key` 헤더)로 **양방향 프록시**한다. `messageId`는 사전에 Core `POST /api/sessions/{id}/messages/voice/stream-begin`이 만든 placeholder. 오디오 내용은 해석하지 않고 프레임 타입을 보존하며 복사만(`copyWS`). +> 브라우저의 `?contentType=` 을 AI 로 전달한다 — AI 가 이 값으로 STT 세션의 디코더를 고르므로, 넘기지 않으면 무엇을 보내든 `audio/webm` 으로 가정되어 Safari(mp4) 같은 경우 틀어진다. 사용자 제어 값이라 화이트리스트(webm/ogg/mp4/mpeg/wav)를 통과한 base MIME 만 붙이고, 그 외에는 기존 기본값으로 떨어진다. --- diff --git a/realtime/internal/transport/ws_audio.go b/realtime/internal/transport/ws_audio.go index 902336b4..a3d84b71 100644 --- a/realtime/internal/transport/ws_audio.go +++ b/realtime/internal/transport/ws_audio.go @@ -5,7 +5,9 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "strconv" + "strings" "time" "github.com/Team-StackUp/stackup/realtime/internal/auth" @@ -48,7 +50,10 @@ func (h *WSAudioHandler) ServeAudioWS(w http.ResponseWriter, r *http.Request) { defer client.CloseNow() ctx := r.Context() - aiURL := fmt.Sprintf("%s?sessionId=%d&messageId=%d", h.AIBaseURL, sid, mid) + // 브라우저가 실제로 만드는 코덱을 AI 에 알려야 STT 세션이 맞는 디코더로 열린다. + // 지금까지는 넘기지 않아 AI 가 항상 audio/webm 으로 가정했다 — Safari(mp4)에서는 틀린다. + contentType := resolveAudioContentType(r.URL.Query().Get("contentType")) + aiURL := buildAIStreamURL(h.AIBaseURL, sid, mid, contentType) upstream, _, err := websocket.Dial(ctx, aiURL, &websocket.DialOptions{ HTTPHeader: http.Header{"X-Internal-API-Key": {h.InternalKey}}, }) @@ -70,6 +75,31 @@ func (h *WSAudioHandler) ServeAudioWS(w http.ResponseWriter, r *http.Request) { slog.Info("ws_audio.proxy.end", "session_id", sid, "message_id", mid) } +// AI 가 다룰 수 있고 Core 가 저장을 허용하는 오디오 타입만 통과시킨다. +// 값은 사용자(브라우저)가 정하므로 그대로 업스트림 URL 에 붙이지 않는다. +var allowedAudioContentTypes = map[string]struct{}{ + "audio/webm": {}, + "audio/ogg": {}, + "audio/mp4": {}, + "audio/mpeg": {}, + "audio/wav": {}, +} + +// resolveAudioContentType 은 "audio/webm;codecs=opus" 같은 값에서 base MIME 만 뽑고, +// 허용 목록에 없으면 기존 동작과 같은 기본값(audio/webm)으로 떨어진다. +func resolveAudioContentType(raw string) string { + base := strings.ToLower(strings.TrimSpace(strings.SplitN(raw, ";", 2)[0])) + if _, ok := allowedAudioContentTypes[base]; ok { + return base + } + return "audio/webm" +} + +func buildAIStreamURL(base string, sessionID, messageID int64, contentType string) string { + return fmt.Sprintf("%s?sessionId=%d&messageId=%d&contentType=%s", + base, sessionID, messageID, url.QueryEscape(contentType)) +} + // copyWS 는 src 에서 받은 프레임을 dst 로 그대로 전달한다 (타입 보존). func copyWS(ctx context.Context, src, dst *websocket.Conn) error { for { diff --git a/realtime/internal/transport/ws_audio_content_type_test.go b/realtime/internal/transport/ws_audio_content_type_test.go new file mode 100644 index 00000000..e56f802b --- /dev/null +++ b/realtime/internal/transport/ws_audio_content_type_test.go @@ -0,0 +1,57 @@ +package transport + +import "testing" + +// AI 는 contentType 으로 STT 세션의 디코더를 고른다. RealTime 이 넘기지 않으면 +// 무엇을 보내든 audio/webm 으로 가정되어 webm 이 아닌 브라우저(Safari=mp4)에서 틀어진다. +func TestResolveAudioContentType(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"코덱 파라미터를 떼고 base MIME 만", "audio/webm;codecs=opus", "audio/webm"}, + {"대문자도 정규화", "AUDIO/MP4", "audio/mp4"}, + {"앞뒤 공백 허용", " audio/ogg ", "audio/ogg"}, + {"허용 목록의 다른 타입", "audio/mpeg", "audio/mpeg"}, + {"빈 값이면 기존 동작과 같은 기본값", "", "audio/webm"}, + {"허용 목록 밖은 기본값", "audio/flac", "audio/webm"}, + // 사용자 제어 값이므로 임의 문자열이 업스트림 URL 로 새어 나가면 안 된다. + {"주입 시도도 기본값", "audio/webm&apiKey=leak", "audio/webm"}, + {"완전히 다른 값", "text/html", "audio/webm"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := resolveAudioContentType(c.in); got != c.want { + t.Fatalf("resolveAudioContentType(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestBuildAIStreamURL(t *testing.T) { + got := buildAIStreamURL("ws://ai:8000/internal/voice/stream", 7, 42, "audio/mp4") + want := "ws://ai:8000/internal/voice/stream?sessionId=7&messageId=42&contentType=audio%2Fmp4" + if got != want { + t.Fatalf("buildAIStreamURL = %q, want %q", got, want) + } +} + +// 슬래시가 이스케이프되지 않으면 AI 쪽 쿼리 파싱이 어긋난다. +func TestBuildAIStreamURLEscapesContentType(t *testing.T) { + got := buildAIStreamURL("ws://ai:8000/s", 1, 2, "audio/webm") + if want := "contentType=audio%2Fwebm"; !contains(got, want) { + t.Fatalf("expected %q in %q", want, got) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +}