142 lines
5.5 KiB
Go
142 lines
5.5 KiB
Go
|
|
package httpx
|
||
|
|
|
||
|
|
import (
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestIsBlockedIP покрывает адреса, по которым сервер не должен ходить сам
|
||
|
|
// (AGENT.md 9.2, 9.7): внутренние сети, метаданные облака, multicast.
|
||
|
|
func TestIsBlockedIP(t *testing.T) {
|
||
|
|
blocked := []string{
|
||
|
|
"127.0.0.1", "127.9.9.9", "::1",
|
||
|
|
"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1",
|
||
|
|
"169.254.169.254", "fe80::1",
|
||
|
|
"0.0.0.0", "::", "0.1.2.3",
|
||
|
|
"224.0.0.1", "ff02::1",
|
||
|
|
"100.64.0.1", "100.127.255.255",
|
||
|
|
"192.0.0.1",
|
||
|
|
"fc00::1", "fd12:3456::1",
|
||
|
|
"::ffff:10.0.0.1", "::ffff:127.0.0.1",
|
||
|
|
"2001:db8::1",
|
||
|
|
}
|
||
|
|
for _, raw := range blocked {
|
||
|
|
ip := net.ParseIP(raw)
|
||
|
|
if ip == nil {
|
||
|
|
t.Fatalf("не разобран адрес %q", raw)
|
||
|
|
}
|
||
|
|
if !IsBlockedIP(ip) {
|
||
|
|
t.Errorf("IsBlockedIP(%s) = false, ожидалось true", raw)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
allowed := []string{"8.8.8.8", "1.1.1.1", "138.16.226.159", "2606:4700::1111", "100.63.255.255"}
|
||
|
|
for _, raw := range allowed {
|
||
|
|
ip := net.ParseIP(raw)
|
||
|
|
if ip == nil {
|
||
|
|
t.Fatalf("не разобран адрес %q", raw)
|
||
|
|
}
|
||
|
|
if IsBlockedIP(ip) {
|
||
|
|
t.Errorf("IsBlockedIP(%s) = true, ожидалось false", raw)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if !IsBlockedIP(nil) {
|
||
|
|
t.Error("IsBlockedIP(nil) = false, ожидалось true")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestValidatePublicURL(t *testing.T) {
|
||
|
|
cases := []struct {
|
||
|
|
name string
|
||
|
|
raw string
|
||
|
|
allowHTTP bool
|
||
|
|
wantErr bool
|
||
|
|
}{
|
||
|
|
{name: "https", raw: "https://example.com/page?a=1"},
|
||
|
|
{name: "http запрещён", raw: "http://example.com/", wantErr: true},
|
||
|
|
{name: "http разрешён флагом", raw: "http://example.com/", allowHTTP: true},
|
||
|
|
{name: "file", raw: "file:///etc/passwd", wantErr: true},
|
||
|
|
{name: "gopher", raw: "gopher://example.com/", wantErr: true},
|
||
|
|
{name: "javascript", raw: "javascript:alert(1)", wantErr: true},
|
||
|
|
{name: "userinfo", raw: "https://user:pass@example.com/", wantErr: true},
|
||
|
|
{name: "пусто", raw: " ", wantErr: true},
|
||
|
|
{name: "loopback", raw: "https://127.0.0.1/x", wantErr: true},
|
||
|
|
{name: "метаданные", raw: "https://169.254.169.254/latest/meta-data/", wantErr: true},
|
||
|
|
{name: "приватная сеть", raw: "https://10.1.2.3/", wantErr: true},
|
||
|
|
{name: "ipv6 loopback", raw: "https://[::1]/", wantErr: true},
|
||
|
|
{name: "слишком длинный", raw: "https://example.com/" + strings.Repeat("a", 2100), wantErr: true},
|
||
|
|
}
|
||
|
|
for _, testCase := range cases {
|
||
|
|
t.Run(testCase.name, func(t *testing.T) {
|
||
|
|
parsed, err := ValidatePublicURL(testCase.raw, testCase.allowHTTP)
|
||
|
|
if testCase.wantErr {
|
||
|
|
if err == nil {
|
||
|
|
t.Fatalf("ValidatePublicURL(%q) = %v, ожидалась ошибка", testCase.raw, parsed)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ValidatePublicURL(%q) вернул ошибку: %v", testCase.raw, err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestSafeDialControl проверяет, что проверка идёт по уже разрешённому адресу:
|
||
|
|
// именно так закрывается DNS rebinding.
|
||
|
|
func TestSafeDialControl(t *testing.T) {
|
||
|
|
blocked := []string{"127.0.0.1:443", "[::1]:443", "169.254.169.254:80", "10.0.0.5:8080"}
|
||
|
|
for _, address := range blocked {
|
||
|
|
if err := SafeDialControl("tcp", address, nil); err == nil {
|
||
|
|
t.Errorf("SafeDialControl пропустил %s", address)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := SafeDialControl("tcp", "93.184.216.34:443", nil); err != nil {
|
||
|
|
t.Fatalf("SafeDialControl отклонил публичный адрес: %v", err)
|
||
|
|
}
|
||
|
|
// Имя вместо адреса означает, что резолв не произошёл: доверять нельзя.
|
||
|
|
if err := SafeDialControl("tcp", "example.com:443", nil); err == nil {
|
||
|
|
t.Fatal("SafeDialControl принял неразрешённое имя хоста")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSafeClientBlocksUnsafeRedirects(t *testing.T) {
|
||
|
|
client := SafeClient(time.Second)
|
||
|
|
origin := &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com"}}
|
||
|
|
via := []*http.Request{origin}
|
||
|
|
|
||
|
|
redirect := &http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1:8080", Path: "/steal"}}
|
||
|
|
if err := client.CheckRedirect(redirect, via); err == nil {
|
||
|
|
t.Fatal("CheckRedirect пропустил переход на внутренний адрес")
|
||
|
|
}
|
||
|
|
// Схема http запрещена всегда: редирект не должен понижать защиту.
|
||
|
|
redirect = &http.Request{URL: &url.URL{Scheme: "http", Host: "example.com"}}
|
||
|
|
if err := client.CheckRedirect(redirect, via); err == nil {
|
||
|
|
t.Fatal("CheckRedirect пропустил переход на http")
|
||
|
|
}
|
||
|
|
redirect = &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/next"}}
|
||
|
|
if err := client.CheckRedirect(redirect, via); err != nil {
|
||
|
|
t.Fatalf("CheckRedirect отклонил обычный https-редирект: %v", err)
|
||
|
|
}
|
||
|
|
// Больше трёх переходов — отказ (защита от цепочек).
|
||
|
|
many := []*http.Request{origin, origin, origin, origin}
|
||
|
|
if err := client.CheckRedirect(redirect, many); err == nil {
|
||
|
|
t.Fatal("CheckRedirect пропустил слишком длинную цепочку")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSafeTransportHasNoProxy(t *testing.T) {
|
||
|
|
transport := SafeTransport(time.Second)
|
||
|
|
if transport.Proxy != nil {
|
||
|
|
t.Fatal("транспорт не должен использовать прокси из окружения")
|
||
|
|
}
|
||
|
|
if transport.DialContext == nil {
|
||
|
|
t.Fatal("транспорт должен задавать DialContext с проверкой адреса")
|
||
|
|
}
|
||
|
|
}
|