mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
fix(http): drop the global CSRF relaxation and CORS-derived socket origin
CSRFMiddlewareWithCORS changed CSRF handling for every route and, with a wildcard allowlist, disabled the Sec-Fetch-Site check. Restore the original CSRFMiddleware: clients that authenticate with validated header credentials, or with a single-use WebSocket ticket, are already exempt, so the motion routes need no relaxation. The motion WebSocket upgrade no longer reads the CORS allowlist to decide whether a cross-origin upgrade is acceptable. It accepts one only with a valid ticket or validated header credentials; everything else gets the same-origin default. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
3 files changed
+13
-35
No files matched your search
+1
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user