mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-09-16 22:25:27 +02:00
277 lines
9.1 KiB
Go
277 lines
9.1 KiB
Go
package remotebrowser
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/go-rod/rod/lib/launcher"
|
|
)
|
|
|
|
func TestIsInternalIP(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"127.0.0.1": true, "127.5.5.5": true, "::1": true,
|
|
"10.0.0.5": true, "172.16.9.9": true, "172.31.1.1": true, "192.168.1.1": true,
|
|
"169.254.169.254": true, "169.254.1.1": true,
|
|
"100.100.100.200": true, // carrier grade NAT (some metadata services)
|
|
"0.0.0.0": true, "0.1.2.3": true, // this network
|
|
"192.0.0.170": true, // NAT64 discovery
|
|
"198.18.0.1": true, // benchmarking
|
|
"240.0.0.1": true, // reserved
|
|
"255.255.255.255": true, // broadcast
|
|
"::": true,
|
|
"fc00::1": true, "fd12:3456::1": true, "fe80::1": true,
|
|
"::ffff:10.0.0.1": true, "::ffff:127.0.0.1": true, // v4 mapped
|
|
"64:ff9b::7f00:1": true, // NAT64 wrapping 127.0.0.1
|
|
"2002:7f00:1::": true, // 6to4 wrapping 127.0.0.1
|
|
"::a00:1": true, // v4 compatible wrapping 10.0.0.1
|
|
"2001::1": true, // Teredo range
|
|
"224.0.0.1": true, // multicast
|
|
"ff02::1": true, // v6 multicast
|
|
"8.8.8.8": false, "1.1.1.1": false, "203.0.113.7": false, "2606:4700::1111": false,
|
|
}
|
|
for ipStr, want := range cases {
|
|
if got := isInternalIP(net.ParseIP(ipStr)); got != want {
|
|
t.Errorf("isInternalIP(%s)=%v want %v", ipStr, got, want)
|
|
}
|
|
}
|
|
if !isInternalIP(nil) {
|
|
t.Errorf("nil IP must be treated as internal (fail closed)")
|
|
}
|
|
}
|
|
|
|
// The proxy must refuse a request whose target is internal (here loopback).
|
|
func TestGuardedProxyBlocksInternal(t *testing.T) {
|
|
var hits int
|
|
var mu sync.Mutex
|
|
victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
hits++
|
|
mu.Unlock()
|
|
w.Write([]byte("REACHED"))
|
|
}))
|
|
defer victim.Close()
|
|
|
|
proxy, err := startGuardedProxy()
|
|
if err != nil {
|
|
t.Fatalf("start proxy: %v", err)
|
|
}
|
|
defer proxy.close()
|
|
|
|
proxyURL, _ := url.Parse("http://" + proxy.addr())
|
|
client := &http.Client{
|
|
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
resp, err := client.Get(victim.URL + "/ssrf")
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 400 {
|
|
t.Errorf("expected the proxy to block loopback, got status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
if proxy.blockedRequests() == 0 {
|
|
t.Errorf("proxy did not record a blocked request")
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if hits != 0 {
|
|
t.Errorf("internal victim was reached %d times; the guard failed", hits)
|
|
}
|
|
}
|
|
|
|
// TestGuardFlagsConfigured locks the critical guard launcher flags so a change
|
|
// that removes the proxy or adds back a loopback bypass fails in CI without a
|
|
// browser. The renderer uses this same applyGuardFlags, so the two cannot drift.
|
|
func TestGuardFlagsConfigured(t *testing.T) {
|
|
l := applyGuardFlags(launcher.New(), "127.0.0.1:12345")
|
|
if got := l.Get("proxy-server"); got != "127.0.0.1:12345" {
|
|
t.Errorf("proxy-server = %q, want the guard proxy address", got)
|
|
}
|
|
if got := l.Get("proxy-bypass-list"); got != "<-loopback>" {
|
|
t.Errorf("proxy-bypass-list = %q, want <-loopback> so loopback routes through the guard", got)
|
|
}
|
|
if !l.Has("disable-quic") {
|
|
t.Errorf("disable-quic flag missing (UDP egress path could bypass the guard)")
|
|
}
|
|
if got := l.Get("force-webrtc-ip-handling-policy"); got != "disable_non_proxied_udp" {
|
|
t.Errorf("force-webrtc-ip-handling-policy = %q, want disable_non_proxied_udp", got)
|
|
}
|
|
if !l.Has("disable-background-networking") {
|
|
t.Errorf("disable-background-networking flag missing")
|
|
}
|
|
}
|
|
|
|
// proxyClient returns an http client that sends all requests through the proxy.
|
|
func proxyClient(p *guardedProxy) *http.Client {
|
|
proxyURL, _ := url.Parse("http://" + p.addr())
|
|
return &http.Client{
|
|
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
}
|
|
|
|
// The proxy must block the cloud metadata endpoint (plain HTTP path).
|
|
func TestGuardedProxyBlocksMetadataLiteral(t *testing.T) {
|
|
proxy, err := startGuardedProxy()
|
|
if err != nil {
|
|
t.Fatalf("start proxy: %v", err)
|
|
}
|
|
defer proxy.close()
|
|
resp, err := proxyClient(proxy).Get("http://169.254.169.254/latest/meta-data/")
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 400 {
|
|
t.Errorf("metadata endpoint not blocked, status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
if proxy.blockedRequests() == 0 {
|
|
t.Errorf("metadata request was not recorded as blocked")
|
|
}
|
|
}
|
|
|
|
// A hostname that resolves to an internal address must be blocked (the DNS
|
|
// rebinding chokepoint: resolution happens inside the guard).
|
|
func TestGuardedProxyBlocksHostnameResolvingInternal(t *testing.T) {
|
|
// only meaningful if localhost actually resolves to an internal address; skip
|
|
// otherwise so a resolution quirk cannot make this pass for the wrong reason.
|
|
ips, lerr := net.LookupIP("localhost")
|
|
if lerr != nil || len(ips) == 0 || !isInternalIP(ips[0]) {
|
|
t.Skip("localhost does not resolve to an internal address here")
|
|
}
|
|
proxy, err := startGuardedProxy()
|
|
if err != nil {
|
|
t.Fatalf("start proxy: %v", err)
|
|
}
|
|
defer proxy.close()
|
|
// localhost resolves to 127.0.0.1 / ::1, both internal.
|
|
resp, err := proxyClient(proxy).Get("http://localhost/")
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 400 {
|
|
t.Errorf("hostname resolving to loopback not blocked, status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
if proxy.blockedRequests() == 0 {
|
|
t.Errorf("localhost request was not recorded as blocked")
|
|
}
|
|
}
|
|
|
|
// The CONNECT (https) path must also block internal targets.
|
|
func TestGuardedProxyBlocksConnectInternal(t *testing.T) {
|
|
proxy, err := startGuardedProxy()
|
|
if err != nil {
|
|
t.Fatalf("start proxy: %v", err)
|
|
}
|
|
defer proxy.close()
|
|
// https forces the client to issue CONNECT to the proxy.
|
|
_, err = proxyClient(proxy).Get("https://127.0.0.1:1/")
|
|
if err == nil {
|
|
t.Errorf("expected the CONNECT to an internal target to fail")
|
|
}
|
|
if proxy.blockedRequests() == 0 {
|
|
t.Errorf("CONNECT to internal target was not recorded as blocked")
|
|
}
|
|
}
|
|
|
|
// dialGuarded must NOT classify a public destination as blocked. It may fail to
|
|
// connect (no network), but the failure must not be the policy block.
|
|
func TestDialGuardedAllowsPublicDecision(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
conn, err := dialGuarded(ctx, "tcp", "8.8.8.8:80")
|
|
if conn != nil {
|
|
conn.Close()
|
|
}
|
|
if err != nil && errors.Is(err, errBlocked) {
|
|
t.Errorf("public destination 8.8.8.8 was wrongly blocked by policy")
|
|
}
|
|
}
|
|
|
|
// End to end: proves the production Chrome flags route egress through the guard so
|
|
// the browser cannot reach loopback (verified directly by the victim counter). A
|
|
// positive control (the proxy's blocked count reaching the two internal targets
|
|
// given) ensures the requests actually fired, so the test cannot pass vacuously.
|
|
// Metadata endpoint blocking specifically is covered without a browser by
|
|
// TestGuardedProxyBlocksMetadataLiteral. Skips when no browser binary is present.
|
|
func TestReportBrowserEgressGuarded(t *testing.T) {
|
|
bin := ""
|
|
for _, cand := range []string{"/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium"} {
|
|
if _, err := os.Stat(cand); err == nil {
|
|
bin = cand
|
|
break
|
|
}
|
|
}
|
|
if bin == "" {
|
|
t.Skip("no chrome/chromium binary available")
|
|
}
|
|
|
|
var hits int
|
|
var mu sync.Mutex
|
|
victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
hits++
|
|
mu.Unlock()
|
|
}))
|
|
defer victim.Close()
|
|
_, victimPort, _ := net.SplitHostPort(victim.Listener.Addr().String())
|
|
|
|
proxy, err := startGuardedProxy()
|
|
if err != nil {
|
|
t.Fatalf("start proxy: %v", err)
|
|
}
|
|
defer proxy.close()
|
|
|
|
l := applyGuardFlags(
|
|
launcher.New().Bin(bin).Headless(true).
|
|
Set("no-sandbox").Set("disable-gpu").Set("disable-dev-shm-usage"),
|
|
proxy.addr(),
|
|
).
|
|
// turn off Chrome's own network access checks so a 0 result is attributable
|
|
// to this guard, not to a masking browser feature.
|
|
Set("disable-features", "PrivateNetworkAccessChecks,LocalNetworkAccessChecks,BlockInsecurePrivateNetworkRequests")
|
|
u, err := l.Launch()
|
|
if err != nil {
|
|
t.Fatalf("launch: %v", err)
|
|
}
|
|
defer l.Cleanup()
|
|
browser := rod.New().ControlURL(u)
|
|
if err := browser.Connect(); err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer browser.Close()
|
|
|
|
pg := browser.MustPage()
|
|
_ = pg.SetDocumentContent(fmt.Sprintf(`<html><body>
|
|
<img src="http://127.0.0.1:%s/ssrf">
|
|
<img src="http://169.254.169.254/latest/meta-data/">
|
|
</body></html>`, victimPort))
|
|
|
|
// positive control: wait until the guard has refused both internal targets. If
|
|
// the requests never fire, this stays below 2 and the test fails loudly rather
|
|
// than passing without exercising the guard.
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for proxy.blockedRequests() < 2 && time.Now().Before(deadline) {
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
pg.Close()
|
|
|
|
if got := proxy.blockedRequests(); got < 2 {
|
|
t.Fatalf("guard refused %d requests; expected it to refuse both loopback and metadata (the requests may not have fired)", got)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if hits != 0 {
|
|
t.Fatalf("loopback victim reached %d times through the report browser; SSRF guard failed", hits)
|
|
}
|
|
}
|