171 lines
7.0 KiB
Go
171 lines
7.0 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
func TestImageURLAcceptsPublicCDNsWithoutDomainList(t *testing.T) {
|
|
for _, address := range []string{"https://new-covers.example.org/a?token=%2F%2b%3D", "https://another.example.com/cover", "https://93.184.216.34/image", "https://[2606:4700:4700::1111]/image"} {
|
|
parsed, err := url.Parse(address)
|
|
if err != nil || !validImageURL(parsed) {
|
|
t.Fatal("public cover URL required a hard-coded domain", address, err)
|
|
}
|
|
}
|
|
for _, address := range []string{
|
|
"http://example.org/a", "file:///etc/passwd", "https://user@example.org/a", "https://example.org:8443/a",
|
|
"https://localhost/a", "https://app.localhost/a", "https://router.local/a", "https://private.lan/a",
|
|
"https://127.0.0.1/a", "https://10.1.2.3/a", "https://172.16.0.1/a", "https://192.168.1.1/a",
|
|
"https://169.254.169.254/a", "https://100.100.100.200/a", "https://0.0.0.0/a", "https://255.255.255.255/a",
|
|
"https://[::1]/a", "https://[::ffff:127.0.0.1]/a", "https://[fc00::1]/a", "https://[fe80::1%25en0]/a",
|
|
"https://[64:ff9b::a00:1]/a", "https://example.invalid/a", "https://empty../a",
|
|
} {
|
|
parsed, err := url.Parse(address)
|
|
if err == nil && validImageURL(parsed) {
|
|
t.Fatal("unsafe cover URL was accepted", address)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImageTransportRejectsPrivateDNSBeforeAnyConnection(t *testing.T) {
|
|
for _, addresses := range [][]string{{"127.0.0.1"}, {"10.0.0.1"}, {"100.100.100.200"}, {"::ffff:192.168.1.1"}, {"93.184.216.34", "169.254.169.254"}} {
|
|
t.Run(strings.Join(addresses, "/"), func(t *testing.T) {
|
|
var calls atomic.Int32
|
|
standard := http.DefaultTransport.(*http.Transport).Clone()
|
|
standard.DialContext = func(context.Context, string, string) (net.Conn, error) {
|
|
calls.Add(1)
|
|
return nil, errors.New("must not connect")
|
|
}
|
|
transport := newImageTransport(standard, standard)
|
|
transport.lookup = func(context.Context, string) ([]string, error) { return addresses, nil }
|
|
transport.fallback = func(context.Context, string) ([]string, error) {
|
|
calls.Add(1)
|
|
return []string{"93.184.216.34"}, nil
|
|
}
|
|
request := httptest.NewRequest(http.MethodGet, "https://rebound.example.org/cover", nil)
|
|
request = request.WithContext(context.WithValue(request.Context(), coverRequestKey{}, true))
|
|
if _, err := transport.RoundTrip(request); err == nil || calls.Load() != 0 {
|
|
t.Fatal("private DNS was fetched or retried as a public destination", calls.Load(), err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImageTransportPinsDNSPreservesSNIAndProxy(t *testing.T) {
|
|
for _, mode := range []string{"direct", "http-proxy", "https-proxy"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
var targetCalls atomic.Int32
|
|
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
targetCalls.Add(1)
|
|
if r.Host != "new-covers.example.org" || r.TLS.ServerName != "new-covers.example.org" || r.URL.RawQuery != "signature=%2F%2b%3D" || r.Header.Get("Referer") != "https://source.example.org/" {
|
|
t.Error("pinned image request changed the hostname, TLS name, query or source headers")
|
|
}
|
|
io.WriteString(w, "synthetic transport fixture")
|
|
}))
|
|
defer target.Close()
|
|
var mu sync.Mutex
|
|
var dials, connects []string
|
|
proxyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
connects = append(connects, r.Host)
|
|
mu.Unlock()
|
|
if r.Method != http.MethodConnect || r.Host != "93.184.216.34:443" {
|
|
http.Error(w, "unexpected CONNECT destination", http.StatusBadRequest)
|
|
return
|
|
}
|
|
upstream, err := net.Dial("tcp", target.Listener.Addr().String())
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
connection, buffer, err := w.(http.Hijacker).Hijack()
|
|
if err != nil {
|
|
upstream.Close()
|
|
t.Error(err)
|
|
return
|
|
}
|
|
buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
buffer.Flush()
|
|
go func() { io.Copy(upstream, buffer); upstream.Close() }()
|
|
io.Copy(connection, upstream)
|
|
connection.Close()
|
|
})
|
|
standard := http.DefaultTransport.(*http.Transport).Clone()
|
|
standard.Proxy = nil
|
|
standard.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
var proxyAddress string
|
|
if mode != "direct" {
|
|
var proxy *httptest.Server
|
|
if mode == "https-proxy" {
|
|
proxy = httptest.NewTLSServer(proxyHandler)
|
|
} else {
|
|
proxy = httptest.NewServer(proxyHandler)
|
|
}
|
|
defer proxy.Close()
|
|
proxyURL, _ := url.Parse(proxy.URL)
|
|
proxyAddress = proxyURL.Host
|
|
standard.Proxy = http.ProxyURL(proxyURL)
|
|
}
|
|
standard.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
mu.Lock()
|
|
dials = append(dials, address)
|
|
mu.Unlock()
|
|
if mode == "direct" && address == "93.184.216.34:443" {
|
|
address = target.Listener.Addr().String()
|
|
} else if mode == "direct" || address != proxyAddress {
|
|
return nil, errors.New("request bypassed its pinned address or configured proxy")
|
|
}
|
|
return (&net.Dialer{}).DialContext(ctx, network, address)
|
|
}
|
|
transport := newImageTransport(standard, standard)
|
|
defer transport.CloseIdleConnections()
|
|
transport.lookup = func(ctx context.Context, host string) ([]string, error) {
|
|
if host != "new-covers.example.org" {
|
|
t.Error("unexpected hostname", host)
|
|
}
|
|
return []string{"93.184.216.34"}, nil
|
|
}
|
|
request, _ := http.NewRequestWithContext(context.WithValue(context.Background(), coverRequestKey{}, true), http.MethodGet, "https://new-covers.example.org/cover?signature=%2F%2b%3D", nil)
|
|
request.Header.Set("Referer", "https://source.example.org/")
|
|
response, err := (&http.Client{Transport: transport}).Do(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := io.ReadAll(response.Body)
|
|
response.Body.Close()
|
|
if err != nil || string(body) != "synthetic transport fixture" || response.Request.URL.String() != request.URL.String() {
|
|
t.Fatal("pinned response lost the original address", err)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if targetCalls.Load() != 1 || len(dials) != 1 || mode == "direct" && len(connects) != 0 || mode != "direct" && !reflect.DeepEqual(connects, []string{"93.184.216.34:443"}) {
|
|
t.Fatal("image request did not follow the expected pinned route", dials, connects, targetCalls.Load())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImageSourceRequiresRegisteredCoverForEveryViewer(t *testing.T) {
|
|
address := "https://new-covers.example.org/cover?token=fixture"
|
|
app := &UIApp{dramas: []Drama{{ID: "hongguo:7000000000000000001", Source: sourceHongguo, CoverURL: address}}}
|
|
for _, ctx := range []context.Context{context.Background(), withSourceScope(context.Background(), accountRecord{Admin: true}), withSourceScope(context.Background(), accountRecord{Sources: []string{sourceHongguo}})} {
|
|
if source, allowed := app.imageSource(ctx, address); !allowed || source != sourceHongguo {
|
|
t.Fatal("registered cover was rejected")
|
|
}
|
|
if _, allowed := app.imageSource(ctx, address+"-forged"); allowed {
|
|
t.Fatal("unregistered cover was accepted")
|
|
}
|
|
}
|
|
}
|