Compare commits

...
Author SHA1 Message Date
Nakul Bharti 4eb63b0f8e perf(jarm): integrate compatible shared fingerprints 2026-09-10 22:29:59 +05:30
Nakul Bharti 5aebf0dad2 fix(interactsh): guard non-fuzz callback accounting 2026-09-10 17:56:19 +05:30
Nakul Bharti a8f6e6bfc6 fix(interactsh): recognize raw SNI callbacks 2026-09-10 17:12:44 +05:30
Nakul Bharti 5e34b46c1d perf(hosterrors): allocate storage on first failure 2026-09-10 12:28:33 +05:30
Nakul Bharti cc69698813 fix(interactsh): drain in-flight scoped callbacks 2026-09-10 11:40:13 +05:30
Nakul Bharti e052f15f88 fix(protocols): preserve interactsh request scope 2026-09-10 11:06:24 +05:30
Nakul Bharti 54b76fd605 fix(sdk): isolate shared interactsh executions 2026-09-10 11:03:28 +05:30
Nakul Bharti 59304896f6 perf(stats): scan waf literals once per response 2026-09-09 21:34:25 +05:30
Nakul Bharti f35ca5faae perf(stats): prefilter waf response regexes 2026-09-09 20:38:06 +05:30
Nakul Bharti 122c3e98f9 perf(hosterrors): release cache storage on close 2026-09-08 16:27:18 +05:30
Nakul Bharti 0cd237a96c perf(stats): reuse compiled waf detector 2026-09-08 16:23:31 +05:30
Nakul Bharti 3cb82ef3ec perf(interactsh): allocate caches on first use 2026-09-08 16:16:00 +05:30
Nakul Bharti e1788e84db fix(core): report context-interrupted template executions 2026-09-08 15:36:21 +05:30
Nakul Bharti 5329494e55 feat(core): expose template execution lifecycle 2026-09-08 15:31:40 +05:30
Nakul Bharti c8fa2812e4 fix(headless): include payloads in request count 2026-09-08 13:05:13 +05:30
Nakul Bharti f85303e506 fix(network): include ports and payloads in request count 2026-09-08 12:57:51 +05:30
Stan Ivanov eca24eeef2 Let -c and payload threads raise the idle connection pool
Review asked for the pool to be sized from the configured concurrency instead of
a constant, as max(TemplateThreads, request Threads) with TemplateThreads
defaulting to 25. The first half of that is right and is what this does now:
TemplateThreads and a template's payload Threads both raise the pool, which fixes
-c 800 being served by a 500-deep pool.

They cannot also lower it, because -c is not an upper bound on how many
connections the transport has open. http.Transport starts a dial whenever a
request does not find an idle connection; if one frees up first the request
takes it and the racing dial still completes. Measured against a keep-alive
target at -c 25, 1,965 templates, 5,388 requests, counting at the target:

  requests in flight   peaked at 22-31
  open connections     peaked at 121-178

Sizing the pool at -c therefore discards most of what the transport opened:

  pool 500 (floor)   414 connections    13.0 req/conn
  pool 25  (= -c)  3,070 connections     1.8 req/conn
  pool 4   (v3.11.1) 4,352 connections   1.2 req/conn

So the floor stays at 500 and the configured concurrency raises it. A pool that
is never filled costs nothing: it is a ceiling on retained connections, not an
allocation, and the transport only ever holds connections it actually opened.

Tests: TestIdleConnPoolSize is the requested table (unset, -c 50, -c 800,
payload threads raising the pool, payload threads never shrinking it);
TestIdleConnPoolSizeIsWiredToTransport asserts the size reaches http.Transport
rather than only the helper; TestIdleConnPoolAllowsKeepAliveReuseWithPayloadThreads
covers the client that payload templates share -- Configuration.Threads is in the
client cache key, so every template with the same payload thread count piles onto
one pool. It holds each group of requests at a cyclic barrier so the overlap is
sustained for the whole run; with a single-shot barrier a pool that is too small
still passes, because once requests stop overlapping a shallow pool is enough.
2026-09-08 11:48:53 +05:30
Stanimir Ivanov dd33cf846a Make the idle-pool test fail loudly instead of vacuously
The connection-count assertion held trivially in two cases the test did not
rule out: if every request errored (client.Do errors were swallowed, so zero
successful requests also means zero connections), or if the requests happened
to serialise, since the semaphore caps concurrency without forcing overlap.

Requests now block in the handler until "concurrency" of them have arrived, so
that many connections are genuinely required, and the test asserts every
request succeeded before looking at the connection count. A timer releases the
barrier so a shortfall fails on the assertions rather than hanging.

Verified both ways: reverting the pool to 4 fails on the connection count (400
requests over 176 connections), and pointing the client at a closed server
fails on "requests must succeed" rather than passing on zero connections.
2026-09-08 11:48:53 +05:30
Stanimir Ivanov e2cad13643 Add regression test for per-host idle connection pool sizing
Counts connections accepted by the server rather than requests served,
because the regression is invisible in request counts: the scan still issues
every request, it just opens a new connection for most of them.

Fails at MaxIdleConnsPerHost=4 (400 requests over 157 connections) and passes
with the pool sized to concurrency (400 requests over 50 connections).
2026-09-08 11:48:53 +05:30
Stanimir Ivanov 96919ebf6d Size the per-host idle connection pool to actual concurrency
The per-host client pool introduced in v3.10.0 sets MaxIdleConns and
MaxIdleConnsPerHost to 4. http.Transport closes a connection returned to a
host whose idle pool is already full, so an idle pool smaller than the number
of requests in flight against that host silently defeats keep-alive: every
request past the fourth pays a fresh TCP (and TLS) handshake instead of
reusing a pooled connection.

Clients are scoped to a single host and, in host-spray mode, every template
runs against that host, so concurrency is TemplateThreads multiplied by a
template's payload parallelism. With the defaults that is far above 4.

Measured against a local keep-alive target (6,674 templates, host-spray,
TemplateThreads=50), counting connections accepted by the target:

  before: 17,564 connections for 19,997 requests (1.14 requests/connection)
  after:      885 connections for 19,997 requests (22.6 requests/connection)

Identical request count and identical findings (7 and 7), so this is purely
connection reuse. The connection churn also defeats NAT: behind a Cloud NAT
gateway the pre-fix rate of ~147 new connections/sec per scanner exhausted
per-endpoint port mappings and the gateway began dropping outbound SYNs.

configuration.Threads may now only raise the pool, never lower it, since one
template's thread count does not bound total concurrency against the host.
2026-09-08 11:48:53 +05:30
Rehan Khan da4b23d3e1 fix(http): honor disable-cookie with shared jars
Honor disable-cookie when a workflow supplies a shared cookie jar. Add an integration fixture proving that a cookie set by one request is not attached to a later request with cookie handling disabled.

Signed-off-by: Muhammad Rehan Khan <[email protected]>
Signed-off-by: Rehan Khan <[email protected]>
2026-09-08 11:48:44 +05:30
Mzack9999 4a48adcc65 per-host http client pool (#7301)
* attempting per-host http client pool

* close idle conns

* fixing idle conn leak

* lint

* ignore http internal routines

* fixing tests

* lowering flaky http requirement

* fixing open gate issue

* adding perf tests

* pool rework
2026-09-08 11:47:55 +05:30
Nakul Bharti b5267587da perf(loader): allow execution-scoped metadata reuse 2026-09-08 11:15:00 +05:30
Nakul Bharti 27997de03d feat(templates): add execution-local parser lifecycle 2026-09-08 10:24:08 +05:30
53 changed files with 3937 additions and 357 deletions

No files matched your search

+4 -3
View File
@@ -97,7 +97,7 @@ require (
github.com/oiweiwei/go-msrpc v1.2.12
github.com/ory/dockertest/v3 v3.12.0
github.com/praetorian-inc/fingerprintx v1.1.15
github.com/projectdiscovery/dsl v0.8.19
github.com/projectdiscovery/dsl v0.8.20-0.20260910165339-d6b346923363
github.com/projectdiscovery/fasttemplate v0.0.2
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb
@@ -116,7 +116,7 @@ require (
github.com/projectdiscovery/tlsx v1.2.2
github.com/projectdiscovery/uncover v1.2.1
github.com/projectdiscovery/useragent v0.0.108
github.com/projectdiscovery/utils v0.11.1
github.com/projectdiscovery/utils v0.11.2-0.20260910165339-69be5db48e94
github.com/projectdiscovery/wappalyzergo v0.2.84
github.com/redis/go-redis/v9 v9.11.0
github.com/rs/zerolog v1.34.0
@@ -130,6 +130,7 @@ require (
github.com/zmap/zgrab2 v0.1.8
gitlab.com/gitlab-org/api/client-go v1.9.1
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/sync v0.20.0
golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
moul.io/http2curl v1.0.0
@@ -337,6 +338,7 @@ require (
github.com/projectdiscovery/blackrock v0.0.1 // indirect
github.com/projectdiscovery/cdncheck v1.2.39 // indirect
github.com/projectdiscovery/freeport v0.0.7 // indirect
github.com/projectdiscovery/jarm-go v0.0.0-20260910160638-430e2bae86f6 // indirect
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
@@ -395,7 +397,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/sync v0.20.0 // indirect
mellium.im/sasl v0.3.2 // indirect
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
)
+6
View File
@@ -854,6 +854,8 @@ github.com/projectdiscovery/clistats v0.1.4 h1:kDnXoNxIdOvQElOF7k2Mt6XosGa5GbMKP
github.com/projectdiscovery/clistats v0.1.4/go.mod h1:hjJYNcUubk9T3cuFvA+JkLhZGjzYW50fkC48dqUAtbU=
github.com/projectdiscovery/dsl v0.8.19 h1:qA5OFJMfghSCjKqS4AdsEtnur/SoriXDw3geE7+mReU=
github.com/projectdiscovery/dsl v0.8.19/go.mod h1:Twk93q7fxQ43v/8nR+0TJV8/eFTdBAC5tIXe3qzua9Y=
github.com/projectdiscovery/dsl v0.8.20-0.20260910165339-d6b346923363 h1:nISqd10FtEBjpm3kCiDhmfEXqrH5JIOw1pcUfM2+c3Y=
github.com/projectdiscovery/dsl v0.8.20-0.20260910165339-d6b346923363/go.mod h1:+o9tlgoGf7W18ReAt920iiV9mdJUJ0K62Jxz6L5/6ys=
github.com/projectdiscovery/fastdialer v0.5.10 h1:dB9MSu4cSo22qne4pHiK9iYSxfOgpwlKB6zfOHvz3RI=
github.com/projectdiscovery/fastdialer v0.5.10/go.mod h1:W1ZkULr9mMR6i0oRFTztANnpVyEEzPUovK8sUM4eAw8=
github.com/projectdiscovery/fasttemplate v0.0.2 h1:h2cISk5xDhlJEinlBQS6RRx0vOlOirB2y3Yu4PJzpiA=
@@ -880,6 +882,8 @@ github.com/projectdiscovery/httpx v1.9.0 h1:5yn4ik/LqZ+v3MLgU7+CZJQyND9osW9NmZ3s
github.com/projectdiscovery/httpx v1.9.0/go.mod h1:jGTRyUHddo2WyK4klWIwQXgGF1Lu39XVyzlue4H3pX8=
github.com/projectdiscovery/interactsh v1.3.1 h1:5HzeVGVCAX/cjTguJ+7ClOmML5r97Ty7op9s+/F7BiM=
github.com/projectdiscovery/interactsh v1.3.1/go.mod h1:MXQ11EoBPROb4bEw+WP9e4DX4fMhrpS6EwfMfZomBsw=
github.com/projectdiscovery/jarm-go v0.0.0-20260910160638-430e2bae86f6 h1:GUHQYLllWhoca+C4UAT0+TTB1PrUT05klj39V6/KpK8=
github.com/projectdiscovery/jarm-go v0.0.0-20260910160638-430e2bae86f6/go.mod h1:f8P5X5+jEkUqK9z3oNWtSH1g23jMfw3nuEqmtA1jXA4=
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb h1:MGtI4oE12ruWv11ZlPXXd7hl/uAaQZrFvrIDYDeVMd8=
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb/go.mod h1:vmgC0DTFCfoCLp0RAfsfYTZZan0QMVs+cmTbH6blfjk=
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 h1:eR+0HE//Ciyfwy3HC7fjRyKShSJHYoX2Pv7pPshjK/Q=
@@ -912,6 +916,8 @@ github.com/projectdiscovery/useragent v0.0.108 h1:fb+uLuFJvC+MHZjCtxQJxtvp1X6A8n
github.com/projectdiscovery/useragent v0.0.108/go.mod h1:XdNRrlvtDmYfVL1Oybat4uMe+W6cLwsK9S18ond17CI=
github.com/projectdiscovery/utils v0.11.1 h1:PWj1KjIASxt8icxommH72C0TQqNOvGkcSODRkiq0SQw=
github.com/projectdiscovery/utils v0.11.1/go.mod h1:yktGrHGk2CTjNiccXovnvGrLHX9sV2bqz9nSnbA3V8M=
github.com/projectdiscovery/utils v0.11.2-0.20260910165339-69be5db48e94 h1:vTK5LupYuhXkJ5LimTjZC0sKVuX9TbOxh8rIJa6rZeQ=
github.com/projectdiscovery/utils v0.11.2-0.20260910165339-69be5db48e94/go.mod h1:JxVWP7DE+xMLt4+HEcFqEjUijVKUxCKU8xdvquoKfT8=
github.com/projectdiscovery/wappalyzergo v0.2.84 h1:19c+ea8KZCnZIuZPztafFKK2uczDXxcZ/z6/l6DEEEs=
github.com/projectdiscovery/wappalyzergo v0.2.84/go.mod h1:gMH0o5lBp65sKMwHx/tuUdOtW2RjodC6Ti+9QDsYMkY=
github.com/projectdiscovery/yamldoc-go v1.0.6 h1:GCEdIRlQjDux28xTXKszM7n3jlMf152d5nqVpVoetas=
+11 -1
View File
@@ -198,7 +198,7 @@ func New(options *types.Options) (*Runner, error) {
var httpclient *retryablehttp.Client
if options.ProxyInternal && options.AliveHttpProxy != "" || options.AliveSocksProxy != "" {
var err error
httpclient, err = httpclientpool.Get(options, &httpclientpool.Configuration{})
httpclient, err = httpclientpool.Get(options, &httpclientpool.Configuration{}, "")
if err != nil {
return nil, err
}
@@ -427,6 +427,11 @@ func (r *Runner) Close() {
if r.httpStats != nil {
r.httpStats.DisplayTopStats(r.options.NoColor)
}
if newConns, reusedConns := httpclientpool.GetConnectionStats(); newConns+reusedConns > 0 {
total := newConns + reusedConns
ratio := float64(reusedConns) / float64(total) * 100
gologger.Info().Msgf("HTTP connections: %d total, %d new, %d reused (%.1f%%)", total, newConns, reusedConns, ratio)
}
// dump hosterrors cache
if r.hostErrors != nil {
r.hostErrors.Close()
@@ -507,6 +512,11 @@ func (r *Runner) setupPDCPUpload(writer output.Writer) output.Writer {
// RunEnumeration sets up the input layer for giving input nuclei.
// binary and runs the actual enumeration
func (r *Runner) RunEnumeration() error {
// Reset connection-reuse counters so the summary logged on Close()
// reflects only this run, not totals accumulated across multiple
// in-process executions (e.g. SDK / embedded usage).
httpclientpool.ResetConnectionStats()
// If the user has asked for DAST server mode, run the live
// DAST fuzzing server.
if r.options.DASTServer {
+25
View File
@@ -44,6 +44,7 @@ var httpTestcases = []integrationCase{
{Path: "protocols/http/post-json-body.yaml", TestCase: &httpPostJSONBody{}},
{Path: "protocols/http/post-multipart-body.yaml", TestCase: &httpPostMultipartBody{}},
{Path: "protocols/http/raw-cookie-reuse.yaml", TestCase: &httpRawCookieReuse{}},
{Path: "protocols/http/disable-cookie-reuse.yaml", TestCase: &httpDisableCookieReuse{}},
{Path: "protocols/http/raw-dynamic-extractor.yaml", TestCase: &httpRawDynamicExtractor{}},
{Path: "protocols/http/raw-get-query.yaml", TestCase: &httpRawGetQuery{}},
{Path: "protocols/http/raw-get.yaml", TestCase: &httpRawGet{}},
@@ -902,6 +903,30 @@ func (h *httpPaths) Execute(filepath string) error {
type httpRawCookieReuse struct{}
type httpDisableCookieReuse struct{}
// Execute executes a test case and returns an error if occurred
func (h *httpDisableCookieReuse) Execute(filePath string) error {
router := httprouter.New()
router.GET("/login", func(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
http.SetCookie(w, &http.Cookie{Name: "nuclei", Value: "test"})
_, _ = fmt.Fprint(w, "logged in")
})
router.GET("/anonymous", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if _, err := r.Cookie("nuclei"); errors.Is(err, http.ErrNoCookie) {
_, _ = fmt.Fprint(w, "anonymous")
}
})
ts := httptest.NewServer(router)
defer ts.Close()
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, ts.URL, debug)
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
// Execute executes a test case and returns an error if occurred
func (h *httpRawCookieReuse) Execute(filePath string) error {
router := httprouter.New()
@@ -0,0 +1,27 @@
id: disable-cookie-reuse
info:
name: Disable Cookie Reuse
author: pdteam
severity: info
http:
- method: GET
path:
- "{{BaseURL}}/login"
matchers:
- type: word
words:
- "logged in"
internal: true
- method: GET
path:
- "{{BaseURL}}/anonymous"
disable-cookie: true
matchers:
- type: word
words:
- "anonymous"
+7
View File
@@ -41,6 +41,9 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
Parser: base.parser,
Browser: base.browserInstance,
}
if base.interactshClient != nil {
u.executerOpts.InteractshScope = base.interactshClient.NewRequestScope()
}
if opts.ShouldUseHostError() && base.hostErrCache != nil {
u.executerOpts.HostErrorsCache = base.hostErrCache
}
@@ -59,6 +62,9 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
// closeEphemeralObjects closes all resources used by ephemeral nuclei objects/instances/types
func closeEphemeralObjects(u *unsafeOptions) {
if u.executerOpts.InteractshScope != nil {
u.executerOpts.InteractshScope.Close()
}
if u.executerOpts.RateLimiter != nil {
u.executerOpts.RateLimiter.Stop()
}
@@ -67,6 +73,7 @@ func closeEphemeralObjects(u *unsafeOptions) {
u.executerOpts.Output = nil
u.executerOpts.IssuesClient = nil
u.executerOpts.Interactsh = nil
u.executerOpts.InteractshScope = nil
u.executerOpts.HostErrorsCache = nil
u.executerOpts.Progress = nil
u.executerOpts.Catalog = nil
+1 -1
View File
@@ -177,7 +177,7 @@ func (e *NucleiEngine) init(ctx context.Context) error {
}
if e.opts.ProxyInternal && e.opts.AliveHttpProxy != "" || e.opts.AliveSocksProxy != "" {
httpclient, err := httpclientpool.Get(e.opts, &httpclientpool.Configuration{})
httpclient, err := httpclientpool.Get(e.opts, &httpclientpool.Configuration{}, "")
if err != nil {
return err
}
+4 -3
View File
@@ -14,11 +14,12 @@ import (
)
var knownLeaks = []goleak.Option{
// prettyify the output and generate dependency graph and more details instead of just stack output
goleak.Pretty(),
// net/http transport maintains idle connections which are closed with cooldown
// hence they don't count as leaks
// net/http transport maintains idle keep-alive connections whose goroutines
// exit on idle timeout or explicit close - not real leaks.
goleak.IgnoreAnyFunction("net/http.(*http2ClientConn).readLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
}
func TestSimpleNuclei(t *testing.T) {
+13 -3
View File
@@ -72,6 +72,10 @@ type Config struct {
Catalog catalog.Catalog
ExecutorOptions *protocols.ExecutorOptions
Logger *gologger.Logger
// MetadataIndex is an optional shared index borrowed by the store. The
// caller remains responsible for persisting it.
MetadataIndex *index.Index
}
// Store is a storage for loaded nuclei templates
@@ -170,14 +174,20 @@ func New(cfg *Config) (*Store, error) {
return nil
})
// Initialize metadata index and filter (load from disk & cache for reuse)
store.metadataIndex = store.loadTemplatesIndex()
// Initialize metadata index and filter (load from disk & cache for reuse).
// A configured index is borrowed so multiple stores can share it without
// reloading and rewriting the entire persistent index for every store.
ownsMetadataIndex := cfg.MetadataIndex == nil
store.metadataIndex = cfg.MetadataIndex
if store.metadataIndex == nil {
store.metadataIndex = store.loadTemplatesIndex()
}
store.indexFilter = store.buildIndexFilter()
if cfg.ExecutorOptions != nil {
cfg.ExecutorOptions.TemplateVerificationCallback = store.getTemplateVerification
}
store.saveMetadataIndexOnce = sync.OnceFunc(func() {
if store.metadataIndex == nil {
if store.metadataIndex == nil || !ownsMetadataIndex {
return
}
+23
View File
@@ -1,11 +1,15 @@
package loader
import (
"os"
"path/filepath"
"reflect"
"testing"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
metadataindex "github.com/projectdiscovery/nuclei/v3/pkg/catalog/index"
"github.com/stretchr/testify/require"
)
@@ -46,6 +50,25 @@ func TestLoadTemplates(t *testing.T) {
})
}
func TestNewUsesConfiguredMetadataIndex(t *testing.T) {
indexDir := t.TempDir()
metadataIndex, err := metadataindex.NewIndex(indexDir)
require.NoError(t, err)
store, err := New(&Config{
Catalog: disk.NewCatalog(""),
Logger: &gologger.Logger{},
MetadataIndex: metadataIndex,
})
require.NoError(t, err)
require.Same(t, metadataIndex, store.metadataIndex)
metadataIndex.Set("/tmp/shared.yaml", &metadataindex.Metadata{ID: "shared"})
store.saveMetadataIndexOnce()
_, err = os.Stat(filepath.Join(indexDir, metadataindex.IndexFileName))
require.ErrorIs(t, err, os.ErrNotExist, "borrowed index persistence belongs to its owner")
}
func TestRemoteTemplates(t *testing.T) {
catalog := disk.NewCatalog("")
+53 -5
View File
@@ -4,9 +4,33 @@ import (
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
// TemplateExecutionState identifies a template execution lifecycle transition.
type TemplateExecutionState uint8
const (
// TemplateExecutionStarted is emitted immediately before a template starts on a target.
TemplateExecutionStarted TemplateExecutionState = iota + 1
// TemplateExecutionFinished is emitted after that template execution returns.
TemplateExecutionFinished
)
// TemplateExecutionEvent identifies one template execution on one target.
type TemplateExecutionEvent struct {
TemplateID string
TemplatePath string
Target string
State TemplateExecutionState
ContextErr error
}
// TemplateExecutionCallback observes template execution lifecycle transitions.
// Nuclei can invoke the callback concurrently; implementations must be concurrency-safe.
type TemplateExecutionCallback func(TemplateExecutionEvent)
// Engine is an executer for running Nuclei Templates/Workflows.
//
// The engine contains multiple thread pools which allow using different
@@ -16,11 +40,12 @@ import (
// templates to leading to the final execution by the work pool, it is
// handled by the engine.
type Engine struct {
workPool *WorkPool
options *types.Options
executerOpts *protocols.ExecutorOptions
Callback func(*output.ResultEvent) // Executed on results
Logger *gologger.Logger
workPool *WorkPool
options *types.Options
executerOpts *protocols.ExecutorOptions
Callback func(*output.ResultEvent) // Executed on results
templateExecutionCallback TemplateExecutionCallback
Logger *gologger.Logger
}
// New returns a new Engine instance
@@ -54,6 +79,29 @@ func (e *Engine) SetExecuterOptions(options *protocols.ExecutorOptions) {
e.executerOpts = options
}
// SetTemplateExecutionCallback registers a callback for template execution lifecycle events.
func (e *Engine) SetTemplateExecutionCallback(callback TemplateExecutionCallback) {
e.templateExecutionCallback = callback
}
func (e *Engine) templateExecutionStarted(template *templates.Template, target string) func(error) {
if e.templateExecutionCallback == nil {
return func(error) {}
}
event := TemplateExecutionEvent{
TemplateID: template.ID,
TemplatePath: template.Path,
Target: target,
State: TemplateExecutionStarted,
}
e.templateExecutionCallback(event)
return func(contextErr error) {
event.State = TemplateExecutionFinished
event.ContextErr = contextErr
e.templateExecutionCallback(event)
}
}
// ExecuterOptions returns protocols.ExecutorOptions for nuclei engine.
func (e *Engine) ExecuterOptions() *protocols.ExecutorOptions {
return e.executerOpts
+4
View File
@@ -24,6 +24,8 @@ func (e *Engine) executeAllSelfContained(ctx context.Context, alltemplates []*te
sg.Add(1)
go func(template *templates.Template) {
defer sg.Done()
finished := e.templateExecutionStarted(template, "")
defer func() { finished(ctx.Err()) }()
var err error
var match bool
ctx := scan.NewScanContext(ctx, contextargs.New(ctx))
@@ -245,6 +247,8 @@ func (e *Engine) executeTemplatesOnTarget(ctx context.Context, alltemplates []*t
// executeTemplateOnInput performs template execution for a single input and returns match status and error
func (e *Engine) executeTemplateOnInput(ctx context.Context, template *templates.Template, value *contextargs.MetaInput) (bool, error) {
finished := e.templateExecutionStarted(template, value.Input)
defer func() { finished(ctx.Err()) }()
ctxArgs := contextargs.New(ctx)
ctxArgs.MetaInput = value
scanCtx := scan.NewScanContext(ctx, ctxArgs)
+44
View File
@@ -3,6 +3,7 @@ package core
import (
"context"
"fmt"
"reflect"
"sync/atomic"
"testing"
"time"
@@ -95,6 +96,49 @@ func Test_executeTemplateOnInput_CallbackErrorPropagates(t *testing.T) {
}
}
func TestExecuteTemplateOnInputReportsLifecycle(t *testing.T) {
e := newTestEngine()
var events []TemplateExecutionEvent
e.SetTemplateExecutionCallback(func(event TemplateExecutionEvent) {
events = append(events, event)
})
tpl := &templates.Template{ID: "template-id", Path: "http/example.yaml"}
tpl.Executer = &fakeExecuter{withResults: false}
_, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []TemplateExecutionEvent{
{TemplateID: "template-id", TemplatePath: "http/example.yaml", Target: "https://example.com", State: TemplateExecutionStarted},
{TemplateID: "template-id", TemplatePath: "http/example.yaml", Target: "https://example.com", State: TemplateExecutionFinished},
}
if !reflect.DeepEqual(events, want) {
t.Fatalf("unexpected lifecycle events: got %#v want %#v", events, want)
}
}
func TestExecuteTemplateOnInputReportsCancellationOnFinish(t *testing.T) {
e := newTestEngine()
var finished TemplateExecutionEvent
e.SetTemplateExecutionCallback(func(event TemplateExecutionEvent) {
if event.State == TemplateExecutionFinished {
finished = event
}
})
tpl := &templates.Template{ID: "interrupted"}
tpl.Executer = &fakeExecuter{withResults: false}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _ = e.executeTemplateOnInput(ctx, tpl, &contextargs.MetaInput{Input: "target"})
if finished.ContextErr != context.Canceled {
t.Fatalf("got finish context error %v, want context canceled", finished.ContextErr)
}
}
type fakeTargetProvider struct {
values []*contextargs.MetaInput
}
+41 -3
View File
@@ -9,19 +9,28 @@ import (
"fmt"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/logrusorgru/aurora/v4"
"github.com/projectdiscovery/nuclei/v3/pkg/output/stats/waf"
mapsutil "github.com/projectdiscovery/utils/maps"
)
var sharedWafDetector = sync.OnceValue(waf.NewWafDetector)
// Tracker is a stats tracker instance for nuclei scans
type Tracker struct {
// counters for various stats
statusCodes *mapsutil.SyncLockMap[string, *atomic.Int32]
errorCodes *mapsutil.SyncLockMap[string, *atomic.Int32]
wafDetected *mapsutil.SyncLockMap[string, *atomic.Int32]
wafCalls atomic.Uint64
wafBytes atomic.Uint64
wafRegexes atomic.Uint64
wafSkips atomic.Uint64
wafDuration atomic.Uint64
// internal stuff
wafDetector *waf.WafDetector
@@ -33,7 +42,7 @@ func NewTracker() *Tracker {
statusCodes: mapsutil.NewSyncLockMap[string, *atomic.Int32](),
errorCodes: mapsutil.NewSyncLockMap[string, *atomic.Int32](),
wafDetected: mapsutil.NewSyncLockMap[string, *atomic.Int32](),
wafDetector: waf.NewWafDetector(),
wafDetector: sharedWafDetector(),
}
}
@@ -52,12 +61,41 @@ func (t *Tracker) TrackErrorKind(errKind string) {
// First it detects if a waf is running and if so, it increments
// the counter for the waf.
func (t *Tracker) TrackWAFDetected(httpResponse string) {
waf, ok := t.wafDetector.DetectWAF(httpResponse)
started := time.Now()
id, ok, detectionStats := t.wafDetector.DetectWAFWithStats(httpResponse)
t.wafCalls.Add(1)
t.wafBytes.Add(uint64(len(httpResponse)))
t.wafRegexes.Add(uint64(detectionStats.RegexEvaluations))
t.wafSkips.Add(uint64(detectionStats.PrefilterSkips))
t.wafDuration.Add(uint64(time.Since(started)))
if !ok {
return
}
t.incrementCounter(t.wafDetected, waf)
t.incrementCounter(t.wafDetected, id)
}
// WAFMetrics reports execution-local WAF analysis work. It is separate from
// StatsOutput because it is operational telemetry rather than customer-facing
// scan statistics.
type WAFMetrics struct {
Calls uint64
Bytes uint64
RegexEvaluations uint64
PrefilterSkips uint64
Duration time.Duration
}
// GetWAFMetrics returns an atomic snapshot suitable for progress logging while
// requests are still executing.
func (t *Tracker) GetWAFMetrics() WAFMetrics {
return WAFMetrics{
Calls: t.wafCalls.Load(),
Bytes: t.wafBytes.Load(),
RegexEvaluations: t.wafRegexes.Load(),
PrefilterSkips: t.wafSkips.Load(),
Duration: time.Duration(t.wafDuration.Load()),
}
}
func (t *Tracker) incrementCounter(m *mapsutil.SyncLockMap[string, *atomic.Int32], key string) {
+56
View File
@@ -1,7 +1,10 @@
package stats
import (
"sync"
"testing"
"github.com/stretchr/testify/require"
)
func TestTrackErrorKind(t *testing.T) {
@@ -34,3 +37,56 @@ func TestTrackWaf_Detect(t *testing.T) {
t.Errorf("expected waf detected count to be 1, got %v", count)
}
}
func TestTrackersShareDetectorButNotCounters(t *testing.T) {
first := NewTracker()
second := NewTracker()
require.Same(t, first.wafDetector, second.wafDetector)
first.TrackStatusCode("200")
first.TrackErrorKind("timeout")
first.TrackWAFDetected("Attention Required! | Cloudflare")
require.Equal(t, 1, first.GetStats().StatusCodeStats["200"])
require.Equal(t, 1, first.GetStats().ErrorStats["timeout"])
require.Equal(t, 1, first.GetStats().WAFStats["CloudFlare"])
require.Empty(t, second.GetStats().StatusCodeStats)
require.Empty(t, second.GetStats().ErrorStats)
require.Empty(t, second.GetStats().WAFStats)
}
func TestWAFMetricsAreExecutionLocal(t *testing.T) {
first := NewTracker()
second := NewTracker()
response := "ordinary response with no WAF signature"
first.TrackWAFDetected(response)
metrics := first.GetWAFMetrics()
require.Equal(t, uint64(1), metrics.Calls)
require.Equal(t, uint64(len(response)), metrics.Bytes)
require.Greater(t, metrics.PrefilterSkips, uint64(0))
require.Greater(t, metrics.RegexEvaluations+metrics.PrefilterSkips, uint64(0))
require.Zero(t, second.GetWAFMetrics().Calls)
}
func TestWAFMetricsAreConcurrencySafe(t *testing.T) {
tracker := NewTracker()
response := "ordinary response with no WAF signature"
const calls = 32
var wg sync.WaitGroup
for range calls {
wg.Add(1)
go func() {
defer wg.Done()
tracker.TrackWAFDetected(response)
}()
}
wg.Wait()
metrics := tracker.GetWAFMetrics()
require.Equal(t, uint64(calls), metrics.Calls)
require.Equal(t, uint64(calls*len(response)), metrics.Bytes)
}
+141
View File
@@ -0,0 +1,141 @@
package waf
import "unicode/utf8"
type literalMatcherNode struct {
next [256]int32
failure int32
outputs []string
}
// literalMatcher finds every configured byte string in one pass over the
// response. WAF responses can be several megabytes, so scanning once is much
// cheaper than searching the response once for every WAF signature.
type literalMatcher struct {
nodes []literalMatcherNode
}
func newLiteralMatcher(patterns []string) *literalMatcher {
if len(patterns) == 0 {
return nil
}
matcher := &literalMatcher{nodes: []literalMatcherNode{newLiteralMatcherNode()}}
seen := make(map[string]struct{}, len(patterns))
for _, pattern := range patterns {
if pattern == "" {
continue
}
if _, ok := seen[pattern]; ok {
continue
}
seen[pattern] = struct{}{}
state := int32(0)
for index := 0; index < len(pattern); index++ {
value := pattern[index]
next := matcher.nodes[state].next[value]
if next == -1 {
next = int32(len(matcher.nodes))
matcher.nodes = append(matcher.nodes, newLiteralMatcherNode())
matcher.nodes[state].next[value] = next
}
state = next
}
matcher.nodes[state].outputs = append(matcher.nodes[state].outputs, pattern)
}
queue := make([]int32, 0, len(matcher.nodes))
for value := 0; value < 256; value++ {
child := matcher.nodes[0].next[byte(value)]
if child == -1 {
matcher.nodes[0].next[byte(value)] = 0
continue
}
matcher.nodes[child].failure = 0
queue = append(queue, child)
}
for len(queue) > 0 {
state := queue[0]
queue = queue[1:]
for value := 0; value < 256; value++ {
child := matcher.nodes[state].next[byte(value)]
if child == -1 {
matcher.nodes[state].next[byte(value)] = matcher.nodes[matcher.nodes[state].failure].next[byte(value)]
continue
}
failure := matcher.nodes[matcher.nodes[state].failure].next[byte(value)]
matcher.nodes[child].failure = failure
matcher.nodes[child].outputs = append(matcher.nodes[child].outputs, matcher.nodes[failure].outputs...)
queue = append(queue, child)
}
}
return matcher
}
func newLiteralMatcherNode() literalMatcherNode {
node := literalMatcherNode{}
for index := range node.next {
node.next[index] = -1
}
return node
}
func (m *literalMatcher) find(content string) map[string]struct{} {
found := make(map[string]struct{})
if m == nil || len(m.nodes) == 0 {
return found
}
state := int32(0)
for index := 0; index < len(content); index++ {
state = m.nodes[state].next[content[index]]
for _, pattern := range m.nodes[state].outputs {
found[pattern] = struct{}{}
}
}
return found
}
// findExactAndASCIIFolded advances the case-sensitive and ASCII-folded
// matchers together, avoiding a second copy and traversal of large responses.
func findExactAndASCIIFolded(content string, exactMatcher, foldedMatcher *literalMatcher) (map[string]struct{}, map[string]struct{}) {
exactFound := make(map[string]struct{})
foldedFound := make(map[string]struct{})
var exactState, foldedState int32
for index := 0; index < len(content); index++ {
value := content[index]
if exactMatcher != nil {
exactState = exactMatcher.nodes[exactState].next[value]
for _, pattern := range exactMatcher.nodes[exactState].outputs {
exactFound[pattern] = struct{}{}
}
}
if foldedMatcher != nil {
if value >= 'a' && value <= 'z' {
value -= 'a' - 'A'
}
foldedState = foldedMatcher.nodes[foldedState].next[value]
for _, pattern := range foldedMatcher.nodes[foldedState].outputs {
foldedFound[pattern] = struct{}{}
}
}
}
return exactFound, foldedFound
}
func mergeLiteralMatches(destination, source map[string]struct{}) {
for pattern := range source {
destination[pattern] = struct{}{}
}
}
func isASCII(value string) bool {
for index := 0; index < len(value); index++ {
if value[index] >= utf8.RuneSelf {
return false
}
}
return true
}
+336 -8
View File
@@ -5,11 +5,96 @@ import (
"encoding/json"
"log"
"regexp"
"regexp/syntax"
"strings"
"unicode"
"unicode/utf8"
)
const (
maxLiteralPrefilterClauses = 64
maxLiteralPrefilterNeedlesPerClause = 32
)
type WafDetector struct {
wafs map[string]waf
regexCache map[string]*regexp.Regexp
wafs map[string]waf
regexCache map[string]*regexp.Regexp
prefilterCache map[string]literalPrefilter
exactMatcher *literalMatcher
asciiFoldedMatcher *literalMatcher
unicodeFoldedMatcher *literalMatcher
}
// DetectionStats reports how much of the WAF regex set was evaluated for one
// response. A prefilter skip means the regex could not possibly match because
// a literal required by every matching path was absent.
type DetectionStats struct {
RegexEvaluations int
PrefilterSkips int
}
type literalNeedle struct {
value string
foldCase bool
}
type literalClause []literalNeedle
type literalPrefilter struct {
clauses []literalClause
needsFoldCase bool
}
func (p literalPrefilter) allows(content string) bool {
var foldedContent string
if p.needsFoldCase {
foldedContent = canonicalFoldString(content)
}
return p.allowsWithFoldedContent(content, foldedContent)
}
func (p literalPrefilter) allowsWithFoldedContent(content, foldedContent string) bool {
for _, clause := range p.clauses {
matches := true
for _, needle := range clause {
haystack := content
if needle.foldCase {
haystack = foldedContent
}
if !strings.Contains(haystack, needle.value) {
matches = false
break
}
}
if matches {
return true
}
}
return false
}
func (p literalPrefilter) allowsWithMatches(exactMatches, foldedMatches map[string]struct{}) bool {
for _, clause := range p.clauses {
needle := strongestLiteralNeedle(clause)
matched := exactMatches
if needle.foldCase {
matched = foldedMatches
}
if _, ok := matched[needle.value]; ok {
return true
}
}
return false
}
func strongestLiteralNeedle(clause literalClause) literalNeedle {
strongest := clause[0]
for _, candidate := range clause[1:] {
if len(candidate.value) > len(strongest.value) {
strongest = candidate
}
}
return strongest
}
// waf represents a web application firewall definition
@@ -34,8 +119,9 @@ func NewWafDetector() *WafDetector {
}
store := &WafDetector{
wafs: data.WAFs,
regexCache: make(map[string]*regexp.Regexp),
wafs: data.WAFs,
regexCache: make(map[string]*regexp.Regexp),
prefilterCache: make(map[string]literalPrefilter),
}
for id, waf := range store.wafs {
@@ -48,21 +134,263 @@ func NewWafDetector() *WafDetector {
continue
}
store.regexCache[id] = compiled
if prefilter, ok := buildLiteralPrefilter(waf.Regex); ok {
store.prefilterCache[id] = prefilter
}
}
var exactPatterns []string
var foldedPatterns []string
var unicodePatterns []string
for _, prefilter := range store.prefilterCache {
for _, clause := range prefilter.clauses {
needle := strongestLiteralNeedle(clause)
if needle.foldCase {
if isASCII(needle.value) {
foldedPatterns = append(foldedPatterns, needle.value)
} else {
unicodePatterns = append(unicodePatterns, needle.value)
}
} else {
exactPatterns = append(exactPatterns, needle.value)
}
}
}
store.exactMatcher = newLiteralMatcher(exactPatterns)
store.asciiFoldedMatcher = newLiteralMatcher(foldedPatterns)
store.unicodeFoldedMatcher = newLiteralMatcher(unicodePatterns)
return store
}
func (d *WafDetector) DetectWAF(content string) (string, bool) {
id, matched, _ := d.DetectWAFWithStats(content)
return id, matched
}
// DetectWAFWithStats detects a WAF and reports the amount of regex work done.
// The original compiled regex remains the source of truth. A regex is skipped
// only when a conservatively derived, required literal is absent.
func (d *WafDetector) DetectWAFWithStats(content string) (string, bool, DetectionStats) {
var stats DetectionStats
if d == nil || d.regexCache == nil {
return "", false
return "", false, stats
}
exactMatches, foldedMatches := findExactAndASCIIFolded(content, d.exactMatcher, d.asciiFoldedMatcher)
if d.unicodeFoldedMatcher != nil {
mergeLiteralMatches(foldedMatches, d.unicodeFoldedMatcher.find(canonicalFoldString(content)))
}
for id, regex := range d.regexCache {
if regex != nil && regex.MatchString(content) {
return id, true
if regex == nil {
continue
}
if prefilter, ok := d.prefilterCache[id]; ok {
if !prefilter.allowsWithMatches(exactMatches, foldedMatches) {
stats.PrefilterSkips++
continue
}
}
stats.RegexEvaluations++
if regex.MatchString(content) {
return id, true, stats
}
}
return "", false
return "", false, stats
}
func buildLiteralPrefilter(expression string) (literalPrefilter, bool) {
parsed, err := syntax.Parse(expression, syntax.Perl)
if err != nil {
return literalPrefilter{}, false
}
clauses, ok := requiredLiteralClauses(parsed.Simplify())
if !ok {
return literalPrefilter{}, false
}
deduplicated := make([]literalClause, 0, len(clauses))
needsFoldCase := false
for _, clause := range clauses {
clause = deduplicateLiteralClause(clause)
if len(clause) == 0 || len(clause) > maxLiteralPrefilterNeedlesPerClause {
return literalPrefilter{}, false
}
for _, needle := range clause {
if needle.value == "" {
return literalPrefilter{}, false
}
needsFoldCase = needsFoldCase || needle.foldCase
}
if !containsLiteralClause(deduplicated, clause) {
deduplicated = append(deduplicated, clause)
}
}
if len(deduplicated) == 0 || len(deduplicated) > maxLiteralPrefilterClauses {
return literalPrefilter{}, false
}
return literalPrefilter{clauses: deduplicated, needsFoldCase: needsFoldCase}, true
}
// requiredLiteralClauses returns an OR-set of AND-clauses with this invariant:
// whenever expression matches, every literal in at least one returned clause
// occurs in the input. Unsupported or ambiguous expressions fall back to the
// original regex by returning false.
func requiredLiteralClauses(expression *syntax.Regexp) ([]literalClause, bool) {
if expression == nil {
return nil, false
}
switch expression.Op {
case syntax.OpLiteral:
if len(expression.Rune) == 0 {
return nil, false
}
for _, value := range expression.Rune {
if value == utf8.RuneError {
return nil, false
}
}
foldCase := expression.Flags&syntax.FoldCase != 0
value := string(expression.Rune)
if foldCase {
value = canonicalFoldString(value)
}
return []literalClause{{{value: value, foldCase: foldCase}}}, true
case syntax.OpCapture:
if len(expression.Sub) != 1 {
return nil, false
}
return requiredLiteralClauses(expression.Sub[0])
case syntax.OpPlus:
if len(expression.Sub) != 1 {
return nil, false
}
return requiredLiteralClauses(expression.Sub[0])
case syntax.OpRepeat:
if expression.Min < 1 || len(expression.Sub) != 1 {
return nil, false
}
return requiredLiteralClauses(expression.Sub[0])
case syntax.OpConcat:
clauses := []literalClause{{}}
found := false
for _, child := range expression.Sub {
candidate, ok := requiredLiteralClauses(child)
if !ok {
continue
}
found = true
if len(clauses) > maxLiteralPrefilterClauses/len(candidate) {
return nil, false
}
combined := make([]literalClause, 0, len(clauses)*len(candidate))
for _, currentClause := range clauses {
for _, candidateClause := range candidate {
if len(currentClause)+len(candidateClause) > maxLiteralPrefilterNeedlesPerClause {
return nil, false
}
merged := make(literalClause, 0, len(currentClause)+len(candidateClause))
merged = append(merged, currentClause...)
merged = append(merged, candidateClause...)
combined = append(combined, merged)
}
}
clauses = combined
}
if !found {
return nil, false
}
return clauses, true
case syntax.OpAlternate:
var clauses []literalClause
for _, child := range expression.Sub {
candidate, ok := requiredLiteralClauses(child)
if !ok {
return nil, false
}
if len(clauses)+len(candidate) > maxLiteralPrefilterClauses {
return nil, false
}
clauses = append(clauses, candidate...)
}
if len(clauses) == 0 {
return nil, false
}
return clauses, true
default:
return nil, false
}
}
func deduplicateLiteralClause(clause literalClause) literalClause {
deduplicated := make(literalClause, 0, len(clause))
for _, needle := range clause {
found := false
for _, existing := range deduplicated {
if existing == needle {
found = true
break
}
}
if !found {
deduplicated = append(deduplicated, needle)
}
}
return deduplicated
}
func containsLiteralClause(clauses []literalClause, candidate literalClause) bool {
for _, clause := range clauses {
if len(clause) != len(candidate) {
continue
}
equal := true
for index := range clause {
if clause[index] != candidate[index] {
equal = false
break
}
}
if equal {
return true
}
}
return false
}
func canonicalFoldString(value string) string {
var builder strings.Builder
builder.Grow(len(value))
for index := 0; index < len(value); {
current := value[index]
if current < utf8.RuneSelf {
if current >= 'a' && current <= 'z' {
current -= 'a' - 'A'
}
builder.WriteByte(current)
index++
continue
}
decoded, size := utf8.DecodeRuneInString(value[index:])
builder.WriteRune(canonicalFoldRune(decoded))
index += size
}
return builder.String()
}
func canonicalFoldRune(value rune) rune {
canonical := value
for folded := unicode.SimpleFold(value); folded != value; folded = unicode.SimpleFold(folded) {
if folded < canonical {
canonical = folded
}
}
return canonical
}
func (d *WafDetector) GetWAF(id string) (waf, bool) {
+248
View File
@@ -2,6 +2,7 @@ package waf
import (
"regexp"
"strings"
"testing"
)
@@ -119,3 +120,250 @@ func TestWAFDetectionNilPointerSafety(t *testing.T) {
})
}
}
func TestBuildLiteralPrefilter(t *testing.T) {
tests := []struct {
name string
pattern string
admitted []string
rejected []string
wantFilterable bool
}{
{
name: "literal",
pattern: `Attention Required! \\| Cloudflare`,
admitted: []string{"prefix Attention Required! | Cloudflare suffix"},
rejected: []string{"ordinary response"},
wantFilterable: true,
},
{
name: "alternation admits every branch",
pattern: `alpha|bravo`,
admitted: []string{"alpha", "bravo"},
rejected: []string{"charlie"},
wantFilterable: true,
},
{
name: "required concatenated child survives optional prefix",
pattern: `(?:optional)?required`,
admitted: []string{"required", "optionalrequired"},
rejected: []string{"optional"},
wantFilterable: true,
},
{
name: "concatenation requires every available literal",
pattern: `prefix.+suffix`,
admitted: []string{"prefix value suffix"},
rejected: []string{"prefix only", "suffix only"},
wantFilterable: true,
},
{
name: "required repetition",
pattern: `(?:needle)+`,
admitted: []string{"needle", "needleneedle"},
rejected: []string{"haystack"},
wantFilterable: true,
},
{
name: "optional-only expression falls back",
pattern: `(?:optional)?`,
wantFilterable: false,
},
{
name: "case-folded literal",
pattern: `(?i)cloudflare`,
admitted: []string{"CLOUDFLARE", "CloudFlare"},
rejected: []string{"ordinary response"},
wantFilterable: true,
},
{
name: "case-fold uses unicode simple fold semantics",
pattern: `(?i)kelvin σ`,
admitted: []string{"KELVIN ς", "Kelvin Σ"},
rejected: []string{"ordinary response"},
wantFilterable: true,
},
{
name: "replacement rune falls back for invalid UTF-8 parity",
pattern: "�",
wantFilterable: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
prefilter, ok := buildLiteralPrefilter(test.pattern)
if ok != test.wantFilterable {
t.Fatalf("buildLiteralPrefilter(%q) filterable = %t, want %t", test.pattern, ok, test.wantFilterable)
}
if !ok {
return
}
for _, input := range test.admitted {
if !prefilter.allows(input) {
t.Errorf("prefilter rejected possible match %q", input)
}
}
for _, input := range test.rejected {
if prefilter.allows(input) {
t.Errorf("prefilter admitted impossible match %q", input)
}
}
})
}
}
func TestLiteralPrefilterNeverRejectsProductionRegexMatches(t *testing.T) {
detector := NewWafDetector()
inputs := []string{
"Attention Required! | Cloudflare",
"This error was generated by Mod_Security",
"Generated by Wordfence",
"Access Denied - Sucuri Website Firewall",
"Server: WatchGuard",
string([]byte{0xff, 0xfe, 'x'}),
}
for id, expression := range detector.wafs {
compiled := detector.regexCache[id]
prefilter, filterable := detector.prefilterCache[id]
for _, input := range inputs {
if compiled != nil && compiled.MatchString(input) && filterable && !prefilter.allows(input) {
t.Fatalf("prefilter rejected a production regex match: id=%s expression=%q input=%q", id, expression.Regex, input)
}
if compiled != nil && compiled.MatchString(input) && filterable {
exactMatches := detector.exactMatcher.find(input)
foldedMatches := detector.asciiFoldedMatcher.find(canonicalFoldString(input))
if !prefilter.allowsWithMatches(exactMatches, foldedMatches) {
t.Fatalf("multi-literal prefilter rejected a production regex match: id=%s expression=%q input=%q", id, expression.Regex, input)
}
}
}
}
}
func TestLiteralMatcherFindsOverlappingAndSuffixPatterns(t *testing.T) {
matcher := newLiteralMatcher([]string{"he", "her", "hers", "she", "he"})
found := matcher.find("ushers")
for _, pattern := range []string{"he", "her", "hers", "she"} {
if _, ok := found[pattern]; !ok {
t.Errorf("pattern %q was not found", pattern)
}
}
if _, ok := found["missing"]; ok {
t.Error("unexpected pattern was found")
}
}
func TestLiteralMatcherHandlesArbitraryUTF8Bytes(t *testing.T) {
patterns := []string{"KELVIN", string([]byte{0xff, 0xfe})}
matcher := newLiteralMatcher(patterns)
found := matcher.find("prefix KELVIN " + string([]byte{0xff, 0xfe}) + " suffix")
for _, pattern := range patterns {
if _, ok := found[pattern]; !ok {
t.Errorf("pattern %q was not found", pattern)
}
}
}
func TestASCIIFoldedMatcherMatchesCanonicalFold(t *testing.T) {
pattern := canonicalFoldString("CloudFlare")
matcher := newLiteralMatcher([]string{pattern})
_, found := findExactAndASCIIFolded("prefix cLoUdFlArE suffix", nil, matcher)
if _, ok := found[pattern]; !ok {
t.Fatalf("case-folded pattern %q was not found", pattern)
}
}
func TestUnicodeFoldedMatcherMatchesCanonicalFold(t *testing.T) {
pattern := canonicalFoldString("kelvin σ")
matcher := newLiteralMatcher([]string{pattern})
found := matcher.find(canonicalFoldString("prefix KELVIN ς suffix"))
if _, ok := found[pattern]; !ok {
t.Fatalf("unicode case-folded pattern %q was not found", pattern)
}
}
func TestDetectWAFReportsPrefilterWork(t *testing.T) {
detector := &WafDetector{
wafs: map[string]waf{
"literal": {Name: "Literal", Regex: `definitely-present`},
"fallback": {Name: "Fallback", Regex: `(?i)case-folded`},
},
regexCache: map[string]*regexp.Regexp{
"literal": regexp.MustCompile(`definitely-present`),
"fallback": regexp.MustCompile(`(?i)case-folded`),
},
prefilterCache: make(map[string]literalPrefilter),
}
if prefilter, ok := buildLiteralPrefilter(`definitely-present`); ok {
detector.prefilterCache["literal"] = prefilter
}
if prefilter, ok := buildLiteralPrefilter(`(?i)case-folded`); ok {
detector.prefilterCache["fallback"] = prefilter
}
_, matched, stats := detector.DetectWAFWithStats(strings.Repeat("ordinary response ", 1024))
if matched {
t.Fatal("unexpected WAF match")
}
if stats.PrefilterSkips != 2 {
t.Fatalf("prefilter skips = %d, want 2", stats.PrefilterSkips)
}
if stats.RegexEvaluations != 0 {
t.Fatalf("regex evaluations = %d, want 0", stats.RegexEvaluations)
}
}
func FuzzLiteralPrefilterSoundness(f *testing.F) {
patterns := []string{
`alpha`,
`alpha|bravo`,
`prefix.+suffix`,
`(?:optional)?required`,
`(?:one|two)+`,
`(?s)begin.+end`,
`(?i)case-folded`,
"�",
}
inputs := [][]byte{
[]byte("alpha"),
[]byte("bravo"),
[]byte("prefix\nvalue\nsuffix"),
[]byte("required"),
{0xff},
{0xff, 'a', 0xfe},
}
for _, pattern := range patterns {
for _, input := range inputs {
f.Add(pattern, input)
}
}
f.Fuzz(func(t *testing.T, pattern string, input []byte) {
compiled, err := regexp.Compile(pattern)
if err != nil {
return
}
prefilter, filterable := buildLiteralPrefilter(pattern)
if compiled.Match(input) && filterable && !prefilter.allows(string(input)) {
t.Fatalf("prefilter rejected regex match: pattern=%q input=%q", pattern, input)
}
})
}
func FuzzLiteralMatcherSoundness(f *testing.F) {
f.Add("alpha", []byte("prefix alpha suffix"))
f.Add("aba", []byte("ababa"))
f.Add(string([]byte{0xff, 0xfe}), []byte{0, 0xff, 0xfe, 0})
f.Fuzz(func(t *testing.T, pattern string, input []byte) {
if pattern == "" || !strings.Contains(string(input), pattern) {
return
}
found := newLiteralMatcher([]string{pattern}).find(string(input))
if _, ok := found[pattern]; !ok {
t.Fatalf("literal matcher missed pattern %q in input %q", pattern, input)
}
})
}
+1 -1
View File
@@ -340,7 +340,7 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
event := eventcreator.CreateEvent(request, data, request.options.Options.Debug || request.options.Options.DebugResponse)
if request.options.Interactsh != nil {
event.UsesInteractsh = true
request.options.Interactsh.RequestEvent(interactshURLs, &interactsh.RequestData{
request.options.RegisterInteractshRequest(interactshURLs, &interactsh.RequestData{
MakeResultFunc: request.MakeResultEvent,
Event: event,
Operators: request.CompiledOperators,
@@ -13,6 +13,7 @@ import (
"github.com/logrusorgru/aurora/v4"
"github.com/pkg/errors"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
@@ -22,10 +23,8 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/writer"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
httputil "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils/http"
"github.com/projectdiscovery/nuclei/v3/pkg/scan"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/retryablehttp-go"
"github.com/projectdiscovery/useragent"
mapsutil "github.com/projectdiscovery/utils/maps"
@@ -95,11 +94,12 @@ func New(opts Options) (*Service, error) {
return nil, err
}
// Wappalyzer fingerprinting is a stateless GET reused across every target.
// Disable the cookie jar to avoid retaining cross-target state and the
// associated memory growth from a long-lived shared client.
httpclient, err := httpclientpool.Get(opts.ExecuterOpts.Options, &httpclientpool.Configuration{
Connection: &httpclientpool.ConnectionConfiguration{
DisableKeepAlive: httputil.ShouldDisableKeepAlive(opts.ExecuterOpts.Options),
},
})
DisableCookie: true,
}, "")
if err != nil {
return nil, errors.Wrap(err, "could not get http client")
}
@@ -40,10 +40,13 @@ var (
// It uses an LRU cache internally for skipping unresponsive hosts
// that remain so for a duration.
type Cache struct {
MaxHostError int
verbose bool
failedTargets gcache.Cache[string, *cacheItem]
TrackError []string
MaxHostError int
verbose bool
maxHostsCount int
failedTargets gcache.Cache[string, *cacheItem]
failedTargetsMu sync.RWMutex
TrackError []string
closed atomic.Bool
}
type cacheItem struct {
@@ -58,15 +61,29 @@ const DefaultMaxHostsCount = 10000
// New returns a new host max errors cache
func New(maxHostError, maxHostsCount int, trackError []string) *Cache {
gc := gcache.New[string, *cacheItem](maxHostsCount).ARC().Build()
return &Cache{
failedTargets: gc,
MaxHostError: maxHostError,
maxHostsCount: maxHostsCount,
TrackError: trackError,
}
}
func (c *Cache) getFailedTargets(create bool) gcache.Cache[string, *cacheItem] {
c.failedTargetsMu.RLock()
cache := c.failedTargets
c.failedTargetsMu.RUnlock()
if cache != nil || !create || c.closed.Load() {
return cache
}
c.failedTargetsMu.Lock()
defer c.failedTargetsMu.Unlock()
if c.failedTargets == nil && !c.closed.Load() {
c.failedTargets = gcache.New[string, *cacheItem](c.maxHostsCount).ARC().Build()
}
return c.failedTargets
}
// SetVerbose sets the cache to log at verbose level
func (c *Cache) SetVerbose(verbose bool) {
c.verbose = verbose
@@ -74,13 +91,23 @@ func (c *Cache) SetVerbose(verbose bool) {
// Close closes the host errors cache
func (c *Cache) Close() {
if config.DefaultConfig.IsDebugArgEnabled(config.DebugArgHostErrorStats) {
items := c.failedTargets.GetALL(false)
if !c.closed.CompareAndSwap(false, true) {
return
}
c.failedTargetsMu.Lock()
cache := c.failedTargets
c.failedTargets = nil
c.failedTargetsMu.Unlock()
if cache != nil && config.DefaultConfig.IsDebugArgEnabled(config.DebugArgHostErrorStats) {
items := cache.GetALL(false)
for k, val := range items {
gologger.Info().Label("MaxHostErrorStats").Msgf("Host: %s, Errors: %d", k, val.errors.Load())
}
}
c.failedTargets.Purge()
// The cache owns no background resource. Purge rebuilds an empty ARC with
// the original capacity, which allocates several megabytes immediately
// before callers discard a closed cache. Releasing the reference lets the
// garbage collector reclaim both entries and storage without replacement.
}
// NormalizeCacheValue processes the input value and returns a normalized cache
@@ -127,9 +154,13 @@ func (c *Cache) NormalizeCacheValue(value string) string {
// - Host:port type
// - host type
func (c *Cache) Check(protoType string, ctx *contextargs.Context) bool {
failedTargets := c.getFailedTargets(false)
if failedTargets == nil {
return false
}
finalValue := c.GetKeyFromContext(ctx, nil)
cache, err := c.failedTargets.GetIFPresent(finalValue)
cache, err := failedTargets.GetIFPresent(finalValue)
if err != nil {
return false
}
@@ -156,8 +187,12 @@ func (c *Cache) Check(protoType string, ctx *contextargs.Context) bool {
// Remove removes a host from the cache
func (c *Cache) Remove(ctx *contextargs.Context) {
failedTargets := c.getFailedTargets(false)
if failedTargets == nil {
return
}
key := c.GetKeyFromContext(ctx, nil)
_ = c.failedTargets.Remove(key) // remove even the cache is not present
_ = failedTargets.Remove(key) // remove even the cache is not present
}
// MarkFailed marks a host as failed previously
@@ -224,7 +259,11 @@ func (c *Cache) MarkFailedOrRemove(protoType string, ctx *contextargs.Context, e
}
cacheKey := c.GetKeyFromContext(ctx, err)
cache, cacheErr := c.failedTargets.GetIFPresent(cacheKey)
failedTargets := c.getFailedTargets(true)
if failedTargets == nil {
return
}
cache, cacheErr := failedTargets.GetIFPresent(cacheKey)
if errors.Is(cacheErr, gcache.KeyNotFoundError) {
cache = &cacheItem{errors: atomic.Int32{}}
}
@@ -239,7 +278,7 @@ func (c *Cache) MarkFailedOrRemove(protoType string, ctx *contextargs.Context, e
cache.cause = err
cache.errors.Add(1)
_ = c.failedTargets.Set(cacheKey, cache)
_ = failedTargets.Set(cacheKey, cache)
}
// IsPermanentErr returns true if the error is permanent for the host.
@@ -252,8 +291,12 @@ func (c *Cache) IsPermanentErr(ctx *contextargs.Context, err error) bool {
return true
}
failedTargets := c.getFailedTargets(false)
if failedTargets == nil {
return false
}
cacheKey := c.GetKeyFromContext(ctx, err)
cache, cacheErr := c.failedTargets.GetIFPresent(cacheKey)
cache, cacheErr := failedTargets.GetIFPresent(cacheKey)
if cacheErr != nil {
return false
}
@@ -47,6 +47,46 @@ func TestCacheCheck(t *testing.T) {
})
}
func TestCacheCloseReleasesStorageAndIsIdempotent(t *testing.T) {
cache := New(3, DefaultMaxHostsCount, nil)
cache.MarkFailed(protoType, newCtxArgs(t.Name()), errors.New("timeout"))
cache.Close()
require.Nil(t, cache.failedTargets)
require.NotPanics(t, cache.Close)
}
func TestCacheAllocatesStorageOnlyForTrackedFailure(t *testing.T) {
cache := New(3, DefaultMaxHostsCount, nil)
t.Cleanup(cache.Close)
ctx := newCtxArgs(t.Name())
require.Nil(t, cache.failedTargets)
require.False(t, cache.Check(protoType, ctx))
cache.Remove(ctx)
cache.MarkFailedOrRemove(protoType, ctx, nil)
cache.MarkFailedOrRemove(protoType, ctx, errors.New("template condition failed"))
require.Nil(t, cache.failedTargets, "read, success, and template errors must not allocate host storage")
cache.MarkFailedOrRemove(protoType, ctx, errors.New("net/http: timeout awaiting response headers"))
require.NotNil(t, cache.failedTargets)
}
func TestCacheCloseBeforeFirstFailureKeepsStorageUnallocated(t *testing.T) {
cache := New(3, DefaultMaxHostsCount, nil)
cache.Close()
ctx := newCtxArgs(t.Name())
require.NotPanics(t, func() {
cache.MarkFailedOrRemove(protoType, ctx, errors.New("net/http: timeout awaiting response headers"))
cache.MarkFailedOrRemove(protoType, ctx, nil)
cache.Remove(ctx)
require.False(t, cache.Check(protoType, ctx))
})
require.Nil(t, cache.failedTargets)
}
func TestCacheCheckTimeout(t *testing.T) {
// A host that consistently times out (request deadline exceeded) is
// unresponsive and must be skipped once MaxHostError consecutive timeouts
@@ -211,11 +251,12 @@ func TestCacheMarkFailedConcurrent(t *testing.T) {
}
// the cache is not atomic during items creation, so we pre-create them with counter to zero
failedTargets := cache.getFailedTargets(true)
for _, test := range tests {
normalizedValue := cache.NormalizeCacheValue(test.host)
newItem := &cacheItem{errors: atomic.Int32{}}
newItem.errors.Store(0)
_ = cache.failedTargets.Set(normalizedValue, newItem)
_ = failedTargets.Set(normalizedValue, newItem)
}
wg := sync.WaitGroup{}
+176 -20
View File
@@ -17,10 +17,13 @@ import (
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/interactsh/pkg/client"
"github.com/projectdiscovery/interactsh/pkg/server"
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/frequency"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/progress"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/responsehighlighter"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/writer"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
"github.com/projectdiscovery/retryablehttp-go"
"github.com/projectdiscovery/utils/errkit"
stringsutil "github.com/projectdiscovery/utils/strings"
@@ -30,6 +33,8 @@ import (
type Client struct {
sync.Once
sync.RWMutex
cacheOnce sync.Once
requestLifecycleMu sync.RWMutex
options *Options
@@ -55,31 +60,126 @@ type Client struct {
matched atomic.Bool
}
var interactshSNIAnnotationRegex = regexp.MustCompile(`(?m)^[\t ]*@tls-sni:[\t ]*(?:https?://)?interactsh-url[\t ]*$`)
// RequestScope owns the delayed interaction registrations created by one
// engine execution. A shared Client can serve many concurrent executions, but
// each execution still needs its own callback window and deterministic cleanup.
type RequestScope struct {
client *Client
once sync.Once
mu sync.Mutex
ids map[string]struct{}
closing bool
callbacks sync.WaitGroup
}
// NewRequestScope creates an execution-scoped interaction lifecycle. Call
// Close after the execution work pool has drained.
func (c *Client) NewRequestScope() *RequestScope {
return &RequestScope{client: c, ids: make(map[string]struct{})}
}
func (s *RequestScope) track(id string) {
if s == nil || id == "" {
return
}
s.mu.Lock()
if !s.closing {
s.ids[id] = struct{}{}
}
s.mu.Unlock()
}
func (s *RequestScope) beginCallback() bool {
if s == nil {
return true
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closing {
return false
}
s.callbacks.Add(1)
return true
}
func (s *RequestScope) endCallback() {
if s != nil {
s.callbacks.Done()
}
}
// Close waits the same callback cooldown used by a standalone Client, then
// removes only this execution's unmatched registrations. The shared poller and
// registrations belonging to other executions remain active.
func (s *RequestScope) Close() {
if s == nil || s.client == nil {
return
}
s.once.Do(func() {
s.mu.Lock()
hasRegistrations := len(s.ids) > 0
s.mu.Unlock()
if hasRegistrations && s.client.cooldownDuration > 0 {
time.Sleep(s.client.cooldownDuration)
}
// Serialize registration removal with the poller's request lookup and
// callback admission. A callback that already found this execution's
// request is counted before closing begins; a later callback cannot start.
s.client.requestLifecycleMu.Lock()
s.mu.Lock()
s.closing = true
ids := make([]string, 0, len(s.ids))
for id := range s.ids {
ids = append(ids, id)
}
s.mu.Unlock()
if s.client.requests != nil {
for _, id := range ids {
s.client.requests.Remove(id)
}
}
s.client.requestLifecycleMu.Unlock()
// Result writers, progress reporters, and issue clients remain owned by
// the execution until every callback admitted above has completed.
s.callbacks.Wait()
})
}
// New returns a new interactsh server client
func New(options *Options) (*Client, error) {
requestsCache := gcache.New[string, *RequestData](options.CacheSize).LRU().Build()
interactionsCache := gcache.New[string, []*server.Interaction](defaultMaxInteractionsCount).LRU().Build()
matchedTemplateCache := gcache.New[string, bool](defaultMaxInteractionsCount).LRU().Build()
interactshURLCache := gcache.New[string, string](defaultMaxInteractionsCount).LRU().Build()
interactClient := &Client{
eviction: options.Eviction,
interactions: interactionsCache,
matchedTemplates: matchedTemplateCache,
interactshURLs: interactshURLCache,
options: options,
requests: requestsCache,
pollDuration: options.PollDuration,
cooldownDuration: options.CooldownPeriod,
}
return interactClient, nil
}
func (c *Client) initializeCaches() {
c.cacheOnce.Do(func() {
requests := gcache.New[string, *RequestData](c.options.CacheSize).LRU().Build()
interactions := gcache.New[string, []*server.Interaction](defaultMaxInteractionsCount).LRU().Build()
matchedTemplates := gcache.New[string, bool](defaultMaxInteractionsCount).LRU().Build()
interactshURLs := gcache.New[string, string](defaultMaxInteractionsCount).LRU().Build()
c.interactions = interactions
c.matchedTemplates = matchedTemplates
c.interactshURLs = interactshURLs
c.requests = requests
})
}
func (c *Client) poll() error {
if c.options.NoInteractsh {
// do not init if disabled
return ErrInteractshClientNotInitialized
}
c.initializeCaches()
interactsh, err := client.New(&client.Options{
ServerURL: c.options.ServerURL,
Token: c.options.Authorization,
@@ -100,7 +200,10 @@ func (c *Client) poll() error {
c.setHostname(interactDomain)
err = interactsh.StartPolling(c.pollDuration, func(interaction *server.Interaction) {
c.requestLifecycleMu.RLock()
request, err := c.requests.Get(interaction.UniqueID)
callbackAdmitted := request != nil && request.Scope.beginCallback()
c.requestLifecycleMu.RUnlock()
// for more context in github actions
if strings.EqualFold(os.Getenv("GITHUB_ACTIONS"), "true") && c.options.Debug {
gologger.DefaultLogger.Print().Msgf("[Interactsh]: got interaction of %v for request %v and error %v", interaction, request, err)
@@ -117,6 +220,10 @@ func (c *Client) poll() error {
}
return
}
if !callbackAdmitted {
return
}
defer request.Scope.endCallback()
if requestShouldStopAtFirstMatch(request) || c.options.StopAtFirstMatch {
if gotItem, err := c.matchedTemplates.Get(eventHash(request.Event)); gotItem && err == nil {
@@ -182,11 +289,18 @@ func (c *Client) processInteractionForRequest(interaction *server.Interaction, d
gologger.DefaultLogger.Print().Msgf("[Interactsh]: got result %v and status %v after processing interaction", result, matched)
}
if c.options.FuzzParamsFrequency != nil {
fuzzParamsFrequency := data.FuzzParamsFrequency
if fuzzParamsFrequency == nil {
fuzzParamsFrequency = c.options.FuzzParamsFrequency
}
// Parameter frequency applies only to fuzz-generated requests. Regular OOB
// requests do not carry the concrete HTTP request/parameter needed to build
// a frequency key, even though the execution itself owns a tracker.
if fuzzParamsFrequency != nil && data.Request != nil && data.Operators != nil {
if !matched {
c.options.FuzzParamsFrequency.MarkParameter(data.Parameter, data.Request.String(), data.Operators.TemplateID)
fuzzParamsFrequency.MarkParameter(data.Parameter, data.Request.String(), data.Operators.TemplateID)
} else {
c.options.FuzzParamsFrequency.UnmarkParameter(data.Parameter, data.Request.String(), data.Operators.TemplateID)
fuzzParamsFrequency.UnmarkParameter(data.Parameter, data.Request.String(), data.Operators.TemplateID)
}
}
@@ -222,7 +336,19 @@ func (c *Client) processInteractionForRequest(interaction *server.Interaction, d
}
// if event is not already matched, write it to output
if !data.Event.InteractshMatched.Load() && writer.WriteResult(data.Event, c.options.Output, c.options.Progress, c.options.IssuesClient) {
outputWriter := data.Output
if outputWriter == nil {
outputWriter = c.options.Output
}
progressClient := data.Progress
if progressClient == nil {
progressClient = c.options.Progress
}
issuesClient := data.IssuesClient
if issuesClient == nil {
issuesClient = c.options.IssuesClient
}
if !data.Event.InteractshMatched.Load() && writer.WriteResult(data.Event, outputWriter, progressClient, issuesClient) {
data.Event.InteractshMatched.Store(true)
c.matched.Store(true)
if requestShouldStopAtFirstMatch(data) || c.options.StopAtFirstMatch {
@@ -234,11 +360,15 @@ func (c *Client) processInteractionForRequest(interaction *server.Interaction, d
}
func (c *Client) AlreadyMatched(data *RequestData) bool {
c.initializeCaches()
return c.matchedTemplates.Has(eventHash(data.Event))
}
// URL returns a new URL that can be interacted with
func (c *Client) URL() (string, error) {
if c == nil {
return "", ErrInteractshClientNotInitialized
}
// first time initialization
var err error
c.Do(func() {
@@ -266,10 +396,12 @@ func (c *Client) Close() bool {
_ = c.interactsh.Close()
}
c.requests.Purge()
c.interactions.Purge()
c.matchedTemplates.Purge()
c.interactshURLs.Purge()
if c.requests != nil {
c.requests.Purge()
c.interactions.Purge()
c.matchedTemplates.Purge()
c.interactshURLs.Purge()
}
return c.matched.Load()
}
@@ -309,6 +441,10 @@ func (c *Client) NewURLWithData(data string) (string, error) {
// MakePlaceholders does placeholders for interact URLs and other data to a map
func (c *Client) MakePlaceholders(urls []string, data map[string]interface{}) {
data["interactsh-server"] = c.getHostname()
if len(urls) == 0 {
return
}
c.initializeCaches()
for _, url := range urls {
if interactshURLMarker, err := c.interactshURLs.Get(url); interactshURLMarker != "" && err == nil {
interactshMarker := strings.TrimSuffix(strings.TrimPrefix(interactshURLMarker, "{{"), "}}")
@@ -338,12 +474,25 @@ type RequestData struct {
Parameter string
Request *retryablehttp.Request
// These fields route delayed results back to the execution that registered
// the request when the Interactsh client is shared concurrently.
Output output.Writer
Progress progress.Progress
IssuesClient reporting.Client
FuzzParamsFrequency *frequency.Tracker
Scope *RequestScope
}
// RequestEvent is the event for a network request sent by nuclei.
func (c *Client) RequestEvent(interactshURLs []string, data *RequestData) {
if len(interactshURLs) == 0 {
return
}
c.initializeCaches()
for _, interactshURL := range interactshURLs {
id := strings.TrimRight(strings.TrimSuffix(interactshURL, c.getHostname()), ".")
data.Scope.track(id)
if requestShouldStopAtFirstMatch(data) || c.options.StopAtFirstMatch {
gotItem, err := c.matchedTemplates.Get(eventHash(data.Event))
@@ -355,7 +504,12 @@ func (c *Client) RequestEvent(interactshURLs []string, data *RequestData) {
interactions, err := c.interactions.Get(id)
if interactions != nil && err == nil {
for _, interaction := range interactions {
if c.processInteractionForRequest(interaction, data) {
if !data.Scope.beginCallback() {
break
}
matched := c.processInteractionForRequest(interaction, data)
data.Scope.endCallback()
if matched {
c.interactions.Remove(id)
break
}
@@ -394,9 +548,11 @@ func HasMatchers(op *operators.Operators) bool {
return false
}
// HasMarkers checks if the text contains interactsh markers
// HasMarkers checks if the text contains any syntax that asks Nuclei to
// generate an Interactsh URL. In addition to template markers, raw HTTP
// requests support the special @tls-sni annotation without braces.
func HasMarkers(data string) bool {
return interactshURLMarkerRegex.Match([]byte(data))
return interactshURLMarkerRegex.Match([]byte(data)) || interactshSNIAnnotationRegex.MatchString(data)
}
func (c *Client) debugPrintInteraction(interaction *server.Interaction, event *operators.Result) {
@@ -5,9 +5,13 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/logrusorgru/aurora/v4"
serverint "github.com/projectdiscovery/interactsh/pkg/server"
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/frequency"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/extractors"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
@@ -15,6 +19,182 @@ import (
"github.com/stretchr/testify/require"
)
type requestRoutingWriter struct {
writes atomic.Int32
}
func (*requestRoutingWriter) Close() {}
func (*requestRoutingWriter) Colorizer() *aurora.Aurora {
return aurora.New(aurora.WithColors(false))
}
func (w *requestRoutingWriter) Write(*output.ResultEvent) error {
w.writes.Add(1)
return nil
}
func (*requestRoutingWriter) WriteFailure(*output.InternalWrappedEvent) error { return nil }
func (*requestRoutingWriter) Request(string, string, string, error) {}
func (*requestRoutingWriter) RequestStatsLog(string, string) {}
func (*requestRoutingWriter) WriteStoreDebugData(string, string, string, string) {
}
func (w *requestRoutingWriter) ResultCount() int { return int(w.writes.Load()) }
type requestRoutingProgress struct{}
func (*requestRoutingProgress) Stop() {}
func (*requestRoutingProgress) Init(int64, int, int64) {}
func (*requestRoutingProgress) AddToTotal(int64) {}
func (*requestRoutingProgress) IncrementRequests() {}
func (*requestRoutingProgress) SetRequests(uint64) {}
func (*requestRoutingProgress) IncrementMatched() {}
func (*requestRoutingProgress) IncrementErrorsBy(int64) {}
func (*requestRoutingProgress) IncrementFailedRequestsBy(int64) {}
func TestProcessInteractionRoutesResultToRequestWriter(t *testing.T) {
matcher := &matchers.Matcher{
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Part: "interactsh_protocol",
Words: []string{"dns"},
}
op := &operators.Operators{Matchers: []*matchers.Matcher{matcher}}
require.NoError(t, op.Compile())
baseWriter := &requestRoutingWriter{}
requestWriter := &requestRoutingWriter{}
client := &Client{options: DefaultOptions(baseWriter, nil, &requestRoutingProgress{})}
client.initializeCaches()
data := &RequestData{
Event: &output.InternalWrappedEvent{InternalEvent: output.InternalEvent{
templateIdAttribute: "scoped-oob",
"host": "example.com",
}},
Operators: op,
MatchFunc: func(_ map[string]interface{}, _ *matchers.Matcher) (bool, []string) {
return true, []string{"dns"}
},
ExtractFunc: func(map[string]interface{}, *extractors.Extractor) map[string]struct{} { return nil },
MakeResultFunc: func(*output.InternalWrappedEvent) []*output.ResultEvent {
return []*output.ResultEvent{{TemplateID: "scoped-oob", MatcherStatus: true}}
},
Output: requestWriter,
Progress: &requestRoutingProgress{},
}
matched := client.processInteractionForRequest(&serverint.Interaction{
Protocol: "dns",
RawRequest: "request",
RawResponse: "response",
RemoteAddress: "127.0.0.1",
}, data)
require.True(t, matched)
require.Equal(t, 1, requestWriter.ResultCount())
require.Zero(t, baseWriter.ResultCount(), "a shared client must not route a delayed result to its base writer")
}
func TestProcessInteractionWithFrequencyTrackerAndNonFuzzRequest(t *testing.T) {
matcher := &matchers.Matcher{
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Part: "interactsh_protocol",
Words: []string{"dns"},
}
op := &operators.Operators{Matchers: []*matchers.Matcher{matcher}}
require.NoError(t, op.Compile())
resultWriter := &requestRoutingWriter{}
options := DefaultOptions(resultWriter, nil, &requestRoutingProgress{})
options.FuzzParamsFrequency = frequency.New(10, 2)
client := &Client{options: options}
client.initializeCaches()
data := &RequestData{
Event: &output.InternalWrappedEvent{InternalEvent: output.InternalEvent{
templateIdAttribute: "non-fuzz-oob",
"host": "example.com",
}},
Operators: op,
MatchFunc: func(_ map[string]interface{}, _ *matchers.Matcher) (bool, []string) {
return true, []string{"dns"}
},
ExtractFunc: func(map[string]interface{}, *extractors.Extractor) map[string]struct{} { return nil },
MakeResultFunc: func(*output.InternalWrappedEvent) []*output.ResultEvent {
return []*output.ResultEvent{{TemplateID: "non-fuzz-oob", MatcherStatus: true}}
},
}
matched := client.processInteractionForRequest(&serverint.Interaction{Protocol: "dns"}, data)
require.True(t, matched)
require.Equal(t, 1, resultWriter.ResultCount())
}
func TestRequestScopeRemovesOnlyItsRegistrations(t *testing.T) {
options := DefaultOptions(nil, nil, nil)
options.CooldownPeriod = 0
client, err := New(options)
require.NoError(t, err)
client.setHostname("oast.test")
first := client.NewRequestScope()
second := client.NewRequestScope()
newData := func(scope *RequestScope) *RequestData {
return &RequestData{
Event: &output.InternalWrappedEvent{InternalEvent: output.InternalEvent{}},
Scope: scope,
}
}
client.RequestEvent([]string{"first.oast.test"}, newData(first))
client.RequestEvent([]string{"second.oast.test"}, newData(second))
require.True(t, client.requests.Has("first"))
require.True(t, client.requests.Has("second"))
first.Close()
require.False(t, client.requests.Has("first"))
require.True(t, client.requests.Has("second"), "closing one execution must not purge another execution")
second.Close()
require.False(t, client.requests.Has("second"))
}
func TestRequestScopeCloseWaitsForInFlightCallback(t *testing.T) {
options := DefaultOptions(nil, nil, nil)
options.CooldownPeriod = 0
client, err := New(options)
require.NoError(t, err)
scope := client.NewRequestScope()
require.True(t, scope.beginCallback())
closed := make(chan struct{})
go func() {
scope.Close()
close(closed)
}()
select {
case <-closed:
t.Fatal("scope closed before its admitted callback completed")
case <-time.After(25 * time.Millisecond):
}
scope.endCallback()
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("scope did not close after its admitted callback completed")
}
require.False(t, scope.beginCallback(), "a closed scope must reject later callbacks")
}
func TestHasMarkersDetectsInteractshSNIAnnotation(t *testing.T) {
require.True(t, HasMarkers("@tls-sni: interactsh-url\nGET / HTTP/1.1"))
require.True(t, HasMarkers(" @tls-sni: https://interactsh-url \nGET / HTTP/1.1"))
require.False(t, HasMarkers("@tls-sni: request.host\nGET / HTTP/1.1"))
}
func TestNilClientURLReturnsInitializationError(t *testing.T) {
var client *Client
_, err := client.URL()
require.ErrorIs(t, err, ErrInteractshClientNotInitialized)
}
func TestProcessInteractionForRequestConcurrentEventUpdate(t *testing.T) {
const (
keyCount = 4096
@@ -92,3 +272,40 @@ func TestProcessInteractionForRequestConcurrentEventUpdate(t *testing.T) {
require.False(t, matched)
}
func TestClientDefersCachesUntilInteractshUse(t *testing.T) {
client, err := New(DefaultOptions(nil, nil, nil))
require.NoError(t, err)
require.Nil(t, client.requests)
require.Nil(t, client.interactions)
require.Nil(t, client.matchedTemplates)
require.Nil(t, client.interactshURLs)
require.False(t, client.Close())
require.Nil(t, client.requests)
require.Nil(t, client.interactions)
require.Nil(t, client.matchedTemplates)
require.Nil(t, client.interactshURLs)
}
func TestClientInitializesCachesOnceConcurrently(t *testing.T) {
client, err := New(DefaultOptions(nil, nil, nil))
require.NoError(t, err)
const callers = 32
var waitGroup sync.WaitGroup
waitGroup.Add(callers)
for range callers {
go func() {
defer waitGroup.Done()
client.initializeCaches()
}()
}
waitGroup.Wait()
require.NotNil(t, client.requests)
require.NotNil(t, client.interactions)
require.NotNil(t, client.matchedTemplates)
require.NotNil(t, client.interactshURLs)
}
@@ -7,14 +7,13 @@ import (
"github.com/projectdiscovery/networkpolicy"
"github.com/projectdiscovery/rawhttp"
"github.com/projectdiscovery/retryablehttp-go"
mapsutil "github.com/projectdiscovery/utils/maps"
)
type Dialers struct {
Fastdialer *fastdialer.Dialer
RawHTTPClient *rawhttp.Client
DefaultHTTPClient *retryablehttp.Client
HTTPClientPool *mapsutil.SyncLockMap[string, *retryablehttp.Client]
HTTPClientPool *HTTPPool
NetworkPolicy *networkpolicy.NetworkPolicy
LocalFileAccessAllowed bool
RestrictLocalNetworkAccess bool
@@ -0,0 +1,225 @@
package protocolstate
import (
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/projectdiscovery/retryablehttp-go"
"golang.org/x/sync/singleflight"
)
// closeIdler is implemented by transports that can drop idle connections.
type closeIdler interface{ CloseIdleConnections() }
// httpTransportEntry tracks a pooled transport and its last access time.
type httpTransportEntry struct {
rt http.RoundTripper
lastAccess atomic.Int64 // unix nanoseconds
}
func (e *httpTransportEntry) touch(now int64) { e.lastAccess.Store(now) }
// httpClientEntry tracks a pooled client, the transport it shares and its
// last access time.
type httpClientEntry struct {
client *retryablehttp.Client
transport *httpTransportEntry
lastAccess atomic.Int64 // unix nanoseconds
}
func (e *httpClientEntry) touch(now int64) {
e.lastAccess.Store(now)
if e.transport != nil {
// keep the shared transport alive while any of its clients is active
e.transport.touch(now)
}
}
// HTTPPool is a two-level cache for retryablehttp clients and the
// http.RoundTripper transports they share.
//
// Design goals (hot path = one lookup per outgoing request):
// - lock-free cache hits: sync.Map reads plus atomic last-access updates,
// no global mutex acquisition per request
// - singleflight creation: concurrent first requests to the same key build
// exactly one client/transport instead of N-1 orphans holding sockets
// - transport/client split: client-level settings (redirect policy, cookie
// jar, timeout) get their own cheap client wrapper while sharing one
// transport (and therefore one connection pool) per host
// - eviction closes connections: idle transports get CloseIdleConnections()
// instead of being silently dropped for the GC to find
type HTTPPool struct {
clients sync.Map // string -> *httpClientEntry
transports sync.Map // string -> *httpTransportEntry
clientSF singleflight.Group
transportSF singleflight.Group
inactivity time.Duration
cleanupInterval time.Duration
lastCleanup atomic.Int64 // unix nanoseconds
cleanupRunning atomic.Bool
}
// NewHTTPPool creates a pool whose entries are evicted after the given
// inactivity duration, checked lazily at most once per cleanupInterval.
func NewHTTPPool(inactivity, cleanupInterval time.Duration) *HTTPPool {
p := &HTTPPool{
inactivity: inactivity,
cleanupInterval: cleanupInterval,
}
p.lastCleanup.Store(time.Now().UnixNano())
return p
}
// GetClient returns a cached client for the key, refreshing its eviction
// timestamp. The hit path performs no locking.
func (p *HTTPPool) GetClient(key string) (*retryablehttp.Client, bool) {
v, ok := p.clients.Load(key)
if !ok {
return nil, false
}
entry := v.(*httpClientEntry)
entry.touch(time.Now().UnixNano())
p.maybeCleanup()
return entry.client, true
}
// GetOrCreateClient returns the cached client for clientKey or builds it
// exactly once (singleflight) using a transport shared via transportKey.
func (p *HTTPPool) GetOrCreateClient(
clientKey, transportKey string,
createTransport func() (http.RoundTripper, error),
createClient func(rt http.RoundTripper) (*retryablehttp.Client, error),
) (*retryablehttp.Client, error) {
if client, ok := p.GetClient(clientKey); ok {
return client, nil
}
v, err, _ := p.clientSF.Do(clientKey, func() (interface{}, error) {
if existing, ok := p.clients.Load(clientKey); ok {
return existing.(*httpClientEntry), nil
}
tEntry, err := p.getOrCreateTransportEntry(transportKey, createTransport)
if err != nil {
return nil, err
}
client, err := createClient(tEntry.rt)
if err != nil {
return nil, err
}
entry := &httpClientEntry{client: client, transport: tEntry}
entry.touch(time.Now().UnixNano())
p.clients.Store(clientKey, entry)
return entry, nil
})
if err != nil {
return nil, err
}
entry := v.(*httpClientEntry)
entry.touch(time.Now().UnixNano())
return entry.client, nil
}
// GetOrCreateTransport returns the shared transport for the key, building it
// exactly once. Used directly by callers that need an uncached client (e.g.
// explicit per-request cookie jars) but still want pooled connections.
func (p *HTTPPool) GetOrCreateTransport(key string, create func() (http.RoundTripper, error)) (http.RoundTripper, error) {
entry, err := p.getOrCreateTransportEntry(key, create)
if err != nil {
return nil, err
}
return entry.rt, nil
}
func (p *HTTPPool) getOrCreateTransportEntry(key string, create func() (http.RoundTripper, error)) (*httpTransportEntry, error) {
if v, ok := p.transports.Load(key); ok {
entry := v.(*httpTransportEntry)
entry.touch(time.Now().UnixNano())
return entry, nil
}
v, err, _ := p.transportSF.Do(key, func() (interface{}, error) {
if existing, ok := p.transports.Load(key); ok {
return existing.(*httpTransportEntry), nil
}
rt, err := create()
if err != nil {
return nil, err
}
entry := &httpTransportEntry{rt: rt}
entry.touch(time.Now().UnixNano())
p.transports.Store(key, entry)
return entry, nil
})
if err != nil {
return nil, err
}
entry := v.(*httpTransportEntry)
entry.touch(time.Now().UnixNano())
return entry, nil
}
// maybeCleanup spawns a single background eviction pass if the cleanup
// interval has elapsed. Uses CAS so only one goroutine wins.
func (p *HTTPPool) maybeCleanup() {
if p.inactivity <= 0 {
return
}
now := time.Now().UnixNano()
last := p.lastCleanup.Load()
if now-last < p.cleanupInterval.Nanoseconds() {
return
}
if !p.lastCleanup.CompareAndSwap(last, now) {
return
}
if !p.cleanupRunning.CompareAndSwap(false, true) {
return
}
go func() {
defer p.cleanupRunning.Store(false)
p.evictInactive()
}()
}
// evictInactive drops clients and transports idle for longer than the
// inactivity window. Evicted transports get their idle connections closed
// immediately instead of waiting for the GC / IdleConnTimeout.
func (p *HTTPPool) evictInactive() {
deadline := time.Now().Add(-p.inactivity).UnixNano()
p.clients.Range(func(k, v interface{}) bool {
if v.(*httpClientEntry).lastAccess.Load() < deadline {
p.clients.Delete(k)
}
return true
})
// Transports are touched whenever one of their clients is touched, so a
// transport only goes idle once all clients sharing it are idle too.
p.transports.Range(func(k, v interface{}) bool {
entry := v.(*httpTransportEntry)
if entry.lastAccess.Load() < deadline {
p.transports.Delete(k)
if ci, ok := entry.rt.(closeIdler); ok {
ci.CloseIdleConnections()
}
}
return true
})
}
// Close drops all cached clients and transports, closing idle connections so
// no transport goroutines linger after shutdown.
func (p *HTTPPool) Close() {
p.clients.Range(func(k, _ interface{}) bool {
p.clients.Delete(k)
return true
})
p.transports.Range(func(k, v interface{}) bool {
p.transports.Delete(k)
if ci, ok := v.(*httpTransportEntry).rt.(closeIdler); ok {
ci.CloseIdleConnections()
}
return true
})
}
@@ -0,0 +1,183 @@
package protocolstate
import (
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/projectdiscovery/retryablehttp-go"
mapsutil "github.com/projectdiscovery/utils/maps"
"github.com/stretchr/testify/require"
)
type fakeTransport struct {
closedIdle atomic.Int64
}
func (f *fakeTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, nil }
func (f *fakeTransport) CloseIdleConnections() { f.closedIdle.Add(1) }
func newFakeClient(rt http.RoundTripper) (*retryablehttp.Client, error) {
return retryablehttp.NewWithHTTPClient(&http.Client{Transport: rt}, retryablehttp.DefaultOptionsSingle), nil
}
func TestHTTPPool_ClientCaching(t *testing.T) {
pool := NewHTTPPool(time.Minute, time.Minute)
createTransport := func() (http.RoundTripper, error) { return &fakeTransport{}, nil }
c1, err := pool.GetOrCreateClient("client-a", "transport-a", createTransport, newFakeClient)
require.NoError(t, err)
c2, err := pool.GetOrCreateClient("client-a", "transport-a", createTransport, newFakeClient)
require.NoError(t, err)
require.Same(t, c1, c2, "same client key must hit the cache")
cached, ok := pool.GetClient("client-a")
require.True(t, ok)
require.Same(t, c1, cached)
}
func TestHTTPPool_TransportSharedAcrossClients(t *testing.T) {
pool := NewHTTPPool(time.Minute, time.Minute)
var created atomic.Int64
createTransport := func() (http.RoundTripper, error) {
created.Add(1)
return &fakeTransport{}, nil
}
c1, err := pool.GetOrCreateClient("client-a", "transport-shared", createTransport, newFakeClient)
require.NoError(t, err)
c2, err := pool.GetOrCreateClient("client-b", "transport-shared", createTransport, newFakeClient)
require.NoError(t, err)
require.NotSame(t, c1, c2, "different client keys must produce different clients")
require.Same(t, c1.HTTPClient.Transport, c2.HTTPClient.Transport,
"clients with the same transport key must share one transport")
require.EqualValues(t, 1, created.Load(), "transport must be created exactly once")
}
func TestHTTPPool_SingleflightCreation(t *testing.T) {
pool := NewHTTPPool(time.Minute, time.Minute)
var transportsCreated, clientsCreated atomic.Int64
createTransport := func() (http.RoundTripper, error) {
transportsCreated.Add(1)
time.Sleep(10 * time.Millisecond) // widen the race window
return &fakeTransport{}, nil
}
createClient := func(rt http.RoundTripper) (*retryablehttp.Client, error) {
clientsCreated.Add(1)
return newFakeClient(rt)
}
const workers = 32
clients := make([]*retryablehttp.Client, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
c, err := pool.GetOrCreateClient("client-key", "transport-key", createTransport, createClient)
require.NoError(t, err)
clients[idx] = c
}(i)
}
wg.Wait()
for i := 1; i < workers; i++ {
require.Same(t, clients[0], clients[i], "all concurrent callers must receive the same client")
}
require.EqualValues(t, 1, transportsCreated.Load(), "singleflight must build exactly one transport")
require.EqualValues(t, 1, clientsCreated.Load(), "singleflight must build exactly one client")
}
func TestHTTPPool_EvictionClosesIdleConnections(t *testing.T) {
pool := NewHTTPPool(10*time.Millisecond, time.Hour)
ft := &fakeTransport{}
_, err := pool.GetOrCreateClient("client-a", "transport-a",
func() (http.RoundTripper, error) { return ft, nil }, newFakeClient)
require.NoError(t, err)
time.Sleep(20 * time.Millisecond)
pool.evictInactive()
_, ok := pool.GetClient("client-a")
require.False(t, ok, "idle client must be evicted")
require.EqualValues(t, 1, ft.closedIdle.Load(), "evicted transport must close idle connections")
}
func TestHTTPPool_ActiveClientKeepsTransportAlive(t *testing.T) {
pool := NewHTTPPool(50*time.Millisecond, time.Hour)
ft := &fakeTransport{}
_, err := pool.GetOrCreateClient("client-a", "transport-a",
func() (http.RoundTripper, error) { return ft, nil }, newFakeClient)
require.NoError(t, err)
// keep touching the client; the shared transport must stay alive too
for i := 0; i < 5; i++ {
time.Sleep(20 * time.Millisecond)
_, ok := pool.GetClient("client-a")
require.True(t, ok)
pool.evictInactive()
}
require.EqualValues(t, 0, ft.closedIdle.Load(), "active transport must not be evicted")
}
// BenchmarkHTTPPool_GetClientParallel measures the lock-free hit path under
// contention; compare with BenchmarkSyncLockMap_GetParallel (the previous
// pool backing) to see the effect of removing per-hit mutex acquisitions.
func BenchmarkHTTPPool_GetClientParallel(b *testing.B) {
pool := NewHTTPPool(90*time.Second, 30*time.Second)
_, err := pool.GetOrCreateClient("key", "tkey",
func() (http.RoundTripper, error) { return &fakeTransport{}, nil }, newFakeClient)
require.NoError(b, err)
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, ok := pool.GetClient("key"); !ok {
b.Fatal("cache miss")
}
}
})
}
// BenchmarkSyncLockMap_GetParallel benchmarks the previous pool backing
// (mapsutil.SyncLockMap with eviction) for comparison.
func BenchmarkSyncLockMap_GetParallel(b *testing.B) {
m := mapsutil.NewSyncLockMap(
mapsutil.WithEviction[string, *retryablehttp.Client](90*time.Second, 30*time.Second),
)
client, err := newFakeClient(&fakeTransport{})
require.NoError(b, err)
require.NoError(b, m.Set("key", client))
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, ok := m.Get("key"); !ok {
b.Fatal("cache miss")
}
}
})
}
func TestHTTPPool_Close(t *testing.T) {
pool := NewHTTPPool(time.Minute, time.Minute)
ft := &fakeTransport{}
_, err := pool.GetOrCreateClient("client-a", "transport-a",
func() (http.RoundTripper, error) { return ft, nil }, newFakeClient)
require.NoError(t, err)
pool.Close()
_, ok := pool.GetClient("client-a")
require.False(t, ok, "Close must drop all clients")
require.EqualValues(t, 1, ft.closedIdle.Load(), "Close must close idle connections on transports")
}
+9 -6
View File
@@ -16,7 +16,6 @@ import (
"github.com/projectdiscovery/networkpolicy"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/expand"
"github.com/projectdiscovery/retryablehttp-go"
mapsutil "github.com/projectdiscovery/utils/maps"
)
@@ -187,11 +186,11 @@ func initDialers(options *types.Options) error {
networkPolicy, _ := networkpolicy.New(*npOptions)
httpClientPool := mapsutil.NewSyncLockMap(
// evicts inactive httpclientpool entries after 24 hours
// of inactivity (long running instances)
mapsutil.WithEviction[string, *retryablehttp.Client](24*time.Hour, 12*time.Hour),
)
// Per-host HTTP clients and transports are evicted after 90 seconds of
// inactivity (checked lazily every 30 seconds). Evicted transports get
// their idle connections closed immediately, so connections to
// already-scanned hosts are cleaned up promptly.
httpClientPool := NewHTTPPool(90*time.Second, 30*time.Second)
dialersInstance := &Dialers{
Fastdialer: dialer,
@@ -291,6 +290,10 @@ func Close(executionId string) {
}
if dialersInstance != nil {
// Drop all cached HTTP clients/transports and close their idle
// keep-alive connections to avoid lingering transport goroutines
// after shutdown.
dialersInstance.HTTPClientPool.Close()
dialersInstance.Fastdialer.Close()
}
+3
View File
@@ -168,6 +168,9 @@ func (request *Request) Compile(options *protocols.ExecutorOptions) error {
// Requests returns the total number of requests the YAML rule will perform
func (request *Request) Requests() int {
if request.generator != nil {
return request.generator.NewIterator().Total()
}
return 1
}
+1 -1
View File
@@ -202,7 +202,7 @@ func (request *Request) executeRequestWithPayloads(input *contextargs.Context, p
callback(event)
} else if request.options.Interactsh != nil {
event = &output.InternalWrappedEvent{InternalEvent: outputEvent}
request.options.Interactsh.RequestEvent(page.InteractshURLs, &interactsh.RequestData{
request.options.RegisterInteractshRequest(page.InteractshURLs, &interactsh.RequestData{
MakeResultFunc: request.MakeResultEvent,
Event: event,
Operators: request.CompiledOperators,
@@ -0,0 +1,26 @@
package headless
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
func TestRequestsIncludesPayloadExecutions(t *testing.T) {
payloads := map[string]interface{}{
"username": []string{"admin", "root"},
"password": []string{"password", "toor", "guest"},
}
generator, err := generators.New(payloads, generators.ClusterBombAttack, "", nil, "", types.DefaultOptions())
require.NoError(t, err)
request := &Request{
AttackType: generators.AttackTypeHolder{Value: generators.ClusterBombAttack},
Payloads: payloads,
generator: generator,
}
require.Equal(t, 6, request.Requests())
}
+10 -3
View File
@@ -24,7 +24,6 @@ import (
protocolutils "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils"
httputil "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils/http"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
"github.com/projectdiscovery/rawhttp"
"github.com/projectdiscovery/retryablehttp-go"
"github.com/projectdiscovery/utils/errkit"
@@ -393,6 +392,9 @@ func (r *requestGenerator) generateRawRequest(ctx context.Context, rawRequest st
}
annotationOverrides, _ = r.request.parseAnnotations(rawRequest, annotationRequest)
if annotationOverrides.err != nil {
return nil, errkit.Wrap(annotationOverrides.err, "failed to apply raw request annotations")
}
if annotationOverrides.request != nil && annotationOverrides.request.URL != nil {
rawRequestData.FullURL = annotationOverrides.request.String()
}
@@ -460,6 +462,9 @@ func (r *requestGenerator) generateRawRequest(ctx context.Context, rawRequest st
}
if reqWithOverrides, hasAnnotations := r.request.parseAnnotations(rawRequest, req); hasAnnotations {
if reqWithOverrides.err != nil {
return nil, errkit.Wrap(reqWithOverrides.err, "failed to apply raw request annotations")
}
generatedRequest.request = reqWithOverrides.request
generatedRequest.customCancelFunction = reqWithOverrides.cancelFunc
generatedRequest.interactshURLs = append(generatedRequest.interactshURLs, reqWithOverrides.interactshURLs...)
@@ -485,8 +490,10 @@ func (r *requestGenerator) fillRequest(req *retryablehttp.Request, values map[st
}
}
// In case of multiple threads the underlying connection should remain open to allow reuse
if r.request.Threads <= 0 && req.Header.Get("Connection") == "" && r.options.Options.ScanStrategy != scanstrategy.HostSpray.String() {
// Per-host clients always have keep-alive enabled for connection reuse.
// Only force-close connections when a template explicitly disables keep-alive.
if r.request.connConfiguration != nil && r.request.connConfiguration.Connection != nil &&
r.request.connConfiguration.Connection.DisableKeepAlive && req.Header.Get("Connection") == "" {
req.Close = true
}
+2 -13
View File
@@ -25,10 +25,8 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network/networkclientpool"
httputil "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils/http"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
"github.com/projectdiscovery/rawhttp"
"github.com/projectdiscovery/retryablehttp-go"
fileutil "github.com/projectdiscovery/utils/file"
)
@@ -145,7 +143,6 @@ type Request struct {
totalRequests int
customHeaders map[string]string
generator *generators.PayloadGenerator // optional, only enabled when using payloads
httpClient *retryablehttp.Client
rawhttpClient *rawhttp.Client
dialer *fastdialer.Dialer
@@ -315,10 +312,8 @@ func (request *Request) Compile(options *protocols.ExecutorOptions) error {
MaxRedirects: request.MaxRedirects,
NoTimeout: false,
DisableCookie: request.DisableCookie,
Connection: &httpclientpool.ConnectionConfiguration{
DisableKeepAlive: httputil.ShouldDisableKeepAlive(options.Options),
},
RedirectFlow: httpclientpool.DontFollowRedirect,
Connection: &httpclientpool.ConnectionConfiguration{},
RedirectFlow: httpclientpool.DontFollowRedirect,
}
var customTimeout int
if request.Analyzer != nil && request.Analyzer.Name == "time_delay" {
@@ -353,13 +348,7 @@ func (request *Request) Compile(options *protocols.ExecutorOptions) error {
}
}
request.connConfiguration = connectionConfiguration
client, err := httpclientpool.Get(options.Options, connectionConfiguration)
if err != nil {
return errors.Wrap(err, "could not get dns client")
}
request.customHeaders = make(map[string]string)
request.httpClient = client
dialer, err := networkclientpool.Get(options.Options, &networkclientpool.Configuration{
CustomDialer: options.CustomFastdialer,
+295 -157
View File
@@ -7,10 +7,12 @@ import (
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptrace"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/pkg/errors"
@@ -22,12 +24,65 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
"github.com/projectdiscovery/rawhttp"
"github.com/projectdiscovery/retryablehttp-go"
urlutil "github.com/projectdiscovery/utils/url"
)
var connStats ConnectionStats
// ConnectionStats tracks HTTP connection reuse across the scan.
type ConnectionStats struct {
New atomic.Int64
Reused atomic.Int64
}
// GetConnectionStats returns the current connection statistics.
//
// NOTE: counters are package-global and accumulate across in-process scans.
// Callers running multiple SDK/embedded executions in the same process should
// invoke ResetConnectionStats() at the start of each run to avoid reporting
// totals that mix results from earlier runs.
func GetConnectionStats() (newConns, reused int64) {
return connStats.New.Load(), connStats.Reused.Load()
}
// ResetConnectionStats clears the package-global new/reused connection counters.
// Intended to be called at the start of an execution to scope the metrics
// returned by GetConnectionStats() to a single run.
func ResetConnectionStats() {
connStats.New.Store(0)
connStats.Reused.Store(0)
}
// connTrackingTransport wraps an http.RoundTripper to track connection reuse
// via httptrace. Every request gets a GotConn callback that increments the
// appropriate counter before delegating to the underlying transport.
type connTrackingTransport struct {
base http.RoundTripper
}
func (t *connTrackingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
if info.Reused {
connStats.Reused.Add(1)
} else {
connStats.New.Add(1)
}
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
return t.base.RoundTrip(req)
}
func (t *connTrackingTransport) CloseIdleConnections() {
type closeIdler interface{ CloseIdleConnections() }
if ci, ok := t.base.(closeIdler); ok {
ci.CloseIdleConnections()
}
}
// ConnectionConfiguration contains the custom configuration options for a connection
type ConnectionConfiguration struct {
// DisableKeepAlive of the connection
@@ -111,9 +166,16 @@ func (c *Configuration) Hash() string {
builder.WriteString(strconv.FormatBool(c.DisableCookie))
builder.WriteString("c")
builder.WriteString(strconv.FormatBool(c.Connection != nil))
if c.Connection != nil && c.Connection.CustomMaxTimeout > 0 {
builder.WriteString("k")
builder.WriteString(c.Connection.CustomMaxTimeout.String())
if c.Connection != nil {
// keep-alive flag must participate in the hash; otherwise two
// configurations differing only in DisableKeepAlive will collide and
// return a cached client with the wrong connection-reuse semantics.
builder.WriteString("d")
builder.WriteString(strconv.FormatBool(c.Connection.DisableKeepAlive))
if c.Connection.CustomMaxTimeout > 0 {
builder.WriteString("k")
builder.WriteString(c.Connection.CustomMaxTimeout.String())
}
}
builder.WriteString("r")
builder.WriteString(strconv.FormatInt(int64(c.ResponseHeaderTimeout.Seconds()), 10))
@@ -154,62 +216,196 @@ func GetRawHTTP(options *protocols.ExecutorOptions) *rawhttp.Client {
return dialers.RawHTTPClient
}
// Get creates or gets a client for the protocol based on custom configuration
func Get(options *types.Options, configuration *Configuration) (*retryablehttp.Client, error) {
if configuration.HasStandardOptions() {
dialers := protocolstate.GetDialersWithId(options.ExecutionId)
if dialers == nil {
return nil, fmt.Errorf("dialers not initialized for %s", options.ExecutionId)
}
return dialers.DefaultHTTPClient, nil
}
// defaultIdleConnPoolSize is the floor for the per-host idle connection pool.
//
// The pool has to cover the connections the transport has OPEN against a host
// at once, and that is not the same as the number of requests in flight.
// http.Transport starts a dial whenever a request does not find an idle
// connection immediately; if one frees up first the request takes it and the
// racing dial still completes, so a scan holds many more connections than it
// has simultaneous requests. Measured against a keep-alive target at -c 25:
// requests in flight peaked at 22-31, while open connections peaked at 121-178.
//
// http.Transport closes a connection returned to a host whose idle pool is
// already full, so a pool sized to the nominal concurrency discards most of
// those and the next request dials again: 3,070 connections for 5,388 requests
// at a pool of 25, against 414 at 500.
//
// A pool that is never filled costs nothing -- it is a ceiling on retained
// connections, not an allocation; the transport only ever holds connections it
// actually opened and reaps them after IdleConnTimeout. 500 is the value this
// used before the per-host client pool was introduced.
const defaultIdleConnPoolSize = 500
return wrappedGet(options, configuration)
// idleConnPoolSize returns the per-host idle connection pool size for a client.
//
// templateThreads (-c) and requestThreads (a template's own payload
// parallelism) only ever raise the floor. Neither may lower it: -c is not an
// upper bound on open connections (see above), and a template that declares two
// payload threads must not shrink a pool shared with everything else running
// against that host.
func idleConnPoolSize(templateThreads, requestThreads int) int {
size := defaultIdleConnPoolSize
if templateThreads > size {
size = templateThreads
}
if requestThreads > size {
size = requestThreads
}
return size
}
// Get creates or gets a client for the protocol based on custom configuration.
// The host parameter scopes the client to a specific target, enabling per-host
// connection reuse with keep-alive. Pass an empty string for non-scanning uses.
func Get(options *types.Options, configuration *Configuration, host string) (*retryablehttp.Client, error) {
return wrappedGet(options, configuration, host)
}
// wrappedGet wraps a get operation without normal client check
func wrappedGet(options *types.Options, configuration *Configuration) (*retryablehttp.Client, error) {
var err error
func wrappedGet(options *types.Options, configuration *Configuration, host string) (*retryablehttp.Client, error) {
dialers := protocolstate.GetDialersWithId(options.ExecutionId)
if dialers == nil {
return nil, fmt.Errorf("dialers not initialized for %s", options.ExecutionId)
}
pool := dialers.HTTPClientPool
hash := configuration.Hash()
if client, ok := dialers.HTTPClientPool.Get(hash); ok {
return client, nil
// Explicit per-request cookie jars always bypass the client cache so
// session state is never leaked into the shared pool; they still share
// the pooled per-host transport below.
hasExplicitJar := configuration.Connection != nil && configuration.Connection.HasCookieJar()
clientKey := configuration.Hash()
if host != "" {
clientKey += ":" + host
}
// Multiple Host
retryableHttpOptions := retryablehttp.DefaultOptionsSpraying
disableKeepAlives := true
maxIdleConns := 0
maxConnsPerHost := 0
maxIdleConnsPerHost := -1
// do not split given timeout into chunks for retry
// because this won't work on slow hosts
// Fast path: lock-free cache hit.
if !hasExplicitJar {
if client, ok := pool.GetClient(clientKey); ok {
return client, nil
}
}
// Each client is scoped to a single host, so we optimize for connection
// reuse: keep-alive always on, small idle pool, and an idle timeout that
// lets the transport reclaim unused connections automatically.
retryableHttpOptions := retryablehttp.DefaultOptionsSingle
retryableHttpOptions.NoAdjustTimeout = true
if configuration.Threads > 0 || options.ScanStrategy == scanstrategy.HostSpray.String() {
// Single host
retryableHttpOptions = retryablehttp.DefaultOptionsSingle
disableKeepAlives = false
maxIdleConnsPerHost = 500
maxConnsPerHost = 500
}
retryableHttpOptions.RetryWaitMax = 10 * time.Second
retryableHttpOptions.RetryMax = options.Retries
retryableHttpOptions.Timeout = time.Duration(options.Timeout) * time.Second
if configuration.ResponseHeaderTimeout > 0 && configuration.ResponseHeaderTimeout > retryableHttpOptions.Timeout {
retryableHttpOptions.Timeout = configuration.ResponseHeaderTimeout
}
maxIdleConnsPerHost := idleConnPoolSize(options.TemplateThreads, configuration.Threads)
maxIdleConns := maxIdleConnsPerHost
maxConnsPerHost := 0 // unlimited by default; the SPM handler controls concurrency
disableKeepAlives := configuration.Connection != nil && configuration.Connection.DisableKeepAlive
responseHeaderTimeout := options.GetTimeouts().HttpResponseHeaderTimeout
if configuration.ResponseHeaderTimeout != 0 {
responseHeaderTimeout = configuration.ResponseHeaderTimeout
}
if responseHeaderTimeout < retryableHttpOptions.Timeout {
responseHeaderTimeout = retryableHttpOptions.Timeout
}
if configuration.Connection != nil && configuration.Connection.CustomMaxTimeout > 0 {
responseHeaderTimeout = configuration.Connection.CustomMaxTimeout
}
// Transports are pooled separately from clients: only parameters that
// actually live on http.Transport participate in the key, so clients
// that differ in client-level settings (redirect policy, cookies,
// timeout) still share a single connection pool per host.
transportKey := transportHash(host, disableKeepAlives, maxIdleConns, maxIdleConnsPerHost, maxConnsPerHost, responseHeaderTimeout)
createTransport := func() (http.RoundTripper, error) {
// Set the base TLS configuration definition
tlsConfig := &tls.Config{
Renegotiation: tls.RenegotiateOnceAsClient,
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
ClientSessionCache: sharedTLSSessionCache,
}
if options.SNI != "" {
tlsConfig.ServerName = options.SNI
}
tlsConfig, err := utils.AddConfiguredClientCertToRequest(tlsConfig, options)
if err != nil {
return nil, errors.Wrap(err, "could not create client certificate")
}
transport := &http.Transport{
ForceAttemptHTTP2: options.ForceAttemptHTTP2,
DialContext: dialers.Fastdialer.Dial,
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if options.TlsImpersonate {
return dialers.Fastdialer.DialTLSWithConfigImpersonate(ctx, network, addr, tlsConfig, impersonate.Random, nil)
}
if options.HasClientCertificates() || options.ForceAttemptHTTP2 {
return dialers.Fastdialer.DialTLSWithConfig(ctx, network, addr, tlsConfig)
}
return dialers.Fastdialer.DialTLS(ctx, network, addr)
},
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxConnsPerHost: maxConnsPerHost,
TLSClientConfig: tlsConfig,
DisableKeepAlives: disableKeepAlives,
IdleConnTimeout: 30 * time.Second,
ResponseHeaderTimeout: responseHeaderTimeout,
}
if options.AliveHttpProxy != "" {
if proxyURL, err := url.Parse(options.AliveHttpProxy); err == nil {
transport.Proxy = http.ProxyURL(proxyURL)
}
} else if options.AliveSocksProxy != "" {
socksURL, proxyErr := url.Parse(options.AliveSocksProxy)
if proxyErr != nil {
return nil, proxyErr
}
dialer, err := proxy.FromURL(socksURL, proxy.Direct)
if err != nil {
return nil, err
}
dc := dialer.(interface {
DialContext(ctx context.Context, network, addr string) (net.Conn, error)
})
transport.DialContext = dc.DialContext
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
// upgrade proxy connection to tls
conn, err := dc.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
if tlsConfig.ServerName == "" {
// addr should be in form of host:port already set from canonicalAddr
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
tlsConfig.ServerName = host
}
return tls.Client(conn, tlsConfig), nil
}
}
return &connTrackingTransport{base: transport}, nil
}
redirectFlow := configuration.RedirectFlow
maxRedirects := configuration.MaxRedirects
if options.ShouldFollowHTTPRedirects() {
// by default we enable general redirects following
switch {
case options.FollowHostRedirects:
redirectFlow = FollowSameHostRedirect
@@ -227,133 +423,75 @@ func wrappedGet(options *types.Options, configuration *Configuration) (*retryabl
maxRedirects = 0
}
// override connection's settings if required
if configuration.Connection != nil {
disableKeepAlives = configuration.Connection.DisableKeepAlive
}
// Set the base TLS configuration definition
tlsConfig := &tls.Config{
Renegotiation: tls.RenegotiateOnceAsClient,
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
ClientSessionCache: tls.NewLRUClientSessionCache(1024),
}
if options.SNI != "" {
tlsConfig.ServerName = options.SNI
}
// Add the client certificate authentication to the request if it's configured
tlsConfig, err = utils.AddConfiguredClientCertToRequest(tlsConfig, options)
if err != nil {
return nil, errors.Wrap(err, "could not create client certificate")
}
// responseHeaderTimeout is max timeout for response headers to be read
responseHeaderTimeout := options.GetTimeouts().HttpResponseHeaderTimeout
if configuration.ResponseHeaderTimeout != 0 {
responseHeaderTimeout = configuration.ResponseHeaderTimeout
}
if responseHeaderTimeout < retryableHttpOptions.Timeout {
responseHeaderTimeout = retryableHttpOptions.Timeout
}
if configuration.Connection != nil && configuration.Connection.CustomMaxTimeout > 0 {
responseHeaderTimeout = configuration.Connection.CustomMaxTimeout
}
transport := &http.Transport{
ForceAttemptHTTP2: options.ForceAttemptHTTP2,
DialContext: dialers.Fastdialer.Dial,
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if options.TlsImpersonate {
return dialers.Fastdialer.DialTLSWithConfigImpersonate(ctx, network, addr, tlsConfig, impersonate.Random, nil)
createClient := func(rt http.RoundTripper) (*retryablehttp.Client, error) {
// Each per-host client gets its own default cookie jar. This is safe
// because cookies are domain-scoped per RFC 6265, and same-host iterations
// (workflows, multi-step templates) hit the same cached client so cookies
// are retained across requests. Explicit jars from input.CookieJar bypass
// the client cache for full isolation.
var jar *cookiejar.Jar
if hasExplicitJar {
jar = configuration.Connection.GetCookieJar()
} else if !configuration.DisableCookie {
var err error
if jar, err = cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}); err != nil {
return nil, errors.Wrap(err, "could not create cookiejar")
}
if options.HasClientCertificates() || options.ForceAttemptHTTP2 {
return dialers.Fastdialer.DialTLSWithConfig(ctx, network, addr, tlsConfig)
}
return dialers.Fastdialer.DialTLS(ctx, network, addr)
},
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxConnsPerHost: maxConnsPerHost,
TLSClientConfig: tlsConfig,
DisableKeepAlives: disableKeepAlives,
ResponseHeaderTimeout: responseHeaderTimeout,
}
if options.AliveHttpProxy != "" {
if proxyURL, err := url.Parse(options.AliveHttpProxy); err == nil {
transport.Proxy = http.ProxyURL(proxyURL)
}
} else if options.AliveSocksProxy != "" {
socksURL, proxyErr := url.Parse(options.AliveSocksProxy)
if proxyErr != nil {
return nil, proxyErr
}
dialer, err := proxy.FromURL(socksURL, proxy.Direct)
httpclient := &http.Client{
Transport: rt,
CheckRedirect: makeCheckRedirectFunc(redirectFlow, maxRedirects),
}
if !configuration.NoTimeout {
httpclient.Timeout = options.GetTimeouts().HttpTimeout
if configuration.Connection != nil && configuration.Connection.CustomMaxTimeout > 0 {
httpclient.Timeout = configuration.Connection.CustomMaxTimeout
}
}
client := retryablehttp.NewWithHTTPClient(httpclient, retryableHttpOptions)
if jar != nil {
client.HTTPClient.Jar = jar
}
client.CheckRetry = retryablehttp.HostSprayRetryPolicy()
return client, nil
}
if hasExplicitJar {
rt, err := pool.GetOrCreateTransport(transportKey, createTransport)
if err != nil {
return nil, err
}
return createClient(rt)
}
// Singleflight creation: concurrent first requests to the same host build
// exactly one client instead of racing Get/Set and orphaning transports.
return pool.GetOrCreateClient(clientKey, transportKey, createTransport, createClient)
}
dc := dialer.(interface {
DialContext(ctx context.Context, network, addr string) (net.Conn, error)
})
// sharedTLSSessionCache is shared by all pooled transports so TLS session
// resumption survives transport eviction/re-creation, and a single bounded
// LRU replaces a 128-entry cache per host client.
var sharedTLSSessionCache = tls.NewLRUClientSessionCache(2048)
transport.DialContext = dc.DialContext
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
// upgrade proxy connection to tls
conn, err := dc.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
if tlsConfig.ServerName == "" {
// addr should be in form of host:port already set from canonicalAddr
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
tlsConfig.ServerName = host
}
return tls.Client(conn, tlsConfig), nil
}
}
var jar *cookiejar.Jar
if configuration.Connection != nil && configuration.Connection.HasCookieJar() {
jar = configuration.Connection.GetCookieJar()
} else if !configuration.DisableCookie {
if jar, err = cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}); err != nil {
return nil, errors.Wrap(err, "could not create cookiejar")
}
}
httpclient := &http.Client{
Transport: transport,
CheckRedirect: makeCheckRedirectFunc(redirectFlow, maxRedirects),
}
if !configuration.NoTimeout {
httpclient.Timeout = options.GetTimeouts().HttpTimeout
if configuration.Connection != nil && configuration.Connection.CustomMaxTimeout > 0 {
httpclient.Timeout = configuration.Connection.CustomMaxTimeout
}
}
client := retryablehttp.NewWithHTTPClient(httpclient, retryableHttpOptions)
if jar != nil {
client.HTTPClient.Jar = jar
}
client.CheckRetry = retryablehttp.HostSprayRetryPolicy()
// Only add to client pool if we don't have a cookie jar in place.
if jar == nil {
if err := dialers.HTTPClientPool.Set(hash, client); err != nil {
return nil, err
}
}
return client, nil
// transportHash identifies a shareable transport. Only parameters that live
// on http.Transport participate; everything else (redirects, cookie jars,
// client timeouts) is layered on top by the per-configuration client.
func transportHash(host string, disableKeepAlives bool, maxIdleConns, maxIdleConnsPerHost, maxConnsPerHost int, responseHeaderTimeout time.Duration) string {
builder := &strings.Builder{}
builder.Grow(len(host) + 32)
builder.WriteString(host)
builder.WriteString("|ka")
builder.WriteString(strconv.FormatBool(!disableKeepAlives))
builder.WriteString("|i")
builder.WriteString(strconv.Itoa(maxIdleConns))
builder.WriteString("|ih")
builder.WriteString(strconv.Itoa(maxIdleConnsPerHost))
builder.WriteString("|ch")
builder.WriteString(strconv.Itoa(maxConnsPerHost))
builder.WriteString("|rht")
builder.WriteString(strconv.FormatInt(int64(responseHeaderTimeout), 10))
return builder.String()
}
type RedirectFlow uint8
@@ -0,0 +1,558 @@
package httpclientpool
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// Benchmarks for per-host HTTP client connection reuse.
//
// 20 hosts x 50 templates = 1000 requests, measured on Apple M1 (localhost):
// HTTP : ~3x faster, 98% reuse (1000 -> 20 connections)
// HTTPS : ~18x faster, 98% reuse (each saved conn avoids a TLS handshake)
// benchResult captures the outcome of a run so we can compare connection-level
// behavior between strategies, not just wall-clock time.
type benchResult struct {
Duration time.Duration
TotalReqs int
NewConns int64
ReusedConns int64
}
func (r benchResult) ReusePercent() float64 {
total := r.NewConns + r.ReusedConns
if total == 0 {
return 0
}
return float64(r.ReusedConns) / float64(total) * 100
}
func (r benchResult) String() string {
return fmt.Sprintf(
"reqs=%d new_conns=%d reused_conns=%d reuse=%.1f%% dur=%v rps=%.0f",
r.TotalReqs, r.NewConns, r.ReusedConns, r.ReusePercent(),
r.Duration.Round(time.Millisecond),
float64(r.TotalReqs)/r.Duration.Seconds(),
)
}
func startHTTPServers(n int) []*httptest.Server {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "ok")
})
servers := make([]*httptest.Server, n)
for i := range servers {
servers[i] = httptest.NewServer(handler)
}
return servers
}
func startTLSServers(n int) []*httptest.Server {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "ok")
})
servers := make([]*httptest.Server, n)
for i := range servers {
servers[i] = httptest.NewTLSServer(handler)
}
return servers
}
func closeServers(servers []*httptest.Server) {
for _, s := range servers {
s.Close()
}
}
// connTrackingRoundTripper counts new vs reused connections via httptrace.
type connTrackingRoundTripper struct {
base http.RoundTripper
newConns *atomic.Int64
reused *atomic.Int64
}
func (rt *connTrackingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
if info.Reused {
rt.reused.Add(1)
} else {
rt.newConns.Add(1)
}
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
return rt.base.RoundTrip(req)
}
// CloseIdleConnections forwards to the wrapped transport so test code can
// rely on the same lifecycle semantics as the production wrapper.
func (rt *connTrackingRoundTripper) CloseIdleConnections() {
type closeIdler interface{ CloseIdleConnections() }
if ci, ok := rt.base.(closeIdler); ok {
ci.CloseIdleConnections()
}
}
func tracedClient(disableKeepAlive bool, maxIdlePerHost int) (*http.Client, *atomic.Int64, *atomic.Int64) {
var newConns, reusedConns atomic.Int64
transport := &http.Transport{
DisableKeepAlives: disableKeepAlive,
MaxIdleConnsPerHost: maxIdlePerHost,
MaxConnsPerHost: maxIdlePerHost,
IdleConnTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{
Transport: &connTrackingRoundTripper{
base: transport,
newConns: &newConns,
reused: &reusedConns,
},
}
return client, &newConns, &reusedConns
}
func doRequest(client *http.Client, url string) error {
resp, err := client.Get(url)
if err != nil {
return err
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return nil
}
// scan pattern runners
type clientFactory func() (*http.Client, *atomic.Int64, *atomic.Int64)
type perHostClientFactory func(host string) (*http.Client, *atomic.Int64, *atomic.Int64)
// runTemplateSpray: outer loop = templates, inner loop = hosts (like nuclei template-spray).
func runTemplateSpray(tb testing.TB, servers []*httptest.Server, templates int, factory clientFactory) benchResult {
tb.Helper()
client, newC, reusedC := factory()
total := templates * len(servers)
start := time.Now()
for t := 0; t < templates; t++ {
for _, srv := range servers {
url := srv.URL + fmt.Sprintf("/t%d", t)
if err := doRequest(client, url); err != nil {
tb.Fatalf("request to %s failed: %v", url, err)
}
}
}
return benchResult{time.Since(start), total, newC.Load(), reusedC.Load()}
}
// runHostSpray: outer loop = hosts, inner loop = templates (like nuclei host-spray).
func runHostSpray(tb testing.TB, servers []*httptest.Server, templates int, factory clientFactory) benchResult {
tb.Helper()
client, newC, reusedC := factory()
total := templates * len(servers)
start := time.Now()
for _, srv := range servers {
for t := 0; t < templates; t++ {
url := srv.URL + fmt.Sprintf("/t%d", t)
if err := doRequest(client, url); err != nil {
tb.Fatalf("request to %s failed: %v", url, err)
}
}
}
return benchResult{time.Since(start), total, newC.Load(), reusedC.Load()}
}
// runConcurrentHostSpray: hosts in parallel (bounded by concurrency), templates
// sequential per host. Each host gets its own client (the per-host pool model).
func runConcurrentHostSpray(tb testing.TB, servers []*httptest.Server, templates, concurrency int, factory perHostClientFactory) benchResult {
tb.Helper()
total := templates * len(servers)
var totalNew, totalReused atomic.Int64
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
var firstErr atomic.Value // stores error
start := time.Now()
for _, srv := range servers {
sem <- struct{}{}
wg.Add(1)
go func(s *httptest.Server) {
defer wg.Done()
defer func() { <-sem }()
client, newC, reusedC := factory(s.URL)
for t := 0; t < templates; t++ {
url := s.URL + fmt.Sprintf("/t%d", t)
if err := doRequest(client, url); err != nil {
firstErr.CompareAndSwap(nil, fmt.Errorf("request to %s failed: %w", url, err))
return
}
}
totalNew.Add(newC.Load())
totalReused.Add(reusedC.Load())
}(srv)
}
wg.Wait()
if v := firstErr.Load(); v != nil {
tb.Fatal(v.(error))
}
return benchResult{time.Since(start), total, totalNew.Load(), totalReused.Load()}
}
// assertion and logging helpers
func logComparison(t *testing.T, label string, old, new benchResult) {
t.Helper()
t.Logf("[%s] keep-alive OFF: %s", label, old)
t.Logf("[%s] keep-alive ON: %s", label, new)
speedup := float64(old.Duration) / float64(new.Duration)
connReduction := (1 - float64(new.NewConns)/float64(old.NewConns)) * 100
t.Logf("[%s] measured speedup: %.1fx connection reduction: %d -> %d (%.0f%% fewer)",
label, speedup, old.NewConns, new.NewConns, connReduction)
}
func assertReuse(t *testing.T, numHosts, numTemplates int, old, new benchResult) {
t.Helper()
expectedTotal := int64(numHosts * numTemplates)
// keep-alive OFF: every request opens a new connection
require.Equal(t, expectedTotal, old.NewConns,
"keep-alive OFF should open one connection per request")
require.Equal(t, int64(0), old.ReusedConns,
"keep-alive OFF should never reuse connections")
// keep-alive ON: only one connection per unique host, rest are reused
require.Equal(t, int64(numHosts), new.NewConns,
"keep-alive ON should open exactly one connection per host")
require.Equal(t, expectedTotal-int64(numHosts), new.ReusedConns,
"keep-alive ON should reuse connections for all subsequent requests")
// Log speedup for informational purposes; on localhost, connection
// creation is nearly free so keep-alive may actually be slower due
// to pool management overhead. The connection-count assertions above
// are the authoritative correctness check.
speedup := float64(old.Duration) / float64(new.Duration)
t.Logf("measured speedup: %.2fx (informational only)", speedup)
}
// HTTP tests
func TestConnectionReuse_HTTP_TemplateSpray(t *testing.T) {
const numHosts, numTemplates = 20, 50
servers := startHTTPServers(numHosts)
defer closeServers(servers)
old := runTemplateSpray(t, servers, numTemplates, keepAliveOffFactory)
new := runTemplateSpray(t, servers, numTemplates, keepAliveOnFactory)
logComparison(t, "HTTP/template-spray", old, new)
assertReuse(t, numHosts, numTemplates, old, new)
}
func TestConnectionReuse_HTTP_HostSpray(t *testing.T) {
const numHosts, numTemplates = 20, 50
servers := startHTTPServers(numHosts)
defer closeServers(servers)
old := runHostSpray(t, servers, numTemplates, keepAliveOffFactory)
new := runHostSpray(t, servers, numTemplates, keepAliveOnFactory)
logComparison(t, "HTTP/host-spray", old, new)
assertReuse(t, numHosts, numTemplates, old, new)
}
func TestConnectionReuse_HTTP_ConcurrentHostSpray(t *testing.T) {
const numHosts, numTemplates, concurrency = 20, 50, 5
servers := startHTTPServers(numHosts)
defer closeServers(servers)
old := runConcurrentHostSpray(t, servers, numTemplates, concurrency, perHostKeepAliveOffFactory)
new := runConcurrentHostSpray(t, servers, numTemplates, concurrency, perHostKeepAliveOnFactory)
logComparison(t, "HTTP/concurrent-host-spray", old, new)
assertReuse(t, numHosts, numTemplates, old, new)
}
// HTTPS tests
func TestConnectionReuse_HTTPS_TemplateSpray(t *testing.T) {
const numHosts, numTemplates = 20, 50
servers := startTLSServers(numHosts)
defer closeServers(servers)
old := runTemplateSpray(t, servers, numTemplates, keepAliveOffFactory)
new := runTemplateSpray(t, servers, numTemplates, keepAliveOnFactory)
logComparison(t, "HTTPS/template-spray", old, new)
assertReuse(t, numHosts, numTemplates, old, new)
}
func TestConnectionReuse_HTTPS_HostSpray(t *testing.T) {
const numHosts, numTemplates = 20, 50
servers := startTLSServers(numHosts)
defer closeServers(servers)
old := runHostSpray(t, servers, numTemplates, keepAliveOffFactory)
new := runHostSpray(t, servers, numTemplates, keepAliveOnFactory)
logComparison(t, "HTTPS/host-spray", old, new)
assertReuse(t, numHosts, numTemplates, old, new)
}
func TestConnectionReuse_HTTPS_ConcurrentHostSpray(t *testing.T) {
const numHosts, numTemplates, concurrency = 20, 50, 5
servers := startTLSServers(numHosts)
defer closeServers(servers)
old := runConcurrentHostSpray(t, servers, numTemplates, concurrency, perHostKeepAliveOffFactory)
new := runConcurrentHostSpray(t, servers, numTemplates, concurrency, perHostKeepAliveOnFactory)
logComparison(t, "HTTPS/concurrent-host-spray", old, new)
assertReuse(t, numHosts, numTemplates, old, new)
}
// Connection count precision tests
// Verify exact connection counts with small, deterministic workloads.
func TestConnectionCount_HTTP_ExactCounts(t *testing.T) {
const numHosts, numTemplates = 5, 10
servers := startHTTPServers(numHosts)
defer closeServers(servers)
result := runHostSpray(t, servers, numTemplates, keepAliveOnFactory)
require.Equal(t, int64(numHosts), result.NewConns,
"should open exactly %d connections (one per host)", numHosts)
require.Equal(t, int64(numHosts*(numTemplates-1)), result.ReusedConns,
"should reuse connections for all but the first request per host")
require.Equal(t, numHosts*numTemplates, result.TotalReqs)
}
func TestConnectionCount_HTTPS_ExactCounts(t *testing.T) {
const numHosts, numTemplates = 5, 10
servers := startTLSServers(numHosts)
defer closeServers(servers)
result := runHostSpray(t, servers, numTemplates, keepAliveOnFactory)
require.Equal(t, int64(numHosts), result.NewConns,
"should open exactly %d TLS connections (one per host)", numHosts)
require.Equal(t, int64(numHosts*(numTemplates-1)), result.ReusedConns,
"should reuse TLS connections for all but the first request per host")
require.Equal(t, numHosts*numTemplates, result.TotalReqs)
}
func TestConnectionCount_KeepAliveOff_NoReuse(t *testing.T) {
const numHosts, numTemplates = 5, 10
servers := startHTTPServers(numHosts)
defer closeServers(servers)
result := runHostSpray(t, servers, numTemplates, keepAliveOffFactory)
require.Equal(t, int64(numHosts*numTemplates), result.NewConns,
"with keep-alive off, every request must open a new connection")
require.Equal(t, int64(0), result.ReusedConns,
"with keep-alive off, no connections should be reused")
}
// Factories
var keepAliveOffFactory clientFactory = func() (*http.Client, *atomic.Int64, *atomic.Int64) {
return tracedClient(true, -1)
}
var keepAliveOnFactory clientFactory = func() (*http.Client, *atomic.Int64, *atomic.Int64) {
return tracedClient(false, 4)
}
var perHostKeepAliveOffFactory perHostClientFactory = func(host string) (*http.Client, *atomic.Int64, *atomic.Int64) {
return tracedClient(true, -1)
}
var perHostKeepAliveOnFactory perHostClientFactory = func(host string) (*http.Client, *atomic.Int64, *atomic.Int64) {
return tracedClient(false, 4)
}
// Benchmarks
func BenchmarkTemplateSpray_HTTP_KeepAliveOff(b *testing.B) {
servers := startHTTPServers(10)
defer closeServers(servers)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runTemplateSpray(b, servers, 20, keepAliveOffFactory)
}
}
func BenchmarkTemplateSpray_HTTP_KeepAliveOn(b *testing.B) {
servers := startHTTPServers(10)
defer closeServers(servers)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runTemplateSpray(b, servers, 20, keepAliveOnFactory)
}
}
func BenchmarkHostSpray_HTTP_KeepAliveOff(b *testing.B) {
servers := startHTTPServers(10)
defer closeServers(servers)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runHostSpray(b, servers, 20, keepAliveOffFactory)
}
}
func BenchmarkHostSpray_HTTP_KeepAliveOn(b *testing.B) {
servers := startHTTPServers(10)
defer closeServers(servers)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runHostSpray(b, servers, 20, keepAliveOnFactory)
}
}
func BenchmarkTemplateSpray_HTTPS_KeepAliveOff(b *testing.B) {
servers := startTLSServers(10)
defer closeServers(servers)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runTemplateSpray(b, servers, 20, keepAliveOffFactory)
}
}
func BenchmarkTemplateSpray_HTTPS_KeepAliveOn(b *testing.B) {
servers := startTLSServers(10)
defer closeServers(servers)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runTemplateSpray(b, servers, 20, keepAliveOnFactory)
}
}
func BenchmarkHostSpray_HTTPS_KeepAliveOff(b *testing.B) {
servers := startTLSServers(10)
defer closeServers(servers)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runHostSpray(b, servers, 20, keepAliveOffFactory)
}
}
func BenchmarkHostSpray_HTTPS_KeepAliveOn(b *testing.B) {
servers := startTLSServers(10)
defer closeServers(servers)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runHostSpray(b, servers, 20, keepAliveOnFactory)
}
}
// Goroutine leak tests
// waitForGoroutineCount waits until the goroutine count drops to target or below,
// up to a timeout. Returns the final count.
func waitForGoroutineCount(target, maxWaitMs int) int {
for waited := 0; waited < maxWaitMs; waited += 50 {
runtime.GC()
n := runtime.NumGoroutine()
if n <= target {
return n
}
time.Sleep(50 * time.Millisecond)
}
return runtime.NumGoroutine()
}
func TestConnTrackingTransportForwardsCloseIdleConnections(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "ok")
}))
defer server.Close()
transport := &http.Transport{
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 30 * time.Second,
}
wrapped := &connTrackingTransport{base: transport}
client := &http.Client{Transport: wrapped}
runtime.GC()
time.Sleep(100 * time.Millisecond)
before := runtime.NumGoroutine()
for i := 0; i < 20; i++ {
require.NoError(t, doRequest(client, server.URL))
}
// CloseIdleConnections must propagate through the wrapper
client.CloseIdleConnections()
after := waitForGoroutineCount(before+2, 2000)
require.LessOrEqual(t, after, before+2,
"CloseIdleConnections did not propagate through connTrackingTransport: before=%d after=%d", before, after)
}
func TestConnTrackingTransportNoLeakHTTP(t *testing.T) {
servers := startHTTPServers(5)
defer closeServers(servers)
runtime.GC()
time.Sleep(100 * time.Millisecond)
before := runtime.NumGoroutine()
for round := 0; round < 3; round++ {
transport := &http.Transport{
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 30 * time.Second,
}
client := &http.Client{Transport: &connTrackingTransport{base: transport}}
for _, s := range servers {
for i := 0; i < 10; i++ {
require.NoError(t, doRequest(client, s.URL))
}
}
client.CloseIdleConnections()
}
after := waitForGoroutineCount(before+2, 2000)
require.LessOrEqual(t, after, before+2,
"goroutine leak after HTTP requests: before=%d after=%d", before, after)
}
func TestConnTrackingTransportNoLeakHTTPS(t *testing.T) {
servers := startTLSServers(5)
defer closeServers(servers)
runtime.GC()
time.Sleep(100 * time.Millisecond)
before := runtime.NumGoroutine()
for round := 0; round < 3; round++ {
transport := &http.Transport{
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: &connTrackingTransport{base: transport}}
for _, s := range servers {
for i := 0; i < 10; i++ {
require.NoError(t, doRequest(client, s.URL))
}
}
client.CloseIdleConnections()
}
after := waitForGoroutineCount(before+2, 2000)
require.LessOrEqual(t, after, before+2,
"goroutine leak after HTTPS requests: before=%d after=%d", before, after)
}
@@ -0,0 +1,183 @@
package httpclientpool
import (
"net/http/cookiejar"
"sync"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/net/publicsuffix"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/retryablehttp-go"
)
// newTestOptions returns a fresh *types.Options with a unique ExecutionId so
// tests do not share the package-global dialers/HTTPClientPool state.
func newTestOptions(t *testing.T, executionId string) *types.Options {
t.Helper()
opts := types.DefaultOptions()
opts.SetExecutionID(executionId)
require.NoError(t, protocolstate.Init(opts))
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
return opts
}
// TestGet_HostScopedCache verifies that two Get() calls for the same host with
// the same configuration return the same cached *retryablehttp.Client, while
// different hosts produce different clients (per-host pool isolation).
func TestGet_HostScopedCache(t *testing.T) {
opts := newTestOptions(t, "test-host-scoped-cache")
cfg := &Configuration{}
c1, err := Get(opts, cfg, "example.com")
require.NoError(t, err)
require.NotNil(t, c1)
c2, err := Get(opts, cfg, "example.com")
require.NoError(t, err)
require.Same(t, c1, c2, "second Get() for the same host must hit the cache")
c3, err := Get(opts, cfg, "other.example.com")
require.NoError(t, err)
require.NotSame(t, c1, c3, "different hosts must produce different clients")
}
// TestGet_ExplicitCookieJarBypassesCache verifies that callers passing an
// explicit per-request cookie jar always receive a fresh client (so per-request
// session state is never leaked into the shared pool).
func TestGet_ExplicitCookieJarBypassesCache(t *testing.T) {
opts := newTestOptions(t, "test-explicit-jar-bypass")
jar1, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
require.NoError(t, err)
cfg1 := &Configuration{Connection: &ConnectionConfiguration{}}
cfg1.Connection.SetCookieJar(jar1)
c1, err := Get(opts, cfg1, "example.com")
require.NoError(t, err)
require.NotNil(t, c1)
jar2, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
require.NoError(t, err)
cfg2 := &Configuration{Connection: &ConnectionConfiguration{}}
cfg2.Connection.SetCookieJar(jar2)
c2, err := Get(opts, cfg2, "example.com")
require.NoError(t, err)
require.NotSame(t, c1, c2, "explicit cookie jars must always bypass the cache")
}
// TestGet_DisableKeepAliveAffectsCacheKey verifies that Configuration.Hash
// distinguishes clients that differ only in DisableKeepAlive, so the pool
// cannot return a client with the wrong keep-alive semantics.
func TestGet_DisableKeepAliveAffectsCacheKey(t *testing.T) {
opts := newTestOptions(t, "test-disable-keepalive-hash")
cfgKeepAliveOn := &Configuration{
Connection: &ConnectionConfiguration{DisableKeepAlive: false},
}
cfgKeepAliveOff := &Configuration{
Connection: &ConnectionConfiguration{DisableKeepAlive: true},
}
require.NotEqual(t, cfgKeepAliveOn.Hash(), cfgKeepAliveOff.Hash(),
"Configuration.Hash() must encode DisableKeepAlive to avoid pool-key collisions")
cOn, err := Get(opts, cfgKeepAliveOn, "example.com")
require.NoError(t, err)
cOff, err := Get(opts, cfgKeepAliveOff, "example.com")
require.NoError(t, err)
require.NotSame(t, cOn, cOff,
"clients with different DisableKeepAlive must not share a cache entry")
// Sanity check the underlying transport actually reflects the flag.
require.NotNil(t, cOn.HTTPClient.Transport)
require.NotNil(t, cOff.HTTPClient.Transport)
}
// TestGet_TransportSharedAcrossConfigurations verifies that clients whose
// configurations differ only in client-level settings (redirect policy,
// cookie handling) still share one underlying transport per host, so the
// host's connection pool is reused across templates.
func TestGet_TransportSharedAcrossConfigurations(t *testing.T) {
opts := newTestOptions(t, "test-transport-shared")
cfgNoRedirects := &Configuration{}
cfgRedirects := &Configuration{MaxRedirects: 5, RedirectFlow: FollowAllRedirect}
c1, err := Get(opts, cfgNoRedirects, "example.com")
require.NoError(t, err)
c2, err := Get(opts, cfgRedirects, "example.com")
require.NoError(t, err)
require.NotSame(t, c1, c2, "different configurations must produce different clients")
require.Same(t, c1.HTTPClient.Transport, c2.HTTPClient.Transport,
"clients differing only in client-level settings must share the per-host transport")
c3, err := Get(opts, cfgNoRedirects, "other.example.com")
require.NoError(t, err)
require.NotSame(t, c1.HTTPClient.Transport, c3.HTTPClient.Transport,
"different hosts must not share a transport")
}
// TestGet_ExplicitCookieJarSharesTransport verifies that uncached clients
// created for explicit per-request cookie jars still reuse the pooled
// per-host transport instead of opening their own connections.
func TestGet_ExplicitCookieJarSharesTransport(t *testing.T) {
opts := newTestOptions(t, "test-explicit-jar-transport")
cached, err := Get(opts, &Configuration{Connection: &ConnectionConfiguration{}}, "example.com")
require.NoError(t, err)
jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
require.NoError(t, err)
cfg := &Configuration{Connection: &ConnectionConfiguration{}}
cfg.Connection.SetCookieJar(jar)
withJar, err := Get(opts, cfg, "example.com")
require.NoError(t, err)
require.NotSame(t, cached, withJar, "explicit jar must bypass the client cache")
require.Same(t, cached.HTTPClient.Transport, withJar.HTTPClient.Transport,
"explicit-jar clients must still share the pooled per-host transport")
}
// TestGet_ConcurrentSameHost verifies that concurrent first requests for the
// same (configuration, host) pair produce exactly one shared client.
func TestGet_ConcurrentSameHost(t *testing.T) {
opts := newTestOptions(t, "test-concurrent-same-host")
cfg := &Configuration{}
const workers = 32
clients := make([]*retryablehttp.Client, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
c, err := Get(opts, cfg, "example.com")
require.NoError(t, err)
clients[idx] = c
}(i)
}
wg.Wait()
for i := 1; i < workers; i++ {
require.Same(t, clients[0], clients[i], "concurrent Get() calls must return one shared client")
}
}
// TestResetConnectionStats verifies the global counter reset used between
// in-process scans to keep per-run summaries accurate.
func TestResetConnectionStats(t *testing.T) {
connStats.New.Store(7)
connStats.Reused.Store(11)
ResetConnectionStats()
newC, reused := GetConnectionStats()
require.Equal(t, int64(0), newC, "new conn counter must be reset to 0")
require.Equal(t, int64(0), reused, "reused conn counter must be reset to 0")
}
@@ -0,0 +1,345 @@
package httpclientpool
import (
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/retryablehttp-go"
)
// TestIdleConnPoolAllowsKeepAliveReuse is a regression test for the per-host
// idle connection pool being smaller than the concurrency that runs against a
// host.
//
// http.Transport closes a connection returned to a host whose idle pool is
// already full. When MaxIdleConnsPerHost was 4, a scan running templates
// concurrently against one host reused almost nothing: connections past the
// fourth were closed on return and the next request dialled again, so the
// target saw close to one TCP connection per HTTP request.
//
// The server counts accepted connections, which is what actually matters:
// connection churn is invisible in request counts but is what pays a TCP (and
// TLS) handshake per request and exhausts NAT port mappings.
func TestIdleConnPoolAllowsKeepAliveReuse(t *testing.T) {
const (
requests = 400
concurrency = 50
)
var accepted, arrived atomic.Int64
// Hold the first `concurrency` requests inside the handler until all of
// them have arrived, so the transport is genuinely forced to keep that
// many connections open at once. Without this the requests could
// serialise and the connection count would prove nothing.
barrier := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(barrier) }) }
// Safety valve: never hang the suite if fewer than `concurrency` requests
// ever reach the handler.
timer := time.AfterFunc(30*time.Second, release)
defer timer.Stop()
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if arrived.Add(1) >= concurrency {
release()
}
<-barrier
_, _ = w.Write([]byte("ok"))
}))
server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateNew {
accepted.Add(1)
}
}
server.Start()
defer server.Close()
opts := newTestOptions(t, "test-idle-conn-pool-reuse")
opts.TemplateThreads = concurrency
client, err := Get(opts, &Configuration{}, server.Listener.Addr().String())
require.NoError(t, err)
var succeeded atomic.Int64
var errMu sync.Mutex
var firstErr error
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for i := 0; i < requests; i++ {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
recordErr := func(err error) {
errMu.Lock()
defer errMu.Unlock()
if firstErr == nil {
firstErr = err
}
}
req, err := retryablehttp.NewRequest(http.MethodGet, fmt.Sprintf("%s/path-%d", server.URL, i), nil)
if err != nil {
recordErr(err)
return
}
resp, err := client.Do(req)
if err != nil {
recordErr(err)
return
}
// Drain and close so the connection is eligible for reuse.
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
_ = resp.Body.Close()
recordErr(err)
return
}
if err := resp.Body.Close(); err != nil {
recordErr(err)
return
}
succeeded.Add(1)
}(i)
}
wg.Wait()
// Without these the connection assertion is vacuous: zero successful
// requests also means zero connections.
require.NoError(t, firstErr, "requests must succeed for the connection count to mean anything")
require.Equal(t, int64(requests), succeeded.Load(), "all requests must succeed")
require.GreaterOrEqual(t, arrived.Load(), int64(concurrency),
"handler must have seen at least `concurrency` requests for the overlap barrier to have engaged")
conns := accepted.Load()
t.Logf("%d requests over %d connections (%.1f requests/connection)",
requests, conns, float64(requests)/float64(conns))
// `concurrency` connections are genuinely required by the barrier above.
// Anything much beyond that is the pool discarding connections instead of
// reusing them; the regression lands near one connection per request.
require.LessOrEqual(t, conns, int64(concurrency*2),
"connections should be bounded by concurrency, not by request count")
}
// TestIdleConnPoolSize covers the sizing rule itself: a floor that -c raises
// when it is larger, payload threads raising it too, and nothing either of them
// declares shrinking it.
func TestIdleConnPoolSize(t *testing.T) {
for _, testCase := range []struct {
name string
templateThreads int
requestThreads int
want int
}{
{name: "unset", templateThreads: 0, requestThreads: 0, want: defaultIdleConnPoolSize},
{name: "default -c", templateThreads: 25, requestThreads: 0, want: defaultIdleConnPoolSize},
{name: "-c 50", templateThreads: 50, requestThreads: 0, want: defaultIdleConnPoolSize},
{name: "-c 800", templateThreads: 800, requestThreads: 0, want: 800},
{name: "payload threads raise the pool", templateThreads: 25, requestThreads: 900, want: 900},
{name: "payload threads never shrink the pool", templateThreads: 25, requestThreads: 2, want: defaultIdleConnPoolSize},
{name: "payload threads never shrink -c 800", templateThreads: 800, requestThreads: 2, want: 800},
{name: "negative payload threads never shrink the pool", templateThreads: 50, requestThreads: -1, want: defaultIdleConnPoolSize},
} {
t.Run(testCase.name, func(t *testing.T) {
got := idleConnPoolSize(testCase.templateThreads, testCase.requestThreads)
require.Equal(t, testCase.want, got)
require.GreaterOrEqual(t, got, defaultIdleConnPoolSize,
"the floor exists because -c does not bound how many connections the transport opens")
require.GreaterOrEqual(t, got, testCase.templateThreads,
"the pool must never be smaller than the template concurrency it serves")
})
}
}
// TestIdleConnPoolSizeIsWiredToTransport checks that the size actually reaches
// the transport, not just the helper: a sizing rule that never lands on
// http.Transport would leave the reuse bug in place while the unit test above
// still passed.
func TestIdleConnPoolSizeIsWiredToTransport(t *testing.T) {
opts := newTestOptions(t, "test-idle-conn-pool-wiring")
opts.TemplateThreads = 800
client, err := Get(opts, &Configuration{}, "example.com")
require.NoError(t, err)
tracking, ok := client.HTTPClient.Transport.(*connTrackingTransport)
require.True(t, ok, "expected the connection-tracking transport wrapper")
transport, ok := tracking.base.(*http.Transport)
require.True(t, ok, "expected an *http.Transport underneath")
require.Equal(t, 800, transport.MaxIdleConnsPerHost)
require.Equal(t, 800, transport.MaxIdleConns)
}
// cyclicBarrier releases waiters in groups of n. A single-shot barrier only
// forces the FIRST group of requests to overlap, and a pool that is too small
// still looks fine afterwards, because once the requests stop overlapping a
// shallow pool is enough. Holding every group keeps the concurrency up for the
// whole run, which is what a scan does to a host.
type cyclicBarrier struct {
mu sync.Mutex
n int
count int
gate chan struct{}
abandoned bool
}
func newCyclicBarrier(n int) *cyclicBarrier {
return &cyclicBarrier{n: n, gate: make(chan struct{})}
}
func (b *cyclicBarrier) wait() {
b.mu.Lock()
if b.abandoned {
b.mu.Unlock()
return
}
b.count++
gate := b.gate
if b.count == b.n {
b.count = 0
b.gate = make(chan struct{})
close(gate)
b.mu.Unlock()
return
}
b.mu.Unlock()
<-gate
}
// abandon is the safety valve: it stops blocking so a shortfall fails on the
// assertions instead of hanging the suite.
func (b *cyclicBarrier) abandon() {
b.mu.Lock()
defer b.mu.Unlock()
if b.abandoned {
return
}
b.abandoned = true
close(b.gate)
}
func (b *cyclicBarrier) wasAbandoned() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.abandoned
}
// TestIdleConnPoolAllowsKeepAliveReuseWithPayloadThreads covers the client that
// payload templates share. Configuration.Threads is part of the client cache
// key, so every template declaring the same payload thread count hashes to one
// entry and its workers pile onto a single pool -- templateThreads templates
// times payloadThreads workers each. That client has to reuse connections just
// like the default one, and a rule that sized the pool from a single template's
// threads would leave it far below the concurrency it actually serves.
func TestIdleConnPoolAllowsKeepAliveReuseWithPayloadThreads(t *testing.T) {
const (
templateThreads = 10
payloadThreads = 5
concurrency = templateThreads * payloadThreads
requestsPerWorker = 8
requests = concurrency * requestsPerWorker
)
var accepted atomic.Int64
barrier := newCyclicBarrier(concurrency)
timer := time.AfterFunc(30*time.Second, barrier.abandon)
defer timer.Stop()
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
barrier.wait()
_, _ = w.Write([]byte("ok"))
}))
server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateNew {
accepted.Add(1)
}
}
server.Start()
defer server.Close()
opts := newTestOptions(t, "test-idle-conn-pool-payload-threads")
opts.TemplateThreads = templateThreads
// One client, as the engine would hand out: every payload template with
// this thread count hashes to the same cache entry.
client, err := Get(opts, &Configuration{Threads: payloadThreads}, server.Listener.Addr().String())
require.NoError(t, err)
var succeeded atomic.Int64
var errMu sync.Mutex
var firstErr error
recordErr := func(err error) {
errMu.Lock()
defer errMu.Unlock()
if firstErr == nil {
firstErr = err
}
}
// templateThreads templates in flight, each running payloadThreads workers.
var wg sync.WaitGroup
for template := 0; template < templateThreads; template++ {
for worker := 0; worker < payloadThreads; worker++ {
wg.Add(1)
go func(template, worker int) {
defer wg.Done()
for i := 0; i < requestsPerWorker; i++ {
req, err := retryablehttp.NewRequest(http.MethodGet,
fmt.Sprintf("%s/t-%d/w-%d/%d", server.URL, template, worker, i), nil)
if err != nil {
recordErr(err)
barrier.abandon()
return
}
resp, err := client.Do(req)
if err != nil {
recordErr(err)
barrier.abandon()
return
}
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
_ = resp.Body.Close()
recordErr(err)
return
}
if err := resp.Body.Close(); err != nil {
recordErr(err)
return
}
succeeded.Add(1)
}
}(template, worker)
}
}
wg.Wait()
// Without these the connection assertion is vacuous: zero successful
// requests also means zero connections.
require.NoError(t, firstErr, "requests must succeed for the connection count to mean anything")
require.Equal(t, int64(requests), succeeded.Load(), "all requests must succeed")
require.False(t, barrier.wasAbandoned(),
"the barrier must have held every group; the safety valve firing means the overlap was not sustained")
conns := accepted.Load()
t.Logf("%d requests over %d connections (%.1f requests/connection), pool=%d",
requests, conns, float64(requests)/float64(conns),
idleConnPoolSize(templateThreads, payloadThreads))
require.LessOrEqual(t, conns, int64(concurrency*2),
"connections should be bounded by the concurrency, not by the request count")
}
@@ -0,0 +1,220 @@
package httpclientpool
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
// Benchmarks measuring the end-to-end effect of per-host pooling delivered by
// this PR. Two scenarios are compared on the same workload of N hosts × M
// requests per host:
//
// * "before": a single shared client with keep-alive disabled, mirroring
// pre-PR behavior on a host-spray strategy where every request opened a
// fresh connection.
//
// * "after": one client per host obtained from httpclientpool.Get(...,
// hostname). Keep-alive is always enabled and idle connections are
// reused by the per-host transport pool.
//
// Numbers are most striking for HTTPS, where avoiding the TLS handshake on
// every request dominates the runtime.
const (
prBenchHosts = 10
prBenchRequestsHost = 20
)
func setupPRBenchOptions(b *testing.B, executionId string) *types.Options {
b.Helper()
opts := types.DefaultOptions()
opts.SetExecutionID(executionId)
require.NoError(b, protocolstate.Init(opts))
b.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
return opts
}
func startPRTLSServers(b *testing.B, n int) []*httptest.Server {
b.Helper()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "ok")
})
servers := make([]*httptest.Server, n)
for i := range servers {
servers[i] = httptest.NewTLSServer(handler)
}
b.Cleanup(func() {
for _, s := range servers {
s.Close()
}
})
return servers
}
func startPRHTTPServers(b *testing.B, n int) []*httptest.Server {
b.Helper()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "ok")
})
servers := make([]*httptest.Server, n)
for i := range servers {
servers[i] = httptest.NewServer(handler)
}
b.Cleanup(func() {
for _, s := range servers {
s.Close()
}
})
return servers
}
// hostFromURL extracts host:port from an httptest.Server.URL.
func hostFromURL(b *testing.B, raw string) string {
b.Helper()
u, err := url.Parse(raw)
require.NoError(b, err)
return u.Host
}
// sharedClientNoKeepAlive mirrors the pre-PR shared-client + keep-alive-OFF
// path, which forced a brand new connection (and a TLS handshake when
// applicable) on every request.
func sharedClientNoKeepAlive() *http.Client {
tr := &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return &http.Client{Transport: tr, Timeout: 30 * time.Second}
}
// runBeforePR drives the workload with a single shared client and keep-alive
// disabled.
func runBeforePR(b *testing.B, servers []*httptest.Server) {
b.Helper()
client := sharedClientNoKeepAlive()
for _, srv := range servers {
for i := 0; i < prBenchRequestsHost; i++ {
resp, err := client.Get(srv.URL + fmt.Sprintf("/r%d", i))
require.NoError(b, err)
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}
}
// runAfterPR drives the same workload using httpclientpool.Get(... host) so
// each host gets its own keep-alive enabled client, matching the path taken
// by request.go after this PR.
func runAfterPR(b *testing.B, opts *types.Options, servers []*httptest.Server) {
b.Helper()
cfg := &Configuration{}
for _, srv := range servers {
host := hostFromURL(b, srv.URL)
client, err := Get(opts, cfg, host)
require.NoError(b, err)
for i := 0; i < prBenchRequestsHost; i++ {
req, err := http.NewRequest(http.MethodGet, srv.URL+fmt.Sprintf("/r%d", i), nil)
require.NoError(b, err)
resp, err := client.HTTPClient.Do(req)
require.NoError(b, err)
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}
}
// runAfterPRConcurrent drives the workload with one goroutine per host so
// the per-host pool benefit is measured under realistic scan concurrency.
func runAfterPRConcurrent(b *testing.B, opts *types.Options, servers []*httptest.Server) {
b.Helper()
cfg := &Configuration{}
var wg sync.WaitGroup
for _, srv := range servers {
wg.Add(1)
go func(s *httptest.Server) {
defer wg.Done()
host := hostFromURL(b, s.URL)
client, err := Get(opts, cfg, host)
if err != nil {
b.Errorf("Get(%s): %v", host, err)
return
}
for i := 0; i < prBenchRequestsHost; i++ {
req, err := http.NewRequest(http.MethodGet, s.URL+fmt.Sprintf("/r%d", i), nil)
if err != nil {
b.Errorf("new request: %v", err)
return
}
resp, err := client.HTTPClient.Do(req)
if err != nil {
b.Errorf("do: %v", err)
return
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}(srv)
}
wg.Wait()
}
// HTTPS — TLS handshake amplifies the win.
func BenchmarkPR_BeforePR_HTTPS(b *testing.B) {
servers := startPRTLSServers(b, prBenchHosts)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runBeforePR(b, servers)
}
}
func BenchmarkPR_AfterPR_HTTPS(b *testing.B) {
opts := setupPRBenchOptions(b, "bench-after-pr-https")
servers := startPRTLSServers(b, prBenchHosts)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runAfterPR(b, opts, servers)
}
}
func BenchmarkPR_AfterPR_HTTPS_Concurrent(b *testing.B) {
opts := setupPRBenchOptions(b, "bench-after-pr-https-concurrent")
servers := startPRTLSServers(b, prBenchHosts)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runAfterPRConcurrent(b, opts, servers)
}
}
// HTTP — keep-alive still wins because it skips TCP setup on every request.
func BenchmarkPR_BeforePR_HTTP(b *testing.B) {
servers := startPRHTTPServers(b, prBenchHosts)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runBeforePR(b, servers)
}
}
func BenchmarkPR_AfterPR_HTTP(b *testing.B) {
opts := setupPRBenchOptions(b, "bench-after-pr-http")
servers := startPRHTTPServers(b, prBenchHosts)
b.ResetTimer()
for i := 0; i < b.N; i++ {
runAfterPR(b, opts, servers)
}
}
+24 -11
View File
@@ -3,17 +3,20 @@ package race
import (
"fmt"
"io"
"sync/atomic"
"time"
)
// SyncedReadCloser is compatible with io.ReadSeeker and performs
// gate-based synced writes to enable race condition testing.
type SyncedReadCloser struct {
data []byte
p int64
length int64
openGate chan struct{}
enableBlocking bool
data []byte
p int64
length int64
openGate chan struct{}
// enableBlocking is read by the transport's read goroutine and written
// by callers (SetOpenGate) and by Read itself, hence atomic.
enableBlocking atomic.Bool
}
// NewSyncedReadCloser creates a new SyncedReadCloser instance.
@@ -30,8 +33,8 @@ func NewSyncedReadCloser(r io.ReadCloser) *SyncedReadCloser {
_ = r.Close()
}()
s.length = int64(len(s.data))
s.openGate = make(chan struct{})
s.enableBlocking = true
s.openGate = make(chan struct{}, 1)
s.enableBlocking.Store(true)
return &s
}
@@ -44,18 +47,24 @@ func NewOpenGateWithTimeout(r io.ReadCloser, d time.Duration) *SyncedReadCloser
// SetOpenGate sets the status of the blocking gate
func (s *SyncedReadCloser) SetOpenGate(status bool) {
s.enableBlocking = status
s.enableBlocking.Store(status)
}
// OpenGate opens the gate allowing all requests to be completed
func (s *SyncedReadCloser) OpenGate() {
s.openGate <- struct{}{}
select {
case s.openGate <- struct{}{}:
default:
}
}
// OpenGateAfter schedules gate to be opened after a duration
func (s *SyncedReadCloser) OpenGateAfter(d time.Duration) {
time.AfterFunc(d, func() {
s.openGate <- struct{}{}
select {
case s.openGate <- struct{}{}:
default:
}
})
}
@@ -84,8 +93,12 @@ func (s *SyncedReadCloser) Seek(offset int64, whence int) (int64, error) {
// Read implements read method for io.ReadSeeker
func (s *SyncedReadCloser) Read(p []byte) (n int, err error) {
// If the data fits in the buffer blocks awaiting the sync instruction
if s.p+int64(len(p)) >= s.length && s.enableBlocking {
if s.p+int64(len(p)) >= s.length && s.enableBlocking.Load() {
<-s.openGate
// Once the gate opens, disable blocking so that subsequent reads
// (e.g. after the retryablehttp client seeks back and re-reads)
// pass through without deadlocking.
s.enableBlocking.Store(false)
}
n = copy(p, s.data[s.p:])
s.p += int64(n)
+62 -37
View File
@@ -40,6 +40,7 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types/nucleierr"
"github.com/projectdiscovery/rawhttp"
"github.com/projectdiscovery/retryablehttp-go"
convUtil "github.com/projectdiscovery/utils/conversion"
"github.com/projectdiscovery/utils/errkit"
httpUtils "github.com/projectdiscovery/utils/http"
@@ -265,7 +266,7 @@ func (request *Request) executeParallelHTTP(input *contextargs.Context, dynamicV
}
allOASTUrls := httputils.GetInteractshURLSFromEvent(event.InternalEvent)
allOASTUrls = append(allOASTUrls, t.req.interactshURLs...)
request.options.Interactsh.RequestEvent(sliceutil.Dedupe(allOASTUrls), requestData)
request.options.RegisterInteractshRequest(sliceutil.Dedupe(allOASTUrls), requestData)
}
wrappedCallback(event)
}, 0)
@@ -557,7 +558,7 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
}
allOASTUrls := httputils.GetInteractshURLSFromEvent(event.InternalEvent)
allOASTUrls = append(allOASTUrls, generatedHttpRequest.interactshURLs...)
request.options.Interactsh.RequestEvent(sliceutil.Dedupe(allOASTUrls), requestData)
request.options.RegisterInteractshRequest(sliceutil.Dedupe(allOASTUrls), requestData)
gotMatches = request.options.Interactsh.AlreadyMatched(requestData)
}
// Add the extracts to the dynamic values if any.
@@ -670,6 +671,11 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
fromCache bool
dumpedRequest []byte
projectCacheKey []byte
// executingClient is the client that actually performed the HTTP
// request, preserving any per-request overrides (cookie jar,
// CustomMaxTimeout) applied via connConfig.Clone(). Reused below by
// the analyzer so follow-up requests share the same session/timeout.
executingClient *retryablehttp.Client
)
// Dump request for variables checks
@@ -790,7 +796,15 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
})
} else {
//** For Normal requests **//
hostname = generatedRequest.request.Host
// Use the dial target (URL.Host) rather than the optional Host-header
// override (request.Host), so the per-host pool keys distinct
// connection targets even when templates set a custom Host header
// against multiple IPs/vhosts.
if generatedRequest.request.URL != nil {
hostname = generatedRequest.request.URL.Host
} else {
hostname = generatedRequest.request.Host
}
formedURL = generatedRequest.request.String()
// if nuclei-project is available check if the request was already sent previously
if request.options.ProjectFile != nil {
@@ -805,35 +819,24 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
if errSignature := request.handleSignature(generatedRequest); errSignature != nil {
return errSignature
}
httpclient := request.httpClient
// this will be assigned/updated if this specific request has a custom configuration
var modifiedConfig *httpclientpool.Configuration
// check for cookie related configuration
if input.CookieJar != nil {
connConfiguration := request.connConfiguration.Clone()
connConfiguration.Connection.SetCookieJar(input.CookieJar)
modifiedConfig = connConfiguration
connConfig := request.connConfiguration
if input.CookieJar != nil && !request.DisableCookie {
connConfig = connConfig.Clone()
connConfig.Connection.SetCookieJar(input.CookieJar)
}
// check for request updatedTimeout annotation
updatedTimeout, ok := generatedRequest.request.Context().Value(httpclientpool.WithCustomTimeout{}).(httpclientpool.WithCustomTimeout)
if ok {
if modifiedConfig == nil {
connConfiguration := request.connConfiguration.Clone()
modifiedConfig = connConfiguration
if updatedTimeout, ok := generatedRequest.request.Context().Value(httpclientpool.WithCustomTimeout{}).(httpclientpool.WithCustomTimeout); ok {
if connConfig == request.connConfiguration {
connConfig = connConfig.Clone()
}
modifiedConfig.ResponseHeaderTimeout = updatedTimeout.Timeout
connConfig.ResponseHeaderTimeout = updatedTimeout.Timeout
}
if modifiedConfig != nil {
client, err := httpclientpool.Get(request.options.Options, modifiedConfig)
if err != nil {
return errors.Wrap(err, "could not get http client")
}
httpclient = client
httpclient, clientErr := httpclientpool.Get(request.options.Options, connConfig, hostname)
if clientErr != nil {
return errors.Wrap(clientErr, "could not get http client")
}
executingClient = httpclient
resp, err = httpclient.Do(generatedRequest.request)
}
@@ -993,18 +996,30 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
if request.Analyzer != nil {
analyzer := analyzers.GetAnalyzer(request.Analyzer.Name)
analysisMatched, analysisDetails, err := analyzer.Analyze(&analyzers.Options{
FuzzGenerated: generatedRequest.fuzzGeneratedRequest,
HttpClient: request.httpClient,
ResponseTimeDelay: duration,
AnalyzerParameters: request.Analyzer.Parameters,
})
if err != nil {
gologger.Warning().Msgf("Could not analyze response: %v\n", err)
// Prefer reusing the exact client that executed the request so
// the analyzer inherits any per-request cookie jar / timeout
// overrides; fall back to a per-host lookup for paths that did
// not go through the standard execution flow (pipeline/unsafe).
analyzerClient := executingClient
if analyzerClient == nil {
analyzerClient = request.getHTTPClientForHost(hostname)
}
if analysisMatched {
finalEvent["analyzer_details"] = analysisDetails
finalEvent["analyzer"] = true
if analyzerClient == nil {
gologger.Warning().Msgf("Could not get http client for analyzer %s on %s, skipping analysis\n", request.Analyzer.Name, hostname)
} else {
analysisMatched, analysisDetails, err := analyzer.Analyze(&analyzers.Options{
FuzzGenerated: generatedRequest.fuzzGeneratedRequest,
HttpClient: analyzerClient,
ResponseTimeDelay: duration,
AnalyzerParameters: request.Analyzer.Parameters,
})
if err != nil {
gologger.Warning().Msgf("Could not analyze response: %v\n", err)
}
if analysisMatched {
finalEvent["analyzer_details"] = analysisDetails
finalEvent["analyzer"] = true
}
}
}
@@ -1137,6 +1152,16 @@ func (request *Request) validateNFixEvent(input *contextargs.Context, gr *genera
}
}
// getHTTPClientForHost returns a per-host HTTP client, falling back to a
// host-agnostic client if the lookup fails.
func (request *Request) getHTTPClientForHost(host string) *retryablehttp.Client {
client, err := httpclientpool.Get(request.options.Options, request.connConfiguration, host)
if err != nil {
client, _ = httpclientpool.Get(request.options.Options, request.connConfiguration, "")
}
return client
}
// addCNameIfAvailable adds the cname to the event if available
func (request *Request) addCNameIfAvailable(hostname string, outputEvent map[string]interface{}) {
if request.dialer == nil {
+12 -3
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/projectdiscovery/fastdialer/fastdialer"
commoninteractsh "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/types/nucleierr"
"github.com/projectdiscovery/retryablehttp-go"
@@ -66,6 +67,7 @@ type annotationOverrides struct {
request *retryablehttp.Request
cancelFunc context.CancelFunc
interactshURLs []string
err error
}
// parseAnnotations and override requests settings
@@ -108,10 +110,17 @@ func (r *Request) parseAnnotations(rawRequest string, request *retryablehttp.Req
case "request.host":
value = request.Host
case "interactsh-url":
if interactshURL, err := r.options.Interactsh.NewURLWithData("interactsh-url"); err == nil {
value = interactshURL
if r.options == nil || r.options.Interactsh == nil {
overrides.err = commoninteractsh.ErrInteractshClientNotInitialized
break
}
overrides.interactshURLs = append(overrides.interactshURLs, value)
interactshURL, err := r.options.Interactsh.NewURLWithData("interactsh-url")
if err != nil {
overrides.err = err
break
}
value = interactshURL
overrides.interactshURLs = append(overrides.interactshURLs, interactshURL)
default:
literal = true
}
+20 -2
View File
@@ -7,9 +7,10 @@ import (
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
commoninteractsh "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/retryablehttp-go"
"github.com/stretchr/testify/require"
)
@@ -53,6 +54,23 @@ func TestRequestParseAnnotationsSNI(t *testing.T) {
require.Equal(t, "${jndi:ldap://${hostName}.test.com}", overrides.request.TLS.ServerName)
require.Equal(t, "example.com", overrides.request.Host)
})
t.Run("interactsh-without-client", func(t *testing.T) {
req := &Request{
options: &protocols.ExecutorOptions{},
connConfiguration: &httpclientpool.Configuration{},
}
rawRequest := `@tls-sni: interactsh-url
GET / HTTP/1.1
Host: {{Hostname}}`
httpReq, err := retryablehttp.NewRequest(http.MethodGet, "https://example.com", nil)
require.NoError(t, err)
overrides, modified := req.parseAnnotations(rawRequest, httpReq)
require.True(t, modified)
require.ErrorIs(t, overrides.err, commoninteractsh.ErrInteractshClientNotInitialized)
require.Empty(t, overrides.interactshURLs)
})
}
func TestRequestParseAnnotationsTimeout(t *testing.T) {
+1 -1
View File
@@ -210,7 +210,7 @@ func (request *Request) executeGeneratedFuzzingRequest(gr fuzz.GeneratedRequest,
Request: gr.Request,
}
setInteractshCallback = true
request.options.Interactsh.RequestEvent(gr.InteractURLs, requestData)
request.options.RegisterInteractshRequest(gr.InteractURLs, requestData)
gotMatches = request.options.Interactsh.AlreadyMatched(requestData)
} else {
callback(event)
+2 -2
View File
@@ -380,7 +380,7 @@ func (request *Request) executeWithResults(port string, target *contextargs.Cont
&compiler.ExecuteOptions{
ExecutionId: requestOptions.Options.ExecutionId,
TimeoutVariants: requestOptions.Options.GetTimeouts(),
Source: &request.PreCondition,
Source: &request.PreCondition,
},
)
// if precondition was successful
@@ -631,7 +631,7 @@ func (request *Request) executeRequestWithPayloads(
callback(event)
} else if request.options.Interactsh != nil {
event = &output.InternalWrappedEvent{InternalEvent: data, UsesInteractsh: true}
request.options.Interactsh.RequestEvent(interactshURLs, &interactsh.RequestData{
request.options.RegisterInteractshRequest(interactshURLs, &interactsh.RequestData{
MakeResultFunc: request.MakeResultEvent,
Event: event,
Operators: request.CompiledOperators,
+8 -2
View File
@@ -271,7 +271,14 @@ func (request *Request) Compile(options *protocols.ExecutorOptions) error {
// Requests returns the total number of requests the YAML rule will perform
func (request *Request) Requests() int {
return len(request.Address)
requests := len(request.Address)
if len(request.ports) > 0 {
requests *= len(request.ports)
}
if request.generator != nil {
requests *= request.generator.NewIterator().Total()
}
return requests
}
func (request *Request) SetDialer(dialer *fastdialer.Dialer) {
@@ -282,4 +289,3 @@ func (request *Request) SetDialer(dialer *fastdialer.Dialer) {
func (r *Request) UpdateOptions(opts *protocols.ExecutorOptions) {
r.options.ApplyNewEngineOptions(opts)
}
+28 -1
View File
@@ -5,9 +5,10 @@ import (
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/portutil"
)
@@ -85,6 +86,32 @@ func TestCompileDeduplicatesPorts(t *testing.T) {
require.Equal(t, []string{"21", "22"}, request.ports)
}
func TestRequestsIncludesPayloadExecutions(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
request := &Request{
ID: "testing-network-payload-count",
Address: []string{"{{Host}}"},
Port: "ftp,2121",
Inputs: []*Input{{Data: "USER {{username}}\r\nPASS {{password}}\r\n"}},
AttackType: generators.AttackTypeHolder{
Value: generators.ClusterBombAttack,
},
Payloads: map[string]interface{}{
"username": []string{"admin", "root"},
"password": []string{"password", "toor", "guest"},
},
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
require.NoError(t, request.Compile(executerOpts))
require.Equal(t, 12, request.Requests(), "two ports times six payload combinations")
}
func TestNetworkCompileMake(t *testing.T) {
options := testutils.DefaultOptions
+1 -1
View File
@@ -440,7 +440,7 @@ func (request *Request) executeRequestWithPayloads(variables map[string]interfac
callback(event)
} else if request.options.Interactsh != nil {
event = &output.InternalWrappedEvent{InternalEvent: outputEvent}
request.options.Interactsh.RequestEvent(interactshURLs, &interactsh.RequestData{
request.options.RegisterInteractshRequest(interactshURLs, &interactsh.RequestData{
MakeResultFunc: request.MakeResultEvent,
Event: event,
Operators: request.CompiledOperators,
+55 -37
View File
@@ -96,6 +96,9 @@ type ExecutorOptions struct {
Browser *engine.Browser
// Interactsh is a client for interactsh oob polling server
Interactsh *interactsh.Client
// InteractshScope isolates delayed callbacks and cleanup for one execution
// when Interactsh is shared by concurrent engines.
InteractshScope *interactsh.RequestScope
// HostErrorsCache is an optional cache for handling host errors
HostErrorsCache hosterrorscache.CacheInterface
// Stop execution once first match is found (Assigned while parsing templates)
@@ -151,6 +154,20 @@ type ExecutorOptions struct {
ClusterMappings *templateTypes.ClusterMappingsMap
}
// RegisterInteractshRequest attaches execution-local output dependencies before
// registering a delayed OOB callback on a potentially shared Interactsh client.
func (e *ExecutorOptions) RegisterInteractshRequest(urls []string, data *interactsh.RequestData) {
if e == nil || e.Interactsh == nil || data == nil {
return
}
data.Output = e.Output
data.Progress = e.Progress
data.IssuesClient = e.IssuesClient
data.FuzzParamsFrequency = e.FuzzParamsFrequency
data.Scope = e.InteractshScope
e.Interactsh.RequestEvent(urls, data)
}
// todo: centralizing components is not feasible with current clogged architecture
// a possible approach could be an internal event bus with pub-subs? This would be less invasive than
// reworking dep injection from scratch
@@ -273,44 +290,45 @@ func (e *ExecutorOptions) AddTemplateVar(input *contextargs.MetaInput, templateT
// Copy returns a copy of the executeroptions structure
func (e *ExecutorOptions) Copy() *ExecutorOptions {
copy := &ExecutorOptions{
TemplateID: e.TemplateID,
TemplatePath: e.TemplatePath,
TemplateInfo: e.TemplateInfo,
TemplateVerifier: e.TemplateVerifier,
TemplateID: e.TemplateID,
TemplatePath: e.TemplatePath,
TemplateInfo: e.TemplateInfo,
TemplateVerifier: e.TemplateVerifier,
TemplateVerificationCallback: e.TemplateVerificationCallback,
RawTemplate: e.RawTemplate,
Output: e.Output,
Options: e.Options,
IssuesClient: e.IssuesClient,
Progress: e.Progress,
RateLimiter: e.RateLimiter,
Catalog: e.Catalog,
ProjectFile: e.ProjectFile,
Browser: e.Browser,
Interactsh: e.Interactsh,
HostErrorsCache: e.HostErrorsCache,
StopAtFirstMatch: e.StopAtFirstMatch,
Variables: e.Variables,
Constants: e.Constants,
ExcludeMatchers: e.ExcludeMatchers,
InputHelper: e.InputHelper,
FuzzParamsFrequency: e.FuzzParamsFrequency,
FuzzStatsDB: e.FuzzStatsDB,
Operators: e.Operators,
DoNotCache: e.DoNotCache,
Colorizer: e.Colorizer,
WorkflowLoader: e.WorkflowLoader,
ResumeCfg: e.ResumeCfg,
ProtocolType: e.ProtocolType,
Flow: e.Flow,
IsMultiProtocol: e.IsMultiProtocol,
JsCompiler: e.JsCompiler,
AuthProvider: e.AuthProvider,
TemporaryDirectory: e.TemporaryDirectory,
Parser: e.Parser,
ExportReqURLPattern: e.ExportReqURLPattern,
GlobalMatchers: e.GlobalMatchers,
Logger: e.Logger,
RawTemplate: e.RawTemplate,
Output: e.Output,
Options: e.Options,
IssuesClient: e.IssuesClient,
Progress: e.Progress,
RateLimiter: e.RateLimiter,
Catalog: e.Catalog,
ProjectFile: e.ProjectFile,
Browser: e.Browser,
Interactsh: e.Interactsh,
InteractshScope: e.InteractshScope,
HostErrorsCache: e.HostErrorsCache,
StopAtFirstMatch: e.StopAtFirstMatch,
Variables: e.Variables,
Constants: e.Constants,
ExcludeMatchers: e.ExcludeMatchers,
InputHelper: e.InputHelper,
FuzzParamsFrequency: e.FuzzParamsFrequency,
FuzzStatsDB: e.FuzzStatsDB,
Operators: e.Operators,
DoNotCache: e.DoNotCache,
Colorizer: e.Colorizer,
WorkflowLoader: e.WorkflowLoader,
ResumeCfg: e.ResumeCfg,
ProtocolType: e.ProtocolType,
Flow: e.Flow,
IsMultiProtocol: e.IsMultiProtocol,
JsCompiler: e.JsCompiler,
AuthProvider: e.AuthProvider,
TemporaryDirectory: e.TemporaryDirectory,
Parser: e.Parser,
ExportReqURLPattern: e.ExportReqURLPattern,
GlobalMatchers: e.GlobalMatchers,
Logger: e.Logger,
}
copy.ClusterMappings = e.ClusterMappings.Copy()
copy.CreateTemplateCtxStore()
+18
View File
@@ -0,0 +1,18 @@
package protocols
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/stretchr/testify/require"
)
func TestExecutorOptionsCopyPreservesInteractshScope(t *testing.T) {
client, err := interactsh.New(interactsh.DefaultOptions(nil, nil, nil))
require.NoError(t, err)
scope := client.NewRequestScope()
copy := (&ExecutorOptions{Interactsh: client, InteractshScope: scope}).Copy()
require.Same(t, client, copy.Interactsh)
require.Same(t, scope, copy.InteractshScope)
}
-8
View File
@@ -4,8 +4,6 @@ import (
"regexp"
"strings"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
"github.com/projectdiscovery/retryablehttp-go"
urlutil "github.com/projectdiscovery/utils/url"
)
@@ -44,9 +42,3 @@ func SetHeader(req *retryablehttp.Request, name, value string) {
req.Host = value
}
}
// ShouldDisableKeepAlive depending on scan strategy
func ShouldDisableKeepAlive(options *types.Options) bool {
// with host-spray strategy keep-alive must be enabled
return options.ScanStrategy != scanstrategy.HostSpray.String()
}
+37
View File
@@ -53,6 +53,43 @@ func NewParserWithParsedCache(cache *Cache) *Parser {
}
}
// NewExecutionParser creates a parser for one engine execution. Parsed template
// data is shared with the long-lived parent, while compiled templates remain
// private because they retain execution-specific options and mutable state.
func NewExecutionParser(parent *Parser) *Parser {
if parent == nil {
return NewParser()
}
parent.Lock()
defer parent.Unlock()
return &Parser{
ShouldValidate: parent.ShouldValidate,
NoStrictSyntax: parent.NoStrictSyntax,
parsedTemplatesCache: parent.parsedTemplatesCache,
compiledTemplatesCache: NewCache(),
}
}
// Purge clears the parsed and compiled template caches. It should be called
// when the parser is no longer needed (e.g. on engine Close) so a long-running
// embedder does not retain every compiled template (a heap-heavy object) for
// the entire process lifetime.
func (p *Parser) Purge() {
p.Lock()
defer p.Unlock()
p.parsedTemplatesCache.Purge()
p.compiledTemplatesCache.Purge()
}
// PurgeCompiled releases execution-specific compiled templates without
// clearing parsed template data that may be shared by other executions.
func (p *Parser) PurgeCompiled() {
p.Lock()
defer p.Unlock()
p.compiledTemplatesCache.Purge()
}
// Cache returns the parsed templates cache
func (p *Parser) Cache() *Cache {
return p.parsedTemplatesCache
+62
View File
@@ -0,0 +1,62 @@
package templates
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestParserPurgeClearsCaches ensures Purge drops both the parsed and compiled
// template caches so a long-running embedder does not retain compiled templates
// (heap-heavy objects) for the entire process lifetime.
func TestParserPurgeClearsCaches(t *testing.T) {
p := NewParser()
p.Cache().Store("tpl-a", &Template{}, []byte("raw"), nil)
p.CompiledCache().Store("tpl-a", &Template{}, []byte("raw"), nil)
require.Equal(t, 1, p.ParsedCount(), "parsed cache should hold the stored template")
require.Equal(t, 1, p.CompiledCount(), "compiled cache should hold the stored template")
p.Purge()
require.Equal(t, 0, p.ParsedCount(), "parsed cache must be empty after Purge")
require.Equal(t, 0, p.CompiledCount(), "compiled cache must be empty after Purge")
}
func TestParserExecutionParserSharesOnlyParsedCache(t *testing.T) {
parent := NewParser()
parent.ShouldValidate = true
parent.NoStrictSyntax = true
parent.Cache().Store("parsed", &Template{}, []byte("raw"), nil)
parent.CompiledCache().StoreWithoutRaw("parent-compiled", &Template{}, nil)
execution := NewExecutionParser(parent)
sibling := NewExecutionParser(parent)
require.Same(t, parent.Cache(), execution.Cache())
require.Same(t, parent.Cache(), sibling.Cache())
require.NotSame(t, parent.CompiledCache(), execution.CompiledCache())
require.NotSame(t, execution.CompiledCache(), sibling.CompiledCache())
require.True(t, execution.ShouldValidate)
require.True(t, execution.NoStrictSyntax)
require.Equal(t, 1, execution.ParsedCount())
require.Zero(t, execution.CompiledCount())
execution.CompiledCache().StoreWithoutRaw("execution-compiled", &Template{}, nil)
require.Equal(t, 1, execution.CompiledCount())
require.Zero(t, sibling.CompiledCount())
require.Equal(t, 1, parent.CompiledCount())
}
func TestParserPurgeCompiledPreservesSharedParsedCache(t *testing.T) {
parent := NewParser()
parent.Cache().Store("parsed", &Template{}, []byte("raw"), nil)
execution := NewExecutionParser(parent)
execution.CompiledCache().StoreWithoutRaw("compiled", &Template{}, nil)
execution.PurgeCompiled()
require.Zero(t, execution.CompiledCount())
require.Equal(t, 1, execution.ParsedCount())
require.Equal(t, 1, parent.ParsedCount())
}
+2
View File
@@ -283,6 +283,8 @@ func getErrorCause(err error) string {
// ExecuteWithResults executes the protocol requests and returns results instead of writing them.
func (e *TemplateExecuter) ExecuteWithResults(ctx *scan.ScanContext) ([]*output.ResultEvent, error) {
defer e.options.RemoveTemplateCtx(ctx.Input.MetaInput)
var errx error
if e.options.Flow != "" {
flowexec, err := flow.NewFlowExecutor(e.requests, ctx, e.options, e.results, e.program)