diff --git a/go.mod b/go.mod index c51a57d3..aeb0306a 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( github.com/projectdiscovery/retryablehttp-go v1.3.25 github.com/projectdiscovery/tlsx v1.4.0 github.com/projectdiscovery/useragent v0.0.109 - github.com/projectdiscovery/utils v0.11.3 + github.com/projectdiscovery/utils v0.11.4-0.20260914143159-3e1465cc1ad9 github.com/projectdiscovery/wappalyzergo v0.2.96 github.com/rs/xid v1.6.0 github.com/spaolacci/murmur3 v1.1.0 diff --git a/go.sum b/go.sum index e13aab54..ba4ab5fb 100644 --- a/go.sum +++ b/go.sum @@ -296,8 +296,8 @@ github.com/projectdiscovery/tlsx v1.4.0 h1:OU2mNK6TOB/YiFX3jhmgwOepFWxPoj5rn8npW github.com/projectdiscovery/tlsx v1.4.0/go.mod h1:WCrUXAHjdKbDNjsu5EbVBS7qvzap3fxi/E85v/Flyx8= github.com/projectdiscovery/useragent v0.0.109 h1:b86BPdZvmgbJousa04jAugDRY4Y+rAvEamE0TFCPmeE= github.com/projectdiscovery/useragent v0.0.109/go.mod h1:sRyBqDAcD31RKYrO8fyXGowLd36fNwY1g0MNW9mp7zk= -github.com/projectdiscovery/utils v0.11.3 h1:TNBhSNJ8uFw7DWASUHBf1vfCx7mwMi+jtGizrizMM5s= -github.com/projectdiscovery/utils v0.11.3/go.mod h1:HMxhxLigsAr+M9Oa8n9Z0ROZcs8wAJHGsgR7UGb+oUE= +github.com/projectdiscovery/utils v0.11.4-0.20260914143159-3e1465cc1ad9 h1:QvOW37msb6PFbFgFs2XHDb2QJxY4ALX9HXRnp7WPEkY= +github.com/projectdiscovery/utils v0.11.4-0.20260914143159-3e1465cc1ad9/go.mod h1:HMxhxLigsAr+M9Oa8n9Z0ROZcs8wAJHGsgR7UGb+oUE= github.com/projectdiscovery/wappalyzergo v0.2.96 h1:/YHMiUA2kL2bap2vHDuZs1cJK3RgHZjxdVam5GxJ12k= github.com/projectdiscovery/wappalyzergo v0.2.96/go.mod h1:5ZuunxbKPGvnJeEOMIqlfj5xg/XSEAoakZr/s9l2pNI= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= diff --git a/runner/headless.go b/runner/headless.go index 3d295d82..21ceb254 100644 --- a/runner/headless.go +++ b/runner/headless.go @@ -1,6 +1,7 @@ package runner import ( + "context" "fmt" "os" "strings" @@ -11,6 +12,7 @@ import ( "github.com/go-rod/rod/lib/launcher/flags" "github.com/go-rod/rod/lib/proto" "github.com/pkg/errors" + "github.com/projectdiscovery/utils/chromeshell" fileutil "github.com/projectdiscovery/utils/file" mapsutil "github.com/projectdiscovery/utils/maps" osutils "github.com/projectdiscovery/utils/os" @@ -61,6 +63,28 @@ func NewBrowser(proxy string, useLocal bool, optionalArgs map[string]string) (*B Set("window-size", fmt.Sprintf("%d,%d", 1080, 1920)). Set("mute-audio", "true"). Set("incognito", "true"). + // Performance: keep background/occluded tabs running at full speed so + // many concurrent screenshot pages don't get render-throttled. + Set("disable-background-timer-throttling", "true"). + Set("disable-backgrounding-occluded-windows", "true"). + Set("disable-renderer-backgrounding", "true"). + Set("disable-ipc-flooding-protection", "true"). + Set("disable-hang-monitor", "true"). + // Performance: strip background chrome services we never use. + Set("disable-background-networking", "true"). + Set("disable-client-side-phishing-detection", "true"). + Set("disable-component-update", "true"). + Set("disable-default-apps", "true"). + Set("disable-domain-reliability", "true"). + Set("disable-extensions", "true"). + Set("disable-sync", "true"). + Set("no-first-run", "true"). + Set("no-default-browser-check", "true"). + Set("metrics-recording-only", "true"). + Set("safebrowsing-disable-auto-update", "true"). + // site-per-process stays enabled: screenshots render untrusted pages, so + // Chromium's cross-origin renderer isolation is worth the extra processes. + Set("disable-features", "Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints"). Delete("use-mock-keychain"). Headless(true). UserDataDir(dataStore) @@ -82,6 +106,12 @@ func NewBrowser(proxy string, useLocal bool, optionalArgs map[string]string) (*B } else { return nil, errors.New("the chrome browser is not installed") } + } else if chromeshell.Supported() { + // Prefer chrome-headless-shell on linux/amd64: smaller download and + // faster headless screenshots than full Chromium snapshots. + if shellPath, err := ensureChromeShell(); err == nil { + chromeLauncher.Bin(shellPath) + } } if proxy == "" { @@ -119,13 +149,13 @@ func NewBrowser(proxy string, useLocal bool, optionalArgs map[string]string) (*B } func (b *Browser) ScreenshotWithBody(url string, timeout time.Duration, idle time.Duration, headers []string, fullPage bool, jsCodes []string) ([]byte, string, []NetworkRequest, error) { - page, networkRequests, err := b.setupPageAndNavigate(url, timeout, headers, jsCodes) + page, networkRequests, err := b.setupPageAndNavigate(url, timeout, idle, headers, jsCodes) if err != nil { return nil, "", []NetworkRequest{}, err } defer b.closePage(page) - screenshot, body, err := b.takeScreenshotAndGetBody(page, idle, fullPage) + screenshot, body, err := b.takeScreenshotAndGetBody(page, fullPage) if err != nil { return nil, "", networkRequests, err } @@ -134,7 +164,7 @@ func (b *Browser) ScreenshotWithBody(url string, timeout time.Duration, idle tim } // setupPageAndNavigate opens a page, performs all adaptive actions including JS injection -func (b *Browser) setupPageAndNavigate(url string, timeout time.Duration, headers []string, jsCodes []string) (*rod.Page, []NetworkRequest, error) { +func (b *Browser) setupPageAndNavigate(url string, timeout time.Duration, idle time.Duration, headers []string, jsCodes []string) (*rod.Page, []NetworkRequest, error) { page, err := b.engine.Page(proto.TargetCreateTarget{}) if err != nil { return nil, []NetworkRequest{}, err @@ -208,6 +238,13 @@ func (b *Browser) setupPageAndNavigate(url string, timeout time.Duration, header } page = page.Timeout(timeout) + var waitReqIdle func() + if idle > 0 { + // Register before Navigate so the first SPA XHRs are tracked by the + // request-idle waiter. Zero/negative idle skips this (WaitRequestIdle + // and WaitDOMStable reject non-positive durations). + waitReqIdle = page.WaitRequestIdle(idle, nil, nil, nil) + } if err := page.Navigate(url); err != nil { return page, networkRequests.Slice, err @@ -220,19 +257,50 @@ func (b *Browser) setupPageAndNavigate(url string, timeout time.Duration, header } } - page.Timeout(5 * time.Second).WaitNavigation(proto.PageLifecycleEventNameFirstMeaningfulPaint)() + b.waitPageReady(page, idle, waitReqIdle) return page, networkRequests.Slice, nil } -// takeScreenshotAndGetBody performs the screenshot actions -func (b *Browser) takeScreenshotAndGetBody(page *rod.Page, idle time.Duration, fullPage bool) ([]byte, string, error) { - if err := page.WaitLoad(); err != nil { - return nil, "", err +// waitPageReady blocks until the page is visually settled so we don't capture a +// half-rendered SPA. It gates on three independent signals, each bounded by the +// page timeout: +// - window.onload +// - network request-idle (a quiet network window) +// - DOM stability (the rendered tree stops mutating) +// +// SPAs paint late: the network can briefly go quiet before hydration starts, so +// request-idle alone can fire on the boot screen. Requiring DOM stability on top +// closes that gap. DOM stability is re-checked after the network settles so the +// first snapshot is taken post-hydration rather than on the boot screen. +func (b *Browser) waitPageReady(page *rod.Page, idle time.Duration, waitReqIdle func()) { + _ = page.WaitLoad() + if idle <= 0 || waitReqIdle == nil { + return } - _ = page.WaitIdle(idle) + waitReqIdle() + _ = page.WaitDOMStable(idle, 0) +} + +const chromeShellEnsureTimeout = 2 * time.Minute + +// ensureChromeShell fetches the browser under a deadline. The context has to +// reach the download itself: racing a plain Ensure against a timer would only +// abandon the wait, leaving the transfer running and the shared download lock +// held, so the next caller would block behind it. +func ensureChromeShell() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), chromeShellEnsureTimeout) + defer cancel() + return chromeshell.EnsureContext(ctx) +} + +// takeScreenshotAndGetBody performs the screenshot actions +func (b *Browser) takeScreenshotAndGetBody(page *rod.Page, fullPage bool) ([]byte, string, error) { + _ = page.WaitRepaint() - screenshot, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{}) + screenshot, err := page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ + OptimizeForSpeed: true, + }) if err != nil { return nil, "", err } diff --git a/runner/headless_screenshot_test.go b/runner/headless_screenshot_test.go new file mode 100644 index 00000000..e21297db --- /dev/null +++ b/runner/headless_screenshot_test.go @@ -0,0 +1,139 @@ +package runner + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +const spaHydratedMarker = "hydrated-ok" + +// spaHTML is a tiny SPA-style page: it paints a boot screen first, then +// fetches /app.js which hydrates #root after a short delay. Early captures +// only contain "boot"; a correct wait must include spaHydratedMarker. +const spaHTML = ` +