Files
nuclei/pkg/protocols/http/request_generator.go
Dwi Siswanto bb79a061bc perf(generators): optimize MergeMaps to reduce allocs
`MergeMaps` accounts for 11.41% of allocs (13.8
GB) in clusterbomb mode. With 1,305 combinations
per target, this function is called millions of
times in the hot path.

RCA:
* Request generator calls `MergeMaps` with single
  arg on every payload combination, incurring
  variadic overhead.
* Build request merges same maps multiple times
  per request.
* `BuildPayloadFromOptions` recomputes static CLI
  options on every call.
* Variables calls `MergeMaps` $$2×N$$ times per
  variable evaluation (once in loop, once in
  `evaluateVariableValue`)

Changes:

Core optimizations in maps.go:
* Pre-size merged map to avoid rehashing (30-40%
  reduction)
* Add `CopyMap` for efficient single-map copy
  without variadic overhead.
* Add `MergeMapsInto` for in-place mutation when
  caller owns destination.

Hot path fixes:
* Replace `MergeMaps(r.currentPayloads)` with
  `CopyMap(r.currentPayloads)` to eliminates
  allocation on every combination iteration.
* Pre-allocate combined map once, extend in-place
  during `ForEach` loop instead of creating new
  map per variable (eliminates $$2×N$$ allocations
  per request).

Caching with concurrency safety:
* Cache `BuildPayloadFromOptions` computation in
  `sync.Map` keyed by `types.Options` ptr, but
  return copy to prevent concurrent modification.
* Cost: shallow copy of ~10-20 entries vs. full
  merge of vars + env (85-90% savings in typical
  case)
* Clear cache in `closeInternal()` to prevent
  memory leaks when SDK instances are created or
  destroyed.

Estimated impact: 40-60% reduction in `MergeMaps`
allocations (5.5-8.3 GB savings from original
13.8 GB). Safe for concurrent execution and SDK
usage with multiple instances.

Signed-off-by: Dwi Siswanto <git@dw1.io>
2025-12-19 20:19:43 +07:00

202 lines
5.8 KiB
Go

package http
import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
)
// requestGenerator generates requests sequentially based on various
// configurations for a http request template.
//
// If payload values are present, an iterator is created for the payload
// values. Paths and Raw requests are supported as base input, so
// it will automatically select between them based on the template.
type requestGenerator struct {
currentIndex int
currentPayloads map[string]interface{}
okCurrentPayload bool
request *Request
options *protocols.ExecutorOptions
payloadIterator *generators.Iterator
interactshURLs []string
onceFlow map[string]struct{}
}
// LeaveDefaultPorts skips normalization of default standard ports
var LeaveDefaultPorts = false
// newGenerator creates a new request generator instance
func (request *Request) newGenerator(disablePayloads bool) *requestGenerator {
generator := &requestGenerator{
request: request,
options: request.options,
onceFlow: make(map[string]struct{}),
}
if len(request.Payloads) > 0 && !disablePayloads {
generator.payloadIterator = request.generator.NewIterator()
}
return generator
}
// nextValue returns the next path or the next raw request depending on user input
// It returns false if all the inputs have been exhausted by the generator instance.
func (r *requestGenerator) nextValue() (value string, payloads map[string]interface{}, result bool) {
// Iterate each payload sequentially for each request path/raw
//
// If the sequence has finished for the current payload values
// then restart the sequence from the beginning and move on to the next payloads values
// otherwise use the last request.
var sequence []string
switch {
case len(r.request.Path) > 0:
sequence = r.request.Path
case len(r.request.Raw) > 0:
sequence = r.request.Raw
default:
return "", nil, false
}
hasPayloadIterator := r.payloadIterator != nil
if hasPayloadIterator && r.currentPayloads == nil {
r.currentPayloads, r.okCurrentPayload = r.payloadIterator.Value()
}
var request string
var shouldContinue bool
if nextRequest, nextIndex, found := r.findNextIteration(sequence, r.currentIndex); found {
r.currentIndex = nextIndex + 1
request = nextRequest
shouldContinue = true
} else {
// if found is false which happens at end of iteration of reqData(path or raw request)
// try again from start with index 0
if nextRequest, nextIndex, found := r.findNextIteration(sequence, 0); found && hasPayloadIterator {
r.currentIndex = nextIndex + 1
request = nextRequest
shouldContinue = true
}
}
if shouldContinue {
if r.hasMarker(request, Once) {
r.applyMark(request, Once)
}
if hasPayloadIterator {
return request, generators.CopyMap(r.currentPayloads), r.okCurrentPayload
}
// next should return a copy of payloads and not pointer to payload to avoid data race
return request, generators.CopyMap(r.currentPayloads), true
} else {
return "", nil, false
}
}
// findNextIteration iterates and returns next Request(path or raw request)
// at end of each iteration payload is incremented
func (r *requestGenerator) findNextIteration(sequence []string, index int) (string, int, bool) {
for i, request := range sequence[index:] {
if r.wasMarked(request, Once) {
// if request contains flowmark i.e `@once` and is marked skip it
continue
}
return request, index + i, true
}
// move on to next payload if current payload is applied/returned for all Requests(path or raw request)
if r.payloadIterator != nil {
r.currentPayloads, r.okCurrentPayload = r.payloadIterator.Value()
}
return "", 0, false
}
// applyMark marks given request i.e blacklist request
func (r *requestGenerator) applyMark(request string, mark flowMark) {
switch mark {
case Once:
r.onceFlow[request] = struct{}{}
}
}
// wasMarked checks if request is marked using request blacklist
func (r *requestGenerator) wasMarked(request string, mark flowMark) bool {
switch mark {
case Once:
_, ok := r.onceFlow[request]
return ok
}
return false
}
// hasMarker returns true if request has a marker (ex: @once which means request should only be executed once)
func (r *requestGenerator) hasMarker(request string, mark flowMark) bool {
fo, hasOverrides := parseFlowAnnotations(request)
return hasOverrides && fo == mark
}
// Remaining returns the number of requests that are still left to be
// generated (and therefore to be sent) by this generator.
func (r *requestGenerator) Remaining() int {
var sequence []string
switch {
case len(r.request.Path) > 0:
sequence = r.request.Path
case len(r.request.Raw) > 0:
sequence = r.request.Raw
default:
return 0
}
remainingInCurrentPass := 0
for i := r.currentIndex; i < len(sequence); i++ {
if !r.hasMarker(sequence[i], Once) {
remainingInCurrentPass++
}
}
if r.payloadIterator == nil {
return remainingInCurrentPass
}
numRemainingPayloadSets := r.payloadIterator.Remaining()
totalValidInSequence := 0
for _, req := range sequence {
if !r.hasMarker(req, Once) {
totalValidInSequence++
}
}
// Total remaining = remaining in current pass + (remaining payload sets * requests per full pass)
return remainingInCurrentPass + numRemainingPayloadSets*totalValidInSequence
}
func (r *requestGenerator) Total() int {
var sequence []string
switch {
case len(r.request.Path) > 0:
sequence = r.request.Path
case len(r.request.Raw) > 0:
sequence = r.request.Raw
default:
return 0
}
applicableRequests := 0
additionalRequests := 0
for _, request := range sequence {
if !r.hasMarker(request, Once) {
applicableRequests++
} else {
additionalRequests++
}
}
if r.payloadIterator == nil {
return applicableRequests + additionalRequests
}
return (applicableRequests * r.payloadIterator.Total()) + additionalRequests
}