Skip to content

VNX-GO-006: Go server-side request forgery

Reports outbound HTTP requests, reverse proxies, net/tls dials and websocket dials whose URL or address starts with a request value (query, form, path parameter header, or a decoded or unmarshalled JSON body field), directly, via url.Parse or a variable, or behind a prefix that does not pin the host. Fixed hosts, configured destinations, allowlisted values and guarded dialers are not reported; a HasPrefix test against a bare scheme is normalisation, not a check.

Updated 02 Nov 2026Checked against cli@de9b52a
Field Value
Rule ID VNX-GO-006
Severity High
SARIF level error
Kind sast
Languages Go
CWE CWE-918
CAPEC CAPEC-664
ATT&CK T1190
CWSS vector CWSS:1.0/TI:H/AP:A/AL:A/IC:N/FC:LT/RP:N/RL:A/AV:I/AS:N/IN:A/SC:A/BI:H/DI:H/EX:H/EC:N/P:H
Tags ssrf web cloud
Source internal/sast/rules/vnx-go-006.rego

Overview

This rule detects Go HTTP handlers that pass r.FormValue() or r.URL.Query() values directly to http.Get, http.Post, or http.NewRequest without validating the destination. Server-side request forgery (SSRF) allows an attacker to make your server perform HTTP requests to destinations of their choosing, typically internal services that are not exposed to the internet. This maps to CWE-918: Server-Side Request Forgery (SSRF).

Why This Matters

In cloud environments, SSRF is a critical vulnerability class because virtually every major cloud platform exposes an instance metadata service at a well-known IP address (169.254.169.254 on AWS, GCP, and Azure). By making your server fetch that URL, an attacker can retrieve IAM credentials, account identifiers, and bootstrap tokens, granting them access to your entire cloud infrastructure without any other foothold. Beyond the metadata service, SSRF enables attackers to probe and interact with internal microservices, databases, and management interfaces that are firewalled from the public internet. It can also be used to bypass IP-based allowlists, as the requests originate from your trusted server.

What Gets Flagged

The rule fires when http.Get/Head/Post/PostForm, http.NewRequest(WithContext), a client’s Get/Post, resty R().Get, httputil.NewSingleHostReverseProxy or net.Dial/tls.Dial receives a destination that starts with a request value: a query, form, header or path parameter, or a field of a struct decoded from the request body. The value may be inline, parsed with url.Parse, held in a variable assigned a few lines up, or follow a prefix that leaves the host open ("http://"+v, fmt.Sprintf("http://%s/...", v)). It does not fire for a fixed scheme and host followed by a path ("https://api.github.com/repos/"+v), configured destinations, a value checked against an allowlist or host comparison, a client whose dialer rejects private addresses, or cache and database Get calls.

// FLAGGED: user-supplied URL fetched server-side
func fetchHandler(w http.ResponseWriter, r *http.Request) {
    target := r.FormValue("url")
    resp, err := http.Get(target)
    // Attacker sends: url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
    // Server fetches cloud credentials and returns them
    if err == nil {
        io.Copy(w, resp.Body)
    }
}
// FLAGGED: user-supplied URL in http.NewRequest
func proxyHandler(w http.ResponseWriter, r *http.Request) {
    dest := r.URL.Query().Get("dest")
    req, _ := http.NewRequest("GET", dest, nil)
    client := &http.Client{}
    resp, _ := client.Do(req)
    io.Copy(w, resp.Body)
}

Remediation

  1. Validate the destination URL against an allowlist of permitted hosts. Parse the URL and check that the host matches an explicit list of allowed external services. Reject anything else, including private IP ranges.
import (
    "fmt"
    "net"
    "net/http"
    "net/url"
)

var allowedHosts = map[string]bool{
    "api.trusted-partner.com": true,
    "cdn.example.com":         true,
}

func isAllowedURL(rawURL string) error {
    u, err := url.Parse(rawURL)
    if err != nil {
        return fmt.Errorf("invalid URL")
    }
    if u.Scheme != "https" {
        return fmt.Errorf("only HTTPS is permitted")
    }
    host := u.Hostname()
    if !allowedHosts[host] {
        return fmt.Errorf("host %q is not in the allowlist", host)
    }
    return nil
}

// SAFE: allowlist validation before outbound request
func fetchHandler(w http.ResponseWriter, r *http.Request) {
    target := r.FormValue("url")
    if err := isAllowedURL(target); err != nil {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }
    resp, err := http.Get(target)
    if err != nil {
        http.Error(w, "upstream error", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()
    io.Copy(w, resp.Body)
}
  1. Block private and loopback IP ranges. After DNS resolution, verify that the resolved IP address is not in a private or reserved range (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1). Use a custom http.Transport with a dial hook.
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
    host, port, _ := net.SplitHostPort(addr)
    ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
    if err != nil {
        return nil, err
    }
    for _, ip := range ips {
        if isPrivateIP(ip.IP) {
            return nil, fmt.Errorf("request to private IP %s is forbidden", ip.IP)
        }
    }
    return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(host, port))
}
  1. Disable redirects or validate them. SSRF attacks sometimes chain redirects to reach internal resources. Configure your http.Client to disallow redirects or re-validate the destination on each redirect.

References

Suppressing this rule

Put a nosec comment on the line, or on line 1 to cover the whole file. Use the comment marker of the file’s language (#, //, -- or ;):

# nosec vnx-go-006
// nosec vnx-go-006

Or record an ignore rule: vulnetix ignore add --rule vnx-go-006 --reason "...". See Suppressing findings.

Edit this pageSources: cli/internal/sast/rules/vnx-go-006.rego, cli/internal/sast/docs/vnx-go-006.md
esc
↑ ↓ move↵ openSearches in your browser. A key like autonomy.max or a code like threshold_required jumps straight to it.