mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-08 10:10:18 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34aa29723 | ||
|
|
a42194fce6 | ||
|
|
2ae0db918a | ||
|
|
ef4eb1dcf2 | ||
|
|
ef4ad4f4c7 | ||
|
|
f8a9087ccf | ||
|
|
857cc67702 |
No files matched your search
@@ -102,7 +102,7 @@ require (
|
||||
github.com/projectdiscovery/tlsx v1.4.1
|
||||
github.com/projectdiscovery/uncover v1.2.1
|
||||
github.com/projectdiscovery/useragent v0.0.109
|
||||
github.com/projectdiscovery/utils v0.11.9
|
||||
github.com/projectdiscovery/utils v0.11.10-0.20261017183053-ac02d12acdf3
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.4
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
|
||||
@@ -1005,6 +1005,8 @@ github.com/projectdiscovery/useragent v0.0.109 h1:b86BPdZvmgbJousa04jAugDRY4Y+rA
|
||||
github.com/projectdiscovery/useragent v0.0.109/go.mod h1:sRyBqDAcD31RKYrO8fyXGowLd36fNwY1g0MNW9mp7zk=
|
||||
github.com/projectdiscovery/utils v0.11.9 h1:aKLje3zPcZuOGmopN51PasWxPX/CypPwzP2ke/Qa2bA=
|
||||
github.com/projectdiscovery/utils v0.11.9/go.mod h1:d3BcbsHhuxW9T3jpvrgzwshvqhaL9qPSkoi+3h/DY8E=
|
||||
github.com/projectdiscovery/utils v0.11.10-0.20261017183053-ac02d12acdf3 h1:TcGBnYp/RhcGGwNa6sP8/V/oJBDsbGnmBWU1yR0K/2U=
|
||||
github.com/projectdiscovery/utils v0.11.10-0.20261017183053-ac02d12acdf3/go.mod h1:d3BcbsHhuxW9T3jpvrgzwshvqhaL9qPSkoi+3h/DY8E=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.4 h1:yO+xel+C0d/vOFWcUKVZRpIDAfcEmsjpVDjQ0jifAUw=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.4/go.mod h1:6xTRCUQhX8m+L1eqJDQIALJlRv2Bnhrmku80YU9bBXE=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7 h1:Odmjd1o4EkMff2F6QHi1dIYPIQPZUFy24YqmQCprmTk=
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
|
||||
@@ -23,6 +26,8 @@ var headlessTestcases = []integrationCase{
|
||||
{Path: "protocols/headless/headless-header-action.yaml", TestCase: &headlessHeaderActions{}},
|
||||
{Path: "protocols/headless/headless-extract-values.yaml", TestCase: &headlessExtractValues{}},
|
||||
{Path: "protocols/headless/headless-payloads.yaml", TestCase: &headlessPayloads{}},
|
||||
{Path: "protocols/headless/headless-threaded-payloads.yaml", TestCase: &headlessThreadedPayloads{}},
|
||||
{Path: "protocols/headless/headless-threaded-stop-first.yaml", TestCase: &headlessThreadedStopFirst{}},
|
||||
{Path: "protocols/headless/variables.yaml", TestCase: &headlessVariables{}},
|
||||
{Path: "protocols/headless/headless-local.yaml", TestCase: &headlessLocal{}},
|
||||
{Path: "protocols/headless/file-upload.yaml", TestCase: &headlessFileUpload{}},
|
||||
@@ -140,6 +145,108 @@ func (h *headlessPayloads) Execute(filePath string) error {
|
||||
return expectResultsCount(results, 4)
|
||||
}
|
||||
|
||||
type headlessThreadedPayloads struct{}
|
||||
|
||||
func (h *headlessThreadedPayloads) Execute(filePath string) error {
|
||||
type requestStats struct {
|
||||
active, peak atomic.Int64
|
||||
mu sync.Mutex
|
||||
arrivals []time.Time
|
||||
}
|
||||
newServer := func(stats *requestStats) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
current := stats.active.Add(1)
|
||||
defer stats.active.Add(-1)
|
||||
stats.mu.Lock()
|
||||
stats.arrivals = append(stats.arrivals, time.Now())
|
||||
stats.mu.Unlock()
|
||||
for {
|
||||
previous := stats.peak.Load()
|
||||
if current <= previous || stats.peak.CompareAndSwap(previous, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("<html><body>test</body></html>"))
|
||||
}))
|
||||
}
|
||||
|
||||
firstStats := &requestStats{}
|
||||
server := newServer(firstStats)
|
||||
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, server.URL, debug, "-headless")
|
||||
server.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := expectResultsCount(results, 4); err != nil {
|
||||
return err
|
||||
}
|
||||
if got := firstStats.peak.Load(); got != 2 {
|
||||
return fmt.Errorf("peak concurrent headless payloads = %d, want 2", got)
|
||||
}
|
||||
|
||||
secondStats := &requestStats{}
|
||||
server = newServer(secondStats)
|
||||
results, err = testutils.RunNucleiTemplateAndGetResults(filePath, server.URL, debug, "-headless", "-rl", "1", "-per-host-rate-limit")
|
||||
server.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := expectResultsCount(results, 4); err != nil {
|
||||
return err
|
||||
}
|
||||
if got := secondStats.peak.Load(); got != 1 {
|
||||
return fmt.Errorf("peak rate-limited headless payloads = %d, want 1", got)
|
||||
}
|
||||
secondStats.mu.Lock()
|
||||
arrivals := append([]time.Time(nil), secondStats.arrivals...)
|
||||
secondStats.mu.Unlock()
|
||||
if len(arrivals) != 4 {
|
||||
return fmt.Errorf("rate-limited headless arrivals = %d, want 4", len(arrivals))
|
||||
}
|
||||
for i := 1; i < len(arrivals); i++ {
|
||||
if gap := arrivals[i].Sub(arrivals[i-1]); gap < 750*time.Millisecond {
|
||||
return fmt.Errorf("rate-limited headless arrival gap = %s, want at least 750ms", gap)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type headlessThreadedStopFirst struct{}
|
||||
|
||||
func (h *headlessThreadedStopFirst) Execute(filePath string) error {
|
||||
var requests atomic.Int64
|
||||
var probesMu sync.Mutex
|
||||
var probes []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
requests.Add(1)
|
||||
probesMu.Lock()
|
||||
probes = append(probes, r.URL.Query().Get("probe"))
|
||||
probesMu.Unlock()
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("<html><body>match</body></html>"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, server.URL, debug, "-headless")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got := requests.Load(); got != 2 {
|
||||
return fmt.Errorf("stop-first headless requests = %d, want 2; probes = %v; results = %v", got, probes, results)
|
||||
}
|
||||
return expectResultsCount(results, 2)
|
||||
}
|
||||
|
||||
type headlessVariables struct{}
|
||||
|
||||
// Execute executes a test case and returns an error if occurred
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
id: headless-threaded-payloads
|
||||
|
||||
info:
|
||||
name: headless threaded payloads
|
||||
author: pdteam
|
||||
severity: info
|
||||
tags: headless
|
||||
|
||||
headless:
|
||||
- threads: 2
|
||||
disable-cookie: true
|
||||
attack: batteringram
|
||||
payloads:
|
||||
probe:
|
||||
- one
|
||||
- two
|
||||
- three
|
||||
- four
|
||||
steps:
|
||||
- args:
|
||||
url: "{{BaseURL}}?probe={{probe}}"
|
||||
action: navigate
|
||||
- action: waitload
|
||||
matchers:
|
||||
- type: word
|
||||
words:
|
||||
- "test"
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
id: headless-threaded-stop-first
|
||||
|
||||
info:
|
||||
name: headless threaded stop first
|
||||
author: pdteam
|
||||
severity: info
|
||||
tags: headless
|
||||
|
||||
headless:
|
||||
- threads: 2
|
||||
disable-cookie: true
|
||||
stop-at-first-match: true
|
||||
attack: batteringram
|
||||
payloads:
|
||||
probe:
|
||||
- one
|
||||
- two
|
||||
- three
|
||||
- four
|
||||
steps:
|
||||
- args:
|
||||
url: "{{BaseURL}}?probe={{probe}}"
|
||||
action: navigate
|
||||
- action: waitload
|
||||
matchers:
|
||||
- type: word
|
||||
words:
|
||||
- "match"
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/utils/env"
|
||||
httputil "github.com/projectdiscovery/utils/http"
|
||||
@@ -16,6 +17,7 @@ var (
|
||||
MaxBytesBufferAllocOnLowMemory = env.GetEnvOrDefault("MEMGUARDIAN_ALLOC", 0)
|
||||
memTimer *time.Ticker
|
||||
cancelFunc context.CancelFunc
|
||||
memWatcherDone chan struct{}
|
||||
muGlobalChange sync.Mutex
|
||||
)
|
||||
|
||||
@@ -25,12 +27,18 @@ func StartActiveMemGuardian(ctx context.Context) {
|
||||
if memguardian.DefaultMemGuardian == nil || memTimer != nil {
|
||||
return
|
||||
}
|
||||
if err := refreshMemoryWarning(); err != nil {
|
||||
gologger.Warning().Msgf("could not check memory pressure: %s", err)
|
||||
}
|
||||
|
||||
memTimer = time.NewTicker(memguardian.DefaultInterval)
|
||||
ctx, cancelFunc = context.WithCancel(ctx)
|
||||
memWatcherDone = make(chan struct{})
|
||||
|
||||
ticker := memTimer
|
||||
done := memWatcherDone
|
||||
go func(t *time.Ticker) {
|
||||
defer close(done)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
@@ -39,6 +47,9 @@ func StartActiveMemGuardian(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := refreshMemoryWarning(); err != nil {
|
||||
gologger.Warning().Msgf("could not check memory pressure: %s", err)
|
||||
}
|
||||
if IsLowOnMemory() {
|
||||
_ = GlobalGuardBytesBufferAlloc()
|
||||
} else {
|
||||
@@ -49,6 +60,24 @@ func StartActiveMemGuardian(ctx context.Context) {
|
||||
}(ticker)
|
||||
}
|
||||
|
||||
func refreshMemoryWarning() error {
|
||||
usedRatio, usedBytes, err := memguardian.UsedRam()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
warning := usedRatio >= memguardian.DefaultMaxUsedRamRatio
|
||||
if maxRam := env.GetEnvOrDefault(memguardian.MemGuardianMaxUsedMemoryENV, ""); maxRam != "" {
|
||||
maxBytes, err := units.FromHumanSize(maxRam)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warning = warning || usedBytes >= uint64(maxBytes)
|
||||
}
|
||||
memguardian.DefaultMemGuardian.Warning.Store(warning)
|
||||
return nil
|
||||
}
|
||||
|
||||
func StopActiveMemGuardian() {
|
||||
muGlobalChange.Lock()
|
||||
defer muGlobalChange.Unlock()
|
||||
@@ -61,6 +90,10 @@ func StopActiveMemGuardian() {
|
||||
cancelFunc()
|
||||
cancelFunc = nil
|
||||
}
|
||||
if memWatcherDone != nil {
|
||||
<-memWatcherDone
|
||||
memWatcherDone = nil
|
||||
}
|
||||
if memTimer != nil {
|
||||
memTimer.Stop()
|
||||
memTimer = nil
|
||||
|
||||
@@ -127,3 +127,33 @@ func TestMemGuardianReset(t *testing.T) {
|
||||
StopActiveMemGuardian()
|
||||
time.Sleep(10 * time.Millisecond) // Allow cleanup
|
||||
}
|
||||
|
||||
func TestActiveMemGuardianUpdatesLowMemoryWarning(t *testing.T) {
|
||||
StopActiveMemGuardian()
|
||||
t.Setenv(memguardian.MemGuardianMaxUsedMemoryENV, "")
|
||||
original := memguardian.DefaultMemGuardian
|
||||
originalInterval := memguardian.DefaultInterval
|
||||
originalRatio := memguardian.DefaultMaxUsedRamRatio
|
||||
memguardian.DefaultInterval = 5 * time.Millisecond
|
||||
memguardian.DefaultMaxUsedRamRatio = 1
|
||||
guardian, err := memguardian.New(
|
||||
memguardian.WitInterval(memguardian.DefaultInterval),
|
||||
memguardian.WithMaxRamRatioWarning(1),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
memguardian.DefaultMemGuardian = guardian
|
||||
t.Cleanup(func() {
|
||||
StopActiveMemGuardian()
|
||||
guardian.Close()
|
||||
memguardian.DefaultMemGuardian = original
|
||||
memguardian.DefaultInterval = originalInterval
|
||||
memguardian.DefaultMaxUsedRamRatio = originalRatio
|
||||
})
|
||||
|
||||
StartActiveMemGuardian(context.Background())
|
||||
require.Eventually(t, IsLowOnMemory, 100*time.Millisecond, 5*time.Millisecond)
|
||||
StopActiveMemGuardian()
|
||||
memguardian.DefaultMaxUsedRamRatio = 100
|
||||
require.NoError(t, refreshMemoryWarning())
|
||||
require.False(t, IsLowOnMemory())
|
||||
}
|
||||
@@ -732,6 +732,18 @@ func (p *Page) SelectInputElement(act *Action, out ActionData) error {
|
||||
|
||||
// WaitPageLifecycleEvent waits for specified page lifecycle event name
|
||||
func (p *Page) WaitPageLifecycleEvent(act *Action, out ActionData, event proto.PageLifecycleEventName) error {
|
||||
if event == proto.PageLifecycleEventNameLoad {
|
||||
dur, err := getTimeout(p, act)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.page.Timeout(dur).WaitLoad(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.updateLastNavigatedURL()
|
||||
return nil
|
||||
}
|
||||
|
||||
fn, err := getNavigationFunc(p, act, event)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/tests/testheadless"
|
||||
@@ -642,6 +643,47 @@ func TestActionWaitLoad(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionWaitLoadAfterPageAlreadyLoaded(t *testing.T) {
|
||||
actions := []*Action{{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}}}
|
||||
testHeadlessSimpleResponse(t, "<html><body>ready</body></html>", actions, 10*time.Second, func(page *Page, err error, _ ActionData) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, page.Page().WaitLoad())
|
||||
|
||||
wait := &Action{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}, Data: map[string]string{"timeout": "2s"}}
|
||||
started := time.Now()
|
||||
require.NoError(t, page.WaitPageLifecycleEvent(wait, nil, proto.PageLifecycleEventNameLoad))
|
||||
require.Less(t, time.Since(started), 1500*time.Millisecond, "a completed load must not consume the full wait timeout")
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionWaitLoadWaitsForPendingResource(t *testing.T) {
|
||||
resourceDone := make(chan struct{})
|
||||
actions := []*Action{
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}},
|
||||
}
|
||||
testHeadless(t, actions, 10*time.Second, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/slow.js" {
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, _ = fmt.Fprint(w, "window.resourceLoaded = true")
|
||||
close(resourceDone)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `<html><script src="/slow.js"></script></html>`)
|
||||
}, func(page *Page, err error, _ ActionData) {
|
||||
require.NoError(t, err)
|
||||
select {
|
||||
case <-resourceDone:
|
||||
default:
|
||||
t.Fatal("waitload returned before the pending resource completed")
|
||||
}
|
||||
loaded, evalErr := page.Page().Eval(`() => window.resourceLoaded`)
|
||||
require.NoError(t, evalErr)
|
||||
require.True(t, loaded.Value.Bool())
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionGetResource(t *testing.T) {
|
||||
response := `
|
||||
<html>
|
||||
@@ -839,6 +881,25 @@ func TestActionWaitEventDuration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionNavigateDurationIncludesResponseHeaderWait(t *testing.T) {
|
||||
actions := []*Action{
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}},
|
||||
}
|
||||
|
||||
handler := func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
_, _ = fmt.Fprintln(w, `<html><body>loaded</body></html>`)
|
||||
}
|
||||
|
||||
testHeadless(t, actions, 20*time.Second, handler, func(page *Page, err error, _ ActionData) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page.ActionDurations, 2)
|
||||
require.GreaterOrEqual(t, page.ActionDurations[0], 300*time.Millisecond)
|
||||
require.Less(t, page.ActionDurations[1], time.Second)
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionWaitEventSeesEventFiredBeforeWaiting(t *testing.T) {
|
||||
actions := []*Action{
|
||||
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
|
||||
|
||||
@@ -31,6 +31,7 @@ type Request struct {
|
||||
// of payloads is provided, or optionally a single file can also
|
||||
// be provided as payload which will be read on run-time.
|
||||
Payloads map[string]interface{} `yaml:"payloads,omitempty" json:"payloads,omitempty" jsonschema:"title=payloads for the headless request,description=Payloads contains any payloads for the current request"`
|
||||
Threads int `yaml:"threads,omitempty" json:"threads,omitempty" jsonschema:"title=concurrent headless payloads,description=Opt-in concurrent independent payloads (maximum 2)"`
|
||||
|
||||
// description: |
|
||||
// Steps is the list of actions to run for headless request
|
||||
@@ -115,6 +116,17 @@ func (request *Request) Compile(options *protocols.ExecutorOptions) error {
|
||||
request.Payloads[name] = payloadStr
|
||||
}
|
||||
}
|
||||
if request.Threads < 0 || request.Threads > 2 {
|
||||
return errors.New("headless threads must be between 0 and 2")
|
||||
}
|
||||
if request.Threads > 1 {
|
||||
if len(request.Payloads) == 0 || len(request.Fuzzing) > 0 {
|
||||
return errors.New("headless threads require payloads without fuzzing")
|
||||
}
|
||||
if !request.DisableCookie || options.IsMultiProtocol || options.Flow != "" {
|
||||
return errors.New("headless threads require isolated cookies and standalone templates")
|
||||
}
|
||||
}
|
||||
|
||||
if len(request.Payloads) > 0 {
|
||||
var err error
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"maps"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
@@ -20,6 +22,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/eventcreator"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/responsehighlighter"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/render"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
|
||||
protocolutils "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils"
|
||||
@@ -68,18 +71,28 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, metadata,
|
||||
vars = generators.MergeMaps(vars, metadata, optionVars, variablesMap, request.options.Constants)
|
||||
|
||||
// check for operator matches by wrapping callback
|
||||
gotmatches := false
|
||||
var gotmatches atomic.Bool
|
||||
var callbackMu sync.Mutex
|
||||
wrappedCallback := func(results *output.InternalWrappedEvent) {
|
||||
callbackMu.Lock()
|
||||
defer callbackMu.Unlock()
|
||||
callback(results)
|
||||
if results != nil && results.OperatorsResult != nil {
|
||||
gotmatches = results.OperatorsResult.Matched
|
||||
if results != nil && results.OperatorsResult != nil && results.OperatorsResult.Matched {
|
||||
gotmatches.Store(true)
|
||||
}
|
||||
}
|
||||
// verify if fuzz elaboration was requested
|
||||
if len(request.Fuzzing) > 0 {
|
||||
return request.executeFuzzingRule(input, vars, previous, wrappedCallback)
|
||||
}
|
||||
timing := newHeadlessTimingTrace(request.options.TemplateID, input.MetaInput.Input)
|
||||
if timing != nil {
|
||||
defer timing.emit()
|
||||
}
|
||||
if request.generator != nil {
|
||||
if request.Threads > 1 && !protocolstate.IsLowOnMemory() {
|
||||
return request.executeConcurrentPayloads(input, vars, interactshURLs, previous, wrappedCallback, &gotmatches, timing)
|
||||
}
|
||||
iterator := request.generator.NewIterator()
|
||||
for {
|
||||
value, ok := iterator.Value()
|
||||
@@ -87,7 +100,7 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, metadata,
|
||||
break
|
||||
}
|
||||
|
||||
if gotmatches && (request.StopAtFirstMatch || request.options.Options.StopAtFirstMatch || request.options.StopAtFirstMatch) {
|
||||
if gotmatches.Load() && (request.StopAtFirstMatch || request.options.Options.StopAtFirstMatch || request.options.StopAtFirstMatch) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -103,13 +116,13 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, metadata,
|
||||
return errors.Wrap(err, "could not evaluate payload helper expressions")
|
||||
}
|
||||
|
||||
if err := request.executeRequestWithPayloads(input, renderedValue.Values, previous, renderedValue.InteractURLs, wrappedCallback); err != nil {
|
||||
if err := request.executeRequestWithPayloads(input, renderedValue.Values, previous, renderedValue.InteractURLs, wrappedCallback, timing, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value := maps.Clone(vars)
|
||||
if err := request.executeRequestWithPayloads(input, value, previous, interactshURLs, wrappedCallback); err != nil {
|
||||
if err := request.executeRequestWithPayloads(input, value, previous, interactshURLs, wrappedCallback, timing, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -117,6 +130,66 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, metadata,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (request *Request) executeConcurrentPayloads(input *contextargs.Context, vars map[string]interface{}, interactshURLs []string, previous output.InternalEvent, callback protocols.OutputEventCallback, gotmatches *atomic.Bool, timing *headlessTimingTrace) error {
|
||||
var workers sync.WaitGroup
|
||||
var stateMu, outputMu sync.Mutex
|
||||
var firstErr error
|
||||
var stop atomic.Bool
|
||||
semaphore := make(chan struct{}, request.Threads)
|
||||
stopAtFirstMatch := request.StopAtFirstMatch || request.options.Options.StopAtFirstMatch || request.options.StopAtFirstMatch
|
||||
|
||||
iterator := request.generator.NewIterator()
|
||||
for {
|
||||
value, ok := iterator.Value()
|
||||
if !ok || stop.Load() || (stopAtFirstMatch && gotmatches.Load()) {
|
||||
break
|
||||
}
|
||||
renderedValue, err := render.RenderMap(render.MapInput{
|
||||
Options: request.options.GetOptions(),
|
||||
Source: value,
|
||||
Data: vars,
|
||||
Values: generators.MergeMaps(value, vars),
|
||||
Interactsh: request.options.Interactsh,
|
||||
InteractURLs: interactshURLs,
|
||||
})
|
||||
if err != nil {
|
||||
stateMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = errors.Wrap(err, "could not evaluate payload helper expressions")
|
||||
}
|
||||
stateMu.Unlock()
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case semaphore <- struct{}{}:
|
||||
case <-input.Context().Done():
|
||||
workers.Wait()
|
||||
return input.Context().Err()
|
||||
}
|
||||
if stop.Load() || (stopAtFirstMatch && gotmatches.Load()) {
|
||||
<-semaphore
|
||||
break
|
||||
}
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
defer func() { <-semaphore }()
|
||||
childTiming := timing.child()
|
||||
err := request.executeRequestWithPayloads(input, renderedValue.Values, previous, renderedValue.InteractURLs, callback, childTiming, &outputMu)
|
||||
stateMu.Lock()
|
||||
timing.merge(childTiming)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
stop.Store(true)
|
||||
}
|
||||
stateMu.Unlock()
|
||||
}()
|
||||
}
|
||||
workers.Wait()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// This function extracts the base URL from actions.
|
||||
func extractBaseURLFromActions(steps []*engine.Action) (string, error) {
|
||||
for _, action := range steps {
|
||||
@@ -132,9 +205,15 @@ func extractBaseURLFromActions(steps []*engine.Action) (string, error) {
|
||||
return "", errors.New("no navigation action found")
|
||||
}
|
||||
|
||||
func (request *Request) executeRequestWithPayloads(input *contextargs.Context, payloads map[string]interface{}, previous output.InternalEvent, interactshURLs []string, callback protocols.OutputEventCallback) error {
|
||||
func (request *Request) executeRequestWithPayloads(input *contextargs.Context, payloads map[string]interface{}, previous output.InternalEvent, interactshURLs []string, callback protocols.OutputEventCallback, timing *headlessTimingTrace, outputMu *sync.Mutex) error {
|
||||
if timing != nil {
|
||||
timing.attempts++
|
||||
}
|
||||
pageTimeout := time.Duration(request.options.Options.PageTimeout) * time.Second
|
||||
if _, err := url.Parse(input.MetaInput.Input); err != nil {
|
||||
if timing != nil {
|
||||
timing.errors++
|
||||
}
|
||||
request.options.Output.Request(request.options.TemplatePath, input.MetaInput.Input, request.Type().String(), err)
|
||||
request.options.Progress.IncrementFailedRequestsBy(1)
|
||||
return errors.Wrap(err, errCouldNotGetHtmlElement)
|
||||
@@ -146,33 +225,74 @@ func (request *Request) executeRequestWithPayloads(input *contextargs.Context, p
|
||||
}
|
||||
|
||||
if !options.DisableCookie && input.CookieJar == nil {
|
||||
if timing != nil {
|
||||
timing.errors++
|
||||
}
|
||||
return errors.New("cookie reuse enabled but cookie-jar is nil")
|
||||
}
|
||||
|
||||
// limited as one request per run of the steps, the unit progress counts
|
||||
if err := request.options.RateLimitTakeContext(input.Context(), input.MetaInput.Input); err != nil {
|
||||
request.options.Output.Request(request.options.TemplatePath, input.MetaInput.Input, request.Type().String(), err)
|
||||
var rateWaitStarted time.Time
|
||||
if timing != nil {
|
||||
rateWaitStarted = time.Now()
|
||||
}
|
||||
rateWaitErr := request.options.RateLimitTakeContext(input.Context(), input.MetaInput.Input)
|
||||
if timing != nil {
|
||||
timing.rateWait += time.Since(rateWaitStarted)
|
||||
}
|
||||
if rateWaitErr != nil {
|
||||
if timing != nil {
|
||||
timing.errors++
|
||||
}
|
||||
request.options.Output.Request(request.options.TemplatePath, input.MetaInput.Input, request.Type().String(), rateWaitErr)
|
||||
request.options.Progress.IncrementFailedRequestsBy(1)
|
||||
return errors.Wrap(err, "could not take rate limit")
|
||||
return errors.Wrap(rateWaitErr, "could not take rate limit")
|
||||
}
|
||||
|
||||
var createStarted time.Time
|
||||
if timing != nil {
|
||||
createStarted = time.Now()
|
||||
}
|
||||
instance, err := request.options.Browser.NewInstanceWithContext(input.Context(), pageTimeout)
|
||||
if timing != nil {
|
||||
timing.contextCreate += time.Since(createStarted)
|
||||
}
|
||||
if err != nil {
|
||||
if timing != nil {
|
||||
timing.errors++
|
||||
}
|
||||
request.options.Output.Request(request.options.TemplatePath, input.MetaInput.Input, request.Type().String(), err)
|
||||
request.options.Progress.IncrementFailedRequestsBy(1)
|
||||
return errors.Wrap(err, errCouldNotGetHtmlElement)
|
||||
}
|
||||
var page *engine.Page
|
||||
defer func() {
|
||||
var cleanupStarted time.Time
|
||||
if timing != nil {
|
||||
cleanupStarted = time.Now()
|
||||
}
|
||||
if closeErr := instance.CloseWithPage(page); closeErr != nil {
|
||||
gologger.Warning().Msgf("Could not close headless browser instance: %s", closeErr)
|
||||
}
|
||||
if timing != nil {
|
||||
timing.cleanup += time.Since(cleanupStarted)
|
||||
}
|
||||
}()
|
||||
instance.SetInteractsh(request.options.Interactsh)
|
||||
|
||||
timeStart := time.Now()
|
||||
out, createdPage, err := instance.Run(input, request.Steps, payloads, options)
|
||||
runDuration := time.Since(timeStart)
|
||||
if timing != nil {
|
||||
if err != nil {
|
||||
timing.errors++
|
||||
timing.runOther += runDuration
|
||||
} else if createdPage != nil {
|
||||
timing.recordRun(request.Steps, createdPage.ActionDurations, runDuration)
|
||||
} else {
|
||||
timing.runOther += runDuration
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
request.options.Output.Request(request.options.TemplatePath, input.MetaInput.Input, request.Type().String(), err)
|
||||
request.options.Progress.IncrementFailedRequestsBy(1)
|
||||
@@ -223,6 +343,10 @@ func (request *Request) executeRequestWithPayloads(input *contextargs.Context, p
|
||||
|
||||
outputEvent := request.responseToDSLMap(responseBody, header, statusCode, reqBuilder.String(), input.MetaInput.Input, navigatedURL, page.DumpHistory())
|
||||
addHeadlessDurationFields(outputEvent, page.ActionDurations, runDuration)
|
||||
if outputMu != nil {
|
||||
outputMu.Lock()
|
||||
defer outputMu.Unlock()
|
||||
}
|
||||
// add response fields to template context and merge templatectx variables to output event
|
||||
request.options.AddTemplateVars(input.MetaInput, request.Type(), request.ID, outputEvent)
|
||||
if request.options.HasTemplateCtx(input.MetaInput) {
|
||||
@@ -294,7 +418,7 @@ func (request *Request) executeFuzzingRule(input *contextargs.Context, payloads
|
||||
newInput := input.Clone()
|
||||
newInput.MetaInput.Input = gr.Request.String()
|
||||
|
||||
if err := request.executeRequestWithPayloads(newInput, gr.DynamicValues, previous, gr.InteractURLs, callback); err != nil {
|
||||
if err := request.executeRequestWithPayloads(newInput, gr.DynamicValues, previous, gr.InteractURLs, callback, nil, nil); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
@@ -24,3 +25,24 @@ func TestRequestsIncludesPayloadExecutions(t *testing.T) {
|
||||
}
|
||||
require.Equal(t, 6, request.Requests())
|
||||
}
|
||||
|
||||
func TestHeadlessThreadsRejectUnsafeConfigurations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request Request
|
||||
options protocols.ExecutorOptions
|
||||
want string
|
||||
}{
|
||||
{name: "above cap", request: Request{Threads: 3}, want: "between 0 and 2"},
|
||||
{name: "without payloads", request: Request{Threads: 2, DisableCookie: true}, want: "require payloads"},
|
||||
{name: "shared cookies", request: Request{Threads: 2, Payloads: map[string]interface{}{"probe": []string{"one", "two"}}}, want: "isolated cookies"},
|
||||
{name: "flow", request: Request{Threads: 2, DisableCookie: true, Payloads: map[string]interface{}{"probe": []string{"one", "two"}}}, options: protocols.ExecutorOptions{Flow: "probe"}, want: "standalone templates"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.options.Options = types.DefaultOptions()
|
||||
err := test.request.Compile(&test.options)
|
||||
require.ErrorContains(t, err, test.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package headless
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
|
||||
)
|
||||
|
||||
const (
|
||||
headlessTimingTemplatesEnv = "NUCLEI_HEADLESS_TIMING_TEMPLATES"
|
||||
headlessTimingSampleRate = 50
|
||||
headlessTimingSampleMin = 30 * time.Second
|
||||
headlessTimingOutlierMin = 5 * time.Minute
|
||||
)
|
||||
|
||||
type headlessTimingTrace struct {
|
||||
templateID string
|
||||
targetFingerprint string
|
||||
sampled bool
|
||||
started time.Time
|
||||
attempts int
|
||||
errors int
|
||||
rateWait time.Duration
|
||||
contextCreate time.Duration
|
||||
navigate time.Duration
|
||||
waitLoad time.Duration
|
||||
waitDialog time.Duration
|
||||
otherActions time.Duration
|
||||
runOther time.Duration
|
||||
cleanup time.Duration
|
||||
}
|
||||
|
||||
func newHeadlessTimingTrace(templateID, target string) *headlessTimingTrace {
|
||||
selectedTemplates := os.Getenv(headlessTimingTemplatesEnv)
|
||||
if selectedTemplates == "" {
|
||||
return nil
|
||||
}
|
||||
selected := false
|
||||
for _, candidate := range strings.Split(selectedTemplates, ",") {
|
||||
if strings.TrimSpace(candidate) == templateID {
|
||||
selected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !selected {
|
||||
return nil
|
||||
}
|
||||
|
||||
fingerprint := sha256.Sum256([]byte(target))
|
||||
return &headlessTimingTrace{
|
||||
templateID: templateID,
|
||||
targetFingerprint: fmt.Sprintf("%x", fingerprint[:8]),
|
||||
sampled: binary.BigEndian.Uint64(fingerprint[:8])%headlessTimingSampleRate == 0,
|
||||
started: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *headlessTimingTrace) recordRun(steps []*engine.Action, durations []time.Duration, elapsed time.Duration) {
|
||||
actions := time.Duration(0)
|
||||
if len(steps) != len(durations) {
|
||||
for _, duration := range durations {
|
||||
actions += duration
|
||||
}
|
||||
t.otherActions += actions
|
||||
if elapsed > actions {
|
||||
t.runOther += elapsed - actions
|
||||
}
|
||||
return
|
||||
}
|
||||
for index, duration := range durations {
|
||||
actions += duration
|
||||
if steps[index] == nil {
|
||||
t.otherActions += duration
|
||||
continue
|
||||
}
|
||||
switch steps[index].ActionType.ActionType {
|
||||
case engine.ActionNavigate:
|
||||
t.navigate += duration
|
||||
case engine.ActionWaitLoad:
|
||||
t.waitLoad += duration
|
||||
case engine.ActionWaitDialog:
|
||||
t.waitDialog += duration
|
||||
default:
|
||||
t.otherActions += duration
|
||||
}
|
||||
}
|
||||
if elapsed > actions {
|
||||
t.runOther += elapsed - actions
|
||||
}
|
||||
}
|
||||
|
||||
func (t *headlessTimingTrace) child() *headlessTimingTrace {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return &headlessTimingTrace{}
|
||||
}
|
||||
|
||||
func (t *headlessTimingTrace) merge(other *headlessTimingTrace) {
|
||||
if t == nil || other == nil {
|
||||
return
|
||||
}
|
||||
t.attempts += other.attempts
|
||||
t.errors += other.errors
|
||||
t.rateWait += other.rateWait
|
||||
t.contextCreate += other.contextCreate
|
||||
t.navigate += other.navigate
|
||||
t.waitLoad += other.waitLoad
|
||||
t.waitDialog += other.waitDialog
|
||||
t.otherActions += other.otherActions
|
||||
t.runOther += other.runOther
|
||||
t.cleanup += other.cleanup
|
||||
}
|
||||
|
||||
func (t *headlessTimingTrace) summary(elapsed time.Duration) (string, bool) {
|
||||
if elapsed < headlessTimingOutlierMin && (!t.sampled || elapsed < headlessTimingSampleMin) {
|
||||
return "", false
|
||||
}
|
||||
measured := t.rateWait + t.contextCreate + t.navigate + t.waitLoad + t.waitDialog + t.otherActions + t.runOther + t.cleanup
|
||||
unattributed := max(elapsed-measured, 0)
|
||||
return fmt.Sprintf(
|
||||
"slow headless timing template_id=%q target_fingerprint=%s attempts=%d errors=%d total_ms=%d rate_wait_ms=%d context_create_ms=%d navigate_ms=%d waitload_ms=%d waitdialog_ms=%d other_actions_ms=%d run_other_ms=%d cleanup_ms=%d unattributed_ms=%d",
|
||||
t.templateID, t.targetFingerprint, t.attempts, t.errors, elapsed.Milliseconds(), t.rateWait.Milliseconds(),
|
||||
t.contextCreate.Milliseconds(), t.navigate.Milliseconds(), t.waitLoad.Milliseconds(), t.waitDialog.Milliseconds(),
|
||||
t.otherActions.Milliseconds(), t.runOther.Milliseconds(), t.cleanup.Milliseconds(), unattributed.Milliseconds(),
|
||||
), true
|
||||
}
|
||||
|
||||
func (t *headlessTimingTrace) emit() {
|
||||
if message, ok := t.summary(time.Since(t.started)); ok {
|
||||
gologger.Info().Msg(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package headless
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHeadlessTimingTraceIsTemplateOptIn(t *testing.T) {
|
||||
t.Setenv(headlessTimingTemplatesEnv, "")
|
||||
require.Nil(t, newHeadlessTimingTrace("headless-open-redirect", "https://example.com"))
|
||||
|
||||
t.Setenv(headlessTimingTemplatesEnv, "CVE-2025-8191, headless-open-redirect")
|
||||
require.Nil(t, newHeadlessTimingTrace("other-template", "https://example.com"))
|
||||
|
||||
trace := newHeadlessTimingTrace("headless-open-redirect", "https://example.com")
|
||||
require.NotNil(t, trace)
|
||||
require.Len(t, trace.targetFingerprint, 16)
|
||||
require.NotContains(t, trace.targetFingerprint, "example.com")
|
||||
}
|
||||
|
||||
func TestHeadlessTimingTraceAccountsForActionsAndOverhead(t *testing.T) {
|
||||
trace := &headlessTimingTrace{
|
||||
templateID: "headless-open-redirect",
|
||||
targetFingerprint: "0123456789abcdef",
|
||||
sampled: true,
|
||||
attempts: 2,
|
||||
errors: 1,
|
||||
rateWait: time.Second,
|
||||
contextCreate: time.Second,
|
||||
cleanup: 250 * time.Millisecond,
|
||||
}
|
||||
steps := []*engine.Action{
|
||||
{ActionType: engine.ActionTypeHolder{ActionType: engine.ActionNavigate}},
|
||||
{ActionType: engine.ActionTypeHolder{ActionType: engine.ActionWaitLoad}},
|
||||
{ActionType: engine.ActionTypeHolder{ActionType: engine.ActionWaitDialog}},
|
||||
}
|
||||
trace.recordRun(steps, []time.Duration{2 * time.Second, 500 * time.Millisecond, 3 * time.Second}, 6*time.Second)
|
||||
|
||||
message, ok := trace.summary(30 * time.Second)
|
||||
require.True(t, ok)
|
||||
for _, field := range []string{
|
||||
"attempts=2", "errors=1", "total_ms=30000", "rate_wait_ms=1000",
|
||||
"context_create_ms=1000", "navigate_ms=2000", "waitload_ms=500",
|
||||
"waitdialog_ms=3000", "run_other_ms=500", "cleanup_ms=250", "unattributed_ms=21750",
|
||||
} {
|
||||
require.True(t, strings.Contains(message, field), "missing %s in %s", field, message)
|
||||
}
|
||||
require.NotContains(t, message, "https://")
|
||||
}
|
||||
|
||||
func TestHeadlessTimingTraceBoundsOutput(t *testing.T) {
|
||||
trace := &headlessTimingTrace{sampled: false}
|
||||
_, ok := trace.summary(4 * time.Minute)
|
||||
require.False(t, ok)
|
||||
_, ok = trace.summary(5 * time.Minute)
|
||||
require.True(t, ok)
|
||||
|
||||
trace.sampled = true
|
||||
_, ok = trace.summary(29 * time.Second)
|
||||
require.False(t, ok)
|
||||
_, ok = trace.summary(30 * time.Second)
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestHeadlessTimingTraceDoesNotMisattributePartialActionDurations(t *testing.T) {
|
||||
trace := &headlessTimingTrace{sampled: true}
|
||||
steps := []*engine.Action{
|
||||
{ActionType: engine.ActionTypeHolder{ActionType: engine.ActionNavigate}},
|
||||
{ActionType: engine.ActionTypeHolder{ActionType: engine.ActionWaitLoad}},
|
||||
}
|
||||
trace.recordRun(steps, []time.Duration{2 * time.Second}, 3*time.Second)
|
||||
|
||||
require.Zero(t, trace.navigate)
|
||||
require.Zero(t, trace.waitLoad)
|
||||
require.Equal(t, 2*time.Second, trace.otherActions)
|
||||
require.Equal(t, time.Second, trace.runOther)
|
||||
}
|
||||
Reference in new issue
Block a user