Skip to content

SSRF via hostname-prefix bypass in NVD/CVE reference URL validation (vulnfeeds/conversion/versions.go) #5833

Description

@Sumit-2004

Component: vulnfeeds/conversion/versions.go (NVD/CVE → OSV conversion), sink in vulnfeeds/git/repository.go

Summary

The GitLab-family host check in repo() uses a hostname prefix match instead of proper domain validation:

strings.HasPrefix(parsedURL.Hostname(), "gitlab.")

This appears at four call sites (approx. lines 213, 327, 356, 393). A related check for the generic git prefix has the same flaw:

var supportedHostPrefixes = []string{"git", "gitlab"}
...
if slices.Contains(supportedHosts, parsedURL.Hostname()) ||
   slices.Contains(supportedHostPrefixes, strings.Split(parsedURL.Hostname(), ".")[0]) {

Neither validates the registrable domain. strings.HasPrefix(host, "gitlab.") matches gitlab.com, but also matches any attacker-controlled hostname starting with the literal string gitlab. — e.g. gitlab.attacker-controlled.example. The strings.Split(host, ".")[0] check has the identical flaw for git.attacker-controlled.example.

Impact

The value returned by repo() is passed to git.ValidRepo / git.NormalizeRepoTags (vulnfeeds/git/repository.go), which make a real outbound git-protocol connection via go-git (RemoteRepoRefsWithRetry). This path is reached from the live NVD CVE ingestion pipeline (vulnfeeds/cmd/converters/cve/nvd-cve-osv/main.gonvd.FindRepos), which processes reference URLs sourced from public CVE records — fields populated by the originating CNA, with no per-record human review before the network call. This gives an attacker who can place a URL in one CVE reference field (a low bar, given routine PoC/advisory reference links) a way to trigger outbound requests from the ingestion worker to a host of their choosing, by registering any domain starting with gitlab. or git..

Proof of Concept

package main

import (
	"fmt"
	"github.com/google/osv/vulnfeeds/conversion"
)

func main() {
	attackerURLs := []string{
		"https://gitlab.attacker-controlled.example/x/y/commit/deadbeef",
		"https://git.attacker-controlled.example/x/y",
	}
	for _, u := range attackerURLs {
		repo, err := conversion.Repo(u)
		if err != nil {
			fmt.Printf("REJECTED: %q -> error: %v\n", u, err)
			continue
		}
		fmt.Printf("ACCEPTED (BUG): %q -> trusted repo URL: %q\n", u, repo)
	}
}

Both non-GitHub/GitLab/Bitbucket URLs are accepted as "supported" and returned as trusted-looking repo URLs, which are then used for real network fetches.

Suggested fix

Replace the prefix/first-label checks with exact or suffix-anchored domain validation:

func isGitLabHost(host string) bool {
    host = strings.ToLower(host)
    return host == "gitlab.com" || strings.HasSuffix(host, ".gitlab.com") ||
        host == "gitlab.freedesktop.org" || strings.HasSuffix(host, ".gitlab.freedesktop.org")
    // ... explicit list of every actually-supported GitLab instance
}

Similarly replace supportedHostPrefixes / strings.Split(hostname, ".")[0] with an explicit, exact-match list of intended git.* hosts — several are already enumerated individually elsewhere in the file (git.netfilter.org, git.ffmpeg.org, git.postgresql.org, git.kernel.org, etc.).

Reported to Google's OSS VRP; posting here per their suggestion, since the tier classification means it isn't monetarily rewarded but should still be fixed.


Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions