
Forgejo 16.0.5 - High - Remote - SSRF - hostmatcher allows 0.0.0.1 as a public IP
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · forgejo-hostmatcher-0001-ssrf
Class: SSRF Reach: Remote
Forgejo 16.0.5 (8d24b7d) - Forgejo
Default [webhook] ALLOWED_HOST_LIST is empty, so services/webhook/deliver.go builds the client with builtin external. That predicate is Go IsGlobalUnicast() && !isPrivate(). isPrivate is RFC1918/4193 plus the v16.0.2 CGNAT add. RFC 6890 0.0.0.0/8 is none of those. 127.0.0.1 is loopback and denied. 0.0.0.1 is allowed.
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.
| ID | no CVE yet |
| Class | SSRF |
| Reach | Remote (authenticated; any user who can create a repo webhook) |
| CWE | CWE-918 |
| CVSS | High: 7.7 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N |
| Product | Forgejo |
| Affected | through v16.0.5 (8d24b7d); leftover of the v16.0.2 CGNAT add |
| Auth | signed-in user who can create a repo webhook |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 only |
Register on a default install, create a repo, set a forgejo webhook to http://0.0.0.1:<port>/..., test it, and read the response body.
| 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.
This is not the v16.0.2 CGNAT fix. Forgejo never grew a Gitea-style reserved-net list. Same class as gitea-hostmatcher-0000-ssrf.
v16.0.2 put CGNAT in private. Patches that add a deny-list are a gift: you read the list, then you ask what IANA still has that the list forgot. A sibling hunt claimed Go 1.26 IsGlobalUnicast closed 0.0.0.1. I checked the predicate and the in-tree tests. They never cover 0.0.0.1.
Wrong turns already recorded: treating webhook create HTTP 201 as the oracle (history is the oracle); 127.0.0.1 delivery succeeding (that would be a different bug - lab requires it denied); hitting /api/internal with X-Forgejo-Internal-Auth; a reverse shell. Theatre.
python3 forgejo-hostmatcher-0001-ssrf-Abraxas-Labs.py
or:
cd lab
./run.sh
Target only http://127.0.0.1:19320. Image codeberg.org/forgejo/forgejo:16.0.5. Oracle sidecar shares the Forgejo netns on port 8080.
create-hook name=neg-loopback ...
negative-control 127.0.0.1 blocked
create-hook name=hit-0001 ...
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-WITNESS
Treat RFC 6890 0.0.0.0/8 as not-external (a reserved-net list, not IsGlobalUnicast). 0.0.0.1 must be denied the same way 127.0.0.1 is.
hostmatcher.goGNU Affero GPL v3.0. See LICENSE. Loopback lab only. No warranty.