Blog
Gitea CVE-2026-20896Auth BypassSupply Chain SecurityGenerative Pentest

Gitea CVE-2026-20896: One HTTP Header Owns Your Source-Control Server

CVE-2026-20896 lets an unauthenticated attacker spoof a single HTTP header and impersonate any Gitea admin — reading private repos, committed secrets and deploy keys. Here is the fix.

Zero Hunt Research··8 min read

A self-hosted Git server is the one box that holds the keys to every other box. It stores your source, your CI/CD pipelines, your deploy tokens, and — whether you like it or not — the API keys and database passwords somebody committed by accident three years ago. So when a critical-severity authentication bypass lands on the most popular self-hosted forge and starts getting probed in the wild within two weeks, "we'll patch it next sprint" is not a plan. CVE-2026-20896 is that bug: CVSS 9.8, no password, no token, one HTTP header.

How the Gitea CVE-2026-20896 auth bypass works

Gitea supports reverse-proxy authentication: you put an SSO proxy in front of Gitea, the proxy authenticates the user, and it forwards the identity to Gitea in an HTTP header — X-WEBAUTH-USER. Gitea trusts that header because it trusts the proxy. The safety of the whole scheme rests on one assumption: only your proxy can reach Gitea, and Gitea only honours the header from the proxy's IP.

That assumption is controlled by two config keys — ENABLE_REVERSE_PROXY_AUTHENTICATION and REVERSE_PROXY_TRUSTED_PROXIES. The second is the allowlist of IPs Gitea will accept the identity header from. And in the official Docker image up to and including 1.26.2, that allowlist shipped as:

REVERSE_PROXY_TRUSTED_PROXIES = *

A wildcard. Every source IP on Earth is a "trusted proxy." So on any Dockerised Gitea with reverse-proxy auth enabled, an unauthenticated attacker sends:

GET /user/settings HTTP/1.1
Host: git.victim.example
X-WEBAUTH-USER: admin

Gitea checks the source IP against the allowlist (* matches everything), sees a "trusted" proxy asserting the user is admin, and logs the request in as admin. No credential is validated because, in this model, the credential is the header. Sysdig's threat research team put it plainly:

One HTTP header. Access on any internet-facing Gitea. No password. No token.

The CVE is classed as CWE-284, improper access control, and it shipped as part of a nine-CVE Gitea 1.26.3 security release — including a separate CVSS 9.6 SSRF in webhooks (CVE-2026-22874) that an attacker who just impersonated admin is perfectly positioned to chain.

Why a forge is the worst place for an auth bypass

Read access as admin on a source-control server is not "read some code." It is the top of a supply-chain funnel. Once the header is accepted, the attacker inherits everything the impersonated account can see:

  • Private repositories — proprietary source, and every secret ever committed to it. Grep history for AWS_SECRET, .env, id_rsa, password=. Developers do this by accident constantly; the repo remembers forever.
  • CI/CD configuration and Actions secrets — pipeline definitions plus the encrypted variables (registry credentials, cloud keys, signing material) they inject at build time.
  • Deploy keys and access tokens — the SSH keys and PATs that let the forge push to production, pull private dependencies, or authenticate to your registry.
  • Write access — impersonating admin means you can also push. Poison a build script, add a malicious CI step, or plant a backdoor in a dependency your other projects consume.

That last point is the reason a forge bug is categorically worse than a bug in an internal wiki. SecurityWeek's reporting on the active exploitation notes exactly this progression: impersonate a user, read the private repos, harvest the accidentally-committed API keys, database credentials and deploy tokens, then pivot outward using the credentials you found. The Git server is the initial access and the credential store in one hop.

From disclosure to exploitation in 13 days

Gitea removed the wildcard in the security release and made reverse-proxy authentication opt-in. Then reality did what it always does. Sysdig sensors caught the first in-the-wild exploitation roughly 13 days after disclosure — a VPN-exit scanner spraying the X-WEBAUTH-USER header at internet-facing Gitea instances and collecting the ones that answered. Shodan showed approximately 6,200 internet-exposed Gitea servers at the time; how many run the vulnerable Docker default is unknown, which is precisely the problem — nobody can tell from the outside, and neither can the operators without checking their own config.

Thirteen days is not a comfortable margin. It is the window between "the fix exists" and "someone automated the exploit," and it closed while most teams were still deciding whether a self-hosted dev tool counted as production.

Why the Gitea auth bypass survives the patch

Here is the trap, and it is the reason a version-scanner dashboard going green means less than it looks like.

First, the flaw is a configuration state, not just a code path. The 1.26.3/1.26.4 fix changes the default — the wildcard is gone and reverse-proxy auth is opt-in. But an operator who explicitly set REVERSE_PROXY_TRUSTED_PROXIES = * in their own config (copied from an old tutorial, or carried forward through upgrades) stays wide open after patching, because the package respects the value you set. Two servers on the identical fixed build can have opposite exposure. A banner scan reads the version and calls both safe. Only actually testing whether the running instance accepts a spoofed header tells you the truth.

Second, anything already stolen survives the upgrade. Patching closes the door; it does not un-leak the deploy token an attacker copied last week, or evict the SSH key they added to a repo, or roll back the malicious CI step they pushed. The secrets that were readable are still valid until you rotate them. This is the same lesson the industry keeps re-learning from token-leaking edge bugs: the CVE fix and the incident response are two different jobs, and skipping the second one leaves the attacker's access intact behind a "patched" label.

And note the sibling risk: Forgejo is an independent hard fork of Gitea, so the Gitea advisory does not automatically map to Forgejo version numbers — but the same dangerous REVERSE_PROXY_TRUSTED_PROXIES wildcard pattern is worth auditing there too.

Remediation

Treat any internet-reachable Gitea with reverse-proxy auth as breached-until-proven-clean. Work the list top to bottom.

1. Am I affected? Check your version and — more importantly — your config:

# Version (Docker)
docker exec <gitea> gitea --version

# The two keys that matter — in app.ini or as GITEA__security__* env vars
grep -Ei 'REVERSE_PROXY_TRUSTED_PROXIES|ENABLE_REVERSE_PROXY_AUTHENTICATION' \
  /data/gitea/conf/app.ini

You are exposed if ENABLE_REVERSE_PROXY_AUTHENTICATION = true and REVERSE_PROXY_TRUSTED_PROXIES is * (or unset on a vulnerable Docker build) and Gitea is reachable from anywhere but the proxy. If reverse-proxy auth is off, this specific bug does not apply — but patch anyway for the other eight CVEs in the release.

2. Patch — exact fixed version. Upgrade to 1.26.4 or later. Do not stop at 1.26.3: it fixed the CVE but shipped a regression affecting repository code pages, corrected in 1.26.4 (see the Gitea release notes).

3. Can't patch now? — compensating controls.

  • Set the allowlist to your actual proxy, not the world:
    [security]
    REVERSE_PROXY_LIMIT = 1
    REVERSE_PROXY_TRUSTED_PROXIES = 127.0.0.1,172.18.0.1   # your Docker bridge / proxy IP
    
  • If you don't use SSO header auth at all, set ENABLE_REVERSE_PROXY_AUTHENTICATION = false.
  • Ensure Gitea's HTTP port is only reachable from the reverse proxy — never bind it to 0.0.0.0 on a public interface. The whole trust model assumes the proxy is the only client.

4. Hunt for compromise (map to MITRE ATT&CK):

  • T1190 / T1606 (Exploit Public-Facing App / Forge Web Credentials): grep the reverse-proxy and Gitea access logs for X-WEBAUTH-USER headers arriving from any source IP that is not your proxy. Any such request from the internet is exploitation.
  • T1078 (Valid Accounts): review admin and privileged logins with no corresponding SSO/session establishment. Check Site Administration → Users for accounts or admin grants you did not create.
  • T1213.003 / T1552 (Code Repositories / Credentials in Files): look for anomalous clone/pull spikes, and audit every repo the exposed accounts could read for committed secrets.
  • T1195 / persistence: check for newly added SSH keys, deploy keys, access tokens, OAuth2 applications, and webhook changes (the latter also chains into the CVE-2026-22874 SSRF). Review recent pushes and CI/Actions workflow edits for injected steps.

5. Eradicate + verify. After patching and fixing the config: delete any rogue accounts, keys, tokens and webhooks; then rotate everything the server could read — committed secrets, CI/CD variables, deploy tokens, deploy keys, registry and cloud credentials, user PATs. Rebuild and re-scan pipelines that ran during the exposure window for injected steps. Confirm clean after the patch, not before — a pre-patch scan certifies nothing.

Where continuous validation changes the outcome

Everything above hinges on one uncomfortable fact: you cannot tell from the version string whether your Gitea accepts a spoofed X-WEBAUTH-USER header. It depends on your config, your network placement, and whether an old wildcard is still lurking in app.ini. That is a validation problem, and it is exactly the gap a generative pentest is built to close.

Zero Hunt's 10-agent AI swarm doesn't read your banner and guess. The Web and Credential agents write a per-target probe against your running instance — send the crafted header, from an untrusted source position, and observe whether the server authenticates the request. Every exploit is generated locally for the environment in front of it, not pulled from a PoC database, and each attempt is backtested in the AI Gym against corpora like Vulhub and NYU CTF Bench before it ever runs in production, then executed in an ephemeral, gVisor-hardened Docker sandbox so the test never touches the host. When the header is accepted, the Post-Exploit and Pivot agents do what a real intruder would — enumerate the readable repos, surface the committed secrets and deploy keys, and map the supply-chain blast radius — and sign each finding with ECDSA for an audit trail that proves the instance was actually exploitable, not merely "an old version." A change-triggered campaign re-runs the moment a new Gitea appears on your perimeter, closing that 13-day window before a scanner finds it first.

And because the exploitation itself is a header injection followed by bulk repo and secret egress, it also leaves a wire signature: Zero Hunt's on-prem AI Traffic Analysis model — four inference heads at 2.7+ Gbit/s on the appliance GPU — flags the anomalous authentication pattern and the sudden outbound clone volume from a host that normally only serves internal traffic, while the theft is happening, not in the next morning's log review. Patch the default; then prove the door is actually shut, and watch the wire in case it wasn't.