mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-07 17:43:04 -10:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eb63b0f8e | ||
|
|
5aebf0dad2 | ||
|
|
a8f6e6bfc6 | ||
|
|
5e34b46c1d | ||
|
|
cc69698813 | ||
|
|
e052f15f88 | ||
|
|
54b76fd605 | ||
|
|
59304896f6 | ||
|
|
f35ca5faae | ||
|
|
122c3e98f9 | ||
|
|
0cd237a96c | ||
|
|
3cb82ef3ec | ||
|
|
e1788e84db | ||
|
|
5329494e55 | ||
|
|
c8fa2812e4 | ||
|
|
f85303e506 | ||
|
|
eca24eeef2 | ||
|
|
dd33cf846a | ||
|
|
e2cad13643 | ||
|
|
96919ebf6d | ||
|
|
da4b23d3e1 | ||
|
|
4a48adcc65 | ||
|
|
b5267587da | ||
|
|
27997de03d |
No files matched your search
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user