Overview
SSRF is a real-world risk where an attacker can cause your server to fetch arbitrary URLs. In Go applications using Gin, endpoints that proxy or fetch remote resources based on client input can expose internal services, cloud metadata endpoints, or other protected resources. This can enable an attacker to access internal networks, enumerate services, or exfiltrate data via your server as an intermediary. In cloud or container environments, metadata endpoints and service meshes are often reachable from workloads, making SSRF a practical concern if inputs are not properly validated and restricted.
This vulnerability typically manifests in Gin apps when a handler reads a user-supplied URL (for example, via query parameters or JSON) and uses net/http (http.Get or a configured Client) to fetch it. Default redirect behavior and DNS resolution can allow attackers to pivot toward internal hosts or private networks. Without strict input validation, IP allowlisting, or host-based checks, your server can act as an unintended outbound proxy.
Mitigation requires defense-in-depth: validate and normalize inputs, restrict schemes to http/https, and block private/internal IPs. Implement an allowlist or IP range checks, disable or tightly control redirects with CheckRedirect, and use a dedicated outbound http.Client with sensible timeouts and a Transport that blocks risky destinations. Instrument the fetch path with proper logging and observability to detect abuse.
Finally, consider architecture changes where outbound fetches are routed through a tightly controlled proxy or a dedicated service with strict allowlists rather than exposing URL-fetching capabilities in general-purpose handlers. Regular security testing and code reviews should specifically target SSRF patterns in Gin handlers.
Code Fix Example
Go (Gin) API Security Remediation
package main
import (
"context"
"errors"
"fmt"
"io/ioutil"
"net"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// Vulnerable endpoint: directly uses user-supplied URL
r.GET("/vuln-fetch", func(c *gin.Context) {
target := c.Query("target")
if target == "" {
c.String(400, "missing target")
return
}
resp, err := http.Get(target)
if err != nil {
c.String(500, err.Error())
return
}
defer resp.Body.Close()
b, _ := ioutil.ReadAll(resp.Body)
c.String(resp.StatusCode, string(b))
})
// Fixed endpoint: uses a safe fetcher with validation
r.GET("/fixed-fetch", func(c *gin.Context) {
target := c.Query("target")
body, err := safeFetch(target)
if err != nil {
c.String(400, err.Error())
return
}
c.String(http.StatusOK, body)
})
r.Run(":8080")
}
func safeFetch(rawURL string) (string, error) {
// Basic validation
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
host := u.Hostname()
ips, err := net.LookupIP(host)
if err == nil {
for _, ip := range ips {
if isPrivateIP(ip) {
return "", errors.New("private IPs are not allowed")
}
}
}
// Safe HTTP client: blocks redirects and private IPs via DialContext
dialer := &net.Dialer{ Timeout: 5 * time.Second }
tr := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, _ := net.SplitHostPort(addr)
ips, err := net.LookupIP(host)
if err == nil {
for _, ip := range ips {
if isPrivateIP(ip) {
return nil, fmt.Errorf("blocked internal host: %s", host)
}
}
}
return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
}
client := &http.Client{
Transport: tr,
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) > 0 {
// Do not follow redirects beyond the initial request
return http.ErrUseLastResponse
}
return nil
},
}
resp, err := client.Get(rawURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(b), nil
}
func isPrivateIP(ip net.IP) bool {
blocks := []*net.IPNet{}
for _, cidr := range []string{
"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",
} {
_, block, _ := net.ParseCIDR(cidr)
blocks = append(blocks, block)
}
for _, b := range blocks {
if b.Contains(ip) {
return true
}
}
return false
}