Research/gitlab-gitea-import-ssrf
0-dayNo CVEHighPublic

GitLab, Gitea HTTPS import drops the UrlBlocker pin

GitLab 19.4.1 Gitea HTTPS import throws away the IP-pinned URI and reconnects to the original hostname. Rebind that name to loopback and Faraday GETs it. Repo-list JSON comes back on status.json. HTTP imports keep the pin. No CVE yet.

Name
GitLab, Gitea HTTPS import drops the UrlBlocker pin
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
GitLab
Affected
GitLab CE through 19.4.1-ce.0 (v19.4.1-ee tree 26212baacadb); unpublished. Signed-in user, Gitea import, personal namespace. Lab image gitlab/gitlab-ce:19.4.1-ce.0.
Published
01 Oct 2026
Updated
01 Oct 2026
Tags
0-day, gitlab, ssrf, dns-rebind, cwe-918, cwe-367, authenticated

https throws the pin away

The proof of concept is on GitHub: abraxas/gitlab-gitea-import-ssrf (loopback client; @abraxas_null). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is GitLab CE 19.4.1 (26212baacadb), GitLab Inc. No CVE yet. CWE-918 / CWE-367. 7.7 High (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:N). Authenticated Gitea import. Not unauthenticated. Not host RCE.

Same product, siblings: Gitaly fetch omits resolved_address, webhook 0.0.0.0/8.

What an attacker can do

Set Gitea Host URL to https://<name-they-control>/. UrlBlocker allows the first public A-record. HTTPS client_options keeps that hostname. Rebind to 127.0.0.1. Faraday GETs the catcher on the GitLab worker loopback. GET /import/gitea/status.json provider_repos is whatever the catcher returned.

On all-in-one omnibus that is Workhorse, Puma, exporters. HTTP Host URL stays on the pinned IP. Direct https://127.0.0.1 is blocked.

How I found it

I read the 23 September 2026 19.4.1 / 19.3.3 / 19.2.7 patch notes first, then the v19.4.1-ee tree. CI regex RCE, MR diff XSS, Workhorse file.path are closed on this pin. Import SSRF was not finished.

Import::GiteaController#client_options is the leftover. HTTP uses the IP-pinned verified_url. HTTPS uses provider_url, the name the user typed. LegacyGithubImport::Client builds Octokit api_endpoint from that host. Gitlab::Octokit::UrlValidation calls validate! then @app.call(env) with no rewrite.

I stood up stock gitlab/gitlab-ce:19.4.1-ce.0. Local-requests off. Lab CA in /etc/gitlab/trusted-certs because Faraday ssl.verify is true. DNS for gitea.lab: lookups 1-2 return 1.1.1.1, later lookups return 127.0.0.1. Catcher shares the GitLab netns on :443. status.json returned provider_repos named GITLAB-GITEA-IMPORT-SSRF-WITNESS. Catcher log: GET /api/v1/user/repos peer=127.0.0.1 host=gitea.lab.

Direct https://127.0.0.1/: "Specified URL cannot be used". HTTP http://gitea.lab/ stayed on the pin and did not return the witness.

Wrong turns already recorded: /users/sign_in with Accept: application/json is 404 (HTML is 200); seed password CveLabRoot9! rejected because it contains root; CA bind-mount ro is EROFS after reconfigure links the file; first-two-then-loopback on lookup 2 makes UrlValidation block before Faraday connects. Pairs mode (odd public, even loopback) is what landed. A reverse shell. Theatre. The oracle is the witness in status.json plus the catcher peer.

https keeps the name

Plain text
def client_options
  verified_url, provider_hostname = verify_blocked_uri
  {
    host: verified_url.scheme == 'https' ? provider_url : verified_url.to_s,
    api_version: 'v1',
    hostname: provider_hostname
  }
end

Use verified_url.to_s for https the same way HTTP already does. Rewrite env[:url] in Octokit UrlValidation to the pinned IP.

I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.