diff --git a/core/http/app.go b/core/http/app.go index cd63f2e6c..e3b7ade6b 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -458,8 +458,7 @@ func API(application *application.Application) (*echo.Echo, error) { // could never read a token to send back. if !application.ApplicationConfig().DisableCSRF { xlog.Debug("Enabling CSRF middleware (Sec-Fetch-Site mode)") - e.Use(auth.CSRFMiddlewareWithCORS( - application.ApplicationConfig().CORS && application.ApplicationConfig().CORSAllowOrigins != "")) + e.Use(auth.CSRFMiddleware()) } // Admin middleware: enforces admin role when auth is enabled, no-op otherwise diff --git a/core/http/endpoints/localai/motion.go b/core/http/endpoints/localai/motion.go index bb68b01b5..b62cb0043 100644 --- a/core/http/endpoints/localai/motion.go +++ b/core/http/endpoints/localai/motion.go @@ -62,7 +62,6 @@ type motionSession struct { type MotionEndpoints struct { ingress metric.Int64Counter app *application.Application - corsEnabled bool mu sync.Mutex sessions map[string]*motionSession reserved map[string]bool @@ -74,9 +73,6 @@ type MotionEndpoints struct { func NewMotionEndpoints(app *application.Application) *MotionEndpoints { meter := otel.Meter("github.com/mudler/LocalAI/motion") m := &MotionEndpoints{app: app, sessions: map[string]*motionSession{}, reserved: map[string]bool{}} - if app != nil { - m.corsEnabled = app.ApplicationConfig().CORS && app.ApplicationConfig().CORSAllowOrigins != "" - } m.active, _ = meter.Int64UpDownCounter("localai_motion_sessions", metric.WithDescription("Active motion sessions")) m.ingress, _ = meter.Int64Counter("localai_motion_uploads", metric.WithDescription("Duplex frames accepted or dropped before inference")) m.outcomes, _ = meter.Int64Counter("localai_motion_frames", metric.WithDescription("Motion outcomes and rejected input")) @@ -384,8 +380,9 @@ func (m *MotionEndpoints) Poses(c echo.Context) error { if err != nil { return err } - // Validated header credentials or an explicit CORS policy extend the origin default. - // Reuse middleware approval to keep origin matching consistent with HTTP. + // A non-same-origin upgrade is accepted only with a single-use ticket or + // validated header credentials. The CORS allowlist never relaxes this: a + // cookie-authenticated cross-origin page is rejected by the default check. duplex := false for _, protocol := range websocket.Subprotocols(c.Request()) { if protocol == "localai.motion.v2" { @@ -400,9 +397,7 @@ func (m *MotionEndpoints) Poses(c echo.Context) error { } defer s.frameMu.Unlock() upgrader := websocket.Upgrader{Subprotocols: []string{"localai.motion.v2"}} - origin := c.Request().Header.Get(echo.HeaderOrigin) - allowedOrigin := c.Response().Header().Get(echo.HeaderAccessControlAllowOrigin) - if auth.WebSocketTicketAuthenticated(c) || auth.HeaderAuthenticated(c) || (m.corsEnabled && origin != "" && (allowedOrigin == "*" || allowedOrigin == origin)) { + if auth.WebSocketTicketAuthenticated(c) || auth.HeaderAuthenticated(c) { upgrader.CheckOrigin = func(*http.Request) bool { return true } } ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil) diff --git a/core/http/endpoints/localai/motion_internal_test.go b/core/http/endpoints/localai/motion_internal_test.go index 845c22cba..811e94185 100644 --- a/core/http/endpoints/localai/motion_internal_test.go +++ b/core/http/endpoints/localai/motion_internal_test.go @@ -174,36 +174,20 @@ var _ = Describe("Motion streaming endpoints", func() { Expect(response.StatusCode).To(Equal(403)) Expect(response.Body.Close()).To(Succeed()) }) - DescribeTable("honors explicitly enabled CORS for browser connections", func(enabled bool, allowed []string, origin string, accept bool) { - m.corsEnabled = enabled + DescribeTable("never derives the WebSocket origin check from the CORS allowlist", func(allowed []string, origin string) { e := echo.New() e.Use(echomiddleware.CORSWithConfig(echomiddleware.CORSConfig{AllowOrigins: allowed})) e.GET("/api/motion/sessions/:id/poses", m.Poses) server := httptest.NewServer(e) DeferCleanup(server.Close) - ws, response, err := (&websocket.Dialer{Subprotocols: []string{"localai.motion.v2"}}).Dial("ws"+strings.TrimPrefix(server.URL, "http")+"/api/motion/sessions/session/poses", http.Header{"Origin": []string{origin}}) - if !accept { - Expect(err).To(HaveOccurred()) - Expect(response.StatusCode).To(Equal(403)) - Expect(response.Body.Close()).To(Succeed()) - return - } - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { Expect(ws.Close()).To(Succeed()) }) - Expect(response.StatusCode).To(Equal(101)) - kind, data, err := ws.ReadMessage() - Expect(err).NotTo(HaveOccurred()) - Expect(kind).To(Equal(websocket.BinaryMessage)) - out := &motion.Output{} - Expect(proto.Unmarshal(data, out)).To(Succeed()) - Expect(out.GetDefinition().Profile).To(Equal("smpl24")) + _, response, err := (&websocket.Dialer{Subprotocols: []string{"localai.motion.v2"}}).Dial("ws"+strings.TrimPrefix(server.URL, "http")+"/api/motion/sessions/session/poses", http.Header{"Origin": []string{origin}}) + Expect(err).To(HaveOccurred()) + Expect(response.StatusCode).To(Equal(403)) + Expect(response.Body.Close()).To(Succeed()) }, - Entry("explicit allowed origin", true, []string{"https://consumer.example"}, "https://consumer.example", true), - Entry("explicit wildcard policy", true, []string{"*"}, "http://localhost:3000", true), - Entry("subdomain policy", true, []string{"https://*.example.com"}, "https://consumer.example.com", true), - Entry("disallowed origin", true, []string{"https://consumer.example"}, "https://untrusted.example", false), - Entry("different port", true, []string{"http://localhost:3000"}, "http://localhost:3001", false), - Entry("default HTTP wildcard does not enable cross-origin sockets", false, []string{"*"}, "https://untrusted.example", false), + Entry("explicitly allowed origin", []string{"https://consumer.example"}, "https://consumer.example"), + Entry("wildcard policy", []string{"*"}, "http://localhost:3000"), + Entry("subdomain policy", []string{"https://*.example.com"}, "https://consumer.example.com"), ) DescribeTable("bypasses origins only for validated header credentials", func(header, value string, status int) {