Oct 30, 2026·10 min read·3 visits
Soft Serve versions >= 0.6.0 and < 0.12.3 contain a cache invalidation race condition in the SetPrivate repository method. Evicting the in-memory LRU cache before committing the database transaction allows concurrent unauthenticated requests to re-cache the stale public state, leaving private repositories perpetually readable by anonymous users. Upgrade to version 0.12.3, which removes the in-memory repository cache entirely.
A Time-of-Check to Time-of-Use (TOCTOU) race condition in Charmbracelet Soft Serve versions 0.6.0 through 0.12.2 allows unauthenticated users to retain read access to repositories after their visibility is changed from public to private. The flaw stems from premature cache invalidation in the SetPrivate method prior to committing the visibility update to the underlying database. Concurrent read requests arriving during the database transaction window repopulate the non-expiring in-memory LRU cache with stale public metadata, bypassing access controls across HTTP, SSH, and Git protocols until the server process is restarted.
Charmbracelet Soft Serve (github.com/charmbracelet/soft-serve) is a self-hosted Git server accessible over SSH, HTTP, and the native Git daemon protocol (git://). Beginning in version 0.6.0, Soft Serve migrated its repository metadata management from static configuration files to a relational database backend (supporting SQLite and PostgreSQL). To reduce database query overhead during frequent repository lookups, the backend architecture introduced an in-memory Least Recently Used (LRU) cache (pkg/backend/cache.go) built on github.com/hashicorp/golang-lru/v2.
This in-memory cache stored up to 1,000 repository metadata pointers (*repo) and operated without a Time-To-Live (TTL) expiration policy or cache versioning mechanism. When a client initiates a Git operation—such as cloning a repository or polling info/refs—Soft Serve's access control middleware (Backend.AccessLevelForUser) queries Backend.Repository(ctx, name) to retrieve the repository model and inspect its IsPrivate() boolean flag. Because cache hits were trusted unconditionally, the accuracy of access control enforcement depended entirely on strict cache coherence during metadata mutations.
A Time-of-Check to Time-of-Use (TOCTOU) race condition (classified under CWE-362 and CWE-367) exists in the Backend.SetPrivate method in Soft Serve versions >= 0.6.0 and < 0.12.3. When an administrator transitions a repository from public to private, the backend evicts the repository's cache entry before opening and committing the database transaction that updates the persistent record. If a concurrent read request arrives during the execution window between cache eviction and transaction commit, the reader experiences a cache miss, queries the uncommitted database state (private: false), and repopulates the LRU cache with the stale public record.
Because no post-commit cache invalidation occurs and cached records never expire, the repository remains trapped in an inconsistent state where the database reflects private: true, but the in-memory cache serves private: false. Furthermore, in multi-node or multi-process deployments sharing a single database, local cache eviction on one instance fails to invalidate the in-memory caches of peer instances, resulting in deterministic and permanent authorization bypass without requiring a race condition.
The root cause of GHSA-f3fj-9625-rv3m is the improper ordering of cache eviction relative to database transaction boundaries, combined with the absence of cache synchronization locks or post-transaction invalidation. In a cache-aside architecture, deleting a cache entry prior to updating the backing store creates a classic read-write race window.
When Backend.SetPrivate(ctx, name, true) is invoked, the function immediately executes d.cache.Delete(name). At this exact execution point, the relational database still holds the pre-mutation row where private is false. Before the database record is updated, SetPrivate enters d.db.TransactionContext, performs synchronous filesystem I/O to remove the git-daemon-export-ok marker file from the repository directory, and then executes the SQL UPDATE statement (d.store.SetRepoIsPrivateByName). The inclusion of filesystem syscall operations inside the transaction block widens the timing window between the initial cache deletion and the final database commit.
When a concurrent thread or goroutine calls Backend.Repository(ctx, name) during this window, d.cache.Get(name) returns a cache miss. The reader then initiates its own read transaction via d.store.GetRepoByName, which observes the isolated, pre-commit database state (private: false). Even if the administrator's write transaction commits immediately afterward, the concurrent reader proceeds to execute d.cache.Set(name, r), writing the stale private: false struct pointer back into the LRU cache.
> [!NOTE]
> This flaw also introduces an operational blindspot for administrators. When an operator verifies the repository state via the SSH CLI command repo private <name>, the CLI handler queries the database directly rather than consulting the in-memory cache, returning true. Meanwhile, the visibility_change webhook fails to fire because its trigger comparison relies on the stale cached object, leaving administrators unaware that the repository remains publicly accessible.
The vulnerable caching layer was introduced in commit e398b4dde723437f637b06710e0938d349b360e8 across pkg/backend/cache.go and pkg/backend/repo.go. In the vulnerable implementation of pkg/backend/repo.go, Repository() trusted cached pointers unconditionally, while SetPrivate() invalidated the cache prior to transaction execution:
// Vulnerable code in pkg/backend/repo.go (prior to v0.12.3)
func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {
name = utils.SanitizeRepo(name)
// Unconditional cache hit with no TTL or version validation
if r, ok := d.cache.Get(name); ok && r != nil {
return r, nil
}
rp := filepath.Join(d.repoPath(name))
if _, err := os.Stat(rp); err != nil {
return nil, proto.ErrRepoNotFound
}
var m models.Repo
var err error
if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
m, err = d.store.GetRepoByName(ctx, tx, name)
return err
}); err != nil {
return nil, db.WrapError(err)
}
r := &repo{
name: name,
path: rp,
repo: m,
}
// Stale database read is written back to cache if raced with SetPrivate
d.cache.Set(name, r)
return r, nil
}
func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {
name = utils.SanitizeRepo(name)
rp := filepath.Join(d.repoPath(name))
// BUG: Cache evicted before database transaction starts
d.cache.Delete(name)
if err := db.WrapError(
d.db.TransactionContext(ctx, func(tx *db.Tx) error {
fp := filepath.Join(rp, "git-daemon-export-ok")
if !private {
if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {
return err
}
} else {
if err := os.Remove(fp); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
}
return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
}),
); err != nil {
return err
}
// No post-commit cache invalidation performed here
...
}To remediate the vulnerability in version 0.12.3, the maintainers authored two commits: 5b307b4da4db285f797cc028b381ba4d3d853019 and 0a4e6f0b7e063927c7128756e1c1b3db871edabf. Rather than attempting to synchronize the cache via locks or double-delete patterns (which would still leave multi-process deployments sharing a database vulnerable to stale local caches), commit 5b307b4da4db285f797cc028b381ba4d3d853019 completely removed pkg/backend/cache.go and eliminated the golang-lru/v2 dependency.
Commit 0a4e6f0b7e063927c7128756e1c1b3db871edabf subsequently refined SetPrivate in pkg/backend/repo.go to query the current database state inside the transaction prior to modifying files or emitting visibility webhooks:
// Patched code in pkg/backend/repo.go (v0.12.3)
func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {
name = utils.SanitizeRepo(name)
rp := filepath.Join(d.repoPath(name))
var changed bool
if err := db.WrapError(
d.db.TransactionContext(ctx, func(tx *db.Tx) error {
// Verify repository exists and compare state inside transaction
m, err := d.store.GetRepoByName(ctx, tx, name)
if err != nil {
return err
}
changed = m.Private != private
fp := filepath.Join(rp, "git-daemon-export-ok")
if !private {
if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {
return err
}
} else {
if err := os.Remove(fp); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
}
return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
}),
); err != nil {
return err
}
// Cache is completely eliminated; all reads hit the database directly
...
}This remediation is complete and structurally eliminates the bug class. Because SQLite and PostgreSQL metadata queries take tens of microseconds—negligible compared to the process spawning and disk I/O required by underlying Git packfile operations—removing the cache enforces strict transactional consistency across both single-node and multi-node deployments without noticeable performance degradation.
Exploiting GHSA-f3fj-9625-rv3m requires network access to any of the Git transport protocols exposed by Soft Serve (HTTP Smart Transfer, SSH, or the Git daemon) and requires the server's anonymous access configuration (anon-access) to allow read operations, which is the default setting (read-only). No user credentials or SSH keys are needed.
In a single-node environment, an unauthenticated attacker targets a public repository suspected of undergoing privatization—such as a repository accidentally initialized as public during automated provisioning or CI/CD pipeline creation. The attacker runs an automated loop that continuously sends lightweight read requests to the target repository's HTTP endpoint:
GET /target-repo.git/info/refs?service=git-upload-pack HTTP/1.1
Host: git.example.com:23232
User-Agent: git/2.40.0When an administrator executes the SSH command to restrict repository visibility:
ssh -p 23231 git.example.com repo private target-repo trueIf one of the attacker's HTTP requests is processed by the Go HTTP server (server/web/git.go) after SetPrivate calls d.cache.Delete("target-repo") but before the SQL transaction commits, the withAccess middleware invokes be.AccessLevelForUser(ctx, "target-repo", nil), which calls be.Repository(ctx, "target-repo"). This call reads the uncommitted private: false state from the database and stores it in d.cache.
Once the race window is won, the attacker no longer needs to poll. The stale cache entry persists indefinitely in memory. Any subsequent git clone or git fetch over HTTP (server/web/git.go), SSH (server/ssh/git.go), or the Git daemon (server/daemon/daemon.go) will consult be.Repository(), receive the cached private: false struct, and grant ReadOnlyAccess to anonymous clients. This allows the attacker to pull all historical source code as well as any new commits pushed to the repository after it was marked private.
The vulnerability is rated Medium severity with a CVSS v3.1 base score of 5.9 (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N). The Attack Vector is Network (AV:N) with No Privileges Required (PR:N) and No User Interaction (UI:N), as anonymous read access is enabled by default in Soft Serve (anon-access: read-only).
Attack Complexity is rated High (AC:H) because an attacker in a standard single-instance deployment cannot force an administrator to invoke SetPrivate on demand and must win a narrow timing window during the state transition. However, two common operational scenarios lower the practical barrier to exploitation:
repo create <name> (which defaults to public) followed immediately by repo private <name> true. If external monitors or webhooks query the repository immediately upon creation, the race condition can be triggered inadvertently.SetPrivate command. All other Soft Serve instances that previously cached the public repository will continue serving it to unauthenticated users deterministically (AC:L in practice for those environments).The Confidentiality Impact is High (C:H). An attacker who triggers or encounters the stale cache state gains full read access to the private repository's Git object database, including all branches, tags, commit histories, source code, and any embedded secrets pushed after the administrator believed the repository was secured. Integrity (I:N) and Availability (A:N) are unaffected, as anonymous access remains restricted to read-only operations.
The definitive remediation is to upgrade Charmbracelet Soft Serve to version 0.12.3 or later. Version 0.12.3 removes the in-memory repository LRU cache entirely, ensuring that every access control evaluation queries the authoritative database state directly.
To upgrade a Go module dependency or build from source, run:
go get -u github.com/charmbracelet/soft-serve@v0.12.3Prebuilt binaries and system packages (.deb, .rpm, .apk) for version 0.12.3 are available directly from the official GitHub release page at https://github.com/charmbracelet/soft-serve/releases/tag/v0.12.3.
If an immediate upgrade to 0.12.3 cannot be performed, operators can apply the following mitigations to eliminate or neutralize stale cache entries:
systemctl restart soft-serverepo private <name> true. Instead, pass the -p flag during initial creation so a public record is never written to the database or cache:
ssh -p 23231 git.example.com repo create -p confidential-repoanon_access to no-access in config.yaml or via the administrative CLI. This enforces authentication checks prior to repository access evaluation:
ssh -p 23231 git.example.com settings anon-access no-accessCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
soft-serve charmbracelet | >= 0.6.0, < 0.12.3 | 0.12.3 |
| Attribute | Detail |
|---|---|
| Advisory ID | GHSA-f3fj-9625-rv3m |
| Vulnerability Class | CWE-362 / CWE-367 (TOCTOU Race Condition) |
| CVSS v3.1 Score | 5.9 Medium (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N) |
| Affected Versions | github.com/charmbracelet/soft-serve >= 0.6.0, < 0.12.3 |
| Patched Version | 0.12.3 |
| Attack Vector | Network (HTTP, SSH, and git:// protocols) |
| Security Impact | Unauthenticated read access to private Git repositories |
| Exploit Status | Proof-of-Concept in unit tests (No KEV listing) |
The product contains a code sequence that can run concurrently with other code, and the code sequence requires temporary, exclusive access to a shared resource, but a timing window exists in which the shared resource can be modified by another code sequence that is operating concurrently.
Charmbracelet Soft Serve versions 0.5.0 through 0.12.2 contain an authorization bypass and observable response discrepancy vulnerability in the HTTP Git server. Remote attackers can determine the existence, exact names, and clone URLs of private repositories by issuing crafted Go remote import path discovery requests (?go-get=1) or by observing differential HTTP status codes across Git Smart HTTP, Dumb HTTP, and Git LFS endpoints.
A resource consumption vulnerability exists in the Authlib joserfc Python library prior to version 1.7.5. When processing JSON Web Encryption (JWE) objects formatted using General JSON Serialization, the library fails to enforce an upper bound on the number of elements within the recipients array. An unauthenticated attacker can supply a JWE containing thousands of recipient entries targeting an RSA key management algorithm, forcing the server to perform an equivalent number of computationally expensive RSA private-key decryption operations per request and exhausting available CPU resources.
CVE-2026-119284 (GHSA-9pg5-8c7x-5hfx) is an uncontrolled resource consumption vulnerability (CWE-400) in the compact serialization deserialization routines of joserfc prior to version 1.7.6. The library enforced individual segment size limits only after invoking Python's bytes.split(b'.') on untrusted input tokens. By sending oversized compact JWS or JWE tokens, an unauthenticated remote attacker can force the Python interpreter to duplicate and allocate memory for every segment before size validation executes, resulting in memory exhaustion and an Endpoint Denial of Service (DoS).
An improper input validation vulnerability (CWE-20) exists in the Entity Syntax Tree (EST) JSON deserialization component of the Cedar Policy Language (`cedar-policy` and `@cedar-policy/cedar-wasm`). When parsing JSON-formatted policy templates that define `is ... in` scope constraints on `principal` or `resource`, the parser used wildcard pattern matching that ignored the provided slot identifier (`?principal` or `?resource`). This omission allows malformed JSON templates to bypass initial deserialization validation, resulting in either a runtime panic during template linking or inverted slot bindings that alter authorization outcomes.
The @eeacms/volto-chatbot package for Plone Volto contains an unauthenticated confused deputy vulnerability (CWE-862) in versions 2.0.3 and earlier. The package registers Express middleware on wildcard proxy routes (`**/_v1_da/**` and `**/_v1_ha/**`) without verifying client authentication or restricting target endpoints. When processing incoming HTTP requests, the middleware automatically attaches server-side administrative credentials before forwarding the requests to upstream Danswer (Onyx) or Halloumi (LLMGW) API servers. This allows unauthenticated network attackers to execute arbitrary administrative API calls against the backend infrastructure.
CVE-2026-9705 is an insufficient session expiration and authorization bypass vulnerability in Keycloak and Red Hat build of Keycloak. The vulnerability occurs in the OAuth 2.0 Dynamic Client Registration Management Protocol implementation, where the authentication handler fails to verify whether a target client is enabled before accepting requests authenticated with a previously issued Registration Access Token (RAT). Consequently, an actor possessing a valid RAT for an administratively disabled client can query, update, and re-enable the client configuration, restoring revoked access to realm resources.