Forgejo, authenticated hostmatcher 0.0.0.1 SSRF
Forgejo 16.0.5 default webhook allow-list still treats RFC 6890 0.0.0.0/8 as public. A signed-in user can webhook http://0.0.0.1 and read the HTTP body from hook history. 127.0.0.1 on the same port is denied. No CVE yet.
- Name
- Forgejo, authenticated hostmatcher 0.0.0.1 SSRF
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Forgejo
- Affected
- Forgejo through v16.0.5 (8d24b7d); unpublished leftover of the v16.0.2 CGNAT add. Needs a user who can create a repo webhook.
- Published
- 12 Oct 2026
- Updated
- 12 Oct 2026
- Tags
- 0-day, forgejo, ssrf, webhook, cwe-918, authenticated
0.0.0.1 is not loopback
I am @abraxas_null. The proof of concept is on GitHub: abraxas/forgejo-hostmatcher-0001-ssrf (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, oracle.py, poc.py. Authorized lab only. It talks to loopback.
This is Forgejo v16.0.5 (8d24b7d52df327118cd776cf461d34e52e0ec1ce). No CVE yet. CWE-918. 7.7 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N). Remote SSRF. Authenticated.
Default [webhook] ALLOWED_HOST_LIST is empty, so delivery uses builtin external: Go IsGlobalUnicast() && !isPrivate(). v16.0.2 already put CGNAT 100.64.0.0/10 in private. RFC 6890 0.0.0.0/8 never made that list. 127.0.0.1 is denied. 0.0.0.1 is not.
Linux will deliver 0.0.0.1 to a local socket if that address is on lo. Same listener, two names. 127.0.0.1 denied. 0.0.0.1 delivered. That is the whole trick.
Why Forgejo
I had just finished the Gitea 1.27.3 hostmatcher leftover (gitea-hostmatcher-0000-ssrf). Forgejo never grew Gitea's reservedIPNets. It special-cased CGNAT and stopped. That is a leftover class worth re-checking, not a pack worth copying.
I only wanted Critical or High. Local is in-scope.
Pinning v16.0.5
Latest GA: v16.0.5, commit 8d24b7d, 2026-09-17. Image codeberg.org/forgejo/forgejo:16.0.5.
The skip list is half the hunt
v16.0.2 security-announcements#57 added RFC 6598 100.64.0.0/10 to builtin private. Do not re-file that. Also closed on this pin: 127.0.0.0/8 as loopback, 169.254.0.0/16 including IPv4 IMDS (not global unicast), RFC1918, unspecified 0.0.0.0, IPv4-mapped loopback ::ffff:127.0.0.1. Decimal / 127.1 / octal aliases of 127.0.0.1 fail net.ParseIP and land as 127.0.0.1 after resolve.
Gitea CVE-2026-22874's rest of the reserved list (Azure WireServer, NAT64, 6to4, Teredo, TEST-NET) is not in Forgejo. Env-gated. Not claimed High on a default local pin.
Git HTTP redirect SSRF is closed here (http.followRedirects=false). CVE-2026-82556 LookupIP is published Medium. The Gitea keys IDOR class is still open and is a different post.
Twenty surfaces, one missing CIDR
Templates, git-apply, Actions, packages, migrate, hooks, tokens, federation, OpenID, attachments, restricted users, hostmatcher, LFS, keys, restore, OAuth, markup, internal API, secrets, visibility.
Webhook delivery stores the HTTP body in hook history. That is a better oracle than git clone, which is mostly "did the fetch work." Full-response SSRF.
The missing CIDR
case MatchBuiltinExternal:
if ip.IsGlobalUnicast() && !isPrivate(ip) {
return true
}
// ...
func isPrivate(ip net.IP) bool {
return ip.IsPrivate() || cgNAT.Contains(ip)
}Go IsGlobalUnicast (1.26 in go.mod) is: not unspecified, not loopback, not multicast, not link-local, not IPv4 broadcast. 0.0.0.1 passes. isPrivate is RFC1918/4193 plus CGNAT only. RFC 6890 "this network" 0.0.0.0/8 is none of those.
A sibling hunt claimed Go 1.26 closed this. I checked net.IP.IsGlobalUnicast and the in-tree tests. They use net.IPv4zero as false for external. They never test 0.0.0.1.
Default webhook client, empty ALLOWED_HOST_LIST:
if allowedHostListValue == "" {
allowedHostListValue = hostmatcher.MatchBuiltinExternal
}Delivery io.ReadAlls the body into t.ResponseInfo.Body. The UI prints it. Create-time URL check is scheme+port only. No hostmatcher at create.
IPv4-mapped [::ffff:0.0.0.1] is the same class (To4() then checkIP).
Wrong turns, in the order I made them
Re-filing CGNAT. v16.0.2 closed 100.64.0.0/10. This is 0.0.0.0/8.
Believing Go 1.26 closed 0.0.0.1. IsGlobalUnicast still returns true. Tests never cover it.
Treating webhook create HTTP 201 as the oracle. History is the oracle. 201 means the hook row exists.
127.0.0.1 delivery succeeding. That would be a different bug. The lab requires it denied.
Hitting /api/internal with X-Forgejo-Internal-Auth. The outbound webhook does not pick up that header.
A reverse shell. Theatre. The witness is FORGEJO-HOSTMATCHER-0001-WITNESS in hook history.
What an attacker can do
Register on a default install, create a repo, point a webhook at http://0.0.0.1:<local-port>/, test it, and read the response body from hook history. That is loopback HTTP that 127.0.0.1 on the same port cannot reach: anything listening in Forgejo's netns that bound more broadly than loopback, including services the operator thought were "localhost only" because they blocked 127.0.0.0/8.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
Dial this-network 0.0.0.1 past the default webhook allow-list |
POST /api/v1/repos/{owner}/{repo}/hooks then POST .../hooks/{id}/tests with URL http://0.0.0.1:8080/ssrf |
Recon (outbound HTTP from the Forgejo host) | Lab-proven. History stored HTTP 200 body FORGEJO-HOSTMATCHER-0001-WITNESS |
| Read the full HTTP response of whatever answered | GET /{owner}/{repo}/settings/hooks/{id} prints ResponseInfo.Body |
Recon (full-response SSRF) | Lab-proven. Same witness in the history page |
Same class via IPv4-mapped [::ffff:0.0.0.1] |
Same matcher To4() then checkIP |
Recon | Inferred from hostmatcher. Lab used 0.0.0.1 |
| Reach a listener that bound broader than loopback in the Forgejo netns | Linux connect() to 0.0.0.0/8 is this-host once 0.0.0.1 is on lo |
Recon | Lab-proven against the sidecar oracle. Not a claim about a specific cloud IMDS |
Closed on the same default matcher: 127.0.0.1 (lab-proven deny in history), 127.0.0.0/8, RFC1918, CGNAT 100.64.0.0/10, link-local 169.254.0.0/16 including IPv4 IMDS, unspecified 0.0.0.0. The outbound webhook does not gain X-Forgejo-Internal-Auth / INTERNAL_TOKEN. This is not RCE by itself.
The lab
Source of truth is lab/. Image codeberg.org/forgejo/forgejo:16.0.5. Host bind 127.0.0.1:19320. Oracle sidecar shares Forgejo's netns on port 8080. run.sh adds 0.0.0.1/8 on lo with NET_ADMIN. Negative control first.
git clone https://github.com/abraxas/forgejo-hostmatcher-0001-ssrf
cd forgejo-hostmatcher-0001-ssrf
python3 forgejo-hostmatcher-0001-ssrf-Abraxas-Labs.pyLast lab run, trimmed:
negative-control 127.0.0.1 blocked
poll id=2 witness=True
SUCCESS forgejo-hostmatcher-0001-ssrf how=live pin=v16.0.5 0.0.0.1:8080 HTTP 200 FORGEJO-HOSTMATCHER-0001-WITNESSI am not printing a webhook JSON you can paste at someone else's /hooks. The missing CIDR is the useful part.
The fix
Treat RFC 6890 0.0.0.0/8 as not-external. Do not rely on IsGlobalUnicast. 0.0.0.1 must be denied the same way 127.0.0.1 is. Gitea's reservedIPNets is one shape of that list.
Same tag leftovers: keys IDOR, OpenID discovery SSRF, restore-repo file open.
References
- Proof of concept: abraxas/forgejo-hostmatcher-0001-ssrf · forgejo-hostmatcher-0001-ssrf-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · oracle.py · poc.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-918
- Tree: forgejo/forgejo v16.0.5 (
8d24b7d52df327118cd776cf461d34e52e0ec1ce) ·hostmatcher.go - Nearby patched: v16.0.2 CGNAT
- Same class on Gitea: gitea-hostmatcher-0000-ssrf
- RFC 6890