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") } }