perf(scan): drain response bodies so pooled connections get reused

go only returns a conn to the idle pool when the body is read to EOF
before Close. the header-only and early-return scan paths closed an
unread body, leaking the conn and forcing a fresh dial each request.
route those close sites through httpx.DrainClose so the tuned pool from
phase 1 actually gets reused. body-read paths (scanner/io.ReadAll) are
left untouched.
This commit is contained in:
vmfunc
2026-06-10 15:43:32 -07:00
parent 136ddbddba
commit 0383a7bcd2
14 changed files with 53 additions and 31 deletions
+3 -2
View File
@@ -104,11 +104,12 @@ func checkS3Bucket(ctx context.Context, bucket string, client *http.Client) (boo
if err != nil {
return false, err
}
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return false, err
}
defer resp.Body.Close()
// status only; drain on close so the conn returns to the pool.
defer httpx.DrainClose(resp)
// If we can access the bucket listing, it's public
return resp.StatusCode == http.StatusOK, nil
+3 -2
View File
@@ -128,10 +128,11 @@ func detectWordPress(url string, client *http.Client, bodyString string) bool {
if err != nil {
continue
}
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err == nil {
found := resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound
resp.Body.Close()
// status only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
if found {
return true
}
+3 -3
View File
@@ -175,13 +175,13 @@ func probeCORS(client *http.Client, targetURL, origin, note string) (CORSFinding
}
req.Header.Set("Origin", origin)
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
charmlog.Debugf("cors: request %s with origin %s: %v", targetURL, origin, err)
return CORSFinding{}, false
}
// headers are all we need; drain nothing, just close.
resp.Body.Close()
// headers are all we need; drain the body so the conn returns to the pool.
httpx.DrainClose(resp)
allowOrigin := resp.Header.Get("Access-Control-Allow-Origin")
if allowOrigin == "" {
+3 -2
View File
@@ -229,14 +229,15 @@ func probeSubdomain(client *http.Client, host string) (string, dnsScheme) {
charmlog.Debugf("Error %s: %s", host, err)
continue
}
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
charmlog.Debugf("Error %s: %s", host, err)
continue
}
code := resp.StatusCode
resolved := resp.Request.URL.String()
resp.Body.Close()
// status/url only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
if meaningfulStatus(code) {
return resolved, schemes[i].label
+3 -2
View File
@@ -91,7 +91,7 @@ func Git(url string, timeout time.Duration, threads int, logdir string) ([]strin
charmlog.Debugf("Error creating request for %s: %s", repourl, err)
continue
}
resp, err := client.Do(gitReq)
resp, err := client.Do(gitReq) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
charmlog.Debugf("Error %s: %s", repourl, err)
continue
@@ -109,7 +109,8 @@ func Git(url string, timeout time.Duration, threads int, logdir string) ([]strin
foundUrls = append(foundUrls, resp.Request.URL.String())
mu.Unlock()
}
resp.Body.Close()
// status/headers only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
}
}(thread)
}
+3 -2
View File
@@ -46,11 +46,12 @@ func Headers(url string, timeout time.Duration, logdir string) ([]HeaderResult,
if err != nil {
return nil, err
}
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return nil, err
}
defer resp.Body.Close()
// header-only scan: drain on close so the conn is returned to the pool.
defer httpx.DrainClose(resp)
var results []HeaderResult
+5 -2
View File
@@ -129,7 +129,9 @@ func doSupabaseRequest(projectId, path, apikey string, auth *string, timeout tim
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
// the non-200 branch returns before reading the body, so drain on close to
// keep the conn reusable instead of leaking it.
defer httpx.DrainClose(resp)
if resp.StatusCode != 200 {
return nil, nil, errors.New("request to " + resp.Request.URL.String() + " failed with status code " + strconv.Itoa(resp.StatusCode))
@@ -215,7 +217,8 @@ func ScanSupabase(jsContent string, jsUrl string, timeout time.Duration) ([]supa
auth = authResp.AccessToken
supabaselog.Infof("Created account with JWT %s", auth)
} else {
resp.Body.Close()
// non-200 signup: body never read, so drain to reuse the conn.
httpx.DrainClose(resp)
}
var collections = []supabaseCollection{}
+4 -2
View File
@@ -205,11 +205,13 @@ func passiveGET(ctx context.Context, client *http.Client, reqURL string) ([]byte
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// the non-200 branch returns before reading the body, so drain on close to
// keep the conn reusable instead of leaking it.
defer httpx.DrainClose(resp)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
+4 -2
View File
@@ -229,12 +229,14 @@ func probeRedirect(client *http.Client, testURL string) (location, via string, o
charmlog.Debugf("redirect: build request for %s: %v", testURL, err)
return "", "", false
}
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
charmlog.Debugf("redirect: request %s: %v", testURL, err)
return "", "", false
}
defer resp.Body.Close()
// the header-redirect branch returns before reading the body, so drain on
// close to keep that conn reusable instead of leaking it.
defer httpx.DrainClose(resp)
// header redirect: a 30x whose Location resolves to the sentinel host
if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest {
+9 -5
View File
@@ -74,7 +74,8 @@ func fetchRobotsTXT(url string, client *http.Client) *http.Response {
}
redirectURL := resp.Header.Get("Location")
resp.Body.Close()
// only the Location header is used here; drain so the conn is reusable.
httpx.DrainClose(resp)
if redirectURL == "" {
log.Debugf("Redirect location is empty for %s", url)
return nil
@@ -111,11 +112,13 @@ func Scan(url string, timeout time.Duration, threads int, logdir string) {
return http.ErrUseLastResponse
}
resp := fetchRobotsTXT(url+"/robots.txt", client)
resp := fetchRobotsTXT(url+"/robots.txt", client) //nolint:bodyclose // drained and closed via httpx.DrainClose
if resp == nil {
return
}
defer resp.Body.Close()
// drain on close: the non-success branch never reads the body, so a bare
// close would leak the conn instead of returning it to the pool.
defer httpx.DrainClose(resp)
if resp.StatusCode != 404 && resp.StatusCode != 301 && resp.StatusCode != 302 && resp.StatusCode != 307 {
output.Success("File %s found", output.Status.Render("robots.txt"))
@@ -149,7 +152,7 @@ func Scan(url string, timeout time.Duration, threads int, logdir string) {
log.Debugf("Error creating request for %s: %s", sanitizedRobot, err)
continue
}
resp, err := client.Do(robotReq)
resp, err := client.Do(robotReq) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
log.Debugf("Error %s: %s", sanitizedRobot, err)
continue
@@ -161,7 +164,8 @@ func Scan(url string, timeout time.Duration, threads int, logdir string) {
logger.Write(sanitizedURL, logdir, strconv.Itoa(resp.StatusCode)+" from robots: ["+sanitizedRobot+"]\n")
}
}
resp.Body.Close()
// status only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
}
}(thread)
+3 -2
View File
@@ -71,11 +71,12 @@ func SecurityHeaders(url string, timeout time.Duration, logdir string) (Security
if err != nil {
return nil, err
}
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return nil, err
}
defer resp.Body.Close()
// header-only scan: drain on close so the conn is returned to the pool.
defer httpx.DrainClose(resp)
results := gradeSecurityHeaders(resp.Header, strings.HasPrefix(url, "https://"))
+4 -2
View File
@@ -187,11 +187,13 @@ func doSTRequest(client *http.Client, reqURL, apiKey string) ([]byte, error) {
req.Header.Set("APIKEY", apiKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return nil, fmt.Errorf("SecurityTrails request failed: %w", err)
}
defer resp.Body.Close()
// the auth/rate-limit branches return before reading the body, so drain on
// close to keep the conn reusable instead of leaking it.
defer httpx.DrainClose(resp)
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("invalid SecurityTrails API key (status %d)", resp.StatusCode)
+4 -2
View File
@@ -188,11 +188,13 @@ func queryShodanHost(ip string, apiKey string, timeout time.Duration) (*ShodanRe
if err != nil {
return nil, fmt.Errorf("failed to create Shodan request: %w", err)
}
resp, err := client.Do(req)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return nil, fmt.Errorf("failed to query Shodan: %w", err)
}
defer resp.Body.Close()
// the unauthorized/not-found branches return before reading the body, so
// drain on close to keep the conn reusable instead of leaking it.
defer httpx.DrainClose(resp)
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("invalid Shodan API key")
+2 -1
View File
@@ -208,7 +208,8 @@ func SQL(targetURL string, timeout time.Duration, threads int, logdir string) (*
}
}
} else {
resp.Body.Close()
// uninteresting status; body never read, so drain to reuse the conn.
httpx.DrainClose(resp)
}
}
}()