From 9c665d42a99000e5528e0753ceafed533d579cf8 Mon Sep 17 00:00:00 2001 From: Amir Raminfar Date: Fri, 24 Jul 2026 16:42:29 -0700 Subject: [PATCH] fix(security): require auth for /cloud/callback (GHSA-p66q-2gfp-8v55) (#4863) Co-authored-by: Claude Opus 4.8 --- internal/web/auth_simple_test.go | 30 ++++++++++++++++++++++++++++++ internal/web/routes.go | 7 ++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/internal/web/auth_simple_test.go b/internal/web/auth_simple_test.go index 9de4555d..e5ec0346 100644 --- a/internal/web/auth_simple_test.go +++ b/internal/web/auth_simple_test.go @@ -131,3 +131,33 @@ func Test_createRoutes_simple_bad_password(t *testing.T) { assert.Equal(t, rr.Code, 401, "Response code should be 401.") } + +func Test_createRoutes_simple_cloud_callback_requires_auth(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "index.html", []byte("index page"), 0644), "WriteFile should have no error.") + + handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/", + Authorization: Authorization{ + Provider: SIMPLE, + Authorizer: auth.NewSimpleAuth(auth.UserDatabase{ + Users: map[string]*auth.User{ + "amir": { + Username: "amir", + Password: "$2a$10$4Tvzu0ms9shlv4B8pIfqI.TM9CoqsamsAznP91A1NGuwg/68SGS1m", + }, + }, + }, time.Second*100), + }, + }) + + // The cloud callback must not be reachable without authentication. Otherwise + // an attacker could force-link the instance to their own cloud account. + for _, path := range []string{"/api/cloud/callback?token=attacker", "/api/cloud/status"} { + req, err := http.NewRequest("GET", path, nil) + require.NoError(t, err, "NewRequest should not return an error.") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, 401, rr.Code, "%s should require authentication.", path) + } +} diff --git a/internal/web/routes.go b/internal/web/routes.go index a4da7814..488fd279 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -216,6 +216,10 @@ func createRouter(h *handler) *chi.Mux { r.Patch("/cloud/config", h.updateCloudConfig) r.Delete("/cloud/config", h.deleteCloudConfig) r.Post("/cloud/feedback", h.cloudFeedback) + // Cloud callback handles the OAuth-style code exchange. It must stay + // authenticated so an unauthenticated attacker cannot force-link the + // instance to their own cloud account via SetCloudConfig. + r.Get("/cloud/callback", h.cloudCallback) // MCP (Model Context Protocol) endpoint if h.config.EnableMCP { @@ -229,9 +233,6 @@ func createRouter(h *handler) *chi.Mux { r.Post("/token", h.createToken) r.Delete("/token", h.deleteToken) } - - // Cloud callback (public, handles OAuth-style code exchange) - r.Get("/cloud/callback", h.cloudCallback) }) r.Get("/healthcheck", h.healthcheck)