diff --git a/Sense/server/app/sense/identity/http.go b/Sense/server/app/sense/identity/http.go index 977eee0..bbe339f 100644 --- a/Sense/server/app/sense/identity/http.go +++ b/Sense/server/app/sense/identity/http.go @@ -48,6 +48,21 @@ func Require(permission string, next http.Handler) http.Handler { }) } +// RequireSameOriginFrame authenticates browser iframe navigation that cannot +// attach the X-Product header used by API clients. Fetch Metadata keeps this +// narrow: only a same-origin iframe navigation with a valid Sense session is +// accepted. +func RequireSameOriginFrame(permission string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + module := activeModule.Load() + if module == nil { + platform.WriteError(w, &platform.APIError{Status: http.StatusServiceUnavailable, Code: "identity_not_ready", Message: "身份服务尚未就绪"}) + return + } + module.RequireSameOriginFrame(permission, next).ServeHTTP(w, r) + }) +} + func PrincipalFromContext(ctx context.Context) (Principal, bool) { principal, ok := ctx.Value(principalContextKey{}).(Principal) return principal, ok @@ -84,6 +99,28 @@ func (m *Module) Require(permission string, next http.Handler) http.Handler { })) } +func (m *Module) RequireSameOriginFrame(permission string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Sec-Fetch-Site") != "same-origin" || + r.Header.Get("Sec-Fetch-Mode") != "navigate" || + r.Header.Get("Sec-Fetch-Dest") != "iframe" { + platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"}) + return + } + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"}) + return + } + principal, err := m.service.Authenticate(r.Context(), cookie.Value) + if err != nil || !HasPermission(principal.Role, permission) { + platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"}) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalContextKey{}, principal))) + }) +} + func (m *Module) bootstrap(w http.ResponseWriter, r *http.Request) { var request struct { Username string `json:"username"` diff --git a/Sense/server/app/sense/identity/http_test.go b/Sense/server/app/sense/identity/http_test.go index d96302c..fccc406 100644 --- a/Sense/server/app/sense/identity/http_test.go +++ b/Sense/server/app/sense/identity/http_test.go @@ -21,6 +21,9 @@ func TestHTTPLoginCookieAndProductBoundary(t *testing.T) { } app := platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) NewModule(service, cfg).Register(app) + app.Handle("GET /same-origin-frame", RequireSameOriginFrame(PermissionMediaRead, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }))) bootstrap := httptest.NewRequest(http.MethodPost, "/api/v1/identity/bootstrap", bytes.NewBufferString(`{"username":"admin","display_name":"管理员","password":"StrongPass2026"}`)) bootstrap.Header.Set("X-Sense-Bootstrap-Token", "bootstrap-test") @@ -56,4 +59,40 @@ func TestHTTPLoginCookieAndProductBoundary(t *testing.T) { if senseResult.Code != http.StatusOK { t.Fatalf("Sense product header status = %d", senseResult.Code) } + + meWithoutProduct := httptest.NewRequest(http.MethodGet, "/api/v1/identity/me", nil) + meWithoutProduct.AddCookie(cookies[0]) + missingProductResult := httptest.NewRecorder() + app.Handler().ServeHTTP(missingProductResult, meWithoutProduct) + if missingProductResult.Code != http.StatusUnauthorized { + t.Fatalf("missing product header status = %d", missingProductResult.Code) + } + + frame := httptest.NewRequest(http.MethodGet, "/same-origin-frame", nil) + frame.AddCookie(cookies[0]) + frame.Header.Set("Sec-Fetch-Site", "same-origin") + frame.Header.Set("Sec-Fetch-Mode", "navigate") + frame.Header.Set("Sec-Fetch-Dest", "iframe") + frameResult := httptest.NewRecorder() + app.Handler().ServeHTTP(frameResult, frame) + if frameResult.Code != http.StatusNoContent { + t.Fatalf("same-origin frame status = %d, body = %s", frameResult.Code, frameResult.Body.String()) + } + + frame.Header.Set("Sec-Fetch-Site", "cross-site") + crossSiteResult := httptest.NewRecorder() + app.Handler().ServeHTTP(crossSiteResult, frame) + if crossSiteResult.Code != http.StatusUnauthorized { + t.Fatalf("cross-site frame status = %d", crossSiteResult.Code) + } + + missingCookie := httptest.NewRequest(http.MethodGet, "/same-origin-frame", nil) + missingCookie.Header.Set("Sec-Fetch-Site", "same-origin") + missingCookie.Header.Set("Sec-Fetch-Mode", "navigate") + missingCookie.Header.Set("Sec-Fetch-Dest", "iframe") + missingCookieResult := httptest.NewRecorder() + app.Handler().ServeHTTP(missingCookieResult, missingCookie) + if missingCookieResult.Code != http.StatusUnauthorized { + t.Fatalf("missing-cookie frame status = %d", missingCookieResult.Code) + } } diff --git a/Sense/server/app/sense/liveview/http.go b/Sense/server/app/sense/liveview/http.go index af8930f..cc1fb97 100644 --- a/Sense/server/app/sense/liveview/http.go +++ b/Sense/server/app/sense/liveview/http.go @@ -14,7 +14,7 @@ func (m *Module) Register(app *platform.App) { app.Handle("GET /api/v1/liveview/routes", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.routes))) app.Handle("POST /api/v1/liveview/sessions", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.create))) app.Handle("GET /api/v1/liveview/sessions/{id}", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.get))) - app.Handle("GET /api/v1/liveview/sessions/{id}/player", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.player))) + app.Handle("GET /api/v1/liveview/sessions/{id}/player", identity.RequireSameOriginFrame(identity.PermissionMediaRead, http.HandlerFunc(m.player))) } func (m *Module) routes(w http.ResponseWriter, r *http.Request) { items, err := m.service.Routes(r.Context())