flawopen.com/ssrf/Go
How http.Get in Go allows internal network traversal, and how to build safe http.Client transports with DialContext.
Представьте, что вы отправляете курьера забрать посылку в общественном магазине, но указываете адрес запертого сейфа в кабинете директора. Поскольку у курьера есть служебный пропуск, он заходит в кабинет, открывает сейф и приносит секреты компании.
Web Application SecurityCWE-918.CWE-918CWE-918): Standard Common Weakness Enumeration classification for ssrf-go.Defense-in-DepthСервис принимает внешний URL для загрузки аватарок, вебхуков или генерации документов.
Атакующий передает адрес сервиса метаданных облака (http://169.254.169.254/latest/meta-data/) или локальный адрес loopback.
Сервер отправляет HTTP-запрос изнутри защищенного периметра без проверки принадлежности IP к приватным подсетям.
Сервис метаданных доверяет запросу с локального интерфейса и отдает временные IAM-токены и секреты кластера.
// VULNERABLE: Default http.Get allows internal network calls
package main
import (
"io"
"net/http"
)
func proxyHandler(w http.ResponseWriter, r *http.Request) {
targetURL := r.URL.Query().Get("url")
// Attacker passes: http://169.254.169.254/latest/meta-data/
resp, err := http.Get(targetURL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
io.Copy(w, resp.Body)
}
// HARDENED: Custom DialContext checking net.IP.IsPrivate and IsLoopback
package main
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"time"
)
func isSafeIP(ip net.IP) bool {
return !ip.IsLoopback() &&
!ip.IsPrivate() &&
!ip.IsLinkLocalUnicast() &&
!ip.IsLinkLocalMulticast() &&
!ip.IsUnspecified()
}
func newSafeHTTPClient() *http.Client {
dialer := &net.Dialer{
Timeout: 3 * time.Second,
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
// Resolve all IP addresses
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if !isSafeIP(ip) {
return nil, errors.New("SSRF Blocked: connection to private/internal IP rejected")
}
}
// Connect to the first validated IP
targetAddr := net.JoinHostPort(ips[0].String(), port)
return dialer.DialContext(ctx, network, targetAddr)
},
}
return &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return errors.New("redirects are disabled to prevent SSRF bypass")
},
}
}
func proxyHandler(w http.ResponseWriter, r *http.Request) {
targetURL := r.URL.Query().Get("url")
parsed, err := url.Parse(targetURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
http.Error(w, "Invalid URL scheme", http.StatusBadRequest)
return
}
client := newSafeHTTPClient()
resp, err := client.Get(targetURL)
if err != nil {
http.Error(w, "Request failed: "+err.Error(), http.StatusForbidden)
return
}
defer resp.Body.Close()
io.Copy(w, resp.Body)
}
ip.IsPrivate() && !ip.IsLoopback().