Overview
SSRF vulnerabilities in Go Gin apps occur when a server uses a user-supplied URL to fetch resources without validating the destination. An attacker can instruct the app to contact internal services, cloud metadata endpoints, or other protected resources, potentially exfiltrating data, consuming internal bandwidth, or bypassing access controls. In cloud environments this may enable access to AWS metadata or other services that reside on an internal network.
In Gin, handlers frequently accept user input via query parameters or JSON fields and then use that input to perform outbound HTTP requests. Without strict checks, requests can be steered to private IPs or to internal dashboards, enabling port scans, data leakage, or remote code execution in adverse scenarios.
This class of vulnerability manifests from lack of URL validation, insecure default HTTP clients, and the absence of an allowlist or IP checks. SSRF risks are amplified by DNS rebinding, redirects, and proxies that may be configured in the environment.
Remediation strategies include: validating and sanitizing the URL (scheme http(s) only, host checks); implementing a strict allowlist of outbound destinations; resolving host IPs and rejecting private or loopback addresses; using a purpose-built HTTP client with timeouts and no automatic redirects; and adding tests and monitoring to catch SSRF patterns.
Code Fix Example
Go (Gin) API Security Remediation
package main
import (
"io/ioutil"
"net"
"net/http"
"net/url"
"time"
"crypto/tls"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/vulnerable-fetch", vulnerableFetch)
r.GET("/safe-fetch", safeFetch)
r.Run(":8080")
}
func vulnerableFetch(c *gin.Context) {
raw := c.Query("url")
if raw == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
return
}
resp, err := http.Get(raw)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), body)
}
func safeFetch(c *gin.Context) {
raw := c.Query("url")
if raw == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
return
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
return
}
if !isSafeHost(u) {
c.JSON(http.StatusBadRequest, gin.H{"error": "url not allowed"})
return
}
transport := &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}
client := &http.Client{Timeout: 5 * time.Second, Transport: transport}
resp, err := client.Get(u.String())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), body)
}
func isSafeHost(u *url.URL) bool {
allowed := map[string]bool{"example.com": true, "api.example.org": true}
host := u.Hostname()
if allowed[host] {
return true
}
ips, err := net.LookupIP(host)
if err != nil {
return false
}
for _, ip := range ips {
if isPrivateIP(ip) {
return false
}
}
return true
}
func isPrivateIP(ip net.IP) bool {
if ip4 := ip.To4(); ip4 != nil {
b := ip4
if b[0] == 10 {
return true
}
if b[0] == 172 && b[1] >= 16 && b[1] <= 31 {
return true
}
if b[0] == 192 && b[1] == 168 {
return true
}
return false
}
if ip[0] == 0xfc || ip[0] == 0xfd {
return true
}
if ip[0] == 0xfe && (ip[1]&0xc0) == 0x80 {
return true
}
return false
}