263 lines
10 KiB
Go
263 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A fetch rig: names resolve as the test says, and every dial goes to the test's server whatever address was asked,
|
|
// after the fetcher has checked the address it was about to dial and told the dial what that was.
|
|
type fetchRig struct {
|
|
f *Fetcher
|
|
mu sync.Mutex
|
|
dialed []string
|
|
lookups map[string]int
|
|
names map[string][]netip.Addr
|
|
// after is what a host answers from its second lookup on, when it answers differently (a rebinding).
|
|
after map[string][]netip.Addr
|
|
srv *httptest.Server
|
|
}
|
|
|
|
func newFetchRig(t *testing.T, h http.HandlerFunc) *fetchRig {
|
|
t.Helper()
|
|
r := &fetchRig{lookups: map[string]int{}, names: map[string][]netip.Addr{}, after: map[string][]netip.Addr{}}
|
|
r.srv = httptest.NewServer(h)
|
|
t.Cleanup(r.srv.Close)
|
|
r.f = &Fetcher{MeshRange: netip.MustParsePrefix("10.10.0.0/24"), Timeout: 5 * time.Second, Most: 1 << 20, Redirects: FetchRedirects,
|
|
Resolve: func(_ context.Context, host string) ([]netip.Addr, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.lookups[host]++
|
|
a, ok := r.names[host]
|
|
if !ok {
|
|
return nil, errors.New("no such host")
|
|
}
|
|
if again, rebinds := r.after[host]; rebinds && r.lookups[host] > 1 {
|
|
return again, nil
|
|
}
|
|
return a, nil
|
|
},
|
|
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
r.mu.Lock()
|
|
r.dialed = append(r.dialed, address)
|
|
r.mu.Unlock()
|
|
return (&net.Dialer{}).DialContext(ctx, network, r.srv.Listener.Addr().String())
|
|
}}
|
|
return r
|
|
}
|
|
|
|
func addrs(s ...string) []netip.Addr {
|
|
var out []netip.Addr
|
|
for _, a := range s {
|
|
out = append(out, netip.MustParseAddr(a))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 1599/AC1: a url with a scheme other than http or https is refused by name.
|
|
func TestAURLOfAnotherSchemeIsRefused(t *testing.T) {
|
|
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) {})
|
|
for _, u := range []string{"file:///etc/passwd", "ftp://example.com/a.pdf", "gopher://example.com/", "javascript:alert(1)", "//example.com/a.pdf"} {
|
|
_, err := r.f.Fetch(t.Context(), u)
|
|
if err == nil || (!strings.Contains(err.Error(), "only http and https") && !strings.Contains(err.Error(), "is not an address")) {
|
|
t.Errorf("%s: error = %v", u, err)
|
|
}
|
|
if err := CheckURLScheme(u); err == nil {
|
|
t.Errorf("%s: the tool side took it", u)
|
|
}
|
|
}
|
|
if len(r.dialed) != 0 {
|
|
t.Errorf("dialled %v", r.dialed)
|
|
}
|
|
}
|
|
|
|
// 1599/AC2: a host that is, or resolves to, a loopback, private, link-local, shared, unspecified or multicast address,
|
|
// or one in the mesh's range, is refused naming the class, and no connection is made.
|
|
func TestAddressesInsideTheMeshAndTheMachineAreRefused(t *testing.T) {
|
|
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) { t.Error("a request reached the server") })
|
|
cases := map[string]string{
|
|
"127.0.0.1": "loopback", "::1": "loopback", "10.0.0.1": "private address (RFC 1918)", "192.168.1.1": "private address (RFC 1918)",
|
|
"172.16.0.1": "private address (RFC 1918)", "fd00::1": "private address (RFC 4193)", "169.254.1.1": "link-local", "fe80::1": "link-local",
|
|
"100.64.0.1": "shared address (RFC 6598)", "0.0.0.0": "unspecified", "::": "unspecified", "224.0.0.1": "multicast", "ff02::1": "multicast",
|
|
"10.10.0.7": "private address (RFC 1918)", "::ffff:127.0.0.1": "loopback",
|
|
}
|
|
for ip, class := range cases {
|
|
host := ip
|
|
if strings.Contains(ip, ":") {
|
|
host = "[" + ip + "]"
|
|
}
|
|
_, err := r.f.Fetch(t.Context(), "http://"+host+"/a.pdf")
|
|
if err == nil || !strings.Contains(err.Error(), class) {
|
|
t.Errorf("%s: error = %v, want the class %q", ip, err, class)
|
|
}
|
|
// The same address behind a name.
|
|
r.names["inside.test"] = addrs("203.0.113.9", ip)
|
|
if _, err := r.f.Fetch(t.Context(), "http://inside.test/a.pdf"); err == nil || !strings.Contains(err.Error(), class) {
|
|
t.Errorf("inside.test -> %s: error = %v", ip, err)
|
|
}
|
|
}
|
|
// The mesh's range from fetch.json, when it is not a private range.
|
|
r.f.MeshRange = netip.MustParsePrefix("203.0.113.0/24")
|
|
if _, err := r.f.Fetch(t.Context(), "http://203.0.113.5/a.pdf"); err == nil || !strings.Contains(err.Error(), "the mesh's own network") {
|
|
t.Errorf("the mesh range: error = %v", err)
|
|
}
|
|
if len(r.dialed) != 0 {
|
|
t.Errorf("a connection was made to %v", r.dialed)
|
|
}
|
|
}
|
|
|
|
// 1599/AC3: a redirect to such an address is refused; a sixth redirect is refused; a redirect to a public address is followed.
|
|
func TestRedirectsAreCheckedAndBounded(t *testing.T) {
|
|
hops := 0
|
|
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
|
|
switch req.URL.Path {
|
|
case "/to-loopback":
|
|
http.Redirect(w, req, "http://127.0.0.1/secret", http.StatusFound)
|
|
case "/to-name":
|
|
http.Redirect(w, req, "http://inside.test/x", http.StatusFound)
|
|
case "/loop":
|
|
hops++
|
|
http.Redirect(w, req, "/loop", http.StatusFound)
|
|
case "/ok":
|
|
http.Redirect(w, req, "http://public.test/final.pdf", http.StatusMovedPermanently)
|
|
default:
|
|
w.Write([]byte("%PDF-1.7 final"))
|
|
}
|
|
})
|
|
r.names["public.test"] = addrs("203.0.113.9")
|
|
r.names["inside.test"] = addrs("192.168.0.9")
|
|
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-loopback"); err == nil || !strings.Contains(err.Error(), "loopback") {
|
|
t.Errorf("redirect to loopback: error = %v", err)
|
|
}
|
|
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-name"); err == nil || !strings.Contains(err.Error(), "private address (RFC 1918)") {
|
|
t.Errorf("redirect to a name: error = %v", err)
|
|
}
|
|
if _, err := r.f.Fetch(t.Context(), "http://public.test/loop"); err == nil || !strings.Contains(err.Error(), "redirected more than 5 times") {
|
|
t.Errorf("a loop: error = %v", err)
|
|
}
|
|
if hops != 6 {
|
|
t.Errorf("the loop was followed %d times, want the first request and 5 redirects (6 requests)", hops)
|
|
}
|
|
got, err := r.f.Fetch(t.Context(), "http://public.test/ok")
|
|
if err != nil || string(got.Body) != "%PDF-1.7 final" {
|
|
t.Errorf("a redirect to a public address: %v, %v", got, err)
|
|
}
|
|
}
|
|
|
|
// 1599/AC4: a body over the limit and a fetch over the time are refused, and nothing is returned to be stored.
|
|
func TestABodyOverTheLimitAndAFetchOverTheTimeAreRefused(t *testing.T) {
|
|
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
|
|
switch req.URL.Path {
|
|
case "/big":
|
|
w.Write([]byte(strings.Repeat("x", 2000)))
|
|
case "/chunked":
|
|
w.(http.Flusher).Flush() // no length is declared
|
|
w.Write([]byte(strings.Repeat("x", 2000)))
|
|
case "/slow":
|
|
time.Sleep(500 * time.Millisecond)
|
|
w.Write([]byte("late"))
|
|
}
|
|
})
|
|
r.names["public.test"] = addrs("203.0.113.9")
|
|
r.f.Most = 1000
|
|
for _, p := range []string{"/big", "/chunked"} {
|
|
if got, err := r.f.Fetch(t.Context(), "http://public.test"+p); err == nil || got != nil || !strings.Contains(err.Error(), "over the") {
|
|
t.Errorf("%s: %v, %v", p, got, err)
|
|
}
|
|
}
|
|
r.f.Timeout = 100 * time.Millisecond
|
|
if got, err := r.f.Fetch(t.Context(), "http://public.test/slow"); err == nil || got != nil {
|
|
t.Errorf("a slow fetch: %v, %v", got, err)
|
|
}
|
|
}
|
|
|
|
// 1599/AC5: the connection goes to the address that was checked: a name that answers differently on a second lookup
|
|
// is not dialled again, and each host is looked up once.
|
|
func TestTheCheckedAddressIsTheOneDialled(t *testing.T) {
|
|
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) { w.Write([]byte("%PDF-1.7 x")) })
|
|
r.names["rebind.test"] = addrs("203.0.113.9")
|
|
r.after["rebind.test"] = addrs("127.0.0.1") // a second lookup would say loopback
|
|
if _, err := r.f.Fetch(t.Context(), "http://rebind.test:8080/a.pdf"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(r.dialed) != 1 || r.dialed[0] != "203.0.113.9:8080" {
|
|
t.Errorf("dialled %v, want the checked address and the url's port", r.dialed)
|
|
}
|
|
if r.lookups["rebind.test"] != 1 {
|
|
t.Errorf("the host was looked up %d times", r.lookups["rebind.test"])
|
|
}
|
|
}
|
|
|
|
// 1599/AC6: a fetched PDF is stored under its SHA-256 and printed as that object.
|
|
func TestAFetchedPDFIsStoredUnderItsHashAndPrinted(t *testing.T) {
|
|
fake := newIPPFake(t, 41, 5)
|
|
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
|
|
doc := pdfOf(4000)
|
|
web := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Content-Type", "application/pdf")
|
|
w.Write(doc)
|
|
})
|
|
web.names["public.test"] = addrs("203.0.113.9")
|
|
r.worker.Fetcher = func() (*Fetcher, error) { return web.f, nil }
|
|
got, err := r.call(t, "printer.print", map[string]any{"url": "http://public.test/letter.pdf"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := got.(map[string]any)
|
|
key := sha256Hex(doc)
|
|
if m["object"] != key || m["job-id"] != float64(41) || m["format"] != "application/pdf" {
|
|
t.Errorf("answer = %v", m)
|
|
}
|
|
if stored, err := r.objects.Get(t.Context(), key); err != nil || string(stored) != string(doc) {
|
|
t.Errorf("stored under the hash: %v", err)
|
|
}
|
|
if _, sent := fake.request(t); string(sent) != string(doc) {
|
|
t.Error("the printer was not sent the fetched document")
|
|
}
|
|
queued := r.bus.published[0]
|
|
if queued.URL != "http://public.test/letter.pdf" || queued.Object != "" {
|
|
t.Errorf("queued %+v", queued)
|
|
}
|
|
if err := CheckURLScheme("https://public.test/letter.pdf"); err != nil {
|
|
t.Errorf("https refused: %v", err)
|
|
}
|
|
// A refusal inside the mesh is answered, and nothing is stored nor printed.
|
|
fake.body = nil
|
|
before := len(r.objects.keys())
|
|
if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-shanks-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") {
|
|
t.Errorf("a loopback address: error = %v", err)
|
|
}
|
|
if len(r.objects.keys()) != before || fake.body != nil {
|
|
t.Error("something was stored or printed for a refused address")
|
|
}
|
|
}
|
|
|
|
// The fetch's facts are the file the mesh writes (files/fetch.json) from the `mesh-range` machine fact, and the device
|
|
// process reads them.
|
|
func TestTheFetchFactsAreTheMeshRange(t *testing.T) {
|
|
raw, err := os.ReadFile("../../files/fetch.json")
|
|
if err != nil || !strings.Contains(string(raw), `"mesh-range": "${machine:mesh-range}"`) {
|
|
t.Fatalf("files/fetch.json = %s, %v", raw, err)
|
|
}
|
|
file := filepath.Join(t.TempDir(), "fetch.json")
|
|
os.WriteFile(file, []byte(`{"mesh-range": "10.10.0.0/24"}`), 0o600)
|
|
p, err := ReadFetchFacts(file)
|
|
if err != nil || p.String() != "10.10.0.0/24" {
|
|
t.Errorf("prefix %v, %v", p, err)
|
|
}
|
|
os.WriteFile(file, []byte(`{}`), 0o600)
|
|
if _, err := ReadFetchFacts(file); err == nil {
|
|
t.Error("facts with no range were taken")
|
|
}
|
|
}
|