diff --git a/realtime/CLAUDE.md b/realtime/CLAUDE.md index acd9ad26..5344e4a3 100644 --- a/realtime/CLAUDE.md +++ b/realtime/CLAUDE.md @@ -91,7 +91,7 @@ config는 cmd, internal/* 모두에서 import 가능 |----|--------|------|------|------| | - | GET | `/health` | 헬스체크 | 활성 | | RT2 | GET | `/realtime/stream/me` | user 채널 SSE (분석 상태). userId는 토큰에서 | 활성 | -| RT2 | GET | `/realtime/stream/documents/{id}` | document 채널 SSE | 활성 | +| RT2 | GET | `/realtime/stream/documents/{id}` | document 채널 SSE (DOCUMENT 스코프 토큰 필요 — 현재 발급처 없음) | 활성 | | RT2 | GET | `/realtime/stream/sessions/{id}` | session 채널 SSE (feedback.ready 등 비-라이브) | 활성 | | RT1 | WS | `/realtime/sessions/{id}` | 라이브 텍스트 면접 (서버→클라 push + 클라→서버 답변) | 활성 | | RT3 | WS | `/realtime/sessions/{id}/audio` | 실시간 음성 답변 스트림 (오디오 업 ↔ 자막 다운, AI WS 프록시) | 활성 | @@ -222,7 +222,15 @@ docker build -t stackup-realtime ./realtime - AMQP `q.realtime.session.notify` consumer 활성 — `messageType` 기반 채널 라우팅 → fan-out - WebSocket(RT1 라이브 면접) 활성 — `/realtime/sessions/{id}` 서버→클라 push + 클라→서버 답변 프록시(Core 내부 REST) - WebSocket(RT3 실시간 음성) 활성 — `/realtime/sessions/{id}/audio` 브라우저↔AI WS 오디오 프록시(`WSAudioHandler`, `REALTIME_AI_WS_URL`). 오디오 전용 순수 파이프, STT·메트릭·`callback.voice`는 AI 책임 -- 리소스 소유권 검증 미구현 — 현재 토큰 진위(userId)만 검증. 후속 플랜에서 리소스 스코프 토큰 또는 Core 조회로 강화 +- **리소스 스코프 검증 활성** — 토큰 진위뿐 아니라 `resourceType`/`resourceId` 가 요청 경로의 + 대상과 일치하는지 확인한다(`transport/router.go`). RealTime 은 DB 를 보지 않으므로, Core 가 + 소유권을 확인해 발급한 토큰의 범위를 강제하는 것이 유일한 소유권 검사다. + - `sessions/{id}`(SSE·WS·audio) → `SESSION` + 같은 id + - `documents/{id}` → `DOCUMENT` + 같은 id. 이 채널로는 분석 요약·기술스택·문서 경로가 + 흐르므로(= 남의 이력서 내용) 검증 없이 열어두면 id 를 바꿔가며 긁을 수 있다. + 현재 Core 는 DOCUMENT 스코프 토큰을 발급하지 않고 프론트도 이 채널을 쓰지 않는다 + (분석 상태는 user 채널 `/realtime/stream/me` 로 받는다) + - `stream/me` → 경로에 id 가 없고 토큰의 `userId` 로 채널을 만든다(조작 여지 없음) - DLQ 활성 — handler 실패 메시지는 `dlq.q.realtime.session.notify` 로 격리 - Prometheus 노출 미구현 diff --git a/realtime/internal/transport/router.go b/realtime/internal/transport/router.go index cd036ed0..e625e256 100644 --- a/realtime/internal/transport/router.go +++ b/realtime/internal/transport/router.go @@ -31,7 +31,10 @@ func NewRouter(sse *SSEHandler, ws *WSHandler, audio *WSAudioHandler, verifier * } sse.ServeChannel(w, req, session.Channel{Kind: session.ChannelUser, ID: c.UserID}) }) - pr.Get("/realtime/stream/documents/{id}", channelByPath(sse, session.ChannelDocument)) // TODO: DOCUMENT 스코프 검증 (deferred) + // 이 채널로는 분석 결과(요약·기술스택·문서 경로)가 흐른다 — 남의 이력서 내용이다. + // 세션 채널과 같은 방식으로 토큰의 리소스 범위를 강제한다. 검증이 없으면 유효한 + // 스트림 토큰을 가진 누구나 document id 를 바꿔가며 타인의 분석 결과를 받아볼 수 있다. + pr.Get("/realtime/stream/documents/{id}", scopedChannel(sse, session.ChannelDocument, "DOCUMENT")) pr.Get("/realtime/stream/sessions/{id}", func(w http.ResponseWriter, req *http.Request) { id, err := strconv.ParseInt(chi.URLParam(req, "id"), 10, 64) if err != nil || id <= 0 { @@ -76,13 +79,21 @@ func NewRouter(sse *SSEHandler, ws *WSHandler, audio *WSAudioHandler, verifier * return r } -func channelByPath(sse *SSEHandler, kind session.ChannelKind) http.HandlerFunc { +// scopedChannel serves an SSE channel only to tokens minted for that exact resource. +// Core issues the token after checking ownership, so matching the claim here is the +// only ownership check RealTime can make (it has no database access). +func scopedChannel(sse *SSEHandler, kind session.ChannelKind, resourceType string) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { id, err := strconv.ParseInt(chi.URLParam(req, "id"), 10, 64) if err != nil || id <= 0 { http.Error(w, "invalid id", http.StatusBadRequest) return } + c, _ := auth.ClaimsFromContext(req.Context()) + if c.ResourceType != resourceType || c.ResourceID != id { + http.Error(w, "token resource mismatch", http.StatusForbidden) + return + } sse.ServeChannel(w, req, session.Channel{Kind: kind, ID: id}) } } diff --git a/realtime/internal/transport/router_scope_test.go b/realtime/internal/transport/router_scope_test.go new file mode 100644 index 00000000..cf314c17 --- /dev/null +++ b/realtime/internal/transport/router_scope_test.go @@ -0,0 +1,89 @@ +package transport + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Team-StackUp/stackup/realtime/internal/auth" + "github.com/Team-StackUp/stackup/realtime/internal/session" + "github.com/go-chi/chi/v5" +) + +// document 채널로는 분석 결과(요약·기술스택·문서 경로)가 흐른다 — 남의 이력서 내용이다. +// RealTime 은 DB 를 보지 않으므로, Core 가 소유권을 확인하고 발급한 토큰의 리소스 범위를 +// 그대로 강제하는 것이 유일한 소유권 검사다. +func documentRequest(t *testing.T, id string, claims auth.Claims) *http.Request { + t.Helper() + req := httptest.NewRequest("GET", "/realtime/stream/documents/"+id, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", id) + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = auth.WithClaims(ctx, claims) + return req.WithContext(ctx) +} + +func newDocumentHandler() http.HandlerFunc { + return scopedChannel(NewSSEHandler(session.NewRegistry(), 4, time.Hour), + session.ChannelDocument, "DOCUMENT") +} + +func TestDocumentChannelRejectsTokenForAnotherResourceType(t *testing.T) { + // 세션용 토큰으로 문서 채널을 구독하려는 경우 — 예전엔 그대로 통과했다. + req := documentRequest(t, "101", auth.Claims{UserID: 1, ResourceType: "SESSION", ResourceID: 101}) + rec := httptest.NewRecorder() + + newDocumentHandler()(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for SESSION-scoped token, got %d", rec.Code) + } +} + +func TestDocumentChannelRejectsTokenForAnotherDocument(t *testing.T) { + // 문서 101 토큰으로 문서 102 를 구독 — id 만 바꿔 남의 분석 결과를 긁는 경로다. + req := documentRequest(t, "102", auth.Claims{UserID: 1, ResourceType: "DOCUMENT", ResourceID: 101}) + rec := httptest.NewRecorder() + + newDocumentHandler()(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for token of another document, got %d", rec.Code) + } +} + +func TestDocumentChannelRejectsMissingClaims(t *testing.T) { + req := documentRequest(t, "101", auth.Claims{}) + rec := httptest.NewRecorder() + + newDocumentHandler()(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 when claims are absent, got %d", rec.Code) + } +} + +// 거부만 검증하면 비교를 뒤집어 놔도 통과한다 — 허용 경로도 확인한다. +func TestDocumentChannelAcceptsMatchingToken(t *testing.T) { + req := documentRequest(t, "101", auth.Claims{UserID: 1, ResourceType: "DOCUMENT", ResourceID: 101}) + ctx, cancel := context.WithCancel(req.Context()) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + newDocumentHandler()(rec, req) + close(done) + }() + + // 스트리밍이 시작되므로 잠시 뒤 끊는다. + time.Sleep(50 * time.Millisecond) + cancel() + <-done + + if rec.Code == http.StatusForbidden { + t.Fatalf("matching token must not be rejected, got %d", rec.Code) + } +}