{"id":"GHSA-jpjh-jm2p-39hh","summary":"Arcane: Missing admin authorization on global variables endpoint","details":"## Summary\n\nThe `PUT /api/environments/{id}/templates/variables` endpoint, which writes the system-wide `.env.global` file used for variable substitution in every project's compose file, is missing an admin authorization check. Any authenticated non-admin user can call this endpoint with their bearer token or API key and overwrite the global environment variables that are merged into every project deployment. By overriding values like `REGISTRY`, `IMAGE`, `DATABASE_URL`, or `SECRET_KEY` that other users reference via `${VAR}` in compose files, an attacker can redirect image pulls to attacker-controlled registries (supply-chain RCE on the Docker host), exfiltrate database credentials, or disrupt all projects.\n\n## Details\n\nThe endpoint is registered at `backend/internal/huma/handlers/templates.go:374`:\n\n```go\nhuma.Register(api, huma.Operation{\n    OperationID: \"updateGlobalVariables\",\n    Method:      \"PUT\",\n    Path:        \"/environments/{id}/templates/variables\",\n    ...\n    Security: []map[string][]string{\n        {\"BearerAuth\": {}},\n        {\"ApiKeyAuth\": {}},\n    },\n}, h.UpdateGlobalVariables)\n```\n\nThe handler at `backend/internal/huma/handlers/templates.go:889` performs no role check:\n\n```go\nfunc (h *TemplateHandler) UpdateGlobalVariables(ctx context.Context, input *UpdateGlobalVariablesInput) (*UpdateGlobalVariablesOutput, error) {\n    if h.templateService == nil {\n        return nil, huma.Error500InternalServerError(\"service not available\")\n    }\n\n    if input.EnvironmentID != \"0\" {\n        return h.updateGlobalVariablesForRemoteEnvironmentInternal(ctx, input)\n    }\n\n    if err := h.templateService.UpdateGlobalVariables(ctx, input.Body.Variables); err != nil {\n        return nil, huma.Error500InternalServerError((&common.GlobalVariablesUpdateError{Err: err}).Error())\n    }\n    ...\n}\n```\n\nThis is anomalous compared to every other admin-sensitive handler in the codebase, all of which begin with `if err := checkAdmin(ctx); err != nil { return nil, err }` (see `users.go`, `events.go`, `swarm.go`, `settings.go`, `apikeys.go`, `environments.go`, `notifications.go`, `container_registries.go`, `git_repositories.go`, `system.go`). The helper exists at `backend/internal/huma/handlers/helpers.go:12` but is never invoked from `templates.go`.\n\nThe auth middleware at `backend/internal/huma/middleware/auth.go:192-254` only validates that *some* authenticated user is present (Bearer JWT, API key, or environment access token); it does not enforce roles. Role enforcement is the responsibility of each handler.\n\nThat this endpoint is intended to be admin-only is evidenced by the UI customization search at `backend/internal/huma/handlers/customize.go:82-91` and `:106-114`, which explicitly hides the `variables` and `registries` categories from non-admin users:\n\n```go\nif !humamw.IsAdminFromContext(ctx) {\n    filtered := []category.Category{}\n    for _, cat := range results.Results {\n        if cat.ID != \"registries\" && cat.ID != \"variables\" {\n            filtered = append(filtered, cat)\n        }\n    }\n    results.Results = filtered\n    ...\n}\n```\n\nThe corresponding `container_registries.go` handlers all enforce admin via `checkAdmin()` (e.g. `container_registries.go:273,329,360,387,442`); the equivalent enforcement for the global-variables write was forgotten.\n\nThe service layer at `backend/internal/services/template_service.go:1107` writes attacker-supplied keys/values to `\u003cprojectsDirectory\u003e/.env.global`:\n\n```go\nfunc (s *TemplateService) UpdateGlobalVariables(ctx context.Context, vars []env.Variable) error {\n    envPath, err := s.getGlobalVariablesPath(ctx)\n    ...\n    for _, v := range vars {\n        if strings.TrimSpace(v.Key) == \"\" { continue }\n        key := strings.TrimSpace(v.Key)\n        value := strings.TrimSpace(v.Value)\n        if strings.ContainsAny(value, \" \\t\\n\\r#\") {\n            value = fmt.Sprintf(`\"%s\"`, strings.ReplaceAll(value, `\"`, `\\\"`))\n        }\n        _, _ = fmt.Fprintf(&builder, \"%s=%s\\n\", key, value)\n    }\n    if err := projects.WriteFileWithPerm(envPath, builder.String(), common.FilePerm); err != nil { ... }\n}\n```\n\nThat file is then loaded for every project at deploy time via `backend/pkg/projects/env.go:65-82` (`EnvLoader.LoadEnvironment` → `loadAndMergeGlobalEnv`):\n\n```go\nif strings.TrimSpace(l.projectsDir) != \"\" {\n    globalEnvPath := filepath.Join(l.projectsDir, GlobalEnvFileName)\n    if err := l.loadAndMergeGlobalEnv(ctx, globalEnvPath, envMap, injectionVars); err != nil ...\n}\n```\n\n`loadAndMergeGlobalEnv` (`env.go:94-125`) populates both `envMap` (used by compose-go for `${VAR}` substitution in compose files) and `injectionVars` (auto-injected into containers). The result: a single non-admin write to the global variables endpoint changes the resolved compose state of every project on the host.\n\nAdditionally, the key field is only `strings.TrimSpace`'d (`template_service.go:1128`); embedded newlines inside the key are not removed, so a key like `\"FOO\\nINJECTED\"` will write two lines into `.env.global`, allowing arbitrary key injection and overwrite of variables an attacker did not include in their request body.\n\n## Impact\n\n- **Cross-project supply-chain RCE on the Docker host.** Compose files commonly reference `${REGISTRY}/${IMAGE}:${TAG}`. By pointing `REGISTRY` (or `IMAGE`) at an attacker-controlled registry, the next deploy of any affected project pulls and runs attacker code with whatever privileges Arcane gives that container (commonly Docker socket access, host volume mounts, etc.).\n- **Credential theft from other users' projects.** Variables like `DATABASE_URL`, `SMTP_HOST`, `WEBHOOK_URL`, `S3_ENDPOINT` can be redirected to attacker-controlled servers; the next deploy will hand the new connection strings to applications that then submit credentials/data to the attacker.\n- **Cross-tenant integrity and availability.** A single non-admin user can corrupt `.env.global` to break every project on the instance.\n- **Bypass of intended privilege boundary.** The UI explicitly hides the variables and registries surfaces from non-admins, indicating these are admin-only controls; this finding closes the gap between the documented privilege model and the API enforcement.\n\nThe privilege delta is significant: the project clearly distinguishes admin from non-admin users (separate roles, admin-only UI categories, `checkAdmin()` enforced on dozens of other endpoints), yet this endpoint grants a non-admin the ability to execute attacker-controlled images on the host on behalf of every other tenant.","aliases":["CVE-2026-47125","GO-2026-5476"],"modified":"2026-06-25T23:11:41.629076201Z","published":"2026-05-23T00:16:56Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-05-23T00:16:56Z","nvd_published_at":"2026-05-29T18:17:12Z","cwe_ids":["CWE-862"],"severity":"HIGH"},"references":[{"type":"WEB","url":"https://github.com/getarcaneapp/arcane/security/advisories/GHSA-jpjh-jm2p-39hh"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-47125"},{"type":"PACKAGE","url":"https://github.com/getarcaneapp/arcane"}],"affected":[{"package":{"name":"github.com/getarcaneapp/arcane/backend","ecosystem":"Go","purl":"pkg:golang/github.com/getarcaneapp/arcane/backend"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.19.2"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 1.19.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-jpjh-jm2p-39hh/GHSA-jpjh-jm2p-39hh.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"}]}