Skip to content

Commit ede34be

Browse files
committed
🔒 fix: audit setCORSHeaders callers to pass explicit methods (#8201)
Copilot review on merged PR #8198 flagged that setCORSHeaders still defaults Access-Control-Allow-Methods to "GET, OPTIONS" unless callers pass an explicit method list, and that several existing kc-agent handlers serving POST/DELETE (e.g. /serviceaccounts) were still calling setCORSHeaders(w, r) without methods, so their preflight responses advertised only GET/OPTIONS and browsers rejected cross-origin POST/DELETE. This PR audits every setCORSHeaders caller in pkg/agent and updates the non-GET handlers to pass an explicit method list. The default of "GET, OPTIONS" is preserved for back-compat and remains correct for the read-only handlers. Handlers updated to advertise correct Allow-Methods on preflight: server_http.go - handleNamespacesHTTP GET, POST, DELETE, OPTIONS - handleServiceAccountsHTTP GET, POST, DELETE, OPTIONS - handleServiceExportsHTTP POST, DELETE, OPTIONS - handleRoleBindingsHTTP GET, POST, DELETE, OPTIONS - handleScaleHTTP POST, OPTIONS (consolidated from inline) - handleDeployWorkloadHTTP POST, OPTIONS (consolidated from inline) - handleDeleteWorkloadHTTP POST, OPTIONS (consolidated from inline) - handleAutoUpdateConfig GET, POST, OPTIONS (was OPTIONS-only) - handleAutoUpdateTrigger POST, OPTIONS (was OPTIONS-only) - handleAutoUpdateCancel POST, OPTIONS (was OPTIONS-only) - handleKubeconfigRemoveHTTP POST, OPTIONS (consolidated from inline) server_helm.go - handleHelmRollback POST, OPTIONS - handleHelmUninstall POST, OPTIONS - handleHelmUpgrade POST, OPTIONS server_gitops.go - handleDetectDrift POST, OPTIONS - handleGitopsSync POST, OPTIONS server_console_cr.go - handleConsoleCRManagedWorkloads POST, PUT, DELETE, OPTIONS - handleConsoleCRClusterGroups POST, PUT, DELETE, OPTIONS - handleConsoleCRWorkloadDeployments POST, DELETE, OPTIONS - handleConsoleCRWorkloadDeploymentStatus PUT, OPTIONS server_argocd.go - handleArgoCDSync POST, OPTIONS (consolidated from inline) server_gpu_health.go - handleGPUHealthCronJob POST, DELETE, OPTIONS server_operations.go - handleLocalClusters GET, POST, DELETE, OPTIONS - handleLocalClusterLifecycle POST, OPTIONS - handleVClusterCreate POST, OPTIONS - handleVClusterConnect POST, OPTIONS - handleVClusterDisconnect POST, OPTIONS - handleVClusterDelete POST, OPTIONS GET-only handlers (gpu-nodes, nodes, pods, events, deployments, replicasets, statefulsets, daemonsets, cronjobs, ingresses, services, configmaps, secrets, jobs, hpas, pvcs, roles, resourcequotas, limitranges, resolvedeps, clusterhealth, autoupdatestatus, kagenti/*, kagent-crds/*, prometheus query, vCluster list/check, cloudCLIStatus, localClusterTools, rbac/permissions, permissions/summary) keep the default and remain correct. Also updates the setCORSHeaders doc comment with an explicit audit rule so future handlers serving non-GET methods don't silently regress. Adds a regression test (TestHandleServiceAccounts_CORSMethodsHeader) that pins the fix on the specific handler Copilot called out. Fixes #8201 Signed-off-by: Andy Anderson <[email protected]>
1 parent 8564e81 commit ede34be

8 files changed

Lines changed: 91 additions & 44 deletions

pkg/agent/server_argocd.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,8 @@ type agentArgoSyncRequest struct {
5151
// ServiceAccount on the backend; on kc-agent it runs under the user's
5252
// kubeconfig (#7993 Phase 3c).
5353
func (s *Server) handleArgoCDSync(w http.ResponseWriter, r *http.Request) {
54-
s.setCORSHeaders(w, r)
55-
// #8040: setCORSHeaders defaults Access-Control-Allow-Methods to
56-
// "GET, OPTIONS" (pkg/agent/server_http.go). This endpoint is POST-only,
57-
// so browsers would reject the CORS preflight without this override.
58-
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
54+
// POST-only ArgoCD sync — preflight must advertise POST (#8040, #8201).
55+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
5956
w.Header().Set("Content-Type", "application/json")
6057
if r.Method == "OPTIONS" {
6158
w.WriteHeader(http.StatusOK)

pkg/agent/server_console_cr.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ func (s *Server) resolveConsoleCRTarget(w http.ResponseWriter, r *http.Request)
6868
// CRs. POST creates with a body-supplied spec. PUT updates by name (name
6969
// also in query). DELETE removes by name.
7070
func (s *Server) handleConsoleCRManagedWorkloads(w http.ResponseWriter, r *http.Request) {
71-
s.setCORSHeaders(w, r)
71+
// #8201: POST create, PUT update, DELETE remove — preflight must advertise all.
72+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions)
7273
w.Header().Set("Content-Type", "application/json")
7374
if r.Method == http.MethodOptions {
7475
w.WriteHeader(http.StatusOK)
@@ -150,7 +151,8 @@ func (s *Server) handleConsoleCRManagedWorkloads(w http.ResponseWriter, r *http.
150151

151152
// handleConsoleCRClusterGroups serves POST/PUT/DELETE for ClusterGroup CRs.
152153
func (s *Server) handleConsoleCRClusterGroups(w http.ResponseWriter, r *http.Request) {
153-
s.setCORSHeaders(w, r)
154+
// #8201: POST create, PUT update, DELETE remove — preflight must advertise all.
155+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions)
154156
w.Header().Set("Content-Type", "application/json")
155157
if r.Method == http.MethodOptions {
156158
w.WriteHeader(http.StatusOK)
@@ -235,7 +237,8 @@ func (s *Server) handleConsoleCRClusterGroups(w http.ResponseWriter, r *http.Req
235237
// exposed status updates (see handleConsoleCRWorkloadDeploymentStatus), and
236238
// spec updates are reserved for the system-internal reconciler.
237239
func (s *Server) handleConsoleCRWorkloadDeployments(w http.ResponseWriter, r *http.Request) {
238-
s.setCORSHeaders(w, r)
240+
// #8201: POST create, DELETE remove — preflight must advertise both.
241+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodDelete, http.MethodOptions)
239242
w.Header().Set("Content-Type", "application/json")
240243
if r.Method == http.MethodOptions {
241244
w.WriteHeader(http.StatusOK)
@@ -299,7 +302,8 @@ func (s *Server) handleConsoleCRWorkloadDeployments(w http.ResponseWriter, r *ht
299302
// of WorkloadDeployment CRs. The frontend sends a partial spec containing only
300303
// the status field, merged with the current resource on the apiserver side.
301304
func (s *Server) handleConsoleCRWorkloadDeploymentStatus(w http.ResponseWriter, r *http.Request) {
302-
s.setCORSHeaders(w, r)
305+
// PUT-only status update — preflight must advertise PUT (#8201).
306+
s.setCORSHeaders(w, r, http.MethodPut, http.MethodOptions)
303307
w.Header().Set("Content-Type", "application/json")
304308
if r.Method == http.MethodOptions {
305309
w.WriteHeader(http.StatusOK)

pkg/agent/server_gitops.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,8 @@ func gitopsParseApplyOutput(output string) []string {
280280
// portable to kc-agent — this handler always uses the kubectl path, matching
281281
// the backend's fallback behavior when `h.bridge` is nil (#7993 Phase 3b).
282282
func (s *Server) handleDetectDrift(w http.ResponseWriter, r *http.Request) {
283-
s.setCORSHeaders(w, r)
283+
// POST-only drift detection — preflight must advertise POST (#8201).
284+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
284285
w.Header().Set("Content-Type", "application/json")
285286
if r.Method == "OPTIONS" {
286287
w.WriteHeader(http.StatusOK)
@@ -391,7 +392,8 @@ func (s *Server) handleDetectDrift(w http.ResponseWriter, r *http.Request) {
391392
// user's kubeconfig. Backend had an MCP-first path; kc-agent always uses
392393
// kubectl (#7993 Phase 3b).
393394
func (s *Server) handleGitopsSync(w http.ResponseWriter, r *http.Request) {
394-
s.setCORSHeaders(w, r)
395+
// POST-only gitops sync — preflight must advertise POST (#8201).
396+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
395397
w.Header().Set("Content-Type", "application/json")
396398
if r.Method == "OPTIONS" {
397399
w.WriteHeader(http.StatusOK)

pkg/agent/server_gpu_health.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ func isValidCronScheduleAgent(schedule string) bool {
5858
// s.k8sClient (MultiClusterClient) which, in kc-agent, uses the user's
5959
// kubeconfig.
6060
func (s *Server) handleGPUHealthCronJob(w http.ResponseWriter, r *http.Request) {
61-
s.setCORSHeaders(w, r)
61+
// #8201: POST install, DELETE uninstall — preflight must advertise both.
62+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodDelete, http.MethodOptions)
6263
w.Header().Set("Content-Type", "application/json")
6364

6465
if r.Method == "OPTIONS" {

pkg/agent/server_helm.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ type helmUpgradeRequest struct {
141141
// Part of #7993 Phase 3a — the backend handler is still present until
142142
// Phase 4 deletes it.
143143
func (s *Server) handleHelmRollback(w http.ResponseWriter, r *http.Request) {
144-
s.setCORSHeaders(w, r)
144+
// POST-only Helm rollback — preflight must advertise POST (#8201).
145+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
145146
w.Header().Set("Content-Type", "application/json")
146147
if r.Method == "OPTIONS" {
147148
w.WriteHeader(http.StatusOK)
@@ -218,7 +219,8 @@ func (s *Server) handleHelmRollback(w http.ResponseWriter, r *http.Request) {
218219
// handleHelmUninstall is the kc-agent version of the legacy backend
219220
// /api/gitops/helm-uninstall endpoint.
220221
func (s *Server) handleHelmUninstall(w http.ResponseWriter, r *http.Request) {
221-
s.setCORSHeaders(w, r)
222+
// POST-only Helm uninstall — preflight must advertise POST (#8201).
223+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
222224
w.Header().Set("Content-Type", "application/json")
223225
if r.Method == "OPTIONS" {
224226
w.WriteHeader(http.StatusOK)
@@ -290,7 +292,8 @@ func (s *Server) handleHelmUninstall(w http.ResponseWriter, r *http.Request) {
290292
// handleHelmUpgrade is the kc-agent version of the legacy backend
291293
// /api/gitops/helm-upgrade endpoint.
292294
func (s *Server) handleHelmUpgrade(w http.ResponseWriter, r *http.Request) {
293-
s.setCORSHeaders(w, r)
295+
// POST-only Helm upgrade — preflight must advertise POST (#8201).
296+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
294297
w.Header().Set("Content-Type", "application/json")
295298
if r.Method == "OPTIONS" {
296299
w.WriteHeader(http.StatusOK)

pkg/agent/server_http.go

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,9 @@ func (s *Server) handleEventsHTTP(w http.ResponseWriter, r *http.Request) {
318318
// pkg/api/handlers/mcp_resources.go#CreateOrUpdateResourceQuota) because the
319319
// reservation operator owns quota semantics and needs pod-SA access.
320320
func (s *Server) handleNamespacesHTTP(w http.ResponseWriter, r *http.Request) {
321-
s.setCORSHeaders(w, r)
321+
// #8201: GET list, POST create, DELETE remove — preflight must advertise all
322+
// three so browsers don't reject cross-origin POST/DELETE.
323+
s.setCORSHeaders(w, r, http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodOptions)
322324
w.Header().Set("Content-Type", "application/json")
323325

324326
if r.Method == "OPTIONS" {
@@ -793,7 +795,9 @@ func (s *Server) handleSecretsHTTP(w http.ResponseWriter, r *http.Request) {
793795
// mutations that run under the user's kubeconfig via kc-agent rather than the
794796
// backend's pod ServiceAccount (#7993 Phase 1.5 PR A).
795797
func (s *Server) handleServiceAccountsHTTP(w http.ResponseWriter, r *http.Request) {
796-
s.setCORSHeaders(w, r)
798+
// #8201: GET list, POST create, DELETE remove — preflight must advertise all
799+
// three so browsers don't reject cross-origin POST/DELETE.
800+
s.setCORSHeaders(w, r, http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodOptions)
797801
w.Header().Set("Content-Type", "application/json")
798802
if r.Method == "OPTIONS" {
799803
w.WriteHeader(http.StatusOK)
@@ -904,7 +908,8 @@ func (s *Server) deleteServiceAccountHTTP(w http.ResponseWriter, r *http.Request
904908
// frontend consumer and have been removed — any future UI that adds MCS
905909
// export management should call this route.
906910
func (s *Server) handleServiceExportsHTTP(w http.ResponseWriter, r *http.Request) {
907-
s.setCORSHeaders(w, r)
911+
// #8201: POST create, DELETE remove — preflight must advertise both.
912+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodDelete, http.MethodOptions)
908913
w.Header().Set("Content-Type", "application/json")
909914
if r.Method == "OPTIONS" {
910915
w.WriteHeader(http.StatusOK)
@@ -1140,7 +1145,9 @@ func (s *Server) handleRolesHTTP(w http.ResponseWriter, r *http.Request) {
11401145
// user-initiated mutations that run under the user's kubeconfig via kc-agent
11411146
// rather than the backend's pod ServiceAccount (#7993 Phase 1.5 PR A).
11421147
func (s *Server) handleRoleBindingsHTTP(w http.ResponseWriter, r *http.Request) {
1143-
s.setCORSHeaders(w, r)
1148+
// #8201: GET list, POST create, DELETE remove — preflight must advertise all
1149+
// three so browsers don't reject cross-origin POST/DELETE.
1150+
s.setCORSHeaders(w, r, http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodOptions)
11441151
w.Header().Set("Content-Type", "application/json")
11451152
if r.Method == "OPTIONS" {
11461153
w.WriteHeader(http.StatusOK)
@@ -1461,11 +1468,9 @@ func (s *Server) handleResolveDepsHTTP(w http.ResponseWriter, r *http.Request) {
14611468
// replica count via the Kubernetes API. Only POST with a JSON body is accepted;
14621469
// GET-based mutations are rejected to prevent CSRF-style attacks (#4150).
14631470
func (s *Server) handleScaleHTTP(w http.ResponseWriter, r *http.Request) {
1464-
s.setCORSHeaders(w, r)
1465-
// setCORSHeaders defaults Access-Control-Allow-Methods to "GET, OPTIONS".
1466-
// This is a mutating POST endpoint — browsers would otherwise reject the
1467-
// cross-origin POST preflight (#8019, #8021).
1468-
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
1471+
// POST-only mutating endpoint — preflight must advertise POST so browsers
1472+
// don't reject the cross-origin request (#8019, #8021, #8201).
1473+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
14691474
w.Header().Set("Content-Type", "application/json")
14701475
if r.Method == "OPTIONS" {
14711476
w.WriteHeader(http.StatusOK)
@@ -1621,9 +1626,8 @@ func (s *Server) handleScaleHTTP(w http.ResponseWriter, r *http.Request) {
16211626
// Only POST with a JSON body is accepted; GET-based mutations are rejected to
16221627
// prevent CSRF-style attacks (#4150 pattern, same as handleScaleHTTP).
16231628
func (s *Server) handleDeployWorkloadHTTP(w http.ResponseWriter, r *http.Request) {
1624-
s.setCORSHeaders(w, r)
1625-
// setCORSHeaders defaults Methods to "GET, OPTIONS"; override for POST (#8021).
1626-
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
1629+
// POST-only deploy endpoint — preflight must advertise POST (#8021, #8201).
1630+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
16271631
w.Header().Set("Content-Type", "application/json")
16281632
if r.Method == "OPTIONS" {
16291633
w.WriteHeader(http.StatusOK)
@@ -1771,9 +1775,8 @@ func (s *Server) handleDeployWorkloadHTTP(w http.ResponseWriter, r *http.Request
17711775
// is POST-with-body for all mutations (same as /scale), so the frontend sends
17721776
// a POST with {cluster, namespace, name} in the body.
17731777
func (s *Server) handleDeleteWorkloadHTTP(w http.ResponseWriter, r *http.Request) {
1774-
s.setCORSHeaders(w, r)
1775-
// setCORSHeaders defaults Methods to "GET, OPTIONS"; override for POST (#8021).
1776-
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
1778+
// POST-only delete endpoint — preflight must advertise POST (#8021, #8201).
1779+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
17771780
w.Header().Set("Content-Type", "application/json")
17781781
if r.Method == "OPTIONS" {
17791782
w.WriteHeader(http.StatusOK)
@@ -1963,6 +1966,12 @@ const defaultCORSAllowedMethods = "GET, OPTIONS"
19631966
// Access-Control-Allow-Methods value — this is required for POST/PUT/DELETE
19641967
// endpoints so browser preflight requests succeed. When no methods are
19651968
// supplied the header defaults to defaultCORSAllowedMethods.
1969+
//
1970+
// Audit rule (#8201): every handler that serves any method other than GET
1971+
// MUST pass an explicit method list including OPTIONS, e.g.
1972+
// setCORSHeaders(w, r, http.MethodPost, http.MethodOptions). Handlers that
1973+
// rely on the default and silently advertise "GET, OPTIONS" will fail
1974+
// browser preflight for cross-origin POST/DELETE requests.
19661975
func (s *Server) setCORSHeaders(w http.ResponseWriter, r *http.Request, methods ...string) {
19671976
origin := r.Header.Get("Origin")
19681977
if s.isAllowedOrigin(origin) {
@@ -2254,11 +2263,11 @@ func (s *Server) checkBackendHealth() bool {
22542263

22552264
// handleAutoUpdateConfig handles GET/POST for auto-update configuration.
22562265
func (s *Server) handleAutoUpdateConfig(w http.ResponseWriter, r *http.Request) {
2257-
s.setCORSHeaders(w, r)
2266+
// #8201: GET reads config, POST writes config — preflight must advertise both.
2267+
s.setCORSHeaders(w, r, http.MethodGet, http.MethodPost, http.MethodOptions)
22582268
w.Header().Set("Content-Type", "application/json")
22592269

22602270
if r.Method == "OPTIONS" {
2261-
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
22622271
w.WriteHeader(http.StatusOK)
22632272
return
22642273
}
@@ -2352,11 +2361,11 @@ func (s *Server) handleAutoUpdateStatus(w http.ResponseWriter, r *http.Request)
23522361

23532362
// handleAutoUpdateTrigger triggers an immediate update check.
23542363
func (s *Server) handleAutoUpdateTrigger(w http.ResponseWriter, r *http.Request) {
2355-
s.setCORSHeaders(w, r)
2364+
// POST-only trigger endpoint — preflight must advertise POST (#8201).
2365+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
23562366
w.Header().Set("Content-Type", "application/json")
23572367

23582368
if r.Method == "OPTIONS" {
2359-
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
23602369
w.WriteHeader(http.StatusOK)
23612370
return
23622371
}
@@ -2403,11 +2412,11 @@ func (s *Server) handleAutoUpdateTrigger(w http.ResponseWriter, r *http.Request)
24032412
// honored, and the update cannot be cancelled once the restart step has begun
24042413
// (startup-oauth.sh is spawned as a detached process).
24052414
func (s *Server) handleAutoUpdateCancel(w http.ResponseWriter, r *http.Request) {
2406-
s.setCORSHeaders(w, r)
2415+
// POST-only cancel endpoint — preflight must advertise POST (#8201).
2416+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
24072417
w.Header().Set("Content-Type", "application/json")
24082418

24092419
if r.Method == "OPTIONS" {
2410-
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
24112420
w.WriteHeader(http.StatusOK)
24122421
return
24132422
}
@@ -2618,8 +2627,8 @@ type kubeconfigAddResponse struct {
26182627

26192628
// handleKubeconfigRemoveHTTP removes a cluster context from the kubeconfig (#5658).
26202629
func (s *Server) handleKubeconfigRemoveHTTP(w http.ResponseWriter, r *http.Request) {
2621-
s.setCORSHeaders(w, r)
2622-
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") // Copilot: setCORSHeaders defaults to GET
2630+
// POST-only kubeconfig removal — preflight must advertise POST (#8201).
2631+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
26232632
w.Header().Set("Content-Type", "application/json")
26242633

26252634
if r.Method == "OPTIONS" {

pkg/agent/server_operations.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,7 +1285,8 @@ func (s *Server) handleLocalClusterTools(w http.ResponseWriter, r *http.Request)
12851285

12861286
// handleLocalClusters handles local cluster operations (list, create, delete)
12871287
func (s *Server) handleLocalClusters(w http.ResponseWriter, r *http.Request) {
1288-
s.setCORSHeaders(w, r)
1288+
// #8201: GET list, POST create, DELETE remove — preflight must advertise all.
1289+
s.setCORSHeaders(w, r, http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodOptions)
12891290
w.Header().Set("Content-Type", "application/json")
12901291

12911292
if r.Method == "OPTIONS" {
@@ -1446,7 +1447,8 @@ func (s *Server) handleLocalClusters(w http.ResponseWriter, r *http.Request) {
14461447

14471448
// handleLocalClusterLifecycle handles start/stop/restart for local clusters
14481449
func (s *Server) handleLocalClusterLifecycle(w http.ResponseWriter, r *http.Request) {
1449-
s.setCORSHeaders(w, r)
1450+
// POST-only lifecycle action — preflight must advertise POST (#8201).
1451+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
14501452
w.Header().Set("Content-Type", "application/json")
14511453

14521454
if r.Method == "OPTIONS" {
@@ -1566,7 +1568,8 @@ func (s *Server) handleVClusterList(w http.ResponseWriter, r *http.Request) {
15661568

15671569
// handleVClusterCreate creates a new vCluster
15681570
func (s *Server) handleVClusterCreate(w http.ResponseWriter, r *http.Request) {
1569-
s.setCORSHeaders(w, r)
1571+
// POST-only vCluster create — preflight must advertise POST (#8201).
1572+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
15701573
w.Header().Set("Content-Type", "application/json")
15711574

15721575
if r.Method == "OPTIONS" {
@@ -1645,7 +1648,8 @@ func (s *Server) handleVClusterCreate(w http.ResponseWriter, r *http.Request) {
16451648

16461649
// handleVClusterConnect connects to an existing vCluster
16471650
func (s *Server) handleVClusterConnect(w http.ResponseWriter, r *http.Request) {
1648-
s.setCORSHeaders(w, r)
1651+
// POST-only vCluster connect — preflight must advertise POST (#8201).
1652+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
16491653
w.Header().Set("Content-Type", "application/json")
16501654

16511655
if r.Method == "OPTIONS" {
@@ -1702,7 +1706,8 @@ func (s *Server) handleVClusterConnect(w http.ResponseWriter, r *http.Request) {
17021706

17031707
// handleVClusterDisconnect disconnects from a vCluster
17041708
func (s *Server) handleVClusterDisconnect(w http.ResponseWriter, r *http.Request) {
1705-
s.setCORSHeaders(w, r)
1709+
// POST-only vCluster disconnect — preflight must advertise POST (#8201).
1710+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
17061711
w.Header().Set("Content-Type", "application/json")
17071712

17081713
if r.Method == "OPTIONS" {
@@ -1759,7 +1764,8 @@ func (s *Server) handleVClusterDisconnect(w http.ResponseWriter, r *http.Request
17591764

17601765
// handleVClusterDelete deletes a vCluster
17611766
func (s *Server) handleVClusterDelete(w http.ResponseWriter, r *http.Request) {
1762-
s.setCORSHeaders(w, r)
1767+
// POST-only vCluster delete — preflight must advertise POST (#8201).
1768+
s.setCORSHeaders(w, r, http.MethodPost, http.MethodOptions)
17631769
w.Header().Set("Content-Type", "application/json")
17641770

17651771
if r.Method == "OPTIONS" {

pkg/agent/server_rbac_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,28 @@ func TestSetCORSHeaders_ExplicitMethodsJoined(t *testing.T) {
6262
t.Errorf("expected %q, got %q", "POST, OPTIONS", methods)
6363
}
6464
}
65+
66+
// TestHandleServiceAccounts_CORSMethodsHeader verifies the audit fix for
67+
// #8201 — the /serviceaccounts handler now advertises GET/POST/DELETE on the
68+
// preflight response, so cross-origin POST/DELETE requests aren't rejected by
69+
// the browser. Before the audit, every handler that fell through to the
70+
// default "GET, OPTIONS" had this bug; this test pins the fix for one of the
71+
// handlers Copilot specifically called out.
72+
func TestHandleServiceAccounts_CORSMethodsHeader(t *testing.T) {
73+
s := &Server{
74+
allowedOrigins: []string{"http://localhost:3000"},
75+
}
76+
77+
req := httptest.NewRequest("OPTIONS", "/serviceaccounts", nil)
78+
req.Header.Set("Origin", "http://localhost:3000")
79+
w := httptest.NewRecorder()
80+
81+
s.handleServiceAccountsHTTP(w, req)
82+
83+
methods := w.Header().Get("Access-Control-Allow-Methods")
84+
for _, want := range []string{"GET", "POST", "DELETE", "OPTIONS"} {
85+
if !strings.Contains(methods, want) {
86+
t.Errorf("expected Access-Control-Allow-Methods to include %q, got %q", want, methods)
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)