Fetcher.Allowed and Key, the rendered comparison and the Chrome request guard accept an address only on the site's effective port (explicit, else 443 for https and 80 for http), so page code cannot make headless Chrome reach other services on the site host's address and a link to another port is not reported as a page of the site.
179 lines
6.6 KiB
Go
179 lines
6.6 KiB
Go
package crawl
|
|
|
|
import (
|
|
"context"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
)
|
|
|
|
// TestAllowRequest pins what the headless browser may load: https on the
|
|
// site host and its www twin on the site's port, data: and blob:; http
|
|
// only in trusted mode, also on the site's port; other hosts and ports,
|
|
// credentials in the URL and every other scheme refused (L3 WR-03).
|
|
func TestAllowRequest(t *testing.T) {
|
|
hosts := []string{"example.test", "www.example.test"}
|
|
for _, tc := range []struct {
|
|
url string
|
|
trusted bool
|
|
want bool
|
|
}{
|
|
{"https://example.test/a.js", false, true},
|
|
{"https://WWW.example.test/b.css", false, true},
|
|
{"https://example.test:8443/x", false, false},
|
|
{"https://example.test:443/x", false, true},
|
|
{"https://example.test:80/x", false, false},
|
|
{"data:image/png;base64,AAAA", false, true},
|
|
{"blob:https://example.test/uuid", false, true},
|
|
{"http://example.test/a.js", false, false},
|
|
{"http://example.test/a.js", true, false}, // trusted, but port 80 is not the site's 443
|
|
{"https://cdn.other.test/lib.js", false, false},
|
|
{"https://example.test.evil.test/x", false, false},
|
|
{"https://user:pw@example.test/x", false, false},
|
|
{"ws://example.test/socket", false, false},
|
|
{"file:///etc/passwd", false, false},
|
|
{"javascript:alert(1)", true, false},
|
|
{"://bad", false, false},
|
|
} {
|
|
if got := allowRequest(tc.url, hosts, "443", tc.trusted); got != tc.want {
|
|
t.Errorf("allowRequest(%q, trusted=%v) = %v, want %v", tc.url, tc.trusted, got, tc.want)
|
|
}
|
|
}
|
|
// A trusted http site on its own port: that port only.
|
|
if !allowRequest("http://example.test:3000/x", hosts, "3000", true) || allowRequest("http://example.test:3001/x", hosts, "3000", true) ||
|
|
allowRequest("http://example.test/x", hosts, "3000", true) {
|
|
t.Error("trusted http keeps to the site's port")
|
|
}
|
|
if !allowRequest("data:text/plain,x", hosts, "3000", false) || !allowRequest("blob:https://example.test/uuid", hosts, "3000", false) {
|
|
t.Error("data: and blob: are allowed on any port")
|
|
}
|
|
if !allowRequest("https://[::1]/x", []string{"[::1]"}, "443", false) {
|
|
t.Fatal("a bracketed IPv6 host must match")
|
|
}
|
|
}
|
|
|
|
// TestResolverRules pins Chrome's --host-resolver-rules: one MAP per
|
|
// allowed host, sorted, to its first IPv4 address (else the first one, in
|
|
// brackets for IPv6), a host without addresses left out, and MAP *
|
|
// ~NOTFOUND last, since Chrome takes the first matching rule.
|
|
func TestResolverRules(t *testing.T) {
|
|
got := resolverRules(map[string][]netip.Addr{
|
|
"www.example.test": {netip.MustParseAddr("2001:db8::1")},
|
|
"example.test": {netip.MustParseAddr("2001:db8::2"), netip.MustParseAddr("192.0.2.10"), netip.MustParseAddr("192.0.2.11")},
|
|
"empty.test": nil,
|
|
})
|
|
want := "MAP example.test 192.0.2.10, MAP www.example.test [2001:db8::1], MAP * ~NOTFOUND"
|
|
if got != want {
|
|
t.Fatalf("rules = %q\nwant %q", got, want)
|
|
}
|
|
if got := resolverRules(nil); got != "MAP * ~NOTFOUND" {
|
|
t.Fatalf("no hosts = %q", got)
|
|
}
|
|
if !strings.HasSuffix(got, "MAP * ~NOTFOUND") || strings.Count(got, "~NOTFOUND") != 1 {
|
|
t.Fatal("the catch-all must come once and last")
|
|
}
|
|
}
|
|
|
|
// TestNewRenderer pins the comparison's start: no renderer and no error
|
|
// without a Chrome path (skipped), an error for a relative path, a
|
|
// missing file, a directory or a file that is not executable (failed).
|
|
func TestNewRenderer(t *testing.T) {
|
|
site, _ := url.Parse("https://example.test")
|
|
cfg := func(path string) *compass.Config {
|
|
c, err := compass.Open(compass.Options{Dir: t.TempDir()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if path != "" {
|
|
if err := c.Set(ChromePathConfigKey, path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
if r, err := NewRenderer(context.Background(), cfg(""), site, false, "ua"); r != nil || err != nil {
|
|
t.Fatalf("no path: %v %v, want skipped", r, err)
|
|
}
|
|
if r, err := NewRenderer(context.Background(), nil, site, false, "ua"); r != nil || err != nil {
|
|
t.Fatal("no config: skipped")
|
|
}
|
|
dir := t.TempDir()
|
|
plain := filepath.Join(dir, "chrome.txt")
|
|
if err := os.WriteFile(plain, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, path := range map[string]string{"relative": "bin/chrome", "missing": filepath.Join(dir, "nope"), "directory": dir, "not executable": plain} {
|
|
r, err := NewRenderer(context.Background(), cfg(path), site, false, "ua")
|
|
if r != nil || err == nil || !strings.Contains(err.Error(), "Chrome path") {
|
|
t.Errorf("%s: %v %v, want a Chrome path error", name, r, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRenderMax pins the page cap: the default 200, a positive config
|
|
// value, and the default for 0 or less.
|
|
func TestRenderMax(t *testing.T) {
|
|
if RenderMax(nil) != DefaultRenderMax || DefaultRenderMax != 200 {
|
|
t.Fatal("default cap")
|
|
}
|
|
for v, want := range map[int]int{50: 50, 0: DefaultRenderMax, -1: DefaultRenderMax} {
|
|
c, _ := compass.Open(compass.Options{Dir: t.TempDir()})
|
|
_ = c.Set(RenderMaxConfigKey, v)
|
|
if got := RenderMax(c); got != want {
|
|
t.Errorf("RenderMax(%d) = %d, want %d", v, got, want)
|
|
}
|
|
}
|
|
if got := SiteHosts("WWW.Example.test"); len(got) != 2 || got[0] != "www.example.test" || got[1] != "example.test" {
|
|
t.Fatalf("SiteHosts = %v", got)
|
|
}
|
|
if got := SiteHosts("example.test"); got[1] != "www.example.test" || SiteHosts(" ") != nil {
|
|
t.Fatalf("SiteHosts = %v", got)
|
|
}
|
|
}
|
|
|
|
// TestRenderTabTimeout pins L3 WR-06: a tab that does not open answers an
|
|
// error within TabOpenTimeout (lowered for the test) plus a margin, so the
|
|
// crawl's render lock is released; the next Render is not held up by the
|
|
// first one's hung tab.
|
|
func TestRenderTabTimeout(t *testing.T) {
|
|
saved := TabOpenTimeout
|
|
TabOpenTimeout = 50 * time.Millisecond
|
|
t.Cleanup(func() { TabOpenTimeout = saved })
|
|
hung := make(chan struct{})
|
|
t.Cleanup(func() { close(hung) })
|
|
r := &chromeRenderer{
|
|
allowed: []string{"example.test"},
|
|
port: "443",
|
|
browserCtx: context.Background(),
|
|
newTab: func(parent context.Context) (context.Context, context.CancelFunc) {
|
|
return context.WithCancel(parent)
|
|
},
|
|
// A hung Chrome: opening the tab never answers, even when the tab
|
|
// is closed.
|
|
startTab: func(context.Context) error {
|
|
<-hung
|
|
return nil
|
|
},
|
|
}
|
|
for i := range 2 {
|
|
start := time.Now()
|
|
_, err := r.Render(t.Context(), "https://example.test/a")
|
|
if err == nil || !strings.Contains(err.Error(), "did not open a tab within 50ms") {
|
|
t.Fatalf("Render %d: %v, want the tab timeout", i, err)
|
|
}
|
|
if d := time.Since(start); d > time.Second {
|
|
t.Fatalf("Render %d returned after %v", i, d)
|
|
}
|
|
}
|
|
TabOpenTimeout = 5 * time.Second
|
|
if got := seconds(TabOpenTimeout); got != "5 s" {
|
|
t.Fatalf("seconds = %q", got)
|
|
}
|
|
}
|