Skip to content

adding per-host rate limit#7365

Open
Mzack9999 wants to merge 3 commits into
devfrom
3547-rate-limit-per-host
Open

adding per-host rate limit#7365
Mzack9999 wants to merge 3 commits into
devfrom
3547-rate-limit-per-host

Conversation

@Mzack9999
Copy link
Copy Markdown
Member

@Mzack9999 Mzack9999 commented Apr 27, 2026

Proposed changes

Adds a per-host request rate limiter so users can cap requests per individual target without throttling the entire scan to that rate.

Same workload (8 hosts × 20 reqs = 160 reqs) at the same effective per-host rate (10 tokens / 100ms): the base version with -rl 10 takes 1.501s (107 rps), this PR with -rlh 10 takes 0.100s (1597 rps), a ~15x speedup at the same per-host safety guarantee. The gain scales with the number of distinct hosts in the scan; single-host scans are unaffected.

Closes #3547.

Checklist

  • Pull request is created against the dev branch
  • All checks passed (lint, unit/integration/regression tests etc.) with my changes
  • I have added tests that prove my fix is effective or that my feature works
  • I have added necessary documentation (if appropriate)

Summary by CodeRabbit

Release Notes

  • New Features
    • Added per-host rate limiting with two new CLI flags: -rate-limit-host to cap requests per host and -rate-limit-host-duration to configure the time window (defaults to 1 second). When enabled, per-host limits take precedence over global rate limits.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

Walkthrough

This change implements per-host rate limiting for nuclei. It introduces a new hostratelimit package providing a pool-based rate limiter that enforces separate token buckets per hostname, adds CLI flags -rate-limit-host and -rate-limit-host-duration to configure it, integrates it through the SDK and protocol execution layers, and updates DNS, HTTP, and HTTP-fuzzing execution paths to use host-scoped throttling.

Changes

Cohort / File(s) Summary
Documentation
README.md, README_CN.md, README_ES.md, README_ID.md, README_KR.md, README_PT-BR.md
CLI help text updated to document new -rate-limit-host and -rate-limit-host-duration flags. Existing -rate-limit flag description updated to note it is bypassed when per-host limiting is enabled.
CLI Configuration
cmd/nuclei/main.go
New short flags rlh and rlhd registered for -rate-limit-host and -rate-limit-host-duration. Existing rate-limit flag description updated to indicate override behavior.
Type Definitions
pkg/types/types.go
Options struct extended with RateLimitHost (int) and RateLimitHostDuration (time.Duration, defaults to 1 second). Both fields added to Copy() and DefaultOptions().
Host Rate Limiter Package
pkg/protocols/common/hostratelimit/hostratelimit.go
NEW package implementing per-host rate limiting. Pool type lazily creates per-hostname ratelimit.Limiter instances, enforces LRU eviction at MaxHosts cap, periodically sweeps idle entries, and provides Take(host), Get(host), Len(), and Stop() APIs.
Host Rate Limiter Tests
pkg/protocols/common/hostratelimit/hostratelimit_test.go
NEW comprehensive test suite validating pool creation, nil-pool safety, per-host caching, LRU eviction, idle sweep, rate limiting blocking behavior, and proper resource cleanup including goroutine leak detection.
Host Rate Limiter Benchmarks
pkg/protocols/common/hostratelimit/hostratelimit_perf_test.go
NEW performance and integration tests validating per-host budget enforcement via real HTTP servers, demonstrating parallelism speedup vs. global limiting, and benchmarking hot-path Take() overhead across single host, many hosts, nil pool, and concurrent scenarios.
Executor Integration
pkg/protocols/protocols.go
ExecutorOptions gains HostRateLimiter field. New RateLimitTakeFor(host) method conditionally acquires from host-scoped pool when configured, otherwise falls back to global limiter. Both additions included in Copy() and ApplyNewEngineOptions().
Protocol Execution Paths
pkg/protocols/dns/request.go, pkg/protocols/http/request.go, pkg/protocols/http/request_fuzz.go
Rate-limiting calls changed from parameterless RateLimitTake() to RateLimitTakeFor(<hostKey>). HTTP path adds helper extracting stable host:port key from request URL.
Library SDK Core
lib/sdk.go, lib/sdk_private.go
NucleiEngine adds hostRateLimiter field. Conditional pool creation when RateLimitHost > 0, defaults duration to 1 second if zero. Pool wired into protocols.ExecutorOptions. Shutdown includes stopping host limiter.
Library Configuration
lib/config.go
New WithHostRateLimit SDK option exported to set RateLimitHost and RateLimitHostDuration on engine options with priority semantics documented.
Library Execution Wiring
lib/multi.go
createEphemeralObjects creates per-invocation HostRateLimiter when opts.RateLimitHost > 0, forwards through executor options. Cleanup in closeEphemeralObjects includes stopping host limiter.
Runner Integration
internal/runner/runner.go, internal/server/nuclei_sdk.go
Runner struct adds hostRateLimiter field, initialized from options. NucleiExecutorOptions adds HostRateLimiter field. Host limiter forwarded through both DAST server and standard execution paths. Runner shutdown includes stopping host limiter.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as CLI Configuration
    participant Runner as Runner
    participant Executor as Protocol Executor
    participant Pool as HostRateLimiter Pool
    participant Global as Global RateLimiter
    participant Protocol as HTTP/DNS Handler

    CLI->>Runner: Initialize with RateLimitHost + Duration
    Runner->>Pool: Create HostRateLimiter Pool (if enabled)
    Runner->>Executor: Forward HostRateLimiter in options

    loop Per Request
        Protocol->>Executor: Request rate-limiting token
        Executor->>Pool: RateLimitTakeFor(hostname)
        alt Host limiter enabled & host provided
            Pool->>Pool: Lookup/create per-host bucket
            Pool-->>Executor: Acquire token from host bucket
        else Fallback to global
            Executor->>Global: RateLimitTake()
            Global-->>Executor: Acquire global token
        end
        Executor-->>Protocol: Token acquired
        Protocol->>Protocol: Execute request
    end

    Runner->>Pool: Stop() on shutdown
    Pool->>Pool: Drain all per-host limiters
    Pool-->>Runner: Cleanup complete
Loading
sequenceDiagram
    participant App as Application
    participant Sweeper as Sweep Goroutine
    participant Pool as Pool State
    participant LRU as LRU Tracker
    participant Limiter as Per-Host Limiter

    App->>Pool: Create Pool(MaxHosts=100, Inactivity=2m)
    Pool->>Sweeper: Start background sweep loop

    App->>Pool: Take(host1)
    Pool->>LRU: Record access time
    Pool->>Limiter: Get/create limiter for host1
    Limiter-->>Pool: Token acquired

    App->>Pool: Take(host2)
    Pool->>LRU: Record access time
    Pool->>Limiter: Get/create limiter for host2
    Limiter-->>Pool: Token acquired

    loop Sweep Interval
        Sweeper->>Pool: Check idle entries
        Sweeper->>LRU: Find entries idle > Inactivity
        LRU-->>Sweeper: Idle host list
        Sweeper->>Limiter: Stop() idle limiters
        Sweeper->>Pool: Remove from cache
    end

    App->>Pool: Take(host101) [exceeds MaxHosts]
    Pool->>LRU: Find LRU entry
    LRU-->>Pool: Evict least-recently-used
    Pool->>Limiter: Stop() evicted limiter
    Pool->>Limiter: Create limiter for host101
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Per-host rate limits, oh what a delight!
No more global throttle crushing parallelism's flight—
Each hostname now dances at its own pace,
While sweepers tidy memory's forgotten place.
Thump thump, bucket by bucket, we bloom! 🌱

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'adding per-host rate limit' clearly summarizes the main change—introducing per-host rate limiting functionality to the nuclei codebase.
Linked Issues check ✅ Passed The PR successfully implements per-host rate limiting via CLI flags (-rate-limit-host, -rate-limit-host-duration), new hostratelimit package, and integration across executor layers, meeting issue #3547 objectives.
Out of Scope Changes check ✅ Passed All changes are scope-aligned: new hostratelimit package, rate-limit protocol integration, CLI flag additions, SDK config updates, and documentation in multiple languages support the core per-host rate limiting feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 3547-rate-limit-per-host

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Mzack9999 Mzack9999 marked this pull request as ready for review April 28, 2026 18:18
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
pkg/protocols/http/request_fuzz.go (1)

60-67: ⚠️ Potential issue | 🟠 Major

Per-host rate limiting is bypassed when fuzzing from request files due to malformed host-key input.

In the ReqResp flow (lines 60-67), input.MetaInput.Input is overwritten with baseRequest.String(), which returns a raw HTTP request string (e.g., GET /path HTTP/1.1\r\nHost: ...). This string is later passed to rateLimitHostKey(input) at line 184, which attempts to parse it as a URL via urlutil.ParseAbsoluteURL(). Since raw HTTP requests are not valid absolute URLs, the parsing fails and the function silently returns an empty string, disabling per-host rate limiting for fuzzing-from-request files (-rlh flag).

The host information is available in baseRequest.URL or the original rr.URL; extract the host directly before overwriting input.MetaInput.Input.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/protocols/http/request_fuzz.go` around lines 60 - 67, When building from
a ReqResp file in the branch that calls input.MetaInput.ReqResp.BuildRequest()
(the baseRequest path), currently input.MetaInput.Input is set to
baseRequest.String() which overwrites the original URL and breaks
rateLimitHostKey(url) parsing; instead capture the request's absolute URL/host
from baseRequest.URL (or the original rr.URL) before overwriting
input.MetaInput.Input and set that host/URL into the field rateLimitHostKey
expects (e.g., preserve input.MetaInput.Host or set input.MetaInput.InputUrl) so
rateLimitHostKey receives a valid absolute URL; update the code around
BuildRequest, request.addHeadersToRequest, and where input.MetaInput.Input is
assigned to ensure the host is extracted and stored prior to replacing Input
with the raw request string.
pkg/protocols/http/request.go (1)

554-575: ⚠️ Potential issue | 🟠 Major

Take the host-rate-limit token after the final request URL is known.

Here the token is acquired from rateLimitHostKey(input) before generator.Make() and before GetCopyIfHostOutdated(...). Any template that rewrites host/port will be billed against the original input bucket, and plain-host inputs still fall back to the global limiter on the first request.

🐛 Proposed fix
 		executeFunc := func(data string, payloads, dynamicValue map[string]interface{}) (bool, error) {
 			hasInteractMatchers := interactsh.HasMatchers(request.CompiledOperators)
-
-			request.options.RateLimitTakeFor(rateLimitHostKey(input))
 
 			ctx := request.newContext(input)
 			ctxWithTimeout, cancel := context.WithTimeoutCause(ctx, request.options.Options.GetTimeouts().HttpTimeout, ErrHttpEngineRequestDeadline)
 			defer cancel()
@@
 			// ideally if http template used a custom port or hostname
 			// we would want to update it in input but currently templateCtx logic
 			// is closely tied to contextargs.Context so we are temporarily creating
 			// a copy and using it to check for host errors etc
 			// but this should be replaced once templateCtx is refactored properly
 			updatedInput := contextargs.GetCopyIfHostOutdated(input, generatedHttpRequest.URL())
+			request.options.RateLimitTakeFor(rateLimitHostKey(updatedInput))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/protocols/http/request.go` around lines 554 - 575, The host rate-limit
token is taken too early (before generator.Make and before
contextargs.GetCopyIfHostOutdated), causing requests rewritten by templates to
be billed against the original input; remove the call to
request.options.RateLimitTakeFor(rateLimitHostKey(input)) before generator.Make
and instead call
request.options.RateLimitTakeFor(rateLimitHostKey(updatedInput)) after you
compute updatedInput (i.e., after generator.Make returns and after updatedInput
:= contextargs.GetCopyIfHostOutdated(...)); reference
request.options.RateLimitTakeFor, rateLimitHostKey, generator.Make,
contextargs.GetCopyIfHostOutdated, and updatedInput to locate and move the token
acquisition.
lib/sdk_private.go (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Format the code with go fmt before merging.

The file lib/sdk_private.go has formatting issues that must be fixed per Go coding guidelines. Run go fmt ./lib/... to apply required formatting changes. Static analysis with go vet passes without issues.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sdk_private.go` at line 1, The file package declaration in
lib/sdk_private.go is not formatted per Go standards; run the Go formatter over
the lib package (e.g., run `go fmt ./lib/...` or `gofmt -w lib/sdk_private.go`)
to reformat this file (package nuclei) before merging so the package declaration
and file conform to gofmt style.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/multi.go`:
- Around line 32-45: The HostRateLimiter being assigned into u.executerOpts from
base.hostRateLimiter causes closeEphemeralObjects() to unconditionally Stop()
shared base state; update createEphemeralObjects()/the executor initialization
(where u.executerOpts.HostRateLimiter is set) to ensure the per-call executor
either creates/clones its own HostRateLimiter when inheriting (so it owns the
lifecycle) or marks that it is inherited and then change closeEphemeralObjects()
to only Stop() the limiter if it is owned by this executor (i.e., not equal to
base.hostRateLimiter). Specifically, adjust the logic around
createEphemeralObjects(), closeEphemeralObjects(), HostRateLimiter and
base.hostRateLimiter so inherited pointers are never stopped by per-call cleanup
(or are replaced by a newly allocated limiter owned by u).

In `@lib/sdk_private.go`:
- Around line 105-109: The per-host rate limiter setup uses
e.opts.RateLimitHostDuration but only defaults when it equals zero; change the
guard in the host limiter initialization (the block that references
e.hostRateLimiter and e.opts.RateLimitHostDuration) to normalize any
non-positive value to time.Second (i.e., treat <= 0 as default) before using it
to construct the limiter so negative durations cannot be passed into the limiter
configuration.

In `@pkg/protocols/common/hostratelimit/hostratelimit_perf_test.go`:
- Around line 41-47: The helper drainGet currently calls require.NoError which
may be invoked from spawned worker goroutines (used by the workers around
wg.Wait), causing FailNow to only terminate the goroutine; change drainGet to
return an error instead of calling require.NoError (i.e., func drainGet(... )
error), propagate that error from the goroutine into an errors channel or
buffered slice, close the channel when workers are done, and after wg.Wait()
drain the channel and call require.NoError/require.Empty or assert/fail the test
based on collected errors; update all call sites (the worker function loop and
any tests invoking drainGet) to handle the returned error and send it into the
collector instead of using require inside the goroutine.

In `@pkg/protocols/common/hostratelimit/hostratelimit.go`:
- Around line 121-128: The pool currently refreshes lastAccess before calling
Limiter.Take(), allowing Sweep or the LRU path to Stop() and delete a limiter
while goroutines are waiting; fix by adding an in-flight counter on the limiter
(e.g., increment just before calling Limiter.Take() in Pool.Take and decrement
after the call returns/blocks are done) and make Sweep and the LRU removal logic
check that inflight==0 before calling Stop()/deleting the limiter; apply the
same pattern to the other affected methods referenced around the other blocks
(the code paths in the 175-217 and 241-257 regions) so entries with active
waiters are excluded from eviction.

In `@README_ES.md`:
- Around line 241-242: Update the help text for the rate-limit flags to state
that the per-host flag overrides the global flag: explicitly note that when both
-rlh (or -rate-limit-host) and -rl (global rate-limit) are set, -rlh takes
precedence rather than stacking; modify the existing lines for
-rlh/-rate-limit-host and -rl to include a short parenthetical or sentence
indicating this behavior and mirror the same wording in all other localized
README files updated in this PR.

In `@README_KR.md`:
- Around line 208-209: Update the Korean help text to state that the
-rate-limit-host (-rlh) flag takes precedence over the global -rate-limit (-rl)
flag; edit the entries for -rate-limit-host / -rlh and/or -rate-limit / -rl in
README_KR.md so the precedence rule is explicit (e.g., add a short note like
"주의: -rate-limit-host가 -rate-limit보다 우선합니다") referencing the flags by name so
readers understand -rlh overrides -rl rather than being additive.

In `@README_PT-BR.md`:
- Around line 241-242: Add the same precedence note to the PT-BR help text:
state that the per-host flag (-rlh / -rate-limit-host) takes precedence over the
global flag (-rl / -rate-limit) when both are provided; update the lines that
document -rlh/-rate-limit-host to include a short parenthetical or sentence
noting this precedence and mirror the wording used in the main README for
consistency.

In `@README.md`:
- Around line 278-282: Update the description for the -rld, -rate-limit-duration
flag so it describes a time window/interval rather than a request count; replace
the misleading "maximum number of requests to send per second" with wording like
"rate-limit interval/window for -rate-limit (default 1s)" or "refill interval
for the global rate limit bucket (default 1s)" to match the style of
-rlhd/-rate-limit-host-duration.

---

Outside diff comments:
In `@lib/sdk_private.go`:
- Line 1: The file package declaration in lib/sdk_private.go is not formatted
per Go standards; run the Go formatter over the lib package (e.g., run `go fmt
./lib/...` or `gofmt -w lib/sdk_private.go`) to reformat this file (package
nuclei) before merging so the package declaration and file conform to gofmt
style.

In `@pkg/protocols/http/request_fuzz.go`:
- Around line 60-67: When building from a ReqResp file in the branch that calls
input.MetaInput.ReqResp.BuildRequest() (the baseRequest path), currently
input.MetaInput.Input is set to baseRequest.String() which overwrites the
original URL and breaks rateLimitHostKey(url) parsing; instead capture the
request's absolute URL/host from baseRequest.URL (or the original rr.URL) before
overwriting input.MetaInput.Input and set that host/URL into the field
rateLimitHostKey expects (e.g., preserve input.MetaInput.Host or set
input.MetaInput.InputUrl) so rateLimitHostKey receives a valid absolute URL;
update the code around BuildRequest, request.addHeadersToRequest, and where
input.MetaInput.Input is assigned to ensure the host is extracted and stored
prior to replacing Input with the raw request string.

In `@pkg/protocols/http/request.go`:
- Around line 554-575: The host rate-limit token is taken too early (before
generator.Make and before contextargs.GetCopyIfHostOutdated), causing requests
rewritten by templates to be billed against the original input; remove the call
to request.options.RateLimitTakeFor(rateLimitHostKey(input)) before
generator.Make and instead call
request.options.RateLimitTakeFor(rateLimitHostKey(updatedInput)) after you
compute updatedInput (i.e., after generator.Make returns and after updatedInput
:= contextargs.GetCopyIfHostOutdated(...)); reference
request.options.RateLimitTakeFor, rateLimitHostKey, generator.Make,
contextargs.GetCopyIfHostOutdated, and updatedInput to locate and move the token
acquisition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c4f6a3f5-a9f0-4b89-84ff-d8e0d1b32e1d

📥 Commits

Reviewing files that changed from the base of the PR and between b99ce57 and 7a26399.

📒 Files selected for processing (21)
  • README.md
  • README_CN.md
  • README_ES.md
  • README_ID.md
  • README_KR.md
  • README_PT-BR.md
  • cmd/nuclei/main.go
  • internal/runner/runner.go
  • internal/server/nuclei_sdk.go
  • lib/config.go
  • lib/multi.go
  • lib/sdk.go
  • lib/sdk_private.go
  • pkg/protocols/common/hostratelimit/hostratelimit.go
  • pkg/protocols/common/hostratelimit/hostratelimit_perf_test.go
  • pkg/protocols/common/hostratelimit/hostratelimit_test.go
  • pkg/protocols/dns/request.go
  • pkg/protocols/http/request.go
  • pkg/protocols/http/request_fuzz.go
  • pkg/protocols/protocols.go
  • pkg/types/types.go

Comment thread lib/multi.go
Comment on lines 32 to 45
u.executerOpts = &protocols.ExecutorOptions{
Output: base.customWriter,
Options: opts,
Progress: base.customProgress,
Catalog: base.catalog,
IssuesClient: base.rc,
RateLimiter: base.rateLimiter,
Interactsh: base.interactshClient,
Colorizer: aurora.NewAurora(true),
ResumeCfg: types.NewResumeCfg(),
Parser: base.parser,
Browser: base.browserInstance,
Output: base.customWriter,
Options: opts,
Progress: base.customProgress,
Catalog: base.catalog,
IssuesClient: base.rc,
RateLimiter: base.rateLimiter,
HostRateLimiter: base.hostRateLimiter,
Interactsh: base.interactshClient,
Colorizer: aurora.NewAurora(true),
ResumeCfg: types.NewResumeCfg(),
Parser: base.parser,
Browser: base.browserInstance,
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't inherit and then stop the base host limiter.

createEphemeralObjects() seeds HostRateLimiter from base.hostRateLimiter, but closeEphemeralObjects() always stops whatever pointer is there. If a call inherits the base pool, that shuts down shared state for later/concurrent ExecuteNucleiWithOpts* calls. The per-call executor should either own its own pool or avoid stopping inherited ones.

♻️ Proposed fix
 type unsafeOptions struct {
 	executerOpts *protocols.ExecutorOptions
 	engine       *core.Engine
+	ownsHostRateLimiter bool
 }
@@
 	u.executerOpts = &protocols.ExecutorOptions{
 		Output:          base.customWriter,
 		Options:         opts,
 		Progress:        base.customProgress,
 		Catalog:         base.catalog,
 		IssuesClient:    base.rc,
 		RateLimiter:     base.rateLimiter,
-		HostRateLimiter: base.hostRateLimiter,
+		HostRateLimiter: nil,
 		Interactsh:      base.interactshClient,
 		Colorizer:       aurora.NewAurora(true),
 		ResumeCfg:       types.NewResumeCfg(),
 		Parser:          base.parser,
 		Browser:         base.browserInstance,
@@
 	if opts.RateLimitHost > 0 {
 		hostDuration := opts.RateLimitHostDuration
 		if hostDuration == 0 {
 			hostDuration = time.Second
 		}
 		u.executerOpts.HostRateLimiter = hostratelimit.NewPool(ctx, hostratelimit.Options{
 			MaxCount: uint(opts.RateLimitHost),
 			Duration: hostDuration,
 		})
+		u.ownsHostRateLimiter = true
 	}
@@
-	u.executerOpts.HostRateLimiter.Stop()
+	if u.ownsHostRateLimiter && u.executerOpts.HostRateLimiter != nil {
+		u.executerOpts.HostRateLimiter.Stop()
+	}

Also applies to: 58-70, 78-82

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/multi.go` around lines 32 - 45, The HostRateLimiter being assigned into
u.executerOpts from base.hostRateLimiter causes closeEphemeralObjects() to
unconditionally Stop() shared base state; update createEphemeralObjects()/the
executor initialization (where u.executerOpts.HostRateLimiter is set) to ensure
the per-call executor either creates/clones its own HostRateLimiter when
inheriting (so it owns the lifecycle) or marks that it is inherited and then
change closeEphemeralObjects() to only Stop() the limiter if it is owned by this
executor (i.e., not equal to base.hostRateLimiter). Specifically, adjust the
logic around createEphemeralObjects(), closeEphemeralObjects(), HostRateLimiter
and base.hostRateLimiter so inherited pointers are never stopped by per-call
cleanup (or are replaced by a newly allocated limiter owned by u).

Comment thread lib/sdk_private.go
Comment on lines +105 to +109
if e.hostRateLimiter == nil && e.opts.RateLimitHost > 0 {
hostDuration := e.opts.RateLimitHostDuration
if hostDuration == 0 {
hostDuration = time.Second
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against non-positive per-host duration values.

Line 107 only defaults when duration is 0; negative durations pass through. Normalize <= 0 to avoid invalid limiter configuration.

Suggested patch
-		if hostDuration == 0 {
+		if hostDuration <= 0 {
 			hostDuration = time.Second
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if e.hostRateLimiter == nil && e.opts.RateLimitHost > 0 {
hostDuration := e.opts.RateLimitHostDuration
if hostDuration == 0 {
hostDuration = time.Second
}
if e.hostRateLimiter == nil && e.opts.RateLimitHost > 0 {
hostDuration := e.opts.RateLimitHostDuration
if hostDuration <= 0 {
hostDuration = time.Second
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sdk_private.go` around lines 105 - 109, The per-host rate limiter setup
uses e.opts.RateLimitHostDuration but only defaults when it equals zero; change
the guard in the host limiter initialization (the block that references
e.hostRateLimiter and e.opts.RateLimitHostDuration) to normalize any
non-positive value to time.Second (i.e., treat <= 0 as default) before using it
to construct the limiter so negative durations cannot be passed into the limiter
configuration.

Comment on lines +41 to +47
func drainGet(t testing.TB, client *http.Client, u string) {
t.Helper()
resp, err := client.Get(u)
require.NoError(t, err)
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

wc -l pkg/protocols/common/hostratelimit/hostratelimit_perf_test.go

Repository: projectdiscovery/nuclei

Length of output: 133


🏁 Script executed:

cat -n pkg/protocols/common/hostratelimit/hostratelimit_perf_test.go | head -120

Repository: projectdiscovery/nuclei

Length of output: 3908


🌐 Web query:

Go testing FailNow safe goroutine spawned worker

💡 Result:

No, calling t.FailNow from a goroutine spawned by a worker in a Go test is not safe. The official Go testing documentation explicitly states that FailNow must be called from the goroutine running the test or benchmark function, not from other goroutines created during the test. Calling it from a spawned goroutine triggers a data race (detectable with -race), only stops that specific goroutine via runtime.Goexit, and leaves other goroutines (including workers) running, potentially causing leaks, hangs, or missed cleanups. To safely signal failure from a worker goroutine: - Use channels to communicate errors back to the test goroutine. - Call t.Error or t.Fatal from the test goroutine after receiving the signal. - Use sync.WaitGroup to wait for all workers to complete before t.FailNow in the test goroutine. - For leak detection, use tools like go.uber.org/goleak.VerifyNone(t). Example safe pattern for worker goroutines: func TestWithWorkers(t *testing.T) { var wg sync.WaitGroup errCh := make(chan error, 10) wg.Add(10) for i := 0; i < 10; i++ { go func(id int) { defer wg.Done if problem := doWork(id); problem != nil { select { case errCh <- problem: default: } } }(i) } go func { wg.Wait close(errCh) } for err := range errCh { t.Errorf("worker error: %v", err) } } This ensures proper synchronization and failure reporting without violating FailNow rules.

Citations:


Avoid require.* inside worker goroutines.

drainGet calls require.NoError, but this helper is invoked from spawned worker goroutines (line 109). require fails via FailNow, which terminates only the calling goroutine, not the test. A request failure in any worker will silently kill that goroutine without reliably failing the test, creating potential data races and missed assertions. Return the error from drainGet and collect errors into a channel, then assert after wg.Wait() completes.

Also applies to: 95-109

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/protocols/common/hostratelimit/hostratelimit_perf_test.go` around lines
41 - 47, The helper drainGet currently calls require.NoError which may be
invoked from spawned worker goroutines (used by the workers around wg.Wait),
causing FailNow to only terminate the goroutine; change drainGet to return an
error instead of calling require.NoError (i.e., func drainGet(... ) error),
propagate that error from the goroutine into an errors channel or buffered
slice, close the channel when workers are done, and after wg.Wait() drain the
channel and call require.NoError/require.Empty or assert/fail the test based on
collected errors; update all call sites (the worker function loop and any tests
invoking drainGet) to handle the returned error and send it into the collector
instead of using require inside the goroutine.

Comment on lines +121 to +128
func (p *Pool) Take(host string) {
if p == nil || host == "" {
return
}
l := p.getOrCreate(host)
if l != nil {
l.Take()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't evict a limiter while callers can still be blocked on it.

lastAccess is refreshed before Limiter.Take(), so a host with a long refill window can look idle even while goroutines are actively waiting on that bucket. Either the sweep or the LRU path can then Stop() and delete the limiter, and the next access recreates a fresh bucket with full tokens, which breaks the per-host limit.

Please track in-flight users (or otherwise exclude actively waited-on entries from eviction) before reclaiming a limiter.

Also applies to: 175-217, 241-257

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/protocols/common/hostratelimit/hostratelimit.go` around lines 121 - 128,
The pool currently refreshes lastAccess before calling Limiter.Take(), allowing
Sweep or the LRU path to Stop() and delete a limiter while goroutines are
waiting; fix by adding an in-flight counter on the limiter (e.g., increment just
before calling Limiter.Take() in Pool.Take and decrement after the call
returns/blocks are done) and make Sweep and the LRU removal logic check that
inflight==0 before calling Stop()/deleting the limiter; apply the same pattern
to the other affected methods referenced around the other blocks (the code paths
in the 175-217 and 241-257 regions) so entries with active waiters are excluded
from eviction.

Comment thread README_ES.md
Comment on lines +241 to +242
-rlh, -rate-limit-host int número máximo de peticiones por host por rate-limit-host-duration (0 = desactivado)
-rlhd, -rate-limit-host-duration value intervalo de recarga del bucket de rate-limit por host (por defecto 1s)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document that -rlh overrides -rl.

The implementation gives the per-host limiter priority when both flags are set. Please say that explicitly here too; otherwise this help text reads as if the two limits stack. It would be worth mirroring the same wording in the other localized READMEs updated in this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README_ES.md` around lines 241 - 242, Update the help text for the rate-limit
flags to state that the per-host flag overrides the global flag: explicitly note
that when both -rlh (or -rate-limit-host) and -rl (global rate-limit) are set,
-rlh takes precedence rather than stacking; modify the existing lines for
-rlh/-rate-limit-host and -rl to include a short parenthetical or sentence
indicating this behavior and mirror the same wording in all other localized
README files updated in this PR.

Comment thread README_KR.md
Comment on lines +208 to +209
-rlh, -rate-limit-host int 호스트당 rate-limit-host-duration 동안 보낼 최대 요청 수 (0 = 비활성)
-rlhd, -rate-limit-host-duration value 호스트별 rate-limit 버킷의 리필 간격 (기본값 1s)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mention that -rate-limit-host overrides -rate-limit.

The Korean help text adds the new flags but omits the precedence rule now documented in README.md. That can make -rl and -rlh look additive instead of mutually prioritized.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README_KR.md` around lines 208 - 209, Update the Korean help text to state
that the -rate-limit-host (-rlh) flag takes precedence over the global
-rate-limit (-rl) flag; edit the entries for -rate-limit-host / -rlh and/or
-rate-limit / -rl in README_KR.md so the precedence rule is explicit (e.g., add
a short note like "주의: -rate-limit-host가 -rate-limit보다 우선합니다") referencing the
flags by name so readers understand -rlh overrides -rl rather than being
additive.

Comment thread README_PT-BR.md
Comment on lines +241 to +242
-rlh, -rate-limit-host int número máximo de solicitações por host por rate-limit-host-duration (0 = desativado)
-rlhd, -rate-limit-host-duration value intervalo de recarga do bucket de rate-limit por host (padrão 1s)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add the -rlh vs -rl precedence note here too.

The PT-BR help text documents the new flags but not that -rate-limit-host takes priority over the global -rate-limit, which is now called out in the main README.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README_PT-BR.md` around lines 241 - 242, Add the same precedence note to the
PT-BR help text: state that the per-host flag (-rlh / -rate-limit-host) takes
precedence over the global flag (-rl / -rate-limit) when both are provided;
update the lines that document -rlh/-rate-limit-host to include a short
parenthetical or sentence noting this precedence and mirror the wording used in
the main README for consistency.

Comment thread README.md
Comment on lines +278 to +282
-rl, -rate-limit int maximum number of requests to send per second (ignored when -rate-limit-host is set) (default 150)
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
-rlh, -rate-limit-host int maximum number of requests to send per host per rate-limit-host-duration (0 = disabled, takes priority over -rate-limit)
-rlhd, -rate-limit-host-duration value refill interval for the per-host rate limit bucket (default 1s)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

-rate-limit-duration is described like a count, not a window.

-rld is a duration-valued flag, so “maximum number of requests to send per second” is misleading here. The wording should describe the rate-limit interval/window, similar to the new per-host duration flag on Line 282.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 278 - 282, Update the description for the -rld,
-rate-limit-duration flag so it describes a time window/interval rather than a
request count; replace the misleading "maximum number of requests to send per
second" with wording like "rate-limit interval/window for -rate-limit (default
1s)" or "refill interval for the global rate limit bucket (default 1s)" to match
the style of -rlhd/-rate-limit-host-duration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ratelimit per host in host-spray

1 participant