diff --git a/.github/workflows/compat-checks.yaml b/.github/workflows/compat-checks.yaml
index b75a634d99..1a80d80e0f 100644
--- a/.github/workflows/compat-checks.yaml
+++ b/.github/workflows/compat-checks.yaml
@@ -13,7 +13,7 @@ jobs:
permissions:
contents: write
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go/compat-checks@v1
with:
go-version: "stable"
diff --git a/.github/workflows/flamegraph.yaml b/.github/workflows/flamegraph.yaml
index c67099c4f5..c9d73d901c 100644
--- a/.github/workflows/flamegraph.yaml
+++ b/.github/workflows/flamegraph.yaml
@@ -12,7 +12,7 @@ jobs:
TARGET_URL: "http://honey.scanme.sh/-/?foo=bar"
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
- uses: projectdiscovery/nuclei-action/cache@v3
- run: make build
diff --git a/.github/workflows/fuzz.yaml b/.github/workflows/fuzz.yaml
index bddc1647da..aa9617acc3 100644
--- a/.github/workflows/fuzz.yaml
+++ b/.github/workflows/fuzz.yaml
@@ -17,7 +17,7 @@ jobs:
outputs:
matrix: ${{ steps.discover.outputs.matrix }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
with:
go-version-file: go.tool.mod
@@ -37,7 +37,7 @@ jobs:
GOFUZZ_PACKAGE: ${{ matrix.pkg }}
FUZZ_DURATION: 15m
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
with:
go-version-file: go.tool.mod
diff --git a/.github/workflows/generate-docs.yaml b/.github/workflows/generate-docs.yaml
index d40b235c6e..bc901e5b29 100644
--- a/.github/workflows/generate-docs.yaml
+++ b/.github/workflows/generate-docs.yaml
@@ -11,7 +11,7 @@ jobs:
if: ${{ !endsWith(github.actor, '[bot]') }}
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
- uses: projectdiscovery/actions/setup/git@v1
- run: make syntax-docs
diff --git a/.github/workflows/generate-pgo.yaml b/.github/workflows/generate-pgo.yaml
index ec1325d044..b6dc66544b 100644
--- a/.github/workflows/generate-pgo.yaml
+++ b/.github/workflows/generate-pgo.yaml
@@ -18,7 +18,7 @@ jobs:
TARGET_LIST: "/tmp/targets.txt"
PROFILE_MEM: "/tmp/nuclei-profile"
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
- uses: projectdiscovery/nuclei-action/cache@v3
- run: |
diff --git a/.github/workflows/govulncheck.yaml b/.github/workflows/govulncheck.yaml
index 19c90102f4..686df0f96a 100644
--- a/.github/workflows/govulncheck.yaml
+++ b/.github/workflows/govulncheck.yaml
@@ -16,7 +16,7 @@ jobs:
env:
OUTPUT: "/tmp/results.sarif"
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
with:
go-version-file: go.tool.mod
diff --git a/.github/workflows/memogen.yaml b/.github/workflows/memogen.yaml
index 8ba9a83294..eba86214e1 100644
--- a/.github/workflows/memogen.yaml
+++ b/.github/workflows/memogen.yaml
@@ -14,7 +14,7 @@ jobs:
if: ${{ github.repository == 'projectdiscovery/nuclei' && !endsWith(github.actor, '[bot]') }}
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
- uses: projectdiscovery/actions/setup/git@v1
- run: make memogen
diff --git a/.github/workflows/perf-regression.yaml b/.github/workflows/perf-regression.yaml
index 286b595c0e..093f2b551b 100644
--- a/.github/workflows/perf-regression.yaml
+++ b/.github/workflows/perf-regression.yaml
@@ -11,7 +11,7 @@ jobs:
env:
BENCH_OUT: "/tmp/bench.out"
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
- uses: projectdiscovery/nuclei-action/cache@v3
- run: make build-test
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 820b9faf9a..89c4ce6332 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -16,7 +16,7 @@ jobs:
needs: ["pgo"]
runs-on: ubuntu-latest-16-cores
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/download-artifact@v8
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index e38fd5d25d..e03524ab21 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -22,9 +22,14 @@ jobs:
if: ${{ !endsWith(github.actor, '[bot]') }}
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
+ - uses: projectdiscovery/nuclei-action/cache@v3
- uses: projectdiscovery/actions/golangci-lint/v2@v1
+ # go vet runs once here (not per-OS in the tests matrix): it is platform
+ # independent and folding it into the lint gate fails fast on a cheap
+ # runner before the heavy test/integration jobs start.
+ - run: make vet
tests:
name: "Tests"
@@ -37,7 +42,7 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
runs-on: "${{ matrix.os }}"
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
- uses: projectdiscovery/nuclei-action/cache@v3
- uses: projectdiscovery/actions/free-disk-space@v1
@@ -49,14 +54,34 @@ jobs:
misc-packages: 'false'
docker-images: 'false'
tools-cache: 'false'
- - run: make vet
- run: make build
- - run: make test
+ # The data-race detector is OS-independent, so we only pay its ~2-3x cost
+ # on ubuntu; windows/macOS run the same suite without instrumentation.
+ - name: "Unit tests (race)"
+ run: make test
+ if: ${{ matrix.os == 'ubuntu-latest' }}
+ env:
+ PDCP_API_KEY: "${{ secrets.PDCP_API_KEY }}"
+ - name: "Unit tests"
+ run: make test RACE=
+ if: ${{ matrix.os != 'ubuntu-latest' }}
env:
PDCP_API_KEY: "${{ secrets.PDCP_API_KEY }}"
- run: go run -race . -l ${{ github.workspace }}/internal/tests/functional/testdata/targets.txt -id tech-detect,tls-version
if: ${{ matrix.os != 'windows-latest' }} # known issue: https://github.com/golang/go/issues/46099
working-directory: cmd/nuclei/
+ # Hermetic HTTP-engine scale regression: stands up many loopback hosts and
+ # asserts finding parity across a diverse template set (per-host pool,
+ # connection-reuse and http->https tracker + cookie-reuse). Race
+ # build runs on ubuntu only (same rationale as the unit tests above);
+ # other OSes run it plain. Large runs stay opt-in locally via
+ # NUCLEI_SCALE_HOSTS.
+ - name: "Scale regression (race)"
+ run: go test -tags=regression -race -timeout 15m ./lib/tests -run TestScaleRegression
+ if: ${{ matrix.os == 'ubuntu-latest' }}
+ - name: "Scale regression"
+ run: make regression
+ if: ${{ matrix.os != 'ubuntu-latest' }}
sdk:
name: "Run example SDK"
@@ -65,7 +90,7 @@ jobs:
env:
GITHUB_TOKEN: "${{ github.token }}"
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
- uses: projectdiscovery/nuclei-action/cache@v3
- name: "Simple"
@@ -90,9 +115,10 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/python@v1
- uses: projectdiscovery/actions/setup/go@v1
+ - uses: projectdiscovery/nuclei-action/cache@v3
- run: make integration
env:
PDCP_API_KEY: "${{ secrets.PDCP_API_KEY }}"
@@ -109,9 +135,10 @@ jobs:
os: [ubuntu-latest, windows-latest, macOS-latest]
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/python@v1
- uses: projectdiscovery/actions/setup/go@v1
+ - uses: projectdiscovery/nuclei-action/cache@v3
- uses: projectdiscovery/nuclei-action@v3
with:
version: latest
@@ -125,7 +152,7 @@ jobs:
env:
GITHUB_TOKEN: "${{ github.token }}"
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
- uses: projectdiscovery/nuclei-action/cache@v3
- run: make template-validate
@@ -139,19 +166,45 @@ jobs:
contents: read
security-events: write
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: github/codeql-action/init@v4
with:
languages: 'go'
- uses: github/codeql-action/autobuild@v4
- uses: github/codeql-action/analyze@v4
+ with:
+ output: sarif-results
+ upload: never
+ category: "/language:go"
+ # internal/fuzzplayground is a deliberately vulnerable mock server used to
+ # exercise nuclei's fuzzing templates (CMDI/SQLi/SSRF/etc. are by design),
+ # so its CodeQL alerts are expected false positives. paths-ignore has no
+ # effect for compiled languages, so drop those results from the SARIF
+ # before upload instead.
+ - name: "Filter fuzzplayground alerts from SARIF"
+ run: |
+ for sarif in sarif-results/*.sarif; do
+ [ -e "$sarif" ] || continue
+ jq '
+ .runs[].results |= map(
+ select(
+ [ .locations[]?.physicalLocation.artifactLocation.uri // "" ]
+ | any(startswith("internal/fuzzplayground/")) | not
+ )
+ )
+ ' "$sarif" > "$sarif.filtered" && mv "$sarif.filtered" "$sarif"
+ done
+ - uses: github/codeql-action/upload-sarif@v4
+ with:
+ sarif_file: sarif-results
+ category: "/language:go"
release:
name: "Release test"
needs: ["tests"]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: projectdiscovery/actions/setup/go@v1
with:
go-version: "stable"
diff --git a/.github/workflows/typos.yaml b/.github/workflows/typos.yaml
index d754f526a6..9015f58820 100644
--- a/.github/workflows/typos.yaml
+++ b/.github/workflows/typos.yaml
@@ -16,5 +16,5 @@ jobs:
if: ${{ !endsWith(github.actor, '[bot]') }}
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- uses: crate-ci/typos@v1.47.2
diff --git a/Makefile b/Makefile
index f9e042bb0b..b59fc2c0db 100644
--- a/Makefile
+++ b/Makefile
@@ -23,7 +23,7 @@ endif
.PHONY: all build build-stats clean devtools-all devtools-bindgen devtools-scrapefuncs fuzz fuzz-ci fuzz-tools
.PHONY: devtools-tsgen docs docgen dsl-docs functional go-build lint lint-strict fuzzplayground syntax-docs
-.PHONY: integration integration-debug jsupdate-all jsupdate-bindgen jsupdate-tsgen memogen scan-charts test test-with-lint
+.PHONY: integration integration-debug regression jsupdate-all jsupdate-bindgen jsupdate-tsgen memogen scan-charts test test-with-lint
.PHONY: tidy ts verify download vet template-validate build-fuzz discover-fuzz-packages
all: build
@@ -82,7 +82,11 @@ syntax-docs: docgen
syntax-docs:
./bin/docgen SYNTAX-REFERENCE.md nuclei-jsonschema.json
-test: GOFLAGS = -race -v -timeout 1h -count 1
+# RACE controls the data-race detector (on by default for local runs). CI builds
+# the race variant on a single OS and passes RACE= elsewhere, since data races are
+# OS-independent and the detector costs ~2-3x build+run time on every runner.
+RACE ?= -race
+test: GOFLAGS = $(RACE) -v -timeout 1h -count 1
test:
$(GOTEST) $(GOFLAGS) ./...
@@ -92,6 +96,12 @@ integration:
integration-debug:
$(GOTEST) -tags=integration ./internal/tests/integration -v $(GO_TEST_ARGS) -args $(INTEGRATION_ARGS)
+# Opt-in HTTP engine scale regression harness (not part of CI). Stands up many
+# loopback hosts and asserts finding parity across a diverse template set.
+# Override host count with NUCLEI_SCALE_HOSTS, e.g. NUCLEI_SCALE_HOSTS=500 make regression
+regression:
+ $(GOTEST) -tags=regression -timeout 30m ./lib/tests -run TestScaleRegression -v
+
functional: build
@release_binary="$$(command -v nuclei.exe 2>/dev/null || command -v nuclei 2>/dev/null)"; \
if [ -z "$$release_binary" ]; then \
diff --git a/cmd/nuclei/main.go b/cmd/nuclei/main.go
index f4cb0280c2..d15be70317 100644
--- a/cmd/nuclei/main.go
+++ b/cmd/nuclei/main.go
@@ -15,12 +15,12 @@ import (
"github.com/projectdiscovery/gologger"
_pdcp "github.com/projectdiscovery/nuclei/v3/internal/pdcp"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/projectdiscovery/utils/auth/pdcp"
"github.com/projectdiscovery/utils/env"
_ "github.com/projectdiscovery/utils/pprof"
stringsutil "github.com/projectdiscovery/utils/strings"
"github.com/rs/xid"
- "gopkg.in/yaml.v2"
"github.com/projectdiscovery/goflags"
"github.com/projectdiscovery/gologger/levels"
@@ -418,6 +418,7 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.CreateGroup("rate-limit", "Rate-Limit",
flagSet.IntVarP(&options.RateLimit, "rate-limit", "rl", 150, "maximum number of requests to send per second"),
flagSet.DurationVarP(&options.RateLimitDuration, "rate-limit-duration", "rld", time.Second, "maximum number of requests to send per second"),
+ flagSet.BoolVar(&options.PerHostRateLimit, "per-host-rate-limit", false, "enable per-host rate limiting (global rate limit becomes unlimited when enabled)"),
flagSet.IntVarP(&options.RateLimitMinute, "rate-limit-minute", "rlm", 0, "maximum number of requests to send per minute (DEPRECATED)"),
flagSet.IntVarP(&options.BulkSize, "bulk-size", "bs", 25, "maximum number of hosts to be analyzed in parallel per template"),
flagSet.IntVarP(&options.TemplateThreads, "concurrency", "c", 25, "maximum number of templates to be executed in parallel"),
@@ -446,6 +447,7 @@ on extensive configurability, massive extensibility and ease of use.`)
}),
flagSet.DurationVarP(&options.InputReadTimeout, "input-read-timeout", "irt", time.Duration(3*time.Minute), "timeout on input read"),
flagSet.BoolVarP(&options.DisableHTTPProbe, "no-httpx", "nh", false, "disable httpx probing for non-url input"),
+ flagSet.BoolVar(&options.PreflightPortScan, "preflight-portscan", false, "run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)"),
flagSet.BoolVar(&options.DisableStdin, "no-stdin", false, "disable stdin processing"),
)
diff --git a/go.mod b/go.mod
index ec0c7bb785..55f1bc7ac1 100644
--- a/go.mod
+++ b/go.mod
@@ -5,14 +5,12 @@ go 1.25.7
require (
github.com/andygrunwald/go-jira v1.16.1
github.com/antchfx/htmlquery v1.3.5
- github.com/bluele/gcache v0.0.2
github.com/go-playground/validator/v10 v10.26.0
github.com/go-rod/rod v0.116.2
github.com/gobwas/ws v1.4.0
- github.com/google/go-github v17.0.0+incompatible
github.com/invopop/jsonschema v0.13.0
github.com/itchyny/gojq v0.12.17
- github.com/json-iterator/go v1.1.12
+ github.com/json-iterator/go v1.1.12 // indirect
github.com/julienschmidt/httprouter v1.3.0
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/miekg/dns v1.1.68
@@ -24,7 +22,7 @@ require (
github.com/projectdiscovery/interactsh v1.3.1
github.com/projectdiscovery/rawhttp v0.1.90
github.com/projectdiscovery/retryabledns v1.0.115
- github.com/projectdiscovery/retryablehttp-go v1.3.14
+ github.com/projectdiscovery/retryablehttp-go v1.3.15
github.com/projectdiscovery/yamldoc-go v1.0.6
github.com/remeh/sizedwaitgroup v1.0.0
github.com/rs/xid v1.6.0
@@ -32,13 +30,11 @@ require (
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/cast v1.10.0
github.com/syndtr/goleveldb v1.0.0
- github.com/valyala/fasttemplate v1.2.2
- github.com/weppos/publicsuffix-go v0.50.3
+ github.com/weppos/publicsuffix-go v0.50.3 // indirect
go.uber.org/multierr v1.11.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.34.0
golang.org/x/text v0.37.0
- gopkg.in/yaml.v2 v2.4.0
)
require (
@@ -49,18 +45,14 @@ require (
github.com/Azure/go-ntlmssp v0.1.1
github.com/DataDog/gostackparse v0.7.0
github.com/FalconOpsLLC/goexec v0.3.0
- github.com/Masterminds/semver/v3 v3.4.0
- github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057
+ github.com/Masterminds/semver/v3 v3.5.0
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415
- github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697
- github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687
github.com/alexsnet/go-vnc v0.1.0
github.com/alitto/pond v1.9.2
github.com/antchfx/xmlquery v1.4.4
github.com/antchfx/xpath v1.3.6
- github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.29.17
github.com/aws/aws-sdk-go-v2/credentials v1.17.70
@@ -80,13 +72,13 @@ require (
github.com/go-pg/pg/v10 v10.15.0
github.com/go-sql-driver/mysql v1.9.3
github.com/goccy/go-json v0.10.5
+ github.com/google/go-github/v30 v30.1.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.6.0
github.com/h2non/filetype v1.1.3
- github.com/invopop/yaml v0.3.1
+ github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jcmturner/gokrb5/v8 v8.4.4
github.com/kitabisa/go-ci v1.0.3
- github.com/labstack/echo/v4 v4.13.4
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa
github.com/lib/pq v1.11.2
github.com/logrusorgru/aurora/v4 v4.0.0
@@ -102,9 +94,11 @@ require (
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb
github.com/projectdiscovery/goflags v0.1.74
- github.com/projectdiscovery/gologger v1.1.70
+ github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534
+ github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d
+ github.com/projectdiscovery/gologger v1.1.71
github.com/projectdiscovery/gostruct v0.0.2
- github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e
+ github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76
github.com/projectdiscovery/httpx v1.9.0
github.com/projectdiscovery/mapcidr v1.1.97
@@ -117,19 +111,18 @@ require (
github.com/projectdiscovery/uncover v1.2.1
github.com/projectdiscovery/useragent v0.0.108
github.com/projectdiscovery/utils v0.11.1
- github.com/projectdiscovery/wappalyzergo v0.2.84
+ github.com/projectdiscovery/wappalyzergo v0.2.86
github.com/redis/go-redis/v9 v9.11.0
github.com/rs/zerolog v1.34.0
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
github.com/sijms/go-ora/v2 v2.9.0
github.com/stretchr/testify v1.11.1
github.com/tarunKoyalwar/goleak v0.0.0-20240429141123-0efa90dbdcf9
- github.com/testcontainers/testcontainers-go v0.42.0
- github.com/testcontainers/testcontainers-go/modules/mongodb v0.42.0
github.com/yassinebenaid/godump v0.11.1
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
@@ -146,6 +139,7 @@ require (
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 // indirect
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
@@ -159,6 +153,7 @@ require (
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
+ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
@@ -204,9 +199,6 @@ require (
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
- github.com/containerd/log v0.1.0 // indirect
- github.com/containerd/platforms v0.2.1 // indirect
- github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
@@ -214,6 +206,7 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
+ github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/docker/cli v29.2.0+incompatible // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
@@ -250,7 +243,6 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/certificate-transparency-go v1.3.2 // indirect
- github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gosimple/slug v1.15.0 // indirect
@@ -259,7 +251,6 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
- github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf // indirect
github.com/hdm/jarm-go v0.0.7 // indirect
github.com/iangcarroll/cookiemonster v1.6.0 // indirect
@@ -282,14 +273,12 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
- github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/libdns/libdns v1.1.1 // indirect
github.com/lor00x/goldap v0.0.0-20240304151906-8d785c64d1c8 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
github.com/mackerelio/go-osstat v0.2.6 // indirect
- github.com/magiconair/properties v1.8.10 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
@@ -301,13 +290,9 @@ require (
github.com/minio/selfupdate v0.6.1-0.20230907112617-f11e74f84ca7 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
- github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.1 // indirect
- github.com/moby/patternmatcher v0.6.1 // indirect
- github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
- github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -335,7 +320,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/projectdiscovery/asnmap v1.1.1 // indirect
github.com/projectdiscovery/blackrock v0.0.1 // indirect
- github.com/projectdiscovery/cdncheck v1.2.39 // indirect
+ github.com/projectdiscovery/cdncheck v1.2.41 // indirect
github.com/projectdiscovery/freeport v0.0.7 // indirect
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
@@ -349,6 +334,7 @@ require (
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
+ github.com/stretchr/objx v0.5.3 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/buntdb v1.3.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
@@ -395,7 +381,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
)
diff --git a/go.sum b/go.sum
index 3d40b12759..e17aff8d35 100644
--- a/go.sum
+++ b/go.sum
@@ -45,8 +45,6 @@ filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a h1:3i+FJ7IpSZHL+VAjtpQeZCRhrpP0odl5XfoLBY4fxJ8=
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a/go.mod h1:C7hXLmFmPYPjIDGfQl1clsmQ5TMEQfmzWTrJk475bUs=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
@@ -79,8 +77,8 @@ github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/
github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM=
github.com/FalconOpsLLC/goexec v0.3.0 h1:ryLMkrGT6asnkqdc5rFMNOSTYdMH/iCfyEuwu0D6ZhA=
github.com/FalconOpsLLC/goexec v0.3.0/go.mod h1:kiyxVbmFCGbbwXRyZmOSKlOy7PiK+JH2gq07Ztag/k8=
-github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
-github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -92,10 +90,6 @@ github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d h1:DofPB5AcjTnO
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d/go.mod h1:uzdh/m6XQJI7qRvufeBPDa+lj5SVCJO8B9eLxTbtI5U=
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415 h1:lpSZPcbowbxvKFaFvE1reLTCStezWXcRVk0zzBtUatg=
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415/go.mod h1:Wvb2f1Aq6NVL4Fza/dPNxv6/canpeizpgmiTCBGMD50=
-github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 h1:54I+OF5vS4a/rxnUrN5J3hi0VEYKcrTlpc8JosDyP+c=
-github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697/go.mod h1:yNqYRqxYkSROY1J+LX+A0tOSA/6soXQs5m8hZSqYBac=
-github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 h1:+Is1AS20q3naP+qJophNpxuvx1daFOx9C0kLIuI0GVk=
-github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883/go.mod h1:K+FhM7iKGKtalkeXGEviafPPwyVjDv1a/ehomabLF2w=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
@@ -213,8 +207,6 @@ github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCk
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bloom/v3 v3.5.0 h1:AKDvi1V3xJCmSR6QhcBfHbCN4Vf8FfxeWkMNQfmAGhY=
github.com/bits-and-blooms/bloom/v3 v3.5.0/go.mod h1:Y8vrn7nk1tPIlmLtW2ZPV+W7StdVMor6bC1xgpjMZFs=
-github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
-github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
@@ -298,13 +290,7 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
-github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
-github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
-github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
-github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
-github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
@@ -328,6 +314,8 @@ github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYC
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
+github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
@@ -459,6 +447,8 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -524,7 +514,6 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
@@ -604,8 +593,6 @@ github.com/indece-official/go-ebcdic v1.2.0 h1:nKCubkNoXrGvBp3MSYuplOQnhANCDEY51
github.com/indece-official/go-ebcdic v1.2.0/go.mod h1:RBddVJt0Ks0eDLRG5dhPwBDRiTNA7n+yv0dVFpSs46Q=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
-github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
-github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
@@ -680,10 +667,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
-github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
-github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
-github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
@@ -706,8 +689,6 @@ github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDh
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mackerelio/go-osstat v0.2.6 h1:gs4U8BZeS1tjrL08tt5VUliVvSWP26Ai2Ob8Lr7f2i0=
github.com/mackerelio/go-osstat v0.2.6/go.mod h1:lRy8V9ZuHpuRVZh+vyTkODeDPl3/d5MgXHtLSaqG8bA=
-github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
-github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -749,20 +730,12 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
-github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
-github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
-github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
-github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
-github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
-github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
-github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
-github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -848,8 +821,8 @@ github.com/projectdiscovery/asnmap v1.1.1 h1:ImJiKIaACOT7HPx4Pabb5dksolzaFYsD1kI
github.com/projectdiscovery/asnmap v1.1.1/go.mod h1:QT7jt9nQanj+Ucjr9BqGr1Q2veCCKSAVyUzLXfEcQ60=
github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ=
github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
-github.com/projectdiscovery/cdncheck v1.2.39 h1:gNE2dyaK+ZqEdEWyVUFlq8PvromEhSxamhsmFZR2Voc=
-github.com/projectdiscovery/cdncheck v1.2.39/go.mod h1:9oE9KKxCSHNvUf0UaMeqqUwWpC38FkNaTll0ScIBT3w=
+github.com/projectdiscovery/cdncheck v1.2.41 h1:XLgrlHKT7wus9JMArQqMAq3sqT16gW+g+TqY2A72ces=
+github.com/projectdiscovery/cdncheck v1.2.41/go.mod h1:9oE9KKxCSHNvUf0UaMeqqUwWpC38FkNaTll0ScIBT3w=
github.com/projectdiscovery/clistats v0.1.4 h1:kDnXoNxIdOvQElOF7k2Mt6XosGa5GbMKPtRXdPHMVzU=
github.com/projectdiscovery/clistats v0.1.4/go.mod h1:hjJYNcUubk9T3cuFvA+JkLhZGjzYW50fkC48dqUAtbU=
github.com/projectdiscovery/dsl v0.8.19 h1:qA5OFJMfghSCjKqS4AdsEtnur/SoriXDw3geE7+mReU=
@@ -866,12 +839,16 @@ github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb h1:rutG90
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb/go.mod h1:FLjF1DmZ+POoGEiIQdWuYVwS++C/GwpX8YaCsTSm1RY=
github.com/projectdiscovery/goflags v0.1.74 h1:n85uTRj5qMosm0PFBfsvOL24I7TdWRcWq/1GynhXS7c=
github.com/projectdiscovery/goflags v0.1.74/go.mod h1:UMc9/7dFz2oln+10tv6cy+7WZKTHf9UGhaNkF95emh4=
-github.com/projectdiscovery/gologger v1.1.70 h1:A1ZAsUJfRUXO6qqwTwyTWXLVlBrVu/Gpi1zzL1hg5LY=
-github.com/projectdiscovery/gologger v1.1.70/go.mod h1:kpLKNafZWRN9P7WpJYtIOY/XvY/v41GDdU9NzICdKmo=
+github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534 h1:hYd1zQA/dxO2ASyQ6Re73TcJkW1LjLQvt4+86Hxefz8=
+github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534/go.mod h1:SO0AP+uKfYeeoR6jyVH/PKRYJE/f5gJrPMAM00iGEMc=
+github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d h1:fqqH9LHpN2WDz9QuxFrhKNxXSRtzk+Sa6jAhbB7tXcQ=
+github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d/go.mod h1:Ezmbgdaw4EunGGBU4MQViLoGMJc37LA3ip55YV3KeRI=
+github.com/projectdiscovery/gologger v1.1.71 h1:IYU4mw9viKdSzMTIGVpYuw1Gtg7QIHIStqAQgeNXcBQ=
+github.com/projectdiscovery/gologger v1.1.71/go.mod h1:mJwODZcFDg70ihINpOvZevmBtgvpP8H9/l8Y+OPhZPY=
github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBDDSv7VEdG1M=
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
-github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e h1:o+ulEIaC2+9V2Ezr6mI5xEhKWsf0V/+FUQIS723Aj6U=
-github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e/go.mod h1:xH7bPwHxUlz1yx9UlVeTF+UVCUaKhTnZgaxHb5z362E=
+github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e h1:vxzgQlz2Cy/YvizYDQx9OhucBcmBotfDhbQ4yCY2vfA=
+github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e/go.mod h1:xH7bPwHxUlz1yx9UlVeTF+UVCUaKhTnZgaxHb5z362E=
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 h1:AN70bbi6BBs7KpIM9w0LxygUN7uzT/oH+owDIQ+Fz/k=
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76/go.mod h1:cWHYnRXoYWHtTpOYyAp5laGYX8GH8ITUhgQaP8G/8FA=
github.com/projectdiscovery/hmap v0.0.101 h1:zXM6YtLmsn8Q0CUUw8QavhqWmiQYwaw+/U679Rr00pc=
@@ -898,8 +875,8 @@ github.com/projectdiscovery/rdap v0.9.0 h1:wPhHx5pQ2QI+WGhyNb2PjhTl0NtB39Nk7YFZ9
github.com/projectdiscovery/rdap v0.9.0/go.mod h1:zk4yrJFQ2Hy36Aqk+DvotYQxYAeALaCJ5ORySkff36Q=
github.com/projectdiscovery/retryabledns v1.0.115 h1:RKV63FNIznFHUoawg/1hs53pVH3wqPtFhwstCuxVSoA=
github.com/projectdiscovery/retryabledns v1.0.115/go.mod h1:+fEMWoPigw+M0lGNKY7AZ+g8FIgj+4sONjsinMmeL3k=
-github.com/projectdiscovery/retryablehttp-go v1.3.14 h1:vCBLwK8iIuua3i97jEac5/+TWkYTLhTkGblHu9ETPVc=
-github.com/projectdiscovery/retryablehttp-go v1.3.14/go.mod h1:reVhQ+DzMAPYEQHdawCQ6h0tX3CpFyMH4XjcAyq9+U8=
+github.com/projectdiscovery/retryablehttp-go v1.3.15 h1:qhJzaWWRras9Il66HbWU0DJ35clFJoz/ktQvks1ogGU=
+github.com/projectdiscovery/retryablehttp-go v1.3.15/go.mod h1:s0azLAqAbcVCjHI9t0ezPhamevYGM1eoOvFkn4QmpZ8=
github.com/projectdiscovery/sarif v0.1.0 h1:O541T+a448nSJsmIMnXXSOeDQEzpnCAYvRfe0eG5h74=
github.com/projectdiscovery/sarif v0.1.0/go.mod h1:LBC+reM3bkI3qIIhE0rZaINaYX6VG+En6u2hHa5mA7E=
github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA=
@@ -912,8 +889,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/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/wappalyzergo v0.2.86 h1:UYiBus+0Bjl7d2ZgZcXk/gHRVIhqv3sl06AnmQ+pDkc=
+github.com/projectdiscovery/wappalyzergo v0.2.86/go.mod h1:gMH0o5lBp65sKMwHx/tuUdOtW2RjodC6Ti+9QDsYMkY=
github.com/projectdiscovery/yamldoc-go v1.0.6 h1:GCEdIRlQjDux28xTXKszM7n3jlMf152d5nqVpVoetas=
github.com/projectdiscovery/yamldoc-go v1.0.6/go.mod h1:R5lWrNzP+7Oyn77NDVPnBsxx2/FyQZBBkIAaSaCQFxw=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -1026,10 +1003,6 @@ github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFd
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tarunKoyalwar/goleak v0.0.0-20240429141123-0efa90dbdcf9 h1:GXIyLuIJ5Qk46lI8WJ83qHBZKUI3zhmMmuoY9HICUIQ=
github.com/tarunKoyalwar/goleak v0.0.0-20240429141123-0efa90dbdcf9/go.mod h1:uQdBQGrE1fZ2EyOs0pLcCDd1bBV4rSThieuIIGhXZ50=
-github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
-github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
-github.com/testcontainers/testcontainers-go/modules/mongodb v0.42.0 h1:jX10Aprgf1L+Ov+KxcheZ/1JXdiJ/3wdevfWFSkxm6s=
-github.com/testcontainers/testcontainers-go/modules/mongodb v0.42.0/go.mod h1:Ph+xH0hAC6djPFTjPgLa3VmSfE4h82kzVIKxTj3n2o4=
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
@@ -1074,8 +1047,6 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
-github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
github.com/vmihailenco/msgpack/v5 v5.3.4 h1:qMKAwOV+meBw2Y8k9cVwAy7qErtYCwBzZ2ellBfvnqc=
@@ -1176,8 +1147,6 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
-go.mongodb.org/mongo-driver/v2 v2.3.0 h1:sh55yOXA2vUjW1QYw/2tRlHSQViwDyPnW61AwpZ4rtU=
-go.mongodb.org/mongo-driver/v2 v2.3.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -1621,7 +1590,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/internal/fuzzplayground/server.go b/internal/fuzzplayground/server.go
index 5278c12363..b35b8030e1 100644
--- a/internal/fuzzplayground/server.go
+++ b/internal/fuzzplayground/server.go
@@ -2,39 +2,67 @@
package fuzzplayground
import (
+ "encoding/json"
"encoding/xml"
"fmt"
"io"
+ "log"
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
- "github.com/labstack/echo/v4"
- "github.com/labstack/echo/v4/middleware"
"github.com/projectdiscovery/retryablehttp-go"
)
-func GetPlaygroundServer() *echo.Echo {
- e := echo.New()
- e.Use(middleware.Recover())
- e.Use(middleware.Logger())
+// PlaygroundServer wraps the fuzz playground handler with the lifecycle methods
+// used by the integration tests and the standalone playground command.
+type PlaygroundServer struct {
+ handler http.Handler
+ server *http.Server
+}
+
+func GetPlaygroundServer() *PlaygroundServer {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /{$}", indexHandler)
+ mux.HandleFunc("GET /info", infoHandler)
+ mux.HandleFunc("GET /redirect", redirectHandler)
+ mux.HandleFunc("GET /request", requestHandler)
+ mux.HandleFunc("GET /email", emailHandler)
+ mux.HandleFunc("GET /permissions", permissionsHandler)
+
+ mux.HandleFunc("GET /blog/post", numIdorHandler) // for num based idors like ?id=44
+ mux.HandleFunc("POST /reset-password", resetPasswordHandler)
+ mux.HandleFunc("GET /host-header-lab", hostHeaderLabHandler)
+ mux.HandleFunc("GET /user/{id}/profile", userProfileHandler)
+ mux.HandleFunc("POST /user", patchUnsanitizedUserHandler)
+ mux.HandleFunc("GET /blog/posts", getPostsHandler)
+
+ handler := recoverPlaygroundRequest(logPlaygroundRequest(mux))
+ return &PlaygroundServer{handler: handler}
+}
+
+func (s *PlaygroundServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ s.handler.ServeHTTP(w, r)
+}
- e.GET("/", indexHandler)
- e.GET("/info", infoHandler)
- e.GET("/redirect", redirectHandler)
- e.GET("/request", requestHandler)
- e.GET("/email", emailHandler)
- e.GET("/permissions", permissionsHandler)
+func (s *PlaygroundServer) Start(addr string) error {
+ s.server = &http.Server{
+ Addr: addr,
+ Handler: s.handler,
+ }
+ if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ return err
+ }
+ return nil
+}
- e.GET("/blog/post", numIdorHandler) // for num based idors like ?id=44
- e.POST("/reset-password", resetPasswordHandler)
- e.GET("/host-header-lab", hostHeaderLabHandler)
- e.GET("/user/:id/profile", userProfileHandler)
- e.POST("/user", patchUnsanitizedUserHandler)
- e.GET("/blog/posts", getPostsHandler)
- return e
+func (s *PlaygroundServer) Close() error {
+ if s.server == nil {
+ return nil
+ }
+ return s.server.Close()
}
var bodyTemplate = `
@@ -46,8 +74,8 @@ var bodyTemplate = `
`
-func indexHandler(ctx echo.Context) error {
- return ctx.HTML(200, fmt.Sprintf(bodyTemplate, `
Fuzzing Playground
+func indexHandler(w http.ResponseWriter, _ *http.Request) {
+ writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, `Fuzzing Playground
- Info Page XSS
@@ -65,154 +93,172 @@ func indexHandler(ctx echo.Context) error {
`))
}
-func infoHandler(ctx echo.Context) error {
- return ctx.HTML(200, fmt.Sprintf(bodyTemplate, fmt.Sprintf("Name of user: %s%s%s", ctx.QueryParam("name"), ctx.QueryParam("another"), ctx.QueryParam("random"))))
+func infoHandler(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query()
+ writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, fmt.Sprintf("Name of user: %s%s%s", query.Get("name"), query.Get("another"), query.Get("random"))))
}
-func redirectHandler(ctx echo.Context) error {
- url := ctx.QueryParam("redirect_url")
- return ctx.Redirect(302, url)
+func redirectHandler(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, r.URL.Query().Get("redirect_url"), http.StatusFound)
}
-func requestHandler(ctx echo.Context) error {
- url := ctx.QueryParam("url")
- data, err := retryablehttp.DefaultClient().Get(url)
+func requestHandler(w http.ResponseWriter, r *http.Request) {
+ requestURL := r.URL.Query().Get("url")
+ data, err := retryablehttp.DefaultClient().Get(requestURL)
if err != nil {
- return ctx.HTML(500, err.Error())
+ writeHTML(w, http.StatusInternalServerError, err.Error())
+ return
}
defer func() {
_ = data.Body.Close()
}()
body, _ := io.ReadAll(data.Body)
- return ctx.HTML(200, fmt.Sprintf(bodyTemplate, string(body)))
+ writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, string(body)))
}
-func emailHandler(ctx echo.Context) error {
- text := ctx.QueryParam("text")
+func emailHandler(w http.ResponseWriter, r *http.Request) {
+ text := r.URL.Query().Get("text")
if strings.Contains(text, "{{") {
trimmed := strings.SplitN(strings.Trim(text[strings.Index(text, "{"):], "{}"), "*", 2)
if len(trimmed) < 2 {
- return ctx.HTML(500, "invalid template")
+ writeHTML(w, http.StatusInternalServerError, "invalid template")
+ return
}
first, _ := strconv.Atoi(trimmed[0])
second, _ := strconv.Atoi(trimmed[1])
text = strconv.Itoa(first * second)
}
- return ctx.HTML(200, fmt.Sprintf(bodyTemplate, fmt.Sprintf("Text: %s", text)))
+ writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, fmt.Sprintf("Text: %s", text)))
}
-func permissionsHandler(ctx echo.Context) error {
- command := ctx.QueryParam("cmd")
+func permissionsHandler(w http.ResponseWriter, r *http.Request) {
+ command := r.URL.Query().Get("cmd")
fields := strings.Fields(command)
cmd := exec.Command(fields[0], fields[1:]...)
data, _ := cmd.CombinedOutput()
- return ctx.HTML(200, fmt.Sprintf(bodyTemplate, string(data)))
+ writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, string(data)))
}
-func numIdorHandler(ctx echo.Context) error {
+func numIdorHandler(w http.ResponseWriter, r *http.Request) {
// validate if any numerical query param is present
// if not, return 400 if so, return 200
- for k := range ctx.QueryParams() {
- if _, err := strconv.Atoi(ctx.QueryParam(k)); err == nil {
- return ctx.JSON(200, "Profile Info for user with id "+ctx.QueryParam(k))
+ for k := range r.URL.Query() {
+ value := r.URL.Query().Get(k)
+ if _, err := strconv.Atoi(value); err == nil {
+ writeJSON(w, http.StatusOK, "Profile Info for user with id "+value)
+ return
}
}
- return ctx.JSON(400, "No numerical query param found")
+ writeJSON(w, http.StatusBadRequest, "No numerical query param found")
}
-func patchUnsanitizedUserHandler(ctx echo.Context) error {
+func patchUnsanitizedUserHandler(w http.ResponseWriter, r *http.Request) {
var user User
- contentType := ctx.Request().Header.Get("Content-Type")
+ contentType := r.Header.Get("Content-Type")
// manually handle unmarshalling data
if strings.Contains(contentType, "application/json") {
- err := ctx.Bind(&user)
- if err != nil {
- return ctx.JSON(500, "Invalid JSON data")
+ if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
+ writeJSON(w, http.StatusInternalServerError, "Invalid JSON data")
+ return
}
} else if strings.Contains(contentType, "application/x-www-form-urlencoded") {
- user.Name = ctx.FormValue("name")
- user.Age, _ = strconv.Atoi(ctx.FormValue("age"))
- user.Role = ctx.FormValue("role")
- user.ID, _ = strconv.Atoi(ctx.FormValue("id"))
+ user.Name = r.FormValue("name")
+ user.Age, _ = strconv.Atoi(r.FormValue("age"))
+ user.Role = r.FormValue("role")
+ user.ID, _ = strconv.Atoi(r.FormValue("id"))
} else if strings.Contains(contentType, "application/xml") {
- bin, _ := io.ReadAll(ctx.Request().Body)
+ bin, _ := io.ReadAll(r.Body)
err := xml.Unmarshal(bin, &user)
if err != nil {
- return ctx.JSON(500, "Invalid XML data")
+ writeJSON(w, http.StatusInternalServerError, "Invalid XML data")
+ return
}
} else if strings.Contains(contentType, "multipart/form-data") {
- user.Name = ctx.FormValue("name")
- user.Age, _ = strconv.Atoi(ctx.FormValue("age"))
- user.Role = ctx.FormValue("role")
- user.ID, _ = strconv.Atoi(ctx.FormValue("id"))
+ user.Name = r.FormValue("name")
+ user.Age, _ = strconv.Atoi(r.FormValue("age"))
+ user.Role = r.FormValue("role")
+ user.ID, _ = strconv.Atoi(r.FormValue("id"))
} else {
- return ctx.JSON(500, "Invalid Content-Type")
+ writeJSON(w, http.StatusInternalServerError, "Invalid Content-Type")
+ return
}
err := patchUnsanitizedUser(db, user)
if err != nil {
- return ctx.JSON(500, err.Error())
+ writeJSON(w, http.StatusInternalServerError, err.Error())
+ return
}
- return ctx.JSON(200, "User updated successfully")
+ writeJSON(w, http.StatusOK, "User updated successfully")
}
// resetPassword mock
-func resetPasswordHandler(c echo.Context) error {
+func resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
var m map[string]interface{}
- if err := c.Bind(&m); err != nil {
- return c.JSON(500, "Something went wrong")
+ if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
+ writeJSON(w, http.StatusInternalServerError, "Something went wrong")
+ return
}
- host := c.Request().Header.Get("X-Forwarded-For")
+ host := r.Header.Get("X-Forwarded-For")
if host == "" {
- return c.JSON(500, "Something went wrong")
+ writeJSON(w, http.StatusInternalServerError, "Something went wrong")
+ return
}
- resp, err := http.Get("http://internal." + host + "/update?user=1337&pass=" + m["password"].(string))
+ password, ok := m["password"].(string)
+ if !ok {
+ writeJSON(w, http.StatusInternalServerError, "Something went wrong")
+ return
+ }
+ resp, err := http.Get("http://internal." + host + "/update?user=1337&pass=" + password)
if err != nil {
- return c.JSON(500, "Something went wrong")
+ writeJSON(w, http.StatusInternalServerError, "Something went wrong")
+ return
}
defer func() {
_ = resp.Body.Close()
}()
- return c.JSON(200, "Password reset successfully")
+ writeJSON(w, http.StatusOK, "Password reset successfully")
}
-func hostHeaderLabHandler(c echo.Context) error {
+func hostHeaderLabHandler(w http.ResponseWriter, r *http.Request) {
// vulnerable app has custom routing and trusts x-forwarded-host
// to route to internal services
- if c.Request().Header.Get("X-Forwarded-Host") != "" {
- resp, err := http.Get("http://" + c.Request().Header.Get("X-Forwarded-Host"))
+ if r.Header.Get("X-Forwarded-Host") != "" {
+ resp, err := http.Get("http://" + r.Header.Get("X-Forwarded-Host"))
if err != nil {
- return c.JSON(500, "Something went wrong")
+ writeJSON(w, http.StatusInternalServerError, "Something went wrong")
+ return
}
defer func() {
_ = resp.Body.Close()
}()
- c.Response().Header().Set("Content-Type", resp.Header.Get("Content-Type"))
- c.Response().WriteHeader(resp.StatusCode)
- _, err = io.Copy(c.Response().Writer, resp.Body)
+ w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
+ w.WriteHeader(resp.StatusCode)
+ _, err = io.Copy(w, resp.Body)
if err != nil {
- return c.JSON(500, "Something went wrong")
+ return
}
+ return
}
- return c.JSON(200, "Not a Teapot")
+ writeJSON(w, http.StatusOK, "Not a Teapot")
}
-func userProfileHandler(ctx echo.Context) error {
- val, _ := url.PathUnescape(ctx.Param("id"))
+func userProfileHandler(w http.ResponseWriter, r *http.Request) {
+ val, _ := url.PathUnescape(r.PathValue("id"))
fmt.Printf("Unescaped: %s\n", val)
user, err := getUnsanitizedUser(db, val)
if err != nil {
- return ctx.JSON(500, err.Error())
+ writeJSON(w, http.StatusInternalServerError, err.Error())
+ return
}
- return ctx.JSON(200, user)
+ writeJSON(w, http.StatusOK, user)
}
-func getPostsHandler(c echo.Context) error {
- lang, err := c.Cookie("lang")
+func getPostsHandler(w http.ResponseWriter, r *http.Request) {
+ lang, err := r.Cookie("lang")
if err != nil {
// If the language cookie is missing, default to English
lang = new(http.Cookie)
@@ -220,7 +266,38 @@ func getPostsHandler(c echo.Context) error {
}
posts, err := getUnsanitizedPostsByLang(db, lang.Value)
if err != nil {
- return c.JSON(http.StatusInternalServerError, err.Error())
+ writeJSON(w, http.StatusInternalServerError, err.Error())
+ return
}
- return c.JSON(http.StatusOK, posts)
+ writeJSON(w, http.StatusOK, posts)
+}
+
+func writeHTML(w http.ResponseWriter, statusCode int, value string) {
+ w.Header().Set("Content-Type", "text/html; charset=UTF-8")
+ w.WriteHeader(statusCode)
+ _, _ = io.WriteString(w, value)
+}
+
+func writeJSON(w http.ResponseWriter, statusCode int, value interface{}) {
+ w.Header().Set("Content-Type", "application/json; charset=UTF-8")
+ w.WriteHeader(statusCode)
+ _ = json.NewEncoder(w).Encode(value)
+}
+
+func recoverPlaygroundRequest(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ }()
+ next.ServeHTTP(w, r)
+ })
+}
+
+func logPlaygroundRequest(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ log.Printf("%s %s", r.Method, r.URL.RequestURI())
+ next.ServeHTTP(w, r)
+ })
}
diff --git a/internal/runner/preflight_portscan.go b/internal/runner/preflight_portscan.go
new file mode 100644
index 0000000000..1f3bd1a9f2
--- /dev/null
+++ b/internal/runner/preflight_portscan.go
@@ -0,0 +1,655 @@
+package runner
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/projectdiscovery/gologger"
+ "github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
+ "github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
+ inputtypes "github.com/projectdiscovery/nuclei/v3/pkg/input/types"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
+ "github.com/projectdiscovery/nuclei/v3/pkg/templates"
+ "github.com/projectdiscovery/utils/errkit"
+ iputil "github.com/projectdiscovery/utils/ip"
+ mapsutil "github.com/projectdiscovery/utils/maps"
+ sliceutil "github.com/projectdiscovery/utils/slice"
+ stringsutil "github.com/projectdiscovery/utils/strings"
+ syncutil "github.com/projectdiscovery/utils/sync"
+ urlutil "github.com/projectdiscovery/utils/url"
+)
+
+const preflightWorkers = 100
+
+// preflightDialTimeout is intentionally short: this is a coarse filter to skip
+// obviously-dead targets before the real scan (which will use full timeouts).
+const preflightDialTimeout = 750 * time.Millisecond
+
+type filteringInputProvider struct {
+ base provider.InputProvider
+ allowed *mapsutil.SyncLockMap[string, struct{}]
+ allowCnt int64
+ execID string
+}
+
+func (f *filteringInputProvider) Count() int64 { return f.allowCnt }
+func (f *filteringInputProvider) InputType() string { return f.base.InputType() }
+func (f *filteringInputProvider) Close() { f.base.Close() }
+func (f *filteringInputProvider) Set(executionId string, value string) {
+ f.base.Set(executionId, value)
+}
+func (f *filteringInputProvider) SetWithProbe(executionId string, value string, probe inputtypes.InputLivenessProbe) error {
+ return f.base.SetWithProbe(executionId, value, probe)
+}
+func (f *filteringInputProvider) SetWithExclusions(executionId string, value string) error {
+ return f.base.SetWithExclusions(executionId, value)
+}
+func (f *filteringInputProvider) Iterate(callback func(value *contextargs.MetaInput) bool) {
+ f.base.Iterate(func(mi *contextargs.MetaInput) bool {
+ key, err := mi.MarshalString()
+ if err != nil {
+ return callback(mi)
+ }
+ if _, ok := f.allowed.Get(key); !ok {
+ return true
+ }
+ return callback(mi)
+ })
+}
+
+// preflightResolveAndPortScan resolves hostname targets and performs a TCP connect scan for ports
+// required by loaded templates. Targets that are non-resolvable hostnames or have no relevant open
+// ports are filtered out from the input provider.
+func (r *Runner) preflightResolveAndPortScan(store *loader.Store) error {
+ if r.inputProvider == nil {
+ return nil
+ }
+ // MultiFormat inputs may represent complete requests; skip preflight for now.
+ if r.inputProvider.InputType() == provider.MultiFormatInputProvider {
+ return nil
+ }
+
+ finalTemplates := []*templates.Template{}
+ finalTemplates = append(finalTemplates, store.Templates()...)
+ finalTemplates = append(finalTemplates, store.Workflows()...)
+ if len(finalTemplates) == 0 {
+ return nil
+ }
+
+ dialers := protocolstate.GetDialersWithId(r.options.ExecutionId)
+ if dialers == nil || dialers.Fastdialer == nil {
+ return fmt.Errorf("dialers not initialized for %s", r.options.ExecutionId)
+ }
+
+ portsPopularity := portsPopularityFromTemplates(finalTemplates)
+ // Also include ports explicitly present in input list (ip:port or URL with port),
+ // so that a user-provided port isn't dropped even if templates didn't specify it.
+ var inputs []preflightTarget
+ portsFromInputs := map[string]struct{}{}
+ var totalTargets atomic.Int64
+ r.inputProvider.Iterate(func(mi *contextargs.MetaInput) bool {
+ totalTargets.Add(1)
+ key, err := mi.MarshalString()
+ if err != nil {
+ return true
+ }
+ // Use Target()/CustomIP so preflight resolves the same address the real
+ // scan would connect to (honors ReqResp-backed targets and CustomIP),
+ // instead of only the raw Input which can mismatch routing state.
+ inputs = append(inputs, preflightTarget{key: key, target: mi.Target(), customIP: mi.CustomIP})
+ extractPortsFromInput(portsFromInputs, mi.Target())
+ return true
+ })
+
+ portsToScan := sliceutil.Dedupe(append(keysOfPopularity(portsPopularity), keysOf(portsFromInputs)...))
+ portsToScan = filterValidPorts(portsToScan)
+ // Sort by "likely-open" order (nmap-ish/common ports first), then numeric for determinism.
+ likelyRank := likelyOpenPortRank()
+ sort.Slice(portsToScan, func(i, j int) bool {
+ pi, pj := portsToScan[i], portsToScan[j]
+ ri, okRi := likelyRank[pi]
+ rj, okRj := likelyRank[pj]
+ // ranked ports first
+ if okRi != okRj {
+ return okRi
+ }
+ // among ranked ports, lower rank wins
+ if okRi && okRj && ri != rj {
+ return ri < rj
+ }
+ // numeric asc
+ ni, _ := strconv.Atoi(pi)
+ nj, _ := strconv.Atoi(pj)
+ return ni < nj
+ })
+
+ // If no ports were found, nothing to scan -> keep all.
+ if len(portsToScan) == 0 {
+ return nil
+ }
+
+ if !r.options.Silent {
+ r.Logger.Info().Msgf("Running preflight portscan (workers=%d, ports=%d, targets=%d)", preflightWorkers, len(portsToScan), totalTargets.Load())
+ }
+
+ swg, err := syncutil.New(syncutil.WithSize(preflightWorkers))
+ if err != nil {
+ return err
+ }
+
+ // Resolve all targets (once) up-front so we can optionally run a single batched TCP dial scan.
+ // Map original input key -> resolved IPs (deduped).
+ // SyncLockMap requires comparable values; store resolved IPs as a comma-separated string.
+ resolvedIPsByKey := mapsutil.NewSyncLockMap[string, string]()
+ allIPsSet := mapsutil.NewSyncLockMap[string, struct{}]()
+ var resolveProcessed atomic.Int64
+ var resolveDNSFail atomic.Int64
+
+ for _, t := range inputs {
+ swg.Add()
+ go func(t preflightTarget) {
+ defer swg.Done()
+ host, _, _, _ := hostForResolveAndScan(t.target)
+ if t.customIP != "" {
+ host = t.customIP
+ }
+ if host == "" {
+ resolveDNSFail.Add(1)
+ resolveProcessed.Add(1)
+ return
+ }
+ ips := []string{}
+ if iputil.IsIP(host) {
+ ips = append(ips, host)
+ } else {
+ dns, err := dialers.Fastdialer.GetDNSData(host)
+ if err != nil || (len(dns.A) == 0 && len(dns.AAAA) == 0) {
+ resolveDNSFail.Add(1)
+ resolveProcessed.Add(1)
+ return
+ }
+ ips = append(ips, dns.A...)
+ ips = append(ips, dns.AAAA...)
+ }
+ ips = sliceutil.Dedupe(ips)
+ if len(ips) == 0 {
+ resolveDNSFail.Add(1)
+ resolveProcessed.Add(1)
+ return
+ }
+
+ // store
+ // (small contention; acceptable at preflight scale)
+ _ = resolvedIPsByKey.Set(t.key, strings.Join(ips, ","))
+ for _, ip := range ips {
+ _ = allIPsSet.Set(ip, struct{}{})
+ }
+ resolveProcessed.Add(1)
+ }(t)
+ }
+ swg.Wait()
+
+ // Prepare list of all IPs for scan.
+ allIPsMap := allIPsSet.GetAll()
+ allIPs := make([]string, 0, len(allIPsMap))
+ for ip := range allIPsMap {
+ allIPs = append(allIPs, ip)
+ }
+ sort.Strings(allIPs)
+
+ // we do fast TCP dial scanning against resolved IPs.
+ if !r.options.Silent {
+ r.Logger.Info().Msgf("Preflight resolution: total=%d resolvable=%d unresolvable=%d", totalTargets.Load(), int64(len(resolvedIPsByKey.GetAll())), resolveDNSFail.Load())
+ }
+
+ allowed := mapsutil.NewSyncLockMap[string, struct{}]()
+
+ var dnsFail atomic.Int64
+ var portFail atomic.Int64
+ var kept atomic.Int64
+ var processed atomic.Int64
+
+ perPortOpen := mapsutil.NewSyncLockMap[string, *atomic.Int64]()
+
+ // Periodic progress logging
+ // Always enabled unless running in silent mode.
+ debugProgress := true
+ stopProgress := make(chan struct{})
+ if debugProgress && !r.options.Silent {
+ start := time.Now()
+ go func() {
+ t := time.NewTicker(1 * time.Second)
+ defer t.Stop()
+ var lastProcessed int64
+ for {
+ select {
+ case <-t.C:
+ p := processed.Load()
+ if p == lastProcessed {
+ continue
+ }
+ lastProcessed = p
+ total := totalTargets.Load()
+ k := kept.Load()
+ df := dnsFail.Load()
+ pf := portFail.Load()
+ dropped := p - k
+ r.Logger.Info().Msgf("Preflight progress: %d/%d processed (kept=%d dropped=%d dns_fail=%d port_fail=%d elapsed=%s)",
+ p, total, k, dropped, df, pf, time.Since(start).Truncate(time.Second))
+ case <-stopProgress:
+ return
+ }
+ }
+ }()
+ }
+
+ for _, t := range inputs {
+ swg.Add()
+ go func(t preflightTarget) {
+ defer swg.Done()
+ ok, openPort, reason := r.preflightOneResolved(t.key, t.target, portsToScan, resolvedIPsByKey)
+ processed.Add(1)
+ if ok {
+ _ = allowed.Set(t.key, struct{}{})
+ kept.Add(1)
+ if openPort != "" {
+ counter, _ := perPortOpen.Get(openPort)
+ if counter == nil {
+ counter = &atomic.Int64{}
+ _ = perPortOpen.Set(openPort, counter)
+ }
+ counter.Add(1)
+ }
+ return
+ }
+ switch reason {
+ case preflightReasonDNS:
+ dnsFail.Add(1)
+ case preflightReasonPorts:
+ portFail.Add(1)
+ }
+ }(t)
+ }
+ swg.Wait()
+ close(stopProgress)
+
+ // Apply filtering wrapper
+ allowedAll := allowed.GetAll()
+ r.inputProvider = &filteringInputProvider{
+ base: r.inputProvider,
+ allowed: allowed,
+ allowCnt: int64(len(allowedAll)),
+ execID: r.options.ExecutionId,
+ }
+
+ // Summary
+ if !r.options.Silent {
+ dropped := totalTargets.Load() - kept.Load()
+ r.Logger.Info().Msgf("Preflight summary: total=%d kept=%d filtered_dns=%d filtered_ports=%d",
+ totalTargets.Load(), kept.Load(), dnsFail.Load(), portFail.Load())
+ r.Logger.Info().Msgf("Preflight targets: dropped=%d left=%d", dropped, kept.Load())
+ perPortOpenAll := perPortOpen.GetAll()
+ if len(perPortOpenAll) > 0 {
+ type kv struct {
+ port string
+ count int64
+ }
+ kvs := make([]kv, 0, len(perPortOpenAll))
+ for p, c := range perPortOpenAll {
+ if c == nil {
+ continue
+ }
+ kvs = append(kvs, kv{port: p, count: c.Load()})
+ }
+ sort.Slice(kvs, func(i, j int) bool {
+ if kvs[i].count == kvs[j].count {
+ return kvs[i].port < kvs[j].port
+ }
+ return kvs[i].count > kvs[j].count
+ })
+ parts := make([]string, 0, len(kvs))
+ for _, item := range kvs {
+ parts = append(parts, fmt.Sprintf("%s=%d", item.port, item.count))
+ }
+ r.Logger.Info().Msgf("Preflight open-port distribution: %s", strings.Join(parts, " "))
+ }
+ }
+
+ _ = gologger.DefaultLogger // ensure logger imported even when silent builds vary
+ return nil
+}
+
+type preflightTarget struct {
+ key string
+ target string
+ customIP string
+}
+
+type preflightReason int
+
+const (
+ preflightReasonNone preflightReason = iota
+ preflightReasonDNS
+ preflightReasonPorts
+)
+
+func (r *Runner) preflightOneResolved(key string, raw string, ports []string, resolved *mapsutil.SyncLockMap[string, string]) (ok bool, openPort string, reason preflightReason) {
+ resolvedIPsCSV, _ := resolved.Get(key)
+ if resolvedIPsCSV == "" {
+ return false, "", preflightReasonDNS
+ }
+ ips := strings.Split(resolvedIPsCSV, ",")
+
+ // TCP dial scan using resolved IPs
+ host, schemePort, hasSchemePort, _ := hostForResolveAndScan(raw)
+ ordered := ports
+ if hasSchemePort && schemePort != "" {
+ ordered = append([]string{schemePort}, ports...)
+ ordered = sliceutil.Dedupe(ordered)
+ }
+
+ timeout := preflightDialTimeout
+ if r.options.Timeout > 0 {
+ t := time.Duration(r.options.Timeout) * time.Second
+ if t > 0 && t < timeout {
+ timeout = t
+ }
+ }
+ // Use net.Dialer directly for strict timeout enforcement.
+ // We already resolved IPs, so we don't need fastdialer DNS behavior here.
+ // This avoids rare cases where proxy dialers / custom dial stacks may not respect ctx cancellation promptly.
+ d := &net.Dialer{Timeout: timeout}
+
+ // Per-host parallelism: probe up to 3 ports concurrently, stop on first success.
+ ctx, cancelAll := context.WithCancel(context.Background())
+ defer cancelAll()
+
+ type hit struct {
+ port string
+ }
+ resultCh := make(chan hit, 1)
+ portsCh := make(chan string)
+
+ worker := func() {
+ for p := range portsCh {
+ // Stop quickly if someone already found an open port.
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ for _, ip := range ips {
+ _ = host // keep for debugging parity
+ dctx, cancel := context.WithTimeout(ctx, timeout)
+ conn, err := d.DialContext(dctx, "tcp", net.JoinHostPort(ip, p))
+ cancel()
+ if err == nil {
+ _ = conn.Close()
+ // Best-effort: first hit wins.
+ select {
+ case resultCh <- hit{port: p}:
+ cancelAll()
+ default:
+ }
+ return
+ }
+ // If ctx cancelled (other worker won), stop early.
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ }
+ }
+ }
+
+ var wg sync.WaitGroup
+ workers := 3
+ if len(ordered) < workers {
+ workers = len(ordered)
+ }
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ worker()
+ }()
+ }
+
+ go func() {
+ defer close(portsCh)
+ for _, p := range ordered {
+ select {
+ case <-ctx.Done():
+ return
+ case portsCh <- p:
+ }
+ }
+ }()
+
+ // Wait for either a hit or all workers to finish.
+ done := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case h := <-resultCh:
+ <-done
+ return true, h.port, preflightReasonNone
+ case <-done:
+ return false, "", preflightReasonPorts
+ }
+}
+
+func portsPopularityFromTemplates(tpls []*templates.Template) map[string]int {
+ out := map[string]int{}
+ for _, tpl := range tpls {
+ // HTTP templates imply 80/443 for preflight.
+ if len(tpl.RequestsHTTP) > 0 || len(tpl.RequestsWithHTTP) > 0 || len(tpl.RequestsHeadless) > 0 {
+ out["80"]++
+ out["443"]++
+ }
+ // Network templates declare ports directly.
+ for _, req := range tpl.RequestsNetwork {
+ for _, p := range splitPorts(req.Port) {
+ out[p]++
+ }
+ }
+ for _, req := range tpl.RequestsWithTCP {
+ for _, p := range splitPorts(req.Port) {
+ out[p]++
+ }
+ }
+ // Javascript templates may include args.Port (comma-separated).
+ for _, req := range tpl.RequestsJavascript {
+ for _, p := range extractPortsFromJSArgs(req.Args) {
+ out[p]++
+ }
+ }
+ }
+ return out
+}
+
+func keysOfPopularity(m map[string]int) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
+
+// likelyOpenPortRank returns a heuristic "most likely to be open" ranking for common TCP ports.
+// This is intentionally static (fast + deterministic) and loosely aligns with what scanners like nmap
+// tend to prioritize (common services first).
+func likelyOpenPortRank() map[string]int {
+ // Lower index = higher priority.
+ // Keep this list small-ish but useful; anything not in here falls back to template popularity + numeric.
+ common := []string{
+ "80", "443",
+ "22", "21", "23",
+ "25", "110", "143", "465", "587", "993", "995",
+ "53",
+ "3389",
+ "445", "139",
+ "135",
+ "3306", "5432", "1433", "1521",
+ "6379", "27017",
+ "9200", "9300",
+ "8080", "8443", "8000", "8008", "8081", "8888",
+ "9201",
+ "161", "162",
+ "389", "636",
+ "5900",
+ "11211",
+ "69", "123",
+ "1194",
+ "500", "4500",
+ }
+ rank := make(map[string]int, len(common))
+ for i, p := range common {
+ // do not overwrite if duplicates
+ if _, ok := rank[p]; !ok {
+ rank[p] = i
+ }
+ }
+ return rank
+}
+
+func extractPortsFromJSArgs(args map[string]interface{}) []string {
+ if args == nil {
+ return nil
+ }
+ for k, v := range args {
+ if strings.EqualFold(k, "Port") {
+ s := fmt.Sprint(v)
+ return splitPorts(s)
+ }
+ }
+ return nil
+}
+
+func splitPorts(s string) []string {
+ if s == "" {
+ return nil
+ }
+ parts := strings.Split(s, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func extractPortsFromInput(dst map[string]struct{}, input string) {
+ if dst == nil {
+ return
+ }
+ in := strings.TrimSpace(input)
+ if in == "" {
+ return
+ }
+ low := strings.ToLower(in)
+ if strings.HasPrefix(low, "http://") {
+ dst["80"] = struct{}{}
+ }
+ if strings.HasPrefix(low, "https://") {
+ dst["443"] = struct{}{}
+ }
+ // URL parsing (best effort)
+ if u, err := urlutil.Parse(in); err == nil && u != nil {
+ if p := u.Port(); p != "" {
+ dst[p] = struct{}{}
+ } else if u.Scheme == "http" {
+ dst["80"] = struct{}{}
+ } else if u.Scheme == "https" {
+ dst["443"] = struct{}{}
+ }
+ return
+ }
+ // host:port
+ _, p, err := net.SplitHostPort(in)
+ if err == nil && p != "" {
+ dst[p] = struct{}{}
+ }
+}
+
+func hostForResolveAndScan(raw string) (host string, schemeDefaultPort string, hasSchemePort bool, err error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", "", false, errkit.New("empty input")
+ }
+ // If it looks like URL, parse and extract hostname.
+ if stringsutil.ContainsAny(raw, "://") {
+ u, perr := urlutil.ParseAbsoluteURL(raw, false)
+ if perr == nil && u != nil {
+ host = u.Hostname()
+ if u.Port() != "" {
+ return host, "", false, nil
+ }
+ switch strings.ToLower(u.Scheme) {
+ case "http":
+ return host, "80", true, nil
+ case "https":
+ return host, "443", true, nil
+ }
+ return host, "", false, nil
+ }
+ }
+ // Try host:port form
+ h, _, serr := net.SplitHostPort(raw)
+ if serr == nil && h != "" {
+ return h, "", false, nil
+ }
+ // Bare host/ip
+ return raw, "", false, nil
+}
+
+func keysOf(m map[string]struct{}) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
+
+func filterValidPorts(ports []string) []string {
+ out := make([]string, 0, len(ports))
+ for _, p := range ports {
+ if p == "" {
+ continue
+ }
+ // allow numeric only
+ if !isNumeric(p) {
+ continue
+ }
+ i, err := strconv.Atoi(p)
+ if err != nil || i < 1 || i > 65535 {
+ continue
+ }
+ out = append(out, p)
+ }
+ return sliceutil.Dedupe(out)
+}
+
+func isNumeric(s string) bool {
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/runner/runner.go b/internal/runner/runner.go
index 781d4cd23b..dab6751a7f 100644
--- a/internal/runner/runner.go
+++ b/internal/runner/runner.go
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"reflect"
+ "sort"
"strings"
"sync/atomic"
"time"
@@ -55,6 +56,7 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hosterrorscache"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/uncover"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/excludematchers"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
@@ -198,7 +200,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
}
@@ -404,7 +406,12 @@ func New(options *types.Options) (*Runner, error) {
if options.RateLimit > 0 && options.RateLimitDuration == 0 {
options.RateLimitDuration = time.Second
}
- runner.rateLimiter = utils.GetRateLimiter(context.Background(), options.RateLimit, options.RateLimitDuration)
+ // If per-host rate limiting is enabled, make global rate limiter unlimited
+ if options.PerHostRateLimit {
+ runner.rateLimiter = utils.GetRateLimiter(context.Background(), 0, 0)
+ } else {
+ runner.rateLimiter = utils.GetRateLimiter(context.Background(), options.RateLimit, options.RateLimitDuration)
+ }
// Initialization successful, disable cleanup on error
cleanupOnError = false
@@ -427,6 +434,29 @@ 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)
+
+ // Per-host breakdown is opt-in (verbose) since large scans touch many hosts.
+ if r.options.Verbose {
+ perHost := httpclientpool.GetPerHostConnectionStats()
+ sort.Slice(perHost, func(i, j int) bool {
+ return (perHost[i].New + perHost[i].Reused) > (perHost[j].New + perHost[j].Reused)
+ })
+ const maxPerHostLines = 20
+ for i, s := range perHost {
+ if i >= maxPerHostLines {
+ gologger.Info().Msgf("HTTP connections: ... and %d more host(s)", len(perHost)-maxPerHostLines)
+ break
+ }
+ hostTotal := s.New + s.Reused
+ hostRatio := float64(s.Reused) / float64(hostTotal) * 100
+ gologger.Info().Msgf("HTTP connections [%s]: %d total, %d new, %d reused (%.1f%%)", s.Host, hostTotal, s.New, s.Reused, hostRatio)
+ }
+ }
+ }
// dump hosterrors cache
if r.hostErrors != nil {
r.hostErrors.Close()
@@ -507,6 +537,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 {
@@ -674,7 +709,7 @@ func (r *Runner) RunEnumeration() error {
if err := store.ValidateTemplates(); err != nil {
return err
}
- if stats.GetValue(templates.SyntaxErrorStats) == 0 && stats.GetValue(templates.SyntaxWarningStats) == 0 && stats.GetValue(templates.RuntimeWarningsStats) == 0 {
+ if stats.GetValue(templates.TemplateSyntaxErrorStats) == 0 && stats.GetValue(templates.TemplateSyntaxWarningStats) == 0 && stats.GetValue(templates.TemplateRuntimeWarningStats) == 0 {
r.Logger.Info().Msgf("All templates validated successfully")
} else {
return errors.New("encountered errors while performing template validation")
@@ -701,6 +736,15 @@ func (r *Runner) RunEnumeration() error {
_ = r.inputProvider.SetWithExclusions(r.options.ExecutionId, host)
}
}
+
+ // Preflight: resolve hosts + portscan for ports required by loaded templates, then filter inputs.
+ // This reduces time spent on non-resolvable targets or targets with no relevant open ports.
+ // Preflight is a best-effort optimization: on failure we log and continue with the full input set.
+ if r.options.PreflightPortScan {
+ if err := r.preflightResolveAndPortScan(store); err != nil {
+ gologger.Warning().Msgf("preflight resolve/portscan failed, continuing without input filtering: %s", err)
+ }
+ }
// display execution info like version , templates used etc
r.displayExecutionInfo(store)
@@ -723,13 +767,15 @@ func (r *Runner) RunEnumeration() error {
executorOpts.InputHelper.InputsHTTP = inputHelpers
}
+ inputCount := int(r.inputProvider.Count())
+
// initialize stats worker ( this is no-op unless nuclei is built with stats build tag)
// during execution a directory with 2 files will be created in the current directory
// config.json - containing below info
// events.jsonl - containing all start and end times of all templates
events.InitWithConfig(&events.ScanConfig{
Name: "nuclei-stats", // make this configurable
- TargetCount: int(r.inputProvider.Count()),
+ TargetCount: inputCount,
TemplatesCount: len(store.Templates()) + len(store.Workflows()),
TemplateConcurrency: r.options.TemplateThreads,
PayloadConcurrency: r.options.PayloadConcurrency,
@@ -771,6 +817,25 @@ func (r *Runner) RunEnumeration() error {
r.progress.Stop()
timeTaken := time.Since(now)
+
+ // Print pool/tracker stats if available (single dialers lookup, reads under lock)
+ if dialers := protocolstate.GetDialersWithId(r.options.ExecutionId); dialers != nil {
+ dialers.Lock()
+ perHostRateLimitPool := dialers.PerHostRateLimitPool
+ httpToHTTPSPortTracker := dialers.HTTPToHTTPSPortTracker
+ dialers.Unlock()
+
+ if pool, ok := perHostRateLimitPool.(interface{ PrintStats() }); ok {
+ pool.PrintStats()
+ }
+ if pool, ok := perHostRateLimitPool.(interface{ PrintPerHostPPSStats() }); ok {
+ pool.PrintPerHostPPSStats()
+ }
+ if tracker, ok := httpToHTTPSPortTracker.(interface{ PrintStats() }); ok {
+ tracker.PrintStats()
+ }
+ }
+
// todo: error propagation without canonical straight error check is required by cloud?
// use safe dereferencing to avoid potential panics in case of previous unchecked errors
if v := ptrutil.Safe(results); !v.Load() {
@@ -865,36 +930,36 @@ func (r *Runner) executeTemplatesInput(store *loader.Store, engine *core.Engine)
return results, nil
}
-// displayExecutionInfo displays misc info about the nuclei engine execution
+// displayExecutionInfo prints parser stats, version info, and scan counts.
func (r *Runner) displayExecutionInfo(store *loader.Store) {
- // Display stats for any loaded templates' syntax warnings or errors
- stats.Display(templates.SyntaxWarningStats)
- stats.Display(templates.SyntaxErrorStats)
- stats.Display(templates.RuntimeWarningsStats)
+ // Display parser stats for templates loaded into the store.
+ stats.Display(templates.TemplateSyntaxWarningStats)
+ stats.Display(templates.TemplateSyntaxErrorStats)
+ stats.Display(templates.TemplateRuntimeWarningStats)
+
tmplCount := len(store.Templates())
workflowCount := len(store.Workflows())
if r.options.Verbose || (tmplCount == 0 && workflowCount == 0) {
- // only print these stats in verbose mode
- stats.ForceDisplayWarning(templates.ExcludedHeadlessTmplStats)
- stats.ForceDisplayWarning(templates.ExcludedCodeTmplStats)
- stats.ForceDisplayWarning(templates.ExcludedDastTmplStats)
- stats.ForceDisplayWarning(templates.TemplatesExcludedStats)
- stats.ForceDisplayWarning(templates.ExcludedFileStats)
- stats.ForceDisplayWarning(templates.ExcludedSelfContainedStats)
+ // Excluded-template stats are noisy during normal scans, but useful in verbose mode
+ // and when no runnable templates remain.
+ for _, capability := range templates.AllCapabilities() {
+ stats.ForceDisplayWarning(capability.Stat())
+ }
+ stats.ForceDisplayWarning(templates.ExcludedWeakMatcherTemplateStats)
}
if tmplCount == 0 && workflowCount == 0 {
- // if dast flag is used print explicit warning
if r.options.DAST {
r.Logger.Warning().Msg("No DAST templates found")
}
- stats.ForceDisplayWarning(templates.SkippedCodeTmplTamperedStats)
+ stats.ForceDisplayWarning(templates.SkippedUnverifiedCodeTemplateStats)
} else {
- stats.DisplayAsWarning(templates.SkippedCodeTmplTamperedStats)
+ stats.DisplayAsWarning(templates.SkippedUnverifiedCodeTemplateStats)
}
+
stats.DisplayAsWarning(httpProtocol.SetThreadToCountZero)
- stats.ForceDisplayWarning(templates.SkippedUnsignedStats)
- stats.ForceDisplayWarning(templates.SkippedRequestSignatureStats)
+ stats.ForceDisplayWarning(templates.SkippedUnverifiedTemplateStats)
+ stats.ForceDisplayWarning(templates.SkippedRequestSignatureTemplateStats)
cfg := config.DefaultConfig
diff --git a/internal/runner/templates.go b/internal/runner/templates.go
index 03b6027fea..cb1eb6a4c7 100644
--- a/internal/runner/templates.go
+++ b/internal/runner/templates.go
@@ -7,13 +7,13 @@ import (
"strings"
"github.com/alecthomas/chroma/quick"
- jsoniter "github.com/json-iterator/go"
"github.com/logrusorgru/aurora/v4"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
)
// log available templates for verbose (-vv)
@@ -99,7 +99,7 @@ func (r *Runner) listAvailableTags(tagsMap map[string]int) {
for _, tag := range tagsList {
if r.options.JSONL {
- marshalled, _ := jsoniter.Marshal(tag)
+ marshalled, _ := json.Marshal(tag)
r.Logger.Print().Msgf("%s", string(marshalled))
} else {
r.Logger.Print().Msgf("%s (%d)", tag.Key, tag.Value)
diff --git a/internal/server/nuclei_sdk.go b/internal/server/nuclei_sdk.go
index 57ee11b04a..d29429c9e1 100644
--- a/internal/server/nuclei_sdk.go
+++ b/internal/server/nuclei_sdk.go
@@ -190,10 +190,13 @@ func (n *nucleiExecutor) ExecuteScan(target PostRequestsHandlerRequest) error {
}
func (n *nucleiExecutor) Close() {
+ if n == nil || n.executorOpts == nil {
+ return
+ }
if n.executorOpts.FuzzStatsDB != nil {
n.executorOpts.FuzzStatsDB.Close()
}
- if n.options.Interactsh != nil {
+ if n.options != nil && n.options.Interactsh != nil {
_ = n.options.Interactsh.Close()
}
if n.executorOpts.InputHelper != nil {
diff --git a/internal/server/server.go b/internal/server/server.go
index ecb3993861..60e8112c31 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -2,6 +2,7 @@ package server
import (
_ "embed"
+ "encoding/json"
"fmt"
"html/template"
"net/http"
@@ -11,8 +12,6 @@ import (
"time"
"github.com/alitto/pond"
- "github.com/labstack/echo/v4"
- "github.com/labstack/echo/v4/middleware"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/internal/server/scope"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
@@ -25,7 +24,7 @@ import (
// DASTServer is a server that performs execution of fuzzing templates
// on user input passed to the API.
type DASTServer struct {
- echo *echo.Echo
+ httpServer *http.Server
options *Options
tasksPool *pond.WorkerPool
deduplicator *requestDeduplicator
@@ -111,6 +110,7 @@ func New(options *Options) (*DASTServer, error) {
func NewStatsServer(fuzzStatsDB *stats.Tracker) (*DASTServer, error) {
server := &DASTServer{
+ options: &Options{},
nucleiExecutor: &nucleiExecutor{
executorOpts: &protocols.ExecutorOptions{
FuzzStatsDB: fuzzStatsDB,
@@ -124,62 +124,70 @@ func NewStatsServer(fuzzStatsDB *stats.Tracker) (*DASTServer, error) {
}
func (s *DASTServer) Close() {
- s.nucleiExecutor.Close()
- _ = s.echo.Close()
- s.tasksPool.StopAndWaitFor(1 * time.Minute)
+ if s.nucleiExecutor != nil {
+ s.nucleiExecutor.Close()
+ }
+ if s.httpServer != nil {
+ _ = s.httpServer.Close()
+ }
+ if s.tasksPool != nil {
+ s.tasksPool.StopAndWaitFor(1 * time.Minute)
+ }
}
func (s *DASTServer) buildURL(endpoint string) string {
values := make(url.Values)
- if s.options.Token != "" {
- values.Set("token", s.options.Token)
+ opts := s.optionsOrDefault()
+ if opts.Token != "" {
+ values.Set("token", opts.Token)
}
// Use url.URL struct to safely construct the URL
u := &url.URL{
Scheme: "http",
- Host: s.options.Address,
+ Host: opts.Address,
Path: endpoint,
RawQuery: values.Encode(),
}
return u.String()
}
-func (s *DASTServer) setupHandlers(onlyStats bool) {
- e := echo.New()
- e.Use(middleware.Recover())
- if s.options.Verbose {
- cfg := middleware.DefaultLoggerConfig
- cfg.Skipper = func(c echo.Context) bool {
- // Skip /stats and /stats.json
- return c.Request().URL.Path == "/stats" || c.Request().URL.Path == "/stats.json"
- }
- e.Use(middleware.LoggerWithConfig(cfg))
- }
- e.Use(middleware.CORS())
-
- if s.options.Token != "" {
- e.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
- KeyLookup: "query:token",
- Validator: func(key string, c echo.Context) (bool, error) {
- return key == s.options.Token, nil
- },
- }))
+func (s *DASTServer) optionsOrDefault() *Options {
+ if s.options != nil {
+ return s.options
}
+ return &Options{}
+}
- e.HideBanner = true
+func (s *DASTServer) setupHandlers(onlyStats bool) {
+ mux := http.NewServeMux()
// POST /fuzz - Queue a request for fuzzing
if !onlyStats {
- e.POST("/fuzz", s.handleRequest)
+ mux.HandleFunc("POST /fuzz", s.handleRequest)
}
- e.GET("/stats", s.handleStats)
- e.GET("/stats.json", s.handleStatsJSON)
+ mux.HandleFunc("GET /stats", s.handleStats)
+ mux.HandleFunc("GET /stats.json", s.handleStatsJSON)
- s.echo = e
+ handler := http.Handler(mux)
+ opts := s.optionsOrDefault()
+ if opts.Token != "" {
+ handler = s.tokenAuthMiddleware(handler)
+ }
+ handler = corsMiddleware(handler)
+ if opts.Verbose {
+ handler = requestLoggerMiddleware(handler)
+ }
+ handler = recoverMiddleware(handler)
+
+ s.httpServer = &http.Server{Handler: handler}
}
func (s *DASTServer) Start() error {
- if err := s.echo.Start(s.options.Address); err != nil && err != http.ErrServerClosed {
+ if s.httpServer == nil {
+ s.setupHandlers(false)
+ }
+ s.httpServer.Addr = s.optionsOrDefault().Address
+ if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
@@ -191,24 +199,26 @@ type PostRequestsHandlerRequest struct {
URL string `json:"url"`
}
-func (s *DASTServer) handleRequest(c echo.Context) error {
+func (s *DASTServer) handleRequest(w http.ResponseWriter, r *http.Request) {
var req PostRequestsHandlerRequest
- if err := c.Bind(&req); err != nil {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fmt.Printf("Error binding request: %s\n", err)
- return err
+ writeServerJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
}
// Validate the request
if req.RawHTTP == "" || req.URL == "" {
fmt.Printf("Missing required fields\n")
- return c.JSON(400, map[string]string{"error": "missing required fields"})
+ writeServerJSON(w, http.StatusBadRequest, map[string]string{"error": "missing required fields"})
+ return
}
s.endpointsInQueue.Add(1)
s.tasksPool.Submit(func() {
s.consumeTaskRequest(req)
})
- return c.NoContent(200)
+ w.WriteHeader(http.StatusOK)
}
type StatsResponse struct {
@@ -274,23 +284,116 @@ func (s *DASTServer) getStats() (StatsResponse, error) {
//go:embed templates/index.html
var indexTemplate string
-func (s *DASTServer) handleStats(c echo.Context) error {
+func (s *DASTServer) handleStats(w http.ResponseWriter, _ *http.Request) {
stats, err := s.getStats()
if err != nil {
- return c.JSON(500, map[string]string{"error": err.Error()})
+ writeServerJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
}
tmpl, err := template.New("index").Parse(indexTemplate)
if err != nil {
- return c.JSON(500, map[string]string{"error": err.Error()})
+ writeServerJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if err := tmpl.Execute(w, stats); err != nil {
+ writeServerJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
- return tmpl.Execute(c.Response().Writer, stats)
}
-func (s *DASTServer) handleStatsJSON(c echo.Context) error {
+func (s *DASTServer) handleStatsJSON(w http.ResponseWriter, _ *http.Request) {
resp, err := s.getStats()
if err != nil {
- return c.JSON(500, map[string]string{"error": err.Error()})
+ writeServerJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
}
- return c.JSONPretty(200, resp, " ")
+ writeServerJSONPretty(w, http.StatusOK, resp)
+}
+
+func (s *DASTServer) tokenAuthMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if token == "" {
+ writeServerJSON(w, http.StatusBadRequest, map[string]string{"message": "missing key in the query string"})
+ return
+ }
+ if token != s.optionsOrDefault().Token {
+ writeServerJSON(w, http.StatusUnauthorized, map[string]string{"message": "Unauthorized"})
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func corsMiddleware(next http.Handler) http.Handler {
+ const allowMethods = "GET,HEAD,PUT,PATCH,POST,DELETE"
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ origin := r.Header.Get("Origin")
+ w.Header().Add("Vary", "Origin")
+ if origin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ }
+ if r.Method == http.MethodOptions {
+ if origin != "" {
+ w.Header().Add("Vary", "Access-Control-Request-Method")
+ w.Header().Add("Vary", "Access-Control-Request-Headers")
+ w.Header().Set("Access-Control-Allow-Methods", allowMethods)
+ if headers := r.Header.Get("Access-Control-Request-Headers"); headers != "" {
+ w.Header().Set("Access-Control-Allow-Headers", headers)
+ }
+ }
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func requestLoggerMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/stats" || r.URL.Path == "/stats.json" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ recorder := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
+ start := time.Now()
+ next.ServeHTTP(recorder, r)
+ fmt.Printf("%s %s %d %s\n", r.Method, r.URL.RequestURI(), recorder.statusCode, time.Since(start))
+ })
+}
+
+func recoverMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ }()
+ next.ServeHTTP(w, r)
+ })
+}
+
+type statusRecorder struct {
+ http.ResponseWriter
+ statusCode int
+}
+
+func (r *statusRecorder) WriteHeader(statusCode int) {
+ r.statusCode = statusCode
+ r.ResponseWriter.WriteHeader(statusCode)
+}
+
+func writeServerJSON(w http.ResponseWriter, statusCode int, value interface{}) {
+ w.Header().Set("Content-Type", "application/json; charset=UTF-8")
+ w.WriteHeader(statusCode)
+ _ = json.NewEncoder(w).Encode(value)
+}
+
+func writeServerJSONPretty(w http.ResponseWriter, statusCode int, value interface{}) {
+ w.Header().Set("Content-Type", "application/json; charset=UTF-8")
+ w.WriteHeader(statusCode)
+ encoder := json.NewEncoder(w)
+ encoder.SetIndent("", " ")
+ _ = encoder.Encode(value)
}
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
new file mode 100644
index 0000000000..862b5e8bba
--- /dev/null
+++ b/internal/server/server_test.go
@@ -0,0 +1,66 @@
+package server
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDASTServerTokenAuthRejectsMissingAndInvalidTokens(t *testing.T) {
+ server := &DASTServer{options: &Options{Token: "secret"}}
+ server.setupHandlers(false)
+
+ tests := []struct {
+ name string
+ target string
+ statusCode int
+ }{
+ {
+ name: "missing token",
+ target: "/stats",
+ statusCode: http.StatusBadRequest,
+ },
+ {
+ name: "invalid token",
+ target: "/stats?token=wrong",
+ statusCode: http.StatusUnauthorized,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, tt.target, nil)
+ response := httptest.NewRecorder()
+
+ server.httpServer.Handler.ServeHTTP(response, request)
+
+ require.Equal(t, tt.statusCode, response.Code)
+ })
+ }
+}
+
+func TestDASTServerCORSPreflightBypassesTokenAuth(t *testing.T) {
+ server := &DASTServer{options: &Options{Token: "secret"}}
+ server.setupHandlers(false)
+
+ request := httptest.NewRequest(http.MethodOptions, "/stats", nil)
+ request.Header.Set("Origin", "https://example.com")
+ request.Header.Set("Access-Control-Request-Headers", "X-Test")
+ response := httptest.NewRecorder()
+
+ server.httpServer.Handler.ServeHTTP(response, request)
+
+ require.Equal(t, http.StatusNoContent, response.Code)
+ require.Equal(t, "*", response.Header().Get("Access-Control-Allow-Origin"))
+ require.Equal(t, "GET,HEAD,PUT,PATCH,POST,DELETE", response.Header().Get("Access-Control-Allow-Methods"))
+ require.Equal(t, "X-Test", response.Header().Get("Access-Control-Allow-Headers"))
+}
+
+func TestStatsServerCloseDoesNotRequireFullExecutorOptions(t *testing.T) {
+ server, err := NewStatsServer(nil)
+ require.NoError(t, err)
+
+ require.NotPanics(t, server.Close)
+}
diff --git a/internal/tests/integration/code_test.go b/internal/tests/integration/code_test.go
index d80c66ab30..3142415231 100644
--- a/internal/tests/integration/code_test.go
+++ b/internal/tests/integration/code_test.go
@@ -34,6 +34,7 @@ var codeTestCases = []integrationCase{
{Path: "protocols/code/py-file.yaml", TestCase: &codeFile{}, DisableOn: isCodeDisabled},
{Path: "protocols/code/py-env-var.yaml", TestCase: &codeEnvVar{}, DisableOn: isCodeDisabled},
{Path: "protocols/code/unsigned.yaml", TestCase: &unsignedCode{}, DisableOn: isCodeDisabled},
+ {Path: "protocols/code/dast-unsigned.yaml", TestCase: &dastUnsignedCode{}, DisableOn: isCodeDisabled},
{Path: "protocols/code/py-nosig.yaml", TestCase: &codePyNoSig{}, DisableOn: isCodeDisabled},
{Path: "protocols/code/py-interactsh.yaml", TestCase: &codeSnippet{}, DisableOn: isCodeDisabled},
{Path: "protocols/code/ps1-snippet.yaml", TestCase: &codeSnippet{}, DisableOn: func() bool { return !osutils.IsWindows() || isCodeDisabled() }},
@@ -96,6 +97,9 @@ func ensureSignedCodeTemplates() error {
if _, ok := v.TestCase.(*codePyNoSig); ok {
continue
}
+ if _, ok := v.TestCase.(*dastUnsignedCode); ok {
+ continue
+ }
templatesToSign = append(templatesToSign, v.Path)
}
for _, templatePath := range templatesToSign {
@@ -178,6 +182,22 @@ func (h *unsignedCode) Execute(filePath string) error {
return errors.Join(expectResultsCount(results, 1), errors.New("unsigned template was executed"))
}
+type dastUnsignedCode struct{}
+
+// Execute runs an unsigned template that pairs a fuzzable request with a code
+// block under -dast, which must not bypass the code-signing check.
+func (h *dastUnsignedCode) Execute(filePath string) error {
+ results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-dast")
+
+ // should error out
+ if err != nil {
+ return nil
+ }
+
+ // this point should never be reached
+ return errors.Join(expectResultsCount(results, 1), errors.New("unsigned template was executed"))
+}
+
type codePyNoSig struct{}
// Execute executes a test case and returns an error if occurred
diff --git a/internal/tests/integration/dns_test.go b/internal/tests/integration/dns_test.go
index ca5915ace9..02954f5d39 100644
--- a/internal/tests/integration/dns_test.go
+++ b/internal/tests/integration/dns_test.go
@@ -9,13 +9,17 @@ import (
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
)
+func expectPublicDNSResultsCount(results []string, expectedNumbers ...int) error {
+ return expectResultsCount(results, append([]int{0}, expectedNumbers...)...)
+}
+
func TestDNS(t *testing.T) {
t.Run("A", func(t *testing.T) {
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/dns/a.yaml", "one.one.one.one", suite.debug)
if err != nil {
t.Fatalf("dns A request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -25,7 +29,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns AAAA request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -35,7 +39,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns CNAME request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -45,7 +49,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns SRV request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -55,7 +59,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns NS request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -65,7 +69,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns TXT request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -75,7 +79,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns PTR request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -85,7 +89,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns CAA request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -105,7 +109,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns variables request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -115,7 +119,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns payload request failed: %v", err)
}
- if err := expectResultsCount(results, 3); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1, 2, 3); err != nil {
t.Fatal(err)
}
@@ -123,7 +127,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns payload request with CLI override failed: %v", err)
}
- if err := expectResultsCount(results, 4); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1, 2, 3, 4); err != nil {
t.Fatal(err)
}
})
@@ -133,7 +137,7 @@ func TestDNS(t *testing.T) {
if err != nil {
t.Fatalf("dns DSL matcher variable request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
diff --git a/internal/tests/integration/exporters_test.go b/internal/tests/integration/exporters_test.go
index ac12d1d3eb..295795b3d0 100644
--- a/internal/tests/integration/exporters_test.go
+++ b/internal/tests/integration/exporters_test.go
@@ -6,22 +6,24 @@ package integration_test
import (
"context"
"fmt"
- "log"
+ "net"
"time"
+ "github.com/ory/dockertest/v3"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/mongo"
- "github.com/testcontainers/testcontainers-go"
- mongocontainer "github.com/testcontainers/testcontainers-go/modules/mongodb"
-
osutil "github.com/projectdiscovery/utils/os"
mongoclient "go.mongodb.org/mongo-driver/mongo"
mongooptions "go.mongodb.org/mongo-driver/mongo/options"
)
const (
- dbName = "test"
- dbImage = "mongo:8"
+ dbName = "test"
+ dbRepository = "mongo"
+ dbTag = "8"
+ dbPort = "27017/tcp"
+ mongoDatabaseReadyTimeout = 3 * time.Minute
+ mongoServerSelectionDelay = time.Second
)
var exportersTestCases = []integrationCase{
@@ -36,22 +38,43 @@ func (m *mongoExporter) Execute(filepath string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
- // Start a MongoDB container
- mongodbContainer, err := mongocontainer.Run(ctx, dbImage)
- defer func() {
- if err := testcontainers.TerminateContainer(mongodbContainer); err != nil {
- log.Printf("failed to terminate container: %s", err)
- }
- }()
+ pool, err := dockertest.NewPool("")
+ if err != nil {
+ return fmt.Errorf("could not create docker pool: %w", err)
+ }
+ if err := pool.Client.Ping(); err != nil {
+ return fmt.Errorf("could not connect to Docker: %w", err)
+ }
+ pool.MaxWait = mongoDatabaseReadyTimeout
+
+ resource, err := pool.RunWithOptions(&dockertest.RunOptions{
+ Repository: dbRepository,
+ Tag: dbTag,
+ ExposedPorts: []string{dbPort},
+ })
if err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
+ defer purge(pool, resource)
- connString, err := mongodbContainer.ConnectionString(ctx)
+ mappedPort := resource.GetPort(dbPort)
+ if mappedPort == "" {
+ return fmt.Errorf("missing mapped port for %s", dbPort)
+ }
+ connString := fmt.Sprintf("mongodb://%s/%s", net.JoinHostPort("127.0.0.1", mappedPort), dbName)
+
+ err = pool.Retry(func() error {
+ clientOptions := mongooptions.Client().ApplyURI(connString).SetServerSelectionTimeout(mongoServerSelectionDelay)
+ client, err := mongoclient.Connect(ctx, clientOptions)
+ if err != nil {
+ return err
+ }
+ defer client.Disconnect(ctx)
+ return client.Ping(ctx, nil)
+ })
if err != nil {
- return fmt.Errorf("failed to get connection string for MongoDB container: %s", err)
+ return fmt.Errorf("failed to wait for MongoDB container: %w", err)
}
- connString = connString + dbName
// Create a MongoDB exporter and write a test result to the database
opts := mongo.Options{
diff --git a/internal/tests/integration/flow_test.go b/internal/tests/integration/flow_test.go
index 3b50a96afc..1ea89e241a 100644
--- a/internal/tests/integration/flow_test.go
+++ b/internal/tests/integration/flow_test.go
@@ -20,7 +20,7 @@ func TestFlow(t *testing.T) {
if err != nil {
t.Fatalf("conditional flow request failed: %v", err)
}
- if err := expectResultsCount(results, 1); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
@@ -77,7 +77,7 @@ func TestFlow(t *testing.T) {
if err != nil {
t.Fatalf("dns ns probe flow request failed: %v", err)
}
- if err := expectResultsCount(results, 2); err != nil {
+ if err := expectPublicDNSResultsCount(results, 1, 2); err != nil {
t.Fatal(err)
}
})
diff --git a/internal/tests/integration/http_test.go b/internal/tests/integration/http_test.go
index e692e6c872..6002395998 100644
--- a/internal/tests/integration/http_test.go
+++ b/internal/tests/integration/http_test.go
@@ -19,10 +19,9 @@ import (
"time"
"github.com/julienschmidt/httprouter"
- "gopkg.in/yaml.v2"
-
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/projectdiscovery/retryablehttp-go"
"github.com/projectdiscovery/utils/errkit"
logutil "github.com/projectdiscovery/utils/log"
diff --git a/internal/tests/integration/javascript_test.go b/internal/tests/integration/javascript_test.go
index fae16943c1..665beca3ce 100644
--- a/internal/tests/integration/javascript_test.go
+++ b/internal/tests/integration/javascript_test.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net"
+ "os"
"time"
"github.com/go-pg/pg/v10"
@@ -24,6 +25,10 @@ func javascriptDockerDisabled() bool {
return !osutils.IsLinux() || !hasAnyExecutable("docker", "podman")
}
+func javascriptGoExecSambaDisabled() bool {
+ return javascriptDockerDisabled() || os.Getenv("RUN_GOEXEC_SAMBA_LOCAL") != "1"
+}
+
var jsTestcases = []integrationCase{
{Path: "protocols/javascript/redis-pass-brute.yaml", TestCase: &javascriptRedisPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/ssh-server-fingerprint.yaml", TestCase: &javascriptSSHServerFingerprint{}, DisableOn: javascriptDockerDisabled, Serial: true},
@@ -40,7 +45,7 @@ var jsTestcases = []integrationCase{
{Path: "protocols/javascript/wmi-command.yaml", TestCase: &javascriptWMICommand{}},
{Path: "protocols/javascript/goexec-redaction.yaml", TestCase: &javascriptGoExecRedaction{}},
{Path: "protocols/javascript/goexec-modules.yaml", TestCase: &javascriptGoExecModules{}},
- {Path: "protocols/javascript/goexec-samba-ntlm.yaml", TestCase: &javascriptGoExecSambaNTLM{}, DisableOn: javascriptDockerDisabled, Serial: true},
+ {Path: "protocols/javascript/goexec-samba-ntlm.yaml", TestCase: &javascriptGoExecSambaNTLM{}, DisableOn: javascriptGoExecSambaDisabled, Serial: true},
}
var (
@@ -48,7 +53,6 @@ var (
)
const (
- javascriptContainerTTLSeconds = 300
javascriptDatabaseReadyTimeout = 3 * time.Minute
javascriptServiceReadyTimeout = 45 * time.Second
javascriptRetryDelay = 500 * time.Millisecond
@@ -257,10 +261,6 @@ func (j *javascriptGoExecSambaNTLM) Execute(filePath string) error {
}
defer purge(pool, resource)
- if err := resource.Expire(javascriptContainerTTLSeconds); err != nil {
- return fmt.Errorf("could not expire samba: %w", err)
- }
-
targetAddress := "127.0.0.1:445"
if err := waitForTCPService(targetAddress, javascriptServiceReadyTimeout); err != nil {
return err
@@ -419,10 +419,6 @@ func runJavascriptDockerCase(filePath string, spec javascriptDockerSpec, expecte
}
defer purge(pool, resource)
- if err := resource.Expire(javascriptContainerTTLSeconds); err != nil {
- return fmt.Errorf("could not expire resource for %s: %w", filePath, err)
- }
-
mappedPort := resource.GetPort(spec.port)
if mappedPort == "" {
return fmt.Errorf("missing mapped port for %s", spec.port)
@@ -586,7 +582,6 @@ func purge(pool *dockertest.Pool, resource *dockertest.Resource) {
return
}
containerName := resource.Container.Name
- _ = pool.Client.StopContainer(resource.Container.ID, 0)
_ = pool.Purge(resource)
_ = pool.RemoveContainerByName(containerName)
}
diff --git a/internal/tests/integration/network_test.go b/internal/tests/integration/network_test.go
index dc6375504d..c92905a6ab 100644
--- a/internal/tests/integration/network_test.go
+++ b/internal/tests/integration/network_test.go
@@ -6,7 +6,6 @@ package integration_test
import (
"fmt"
"net"
- "strings"
"testing"
"time"
@@ -213,9 +212,24 @@ func TestNetwork(t *testing.T) {
t.Fatal(err)
}
- results, err = testutils.RunNucleiTemplateAndGetResults("protocols/network/network-port.yaml", strings.ReplaceAll(server.URL, "23846", "443"), suite.debug)
+ // An explicitly specified reserved port (e.g. host:8081) must be preserved
+ // and not overridden by the template port (regression test for #7323).
+ // 8081 is a reserved port but is used here on an unprivileged port so the
+ // listener binds without root. The server is started on 8081 and the
+ // template (port 23846) must dial the operator-specified 8081 to match.
+ serverReserved := testutils.NewTCPServer(nil, 8081, func(conn net.Conn) {
+ defer func() { _ = conn.Close() }()
+
+ data, err := reader.ConnReadNWithTimeout(conn, 4, 5*time.Second)
+ if err == nil && string(data) == "PING" {
+ _, _ = conn.Write([]byte("PONG"))
+ }
+ })
+ defer serverReserved.Close()
+
+ results, err = testutils.RunNucleiTemplateAndGetResults("protocols/network/network-port.yaml", serverReserved.URL, suite.debug)
if err != nil {
- t.Fatalf("network-port template failed with overridden input port: %v", err)
+ t.Fatalf("network-port template failed with explicit reserved input port: %v", err)
}
if err := expectResultsCount(results, 1); err != nil {
t.Fatal(err)
diff --git a/internal/tests/integration/template-path_test.go b/internal/tests/integration/template-path_test.go
index 0682e3416f..09145ae8d5 100644
--- a/internal/tests/integration/template-path_test.go
+++ b/internal/tests/integration/template-path_test.go
@@ -34,7 +34,7 @@ func (h *cwdTemplateTest) Execute(filePath string) error {
if err != nil {
return err
}
- return expectResultsCount(results, 1)
+ return expectPublicDNSResultsCount(results, 1)
}
type relativePathTemplateTest struct{}
@@ -45,7 +45,7 @@ func (h *relativePathTemplateTest) Execute(filePath string) error {
if err != nil {
return err
}
- return expectResultsCount(results, 1)
+ return expectPublicDNSResultsCount(results, 1)
}
type absolutePathTemplateTest struct{}
@@ -56,7 +56,7 @@ func (h *absolutePathTemplateTest) Execute(filePath string) error {
if err != nil {
return err
}
- return expectResultsCount(results, 1)
+ return expectPublicDNSResultsCount(results, 1)
}
type folderPathTemplateTest struct{}
diff --git a/internal/tests/integration/testdata/protocols/code/dast-unsigned.yaml b/internal/tests/integration/testdata/protocols/code/dast-unsigned.yaml
new file mode 100644
index 0000000000..5eac20b4ee
--- /dev/null
+++ b/internal/tests/integration/testdata/protocols/code/dast-unsigned.yaml
@@ -0,0 +1,33 @@
+id: dast-unsigned-code
+
+info:
+ name: dast-unsigned-code
+ author: pdteam
+ severity: info
+ tags: dast,code
+ description: |
+ unsigned code paired with a fuzzable request must not execute under -dast
+
+http:
+ - method: GET
+ path:
+ - "{{BaseURL}}/?x=1"
+ fuzzing:
+ - part: query
+ type: replace
+ mode: single
+ keys: ["x"]
+ fuzz:
+ - "test"
+
+code:
+ - engine:
+ - py
+ - python3
+ source: |
+ print("unsigned code")
+
+ matchers:
+ - type: word
+ words:
+ - "unsigned code"
diff --git a/internal/tests/integration/testdata/subdomains.txt b/internal/tests/integration/testdata/subdomains.txt
index db0f25a30c..921465d322 100644
--- a/internal/tests/integration/testdata/subdomains.txt
+++ b/internal/tests/integration/testdata/subdomains.txt
@@ -1,5 +1,4 @@
one
docs
drive
-play
-
+www
diff --git a/lib/config.go b/lib/config.go
index 795f058dd8..023c4bfa94 100644
--- a/lib/config.go
+++ b/lib/config.go
@@ -10,10 +10,6 @@ import (
"github.com/projectdiscovery/goflags"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/internal/runner"
- "github.com/projectdiscovery/nuclei/v3/pkg/utils"
- "github.com/projectdiscovery/utils/errkit"
- "gopkg.in/yaml.v2"
-
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
@@ -24,6 +20,9 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/vardump"
"github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
pkgtypes "github.com/projectdiscovery/nuclei/v3/pkg/types"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
+ "github.com/projectdiscovery/utils/errkit"
)
// TemplateSources contains template sources
diff --git a/lib/sdk.go b/lib/sdk.go
index 8c2d8435b0..1ef6446661 100644
--- a/lib/sdk.go
+++ b/lib/sdk.go
@@ -244,6 +244,12 @@ func (e *NucleiEngine) closeInternal() {
if e.opts != nil {
generators.ClearOptionsPayloadMap(e.opts)
}
+ if e.parser != nil {
+ e.parser.Purge()
+ }
+ if purger, ok := e.executerOpts.Parser.(interface{ Purge() }); ok && purger != nil {
+ purger.Purge()
+ }
}
// Close all resources used by nuclei engine
diff --git a/lib/sdk_private.go b/lib/sdk_private.go
index 8c2dc8143c..98ff283718 100644
--- a/lib/sdk_private.go
+++ b/lib/sdk_private.go
@@ -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
}
diff --git a/lib/tests/scale_regression_test.go b/lib/tests/scale_regression_test.go
new file mode 100644
index 0000000000..8e0112afa9
--- /dev/null
+++ b/lib/tests/scale_regression_test.go
@@ -0,0 +1,253 @@
+//go:build regression
+
+// Package sdk_test contains an opt-in, large-scale regression harness for the
+// HTTP engine. It is gated behind the "regression" build tag so it never runs
+// as part of the normal unit/integration suites; run it explicitly with:
+//
+// go test -tags regression ./lib/tests/ -run TestScaleRegression -v
+//
+// Tune the host count with NUCLEI_SCALE_HOSTS (default 50).
+//
+// The harness stands up many independent loopback HTTP hosts and runs a diverse
+// template set (word/header/regex/status/dsl/extractor matchers, redirects, raw
+// requests and a multi-step cookie-reuse flow) through the engine, asserting
+// that every host yields the full expected finding set. It also includes a host
+// that emits the "plain HTTP request was sent to HTTPS port" 400 body to
+// exercise the HTTP->HTTPS port tracker: a wrongly detected/false-positive scheme
+// rewrite must not silently drop findings of unrelated templates hitting the
+// same host:port (regression guard for the tracker fallback).
+package sdk_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strconv"
+ "sync"
+ "testing"
+
+ nuclei "github.com/projectdiscovery/nuclei/v3/lib"
+ "github.com/projectdiscovery/nuclei/v3/pkg/output"
+ "github.com/stretchr/testify/require"
+)
+
+// scaleTemplates is the diverse template set exercised by the harness. Each one
+// deterministically matches every host so per-template finding counts must
+// equal the number of hosts.
+var scaleTemplates = map[string]string{
+ "01-basic-word.yaml": `id: scale-basic-word
+info: {name: Scale Basic Word, author: regression, severity: info}
+http:
+ - method: GET
+ path: ["{{BaseURL}}/"]
+ matchers-condition: and
+ matchers:
+ - {type: word, part: body, words: ["REGRESSION-OK"]}
+ - {type: status, status: [200]}
+`,
+ "02-header-match.yaml": `id: scale-header-match
+info: {name: Scale Header Match, author: regression, severity: info}
+http:
+ - method: GET
+ path: ["{{BaseURL}}/"]
+ matchers:
+ - {type: word, part: header, words: ["nuclei-regression"]}
+`,
+ "03-regex.yaml": `id: scale-regex
+info: {name: Scale Regex, author: regression, severity: info}
+http:
+ - method: GET
+ path: ["{{BaseURL}}/"]
+ matchers:
+ - {type: regex, part: body, regex: ["token=[A-Z0-9]{6}"]}
+`,
+ "04-multi-cookie.yaml": `id: scale-multi-cookie
+info: {name: Scale Multi-step Cookie Reuse, author: regression, severity: info}
+http:
+ - cookie-reuse: true
+ raw:
+ - |
+ GET /login HTTP/1.1
+ Host: {{Hostname}}
+ User-Agent: regression
+
+ - |
+ GET /profile HTTP/1.1
+ Host: {{Hostname}}
+ User-Agent: regression
+
+ matchers:
+ - {type: word, part: body, words: ["welcome-admin"]}
+`,
+ "05-redirect.yaml": `id: scale-redirect
+info: {name: Scale Redirect, author: regression, severity: info}
+http:
+ - method: GET
+ path: ["{{BaseURL}}/redirect"]
+ host-redirects: true
+ max-redirects: 3
+ matchers:
+ - {type: word, part: body, words: ["REGRESSION-OK"]}
+`,
+ "06-raw.yaml": `id: scale-raw
+info: {name: Scale Raw Request, author: regression, severity: info}
+http:
+ - raw:
+ - |
+ GET / HTTP/1.1
+ Host: {{Hostname}}
+ User-Agent: regression-raw
+
+ matchers-condition: and
+ matchers:
+ - {type: status, status: [200]}
+ - {type: word, part: body, words: ["build=stable"]}
+`,
+ "07-dsl-json.yaml": `id: scale-dsl-json
+info: {name: Scale DSL JSON, author: regression, severity: info}
+http:
+ - method: GET
+ path: ["{{BaseURL}}/api"]
+ matchers:
+ - {type: dsl, dsl: ['contains(body, "\"admin\":true") && status_code == 200']}
+`,
+ "08-extractor.yaml": `id: scale-extractor
+info: {name: Scale Extractor, author: regression, severity: info}
+http:
+ - method: GET
+ path: ["{{BaseURL}}/api"]
+ extractors:
+ - {type: regex, part: body, regex: ['"version":"([0-9.]+)"'], group: 1}
+ matchers:
+ - {type: word, part: body, words: ["active"]}
+`,
+ // This template triggers the HTTP->HTTPS port tracker. With the fallback in
+ // place, the tracker must not cause the other plain-HTTP templates above to
+ // be dropped on the same host:port.
+ "09-httpsport.yaml": `id: scale-httpsport
+info: {name: Scale HTTP to HTTPS Port, author: regression, severity: info}
+http:
+ - method: GET
+ path: ["{{BaseURL}}/httpsport"]
+ matchers:
+ - {type: word, part: body, words: ["plain HTTP request was sent to HTTPS port"]}
+`,
+}
+
+// scaleHandler serves deterministic, template-matchable content. The /httpsport
+// endpoint mimics a server that received plain HTTP on an HTTPS port.
+func scaleHandler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Reg", "nuclei-regression")
+ w.Header().Set("Server", "regression-test/1.0")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("REGRESSION-OK token=ABC123 build=stable\n"))
+ })
+ mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{Name: "sess", Value: "valid-token", Path: "/"})
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("logged-in\n"))
+ })
+ mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
+ if c, err := r.Cookie("sess"); err != nil || c.Value != "valid-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte("unauthorized\n"))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("welcome-admin profile-data\n"))
+ })
+ mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/", http.StatusFound)
+ })
+ mux.HandleFunc("/httpsport", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte("Client sent an HTTP request to an HTTPS server.\nThe plain HTTP request was sent to HTTPS port\n"))
+ })
+ mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"status":"active","version":"2.4.1","admin":true}`))
+ })
+ return mux
+}
+
+func TestScaleRegression(t *testing.T) {
+ hosts := 50
+ if v, err := strconv.Atoi(os.Getenv("NUCLEI_SCALE_HOSTS")); err == nil && v > 0 {
+ hosts = v
+ }
+
+ // Stand up N independent loopback hosts (distinct host:port each, so the
+ // per-host pool / connection-reuse / http->https machinery is exercised
+ // across many keys).
+ handler := scaleHandler()
+ targets := make([]string, 0, hosts)
+ servers := make([]*httptest.Server, 0, hosts)
+ for i := 0; i < hosts; i++ {
+ srv := httptest.NewServer(handler)
+ servers = append(servers, srv)
+ targets = append(targets, srv.URL)
+ }
+ defer func() {
+ for _, srv := range servers {
+ srv.Close()
+ }
+ }()
+
+ // Write the template set to a temp directory.
+ tplDir := t.TempDir()
+ for name, content := range scaleTemplates {
+ require.NoError(t, os.WriteFile(filepath.Join(tplDir, name), []byte(content), 0o644))
+ }
+
+ ne, err := nuclei.NewNucleiEngineCtx(
+ context.Background(),
+ nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: []string{tplDir}}),
+ nuclei.WithSandboxOptions(true, false),
+ nuclei.DisableUpdateCheck(),
+ )
+ require.NoError(t, err)
+ defer ne.Close()
+
+ ne.LoadTargets(targets, false)
+ require.NoError(t, ne.LoadAllTemplates())
+
+ var mu sync.Mutex
+ perTemplate := map[string]int{}
+ perTemplateHost := map[string]map[string]struct{}{}
+ require.NoError(t, ne.ExecuteWithCallback(func(event *output.ResultEvent) {
+ if event == nil {
+ return
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ perTemplate[event.TemplateID]++
+ if perTemplateHost[event.TemplateID] == nil {
+ perTemplateHost[event.TemplateID] = map[string]struct{}{}
+ }
+ // key on matched-at (includes host:port) since Host alone is 127.0.0.1
+ // for every loopback server
+ perTemplateHost[event.TemplateID][event.Matched] = struct{}{}
+ }))
+
+ // Every template must match on every host. In particular scale-multi-cookie
+ // and the other plain-HTTP templates must reach the full host count even
+ // though scale-httpsport marks each host:port as "requires HTTPS" — proving
+ // the http->https tracker fallback prevents silent finding loss.
+ wantIDs := []string{
+ "scale-basic-word", "scale-header-match", "scale-regex", "scale-multi-cookie",
+ "scale-redirect", "scale-raw", "scale-dsl-json", "scale-extractor", "scale-httpsport",
+ }
+ for _, id := range wantIDs {
+ unique := len(perTemplateHost[id])
+ require.Equalf(t, hosts, unique,
+ "template %q matched on %d/%d hosts (per-template hits=%d)", id, unique, hosts, perTemplate[id])
+ }
+
+ t.Logf("scale regression OK: %d hosts x %d templates = %d findings",
+ hosts, len(wantIDs), hosts*len(wantIDs))
+}
diff --git a/lib/tests/sdk_test.go b/lib/tests/sdk_test.go
index 43ed00f008..eeff4b1c97 100644
--- a/lib/tests/sdk_test.go
+++ b/lib/tests/sdk_test.go
@@ -14,11 +14,15 @@ 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"),
+ // expirable LRU cache creates a background goroutine for TTL expiration that persists
+ // see: https://github.com/hashicorp/golang-lru/blob/770151e9c8cdfae1797826b7b74c33d6f103fbd8/expirable/expirable_lru.go#L79
+ goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"),
+ goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
+ goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
}
func TestSimpleNuclei(t *testing.T) {
diff --git a/pkg/catalog/config/constants.go b/pkg/catalog/config/constants.go
index 62ef6b70eb..6922294423 100644
--- a/pkg/catalog/config/constants.go
+++ b/pkg/catalog/config/constants.go
@@ -31,7 +31,7 @@ const (
CLIConfigFileName = "config.yaml"
ReportingConfigFilename = "reporting-config.yaml"
// Version is the current version of nuclei
- Version = `v3.9.0`
+ Version = `v3.10.0`
// Directory Names of custom templates
CustomS3TemplatesDirName = "s3"
CustomGitHubTemplatesDirName = "github"
diff --git a/pkg/catalog/config/ignorefile.go b/pkg/catalog/config/ignorefile.go
index 14c0ec30f2..f738a0e945 100644
--- a/pkg/catalog/config/ignorefile.go
+++ b/pkg/catalog/config/ignorefile.go
@@ -4,7 +4,7 @@ import (
"os"
"github.com/projectdiscovery/gologger"
- "gopkg.in/yaml.v2"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
)
// IgnoreFile is an internal nuclei template blocking configuration file
diff --git a/pkg/catalog/disk/find.go b/pkg/catalog/disk/find.go
index 00e11fc2ee..1267309fcc 100644
--- a/pkg/catalog/disk/find.go
+++ b/pkg/catalog/disk/find.go
@@ -193,6 +193,9 @@ func (c *DiskCatalog) findFileMatches(absPath string, processed map[string]struc
if err != nil {
return "", false, err
}
+ defer func() {
+ _ = info.Close()
+ }()
stat, err := info.Stat()
if err != nil {
return "", false, err
diff --git a/pkg/catalog/loader/loader.go b/pkg/catalog/loader/loader.go
index 554d3093b9..2850bc1a11 100644
--- a/pkg/catalog/loader/loader.go
+++ b/pkg/catalog/loader/loader.go
@@ -431,7 +431,7 @@ func (store *Store) LoadTemplateTags() (map[string]int, error) {
loaded, err := store.config.ExecutorOptions.Parser.LoadTemplate(templatePath, store.tagFilter, nil, store.config.Catalog)
if err != nil {
if strings.Contains(err.Error(), templates.ErrExcluded.Error()) {
- stats.Increment(templates.TemplatesExcludedStats)
+ stats.Increment(templates.ExcludedWeakMatcherTemplateStats)
if config.DefaultConfig.LogAllEvents {
store.logger.Print().Msgf("[%v] %v\n", aurora.Yellow("WRN").String(), err.Error())
}
@@ -491,7 +491,7 @@ func (store *Store) LoadTemplatesOnlyMetadata() error {
loaded, err := store.config.ExecutorOptions.Parser.LoadTemplate(templatePath, store.tagFilter, nil, store.config.Catalog)
if !loaded {
if err != nil && strings.Contains(err.Error(), templates.ErrExcluded.Error()) {
- stats.Increment(templates.TemplatesExcludedStats)
+ stats.Increment(templates.ExcludedWeakMatcherTemplateStats)
if config.DefaultConfig.LogAllEvents {
store.logger.Print().Msgf("[%v] %v\n", aurora.Yellow("WRN").String(), err.Error())
}
@@ -532,7 +532,7 @@ func (store *Store) LoadTemplatesOnlyMetadata() error {
if err != nil {
if strings.Contains(err.Error(), templates.ErrExcluded.Error()) {
- stats.Increment(templates.TemplatesExcludedStats)
+ stats.Increment(templates.ExcludedWeakMatcherTemplateStats)
if config.DefaultConfig.LogAllEvents {
store.logger.Print().Msgf("[%v] %v\n", aurora.Yellow("WRN").String(), err.Error())
}
@@ -549,13 +549,7 @@ func (store *Store) LoadTemplatesOnlyMetadata() error {
}
loadedTemplateIDs := mapsutil.NewSyncLockMap[string, struct{}]()
- caps := templates.Capabilities{
- Headless: store.config.ExecutorOptions.Options.Headless,
- Code: store.config.ExecutorOptions.Options.EnableCodeTemplates,
- DAST: store.config.ExecutorOptions.Options.DAST,
- SelfContained: store.config.ExecutorOptions.Options.EnableSelfContainedTemplates,
- File: store.config.ExecutorOptions.Options.EnableFileTemplates,
- }
+ caps := templates.CapabilitiesFromOptions(store.config.ExecutorOptions.Options)
isListOrDisplay := store.config.ExecutorOptions.Options.TemplateList ||
store.config.ExecutorOptions.Options.TemplateDisplay
@@ -565,8 +559,11 @@ func (store *Store) LoadTemplatesOnlyMetadata() error {
continue
}
- if !isListOrDisplay && !template.IsEnabledFor(caps) {
- continue
+ if !isListOrDisplay {
+ if missingCaps := template.MissingLoadCapabilities(caps); len(missingCaps) > 0 {
+ store.noteMissingCapabilities(templatePath, missingCaps)
+ continue
+ }
}
if loadedTemplateIDs.Has(template.ID) {
@@ -582,6 +579,15 @@ func (store *Store) LoadTemplatesOnlyMetadata() error {
return nil
}
+func (store *Store) noteMissingCapabilities(templatePath string, missingCaps []templates.Capability) {
+ for _, capability := range missingCaps {
+ stats.Increment(capability.Stat())
+ if config.DefaultConfig.LogAllEvents {
+ store.logger.Warning().Msg(capability.MissingFlagMessage(templatePath))
+ }
+ }
+}
+
// ValidateTemplates takes a list of templates and validates them
// erroring out on discovering any faulty templates.
func (store *Store) ValidateTemplates() error {
@@ -801,13 +807,15 @@ func (store *Store) LoadTemplatesWithTags(templatesList, tags []string) ([]*temp
return
}
- stats.Increment(templates.TemplatesExcludedStats)
+ stats.Increment(templates.ExcludedWeakMatcherTemplateStats)
if config.DefaultConfig.LogAllEvents {
- store.logger.Print().Msgf("[%v] %v excluded from default run using .nuclei-ignore\n", aurora.Yellow("WRN").String(), templatePath)
+ store.logger.Warning().Msgf("%v excluded from default run using .nuclei-ignore", templatePath)
}
}
typesOpts := store.config.ExecutorOptions.Options
+ caps := templates.CapabilitiesFromOptions(typesOpts)
+
concurrency := typesOpts.TemplateLoadingConcurrency
if concurrency <= 0 {
concurrency = types.DefaultTemplateLoadingConcurrency
@@ -872,70 +880,46 @@ func (store *Store) LoadTemplatesWithTags(templatesList, tags []string) ([]*temp
if err != nil {
// exclude templates not compatible with offline matching from total runtime warning stats
if !errors.Is(err, templates.ErrIncompatibleWithOfflineMatching) {
- stats.Increment(templates.RuntimeWarningsStats)
+ stats.Increment(templates.TemplateRuntimeWarningStats)
}
store.logger.Warning().Msgf("Could not parse template %s: %s\n", templatePath, err)
} else if parsed != nil {
if !parsed.Verified && typesOpts.DisableUnsignedTemplates {
// skip unverified templates when prompted to
- stats.Increment(templates.SkippedUnsignedStats)
+ stats.Increment(templates.SkippedUnverifiedTemplateStats)
return
}
- if parsed.SelfContained && !typesOpts.EnableSelfContainedTemplates {
- stats.Increment(templates.ExcludedSelfContainedStats)
+ // code-protocol-based templates run arbitrary commands, so an
+ // unsigned one must be reported as an unverified code template
+ // before the generic missing-capability gate can classify it as
+ // just missing -code.
+ if parsed.HasCodeRequest() && !parsed.Verified && !parsed.HasWorkflows() {
+ stats.Increment(templates.SkippedUnverifiedCodeTemplateStats)
+ if config.DefaultConfig.LogAllEvents {
+ store.logger.Warning().Msgf("Unverified code template at %q", templatePath)
+ }
return
}
- if parsed.HasFileRequest() && !typesOpts.EnableFileTemplates {
- stats.Increment(templates.ExcludedFileStats)
+ if missingCaps := parsed.MissingLoadCapabilities(caps); len(missingCaps) > 0 {
+ store.noteMissingCapabilities(templatePath, missingCaps)
return
}
// if template has request signature like aws then only signed and verified templates are allowed
if parsed.UsesRequestSignature() && !parsed.Verified {
- stats.Increment(templates.SkippedRequestSignatureStats)
+ stats.Increment(templates.SkippedRequestSignatureTemplateStats)
return
}
+
// DAST only templates
// Skip DAST filter when loading auth templates
if store.ID() != AuthStoreId && typesOpts.DAST {
// check if the template is a DAST template
// also allow global matchers template to be loaded
if parsed.IsFuzzableRequest() || parsed.IsGlobalMatchersTemplate() {
- if parsed.HasHeadlessRequest() && !typesOpts.Headless {
- stats.Increment(templates.ExcludedHeadlessTmplStats)
- if config.DefaultConfig.LogAllEvents {
- store.logger.Print().Msgf("[%v] Headless flag is required for headless template '%s'.\n", aurora.Yellow("WRN").String(), templatePath)
- }
- } else {
- loadTemplate(parsed)
- }
- }
- } else if parsed.HasHeadlessRequest() && !typesOpts.Headless {
- // donot include headless template in final list if headless flag is not set
- stats.Increment(templates.ExcludedHeadlessTmplStats)
- if config.DefaultConfig.LogAllEvents {
- store.logger.Print().Msgf("[%v] Headless flag is required for headless template '%s'.\n", aurora.Yellow("WRN").String(), templatePath)
- }
- } else if parsed.HasCodeRequest() && !typesOpts.EnableCodeTemplates {
- // donot include 'Code' protocol custom template in final list if code flag is not set
- stats.Increment(templates.ExcludedCodeTmplStats)
- if config.DefaultConfig.LogAllEvents {
- store.logger.Print().Msgf("[%v] Code flag is required for code protocol template '%s'.\n", aurora.Yellow("WRN").String(), templatePath)
- }
- } else if parsed.HasCodeRequest() && !parsed.Verified && !parsed.HasWorkflows() {
- // donot include unverified 'Code' protocol custom template in final list
- stats.Increment(templates.SkippedCodeTmplTamperedStats)
- // these will be skipped so increment skip counter
- stats.Increment(templates.SkippedUnsignedStats)
- if config.DefaultConfig.LogAllEvents {
- store.logger.Print().Msgf("[%v] Tampered/Unsigned template at %v.\n", aurora.Yellow("WRN").String(), templatePath)
- }
- } else if parsed.IsFuzzableRequest() && !typesOpts.DAST {
- stats.Increment(templates.ExcludedDastTmplStats)
- if config.DefaultConfig.LogAllEvents {
- store.logger.Print().Msgf("[%v] -dast flag is required for DAST template '%s'.\n", aurora.Yellow("WRN").String(), templatePath)
+ loadTemplate(parsed)
}
} else {
loadTemplate(parsed)
@@ -944,9 +928,9 @@ func (store *Store) LoadTemplatesWithTags(templatesList, tags []string) ([]*temp
}
if err != nil {
if strings.Contains(err.Error(), templates.ErrExcluded.Error()) {
- stats.Increment(templates.TemplatesExcludedStats)
+ stats.Increment(templates.ExcludedWeakMatcherTemplateStats)
if config.DefaultConfig.LogAllEvents {
- store.logger.Print().Msgf("[%v] %v\n", aurora.Yellow("WRN").String(), err.Error())
+ store.logger.Warning().Msg(err.Error())
}
return
}
diff --git a/pkg/catalog/loader/loader_test.go b/pkg/catalog/loader/loader_test.go
index fb77fae4d6..4850481009 100644
--- a/pkg/catalog/loader/loader_test.go
+++ b/pkg/catalog/loader/loader_test.go
@@ -1,11 +1,18 @@
package loader
import (
+ "os"
+ "path/filepath"
"reflect"
"testing"
+ "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/disk"
+ "github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow"
+ "github.com/projectdiscovery/nuclei/v3/pkg/templates"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
"github.com/stretchr/testify/require"
)
@@ -101,3 +108,101 @@ func TestRemoteTemplates(t *testing.T) {
})
}
}
+
+func TestLoadTemplatesRecordsUnsignedCodeTemplateOnlyAsCodeSkip(t *testing.T) {
+ templatePath := filepath.Join(t.TempDir(), "unsigned-code.yaml")
+ err := os.WriteFile(templatePath, []byte(`id: unsigned-code-template
+
+info:
+ name: Unsigned Code Template
+ author: pdteam
+ severity: info
+
+code:
+ - engine:
+ - sh
+ source: |
+ echo unsigned-code-template
+`), 0o600)
+ require.NoError(t, err)
+
+ options := testutils.DefaultOptions.Copy()
+ options.Logger = &gologger.Logger{}
+ options.ExecutionId = "loader-unsigned-code-template"
+ options.EnableCodeTemplates = false
+ options.DisableUnsignedTemplates = false
+ options.TemplateLoadingConcurrency = 1
+ testutils.Init(options)
+ t.Cleanup(func() {
+ testutils.Cleanup(options)
+ })
+
+ catalog := disk.NewCatalog("")
+ executerOpts := testutils.NewMockExecuterOptions(options, nil)
+ executerOpts.Catalog = catalog
+ executerOpts.Parser = templates.NewParser()
+ executerOpts.Logger = options.Logger
+
+ workflowLoader, err := workflow.NewLoader(executerOpts)
+ require.NoError(t, err)
+ executerOpts.WorkflowLoader = workflowLoader
+
+ store, err := New(NewConfig(options, catalog, executerOpts))
+ require.NoError(t, err)
+
+ initialUnverifiedCode := stats.GetValue(templates.SkippedUnverifiedCodeTemplateStats)
+ initialUnverified := stats.GetValue(templates.SkippedUnverifiedTemplateStats)
+
+ loaded, err := store.LoadTemplates([]string{templatePath})
+ require.NoError(t, err)
+ require.Empty(t, loaded)
+ require.Equal(t, initialUnverifiedCode+1, stats.GetValue(templates.SkippedUnverifiedCodeTemplateStats))
+ require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats))
+}
+
+func TestLoadTemplatesDoesNotRequireGlobalMatchersFlagToLoadTemplate(t *testing.T) {
+ templatePath := filepath.Join(t.TempDir(), "global-matchers.yaml")
+ err := os.WriteFile(templatePath, []byte(`id: global-matchers-template
+
+info:
+ name: Global Matchers Template
+ author: pdteam
+ severity: info
+
+http:
+ - global-matchers: true
+ matchers:
+ - type: word
+ words:
+ - global-matchers-template
+`), 0o600)
+ require.NoError(t, err)
+
+ options := testutils.DefaultOptions.Copy()
+ options.Logger = &gologger.Logger{}
+ options.ExecutionId = "loader-global-matchers-template"
+ options.EnableGlobalMatchersTemplates = false
+ options.TemplateLoadingConcurrency = 1
+ testutils.Init(options)
+ t.Cleanup(func() {
+ testutils.Cleanup(options)
+ })
+
+ catalog := disk.NewCatalog("")
+ executerOpts := testutils.NewMockExecuterOptions(options, nil)
+ executerOpts.Catalog = catalog
+ executerOpts.Parser = templates.NewParser()
+ executerOpts.Logger = options.Logger
+
+ workflowLoader, err := workflow.NewLoader(executerOpts)
+ require.NoError(t, err)
+ executerOpts.WorkflowLoader = workflowLoader
+
+ store, err := New(NewConfig(options, catalog, executerOpts))
+ require.NoError(t, err)
+
+ loaded, err := store.LoadTemplates([]string{templatePath})
+ require.NoError(t, err)
+ require.Len(t, loaded, 1)
+ require.Equal(t, "global-matchers-template", loaded[0].ID)
+}
diff --git a/pkg/external/customtemplates/github.go b/pkg/external/customtemplates/github.go
index 372f6479db..d25f2b5a5c 100644
--- a/pkg/external/customtemplates/github.go
+++ b/pkg/external/customtemplates/github.go
@@ -8,7 +8,7 @@ import (
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport/http"
- "github.com/google/go-github/github"
+ "github.com/google/go-github/v30/github"
"github.com/pkg/errors"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
diff --git a/pkg/fuzz/dataformat/json.go b/pkg/fuzz/dataformat/json.go
index 5b6a1b95fa..11a2d0ff3c 100644
--- a/pkg/fuzz/dataformat/json.go
+++ b/pkg/fuzz/dataformat/json.go
@@ -3,7 +3,7 @@ package dataformat
import (
"strings"
- jsoniter "github.com/json-iterator/go"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
)
// JSON is a JSON encoder
@@ -31,14 +31,14 @@ func (j *JSON) IsType(data string) bool {
// Encode encodes the data into JSON format
func (j *JSON) Encode(data KV) (string, error) {
- encoded, err := jsoniter.Marshal(data.Map)
+ encoded, err := json.Marshal(data.Map)
return string(encoded), err
}
// Decode decodes the data from JSON format
func (j *JSON) Decode(data string) (KV, error) {
var decoded map[string]interface{}
- err := jsoniter.Unmarshal([]byte(data), &decoded)
+ err := json.Unmarshal([]byte(data), &decoded)
return KVMap(decoded), err
}
diff --git a/pkg/fuzz/frequency/tracker.go b/pkg/fuzz/frequency/tracker.go
index 03ffa35723..6aecda37bb 100644
--- a/pkg/fuzz/frequency/tracker.go
+++ b/pkg/fuzz/frequency/tracker.go
@@ -8,7 +8,7 @@ import (
"sync"
"sync/atomic"
- "github.com/bluele/gcache"
+ "github.com/projectdiscovery/gcache"
"github.com/projectdiscovery/gologger"
)
@@ -20,7 +20,7 @@ import (
// This is used to reduce the number of requests made during fuzzing
// for parameters that are less likely to give results for a rule.
type Tracker struct {
- frequencies gcache.Cache
+ frequencies gcache.Cache[string, *cacheItem]
paramOccurrenceThreshold int
isDebug bool
@@ -39,7 +39,7 @@ type cacheItem struct {
// New creates a new frequency tracker with a given maximum
// number of params to track in LRU fashion with a max error threshold
func New(maxTrackCount, paramOccurrenceThreshold int) *Tracker {
- gc := gcache.New(maxTrackCount).ARC().Build()
+ gc := gcache.New[string, *cacheItem](maxTrackCount).ARC().Build()
var isDebug bool
if os.Getenv("FREQ_DEBUG") != "" {
@@ -75,10 +75,9 @@ func (t *Tracker) MarkParameter(parameter, target, template string) {
_ = t.frequencies.Set(key, newItem)
return
}
- existingCacheItemValue := existingCacheItem.(*cacheItem)
- existingCacheItemValue.errors.Add(1)
+ existingCacheItem.errors.Add(1)
- _ = t.frequencies.Set(key, existingCacheItemValue)
+ _ = t.frequencies.Set(key, existingCacheItem)
}
// IsParameterFrequent checks if a parameter is frequently occurring
@@ -92,14 +91,13 @@ func (t *Tracker) IsParameterFrequent(parameter, target, template string) bool {
}
existingCacheItem, err := t.frequencies.GetIFPresent(key)
- if err != nil {
+ if err != nil || existingCacheItem == nil {
return false
}
- existingCacheItemValue := existingCacheItem.(*cacheItem)
- if existingCacheItemValue.errors.Load() >= int32(t.paramOccurrenceThreshold) {
- existingCacheItemValue.Do(func() {
- gologger.Verbose().Msgf("[%s] Skipped %s from parameter for %s as found uninteresting %d times", template, parameter, target, existingCacheItemValue.errors.Load())
+ if existingCacheItem.errors.Load() >= int32(t.paramOccurrenceThreshold) {
+ existingCacheItem.Do(func() {
+ gologger.Verbose().Msgf("[%s] Skipped %s from parameter for %s as found uninteresting %d times", template, parameter, target, existingCacheItem.errors.Load())
})
return true
}
diff --git a/pkg/fuzz/type.go b/pkg/fuzz/type.go
index d9c3beb77e..94e55aa496 100644
--- a/pkg/fuzz/type.go
+++ b/pkg/fuzz/type.go
@@ -5,8 +5,8 @@ import (
"github.com/invopop/jsonschema"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
mapsutil "github.com/projectdiscovery/utils/maps"
- "gopkg.in/yaml.v2"
)
var (
diff --git a/pkg/fuzz/type_test.go b/pkg/fuzz/type_test.go
new file mode 100644
index 0000000000..b0d6d1e969
--- /dev/null
+++ b/pkg/fuzz/type_test.go
@@ -0,0 +1,36 @@
+package fuzz
+
+import (
+ "testing"
+
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSliceOrMapSliceUnmarshalYAMLSequence(t *testing.T) {
+ var value SliceOrMapSlice
+
+ err := yaml.Unmarshal([]byte("- first\n- second\n"), &value)
+ require.NoError(t, err)
+ require.Equal(t, []string{"first", "second"}, value.Value)
+ require.Nil(t, value.KV)
+}
+
+func TestSliceOrMapSliceUnmarshalYAMLMapPreservesOrder(t *testing.T) {
+ var value SliceOrMapSlice
+
+ err := yaml.Unmarshal([]byte("first: one\nsecond: two\nthird: three\n"), &value)
+ require.NoError(t, err)
+ require.NotNil(t, value.KV)
+
+ keys := make([]string, 0, 3)
+ values := make([]string, 0, 3)
+ value.KV.Iterate(func(key, value string) bool {
+ keys = append(keys, key)
+ values = append(values, value)
+ return true
+ })
+
+ require.Equal(t, []string{"first", "second", "third"}, keys)
+ require.Equal(t, []string{"one", "two", "three"}, values)
+}
diff --git a/pkg/input/formats/openapi/generator.go b/pkg/input/formats/openapi/generator.go
index 4362862566..1fd33d3635 100644
--- a/pkg/input/formats/openapi/generator.go
+++ b/pkg/input/formats/openapi/generator.go
@@ -14,6 +14,7 @@ import (
"github.com/clbanning/mxj/v2"
"github.com/getkin/kin-openapi/openapi3"
"github.com/pkg/errors"
+ "github.com/projectdiscovery/fasttemplate"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
@@ -23,7 +24,6 @@ import (
"github.com/projectdiscovery/utils/errkit"
"github.com/projectdiscovery/utils/generic"
mapsutil "github.com/projectdiscovery/utils/maps"
- "github.com/valyala/fasttemplate"
)
const (
diff --git a/pkg/input/formats/swagger/swagger.go b/pkg/input/formats/swagger/swagger.go
index e33ae931ce..ab5556f680 100644
--- a/pkg/input/formats/swagger/swagger.go
+++ b/pkg/input/formats/swagger/swagger.go
@@ -1,18 +1,18 @@
package swagger
import (
+ "fmt"
"io"
"path"
"github.com/getkin/kin-openapi/openapi2"
"github.com/getkin/kin-openapi/openapi2conv"
"github.com/getkin/kin-openapi/openapi3"
- "github.com/invopop/yaml"
"github.com/pkg/errors"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/openapi"
-
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
+ "gopkg.in/yaml.v3"
)
// SwaggerFormat is a Swagger Schema File parser
@@ -48,7 +48,7 @@ func (j *SwaggerFormat) Parse(input io.Reader, resultsCb formats.ParseReqRespCal
if err != nil {
return errors.Wrap(err, "could not read data file")
}
- err = yaml.Unmarshal(data, schemav2)
+ err = decodeYAML(data, schemav2)
} else {
err = json.NewDecoder(input).Decode(schemav2)
}
@@ -66,3 +66,38 @@ func (j *SwaggerFormat) Parse(input io.Reader, resultsCb formats.ParseReqRespCal
}
return openapi.GenerateRequestsFromSchema(schema, j.opts, resultsCb)
}
+
+func decodeYAML(data []byte, target interface{}) error {
+ var value interface{}
+ if err := yaml.Unmarshal(data, &value); err != nil {
+ return err
+ }
+
+ jsonData, err := json.Marshal(normalizeYAMLValue(value))
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(jsonData, target)
+}
+
+func normalizeYAMLValue(value interface{}) interface{} {
+ switch value := value.(type) {
+ case map[interface{}]interface{}:
+ normalized := make(map[string]interface{}, len(value))
+ for key, item := range value {
+ normalized[fmt.Sprint(key)] = normalizeYAMLValue(item)
+ }
+ return normalized
+ case map[string]interface{}:
+ normalized := make(map[string]interface{}, len(value))
+ for key, item := range value {
+ normalized[key] = normalizeYAMLValue(item)
+ }
+ return normalized
+ case []interface{}:
+ for i, item := range value {
+ value[i] = normalizeYAMLValue(item)
+ }
+ }
+ return value
+}
diff --git a/pkg/input/formats/yaml/ytt.go b/pkg/input/formats/yaml/ytt.go
index faaf6ccdcc..ad16c1ad0a 100644
--- a/pkg/input/formats/yaml/ytt.go
+++ b/pkg/input/formats/yaml/ytt.go
@@ -7,7 +7,7 @@ import (
yttcmd "carvel.dev/ytt/pkg/cmd/template"
yttui "carvel.dev/ytt/pkg/cmd/ui"
yttfiles "carvel.dev/ytt/pkg/files"
- "gopkg.in/yaml.v2"
+ yamlutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
)
func ytt(tpl, dvs []string, varFiles []string) ([]byte, error) {
@@ -59,7 +59,7 @@ func templatesAsInput(tpl ...string) (yttcmd.Input, error) {
func mapToKeyValueSlice(m map[string]interface{}) []string {
var result []string
for k, v := range m {
- y, _ := yaml.Marshal(v)
+ y, _ := yamlutil.Marshal(v)
result = append(result, fmt.Sprintf("%s=%s", k, strings.TrimSpace(string(y))))
}
return result
diff --git a/pkg/input/types/fuzz.go b/pkg/input/types/fuzz.go
new file mode 100644
index 0000000000..c656fb2445
--- /dev/null
+++ b/pkg/input/types/fuzz.go
@@ -0,0 +1,18 @@
+//go:build gofuzz
+// +build gofuzz
+
+package types
+
+// Fuzz exercises raw HTTP request parsing used by input format ingestion.
+func Fuzz(data []byte) int {
+ if len(data) == 0 {
+ return 0
+ }
+ if len(data) > fuzzMaxInputSize {
+ return -1
+ }
+ if !fuzzRawRequestParsing(data) {
+ return 0
+ }
+ return 1
+}
diff --git a/pkg/input/types/fuzz_harness.go b/pkg/input/types/fuzz_harness.go
new file mode 100644
index 0000000000..5d24892c4a
--- /dev/null
+++ b/pkg/input/types/fuzz_harness.go
@@ -0,0 +1,288 @@
+package types
+
+import (
+ "fmt"
+ "strings"
+)
+
+const (
+ fuzzMaxInputSize = 16 << 10
+ fuzzMaxHeaders = 8
+ fuzzMaxValueBytes = 256
+)
+
+var (
+ fuzzMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"}
+ fuzzPaths = []string{"/", "/api/v1/users", "/login", "/search?q=nuclei", "?debug=true"}
+ fuzzHosts = []string{"example.com", "scanme.sh", "127.0.0.1", "example.com:8080"}
+)
+
+type fuzzHeader struct {
+ key string
+ value string
+}
+
+type fuzzRawRequestCandidate struct {
+ method string
+ path string
+ host string
+ targetURL string
+ headers []fuzzHeader
+ body string
+}
+
+func fuzzRawRequestParsing(data []byte) bool {
+ raw, targetURL, ok := rawRequestFromFuzzData(data)
+ if !ok {
+ return false
+ }
+
+ parsed := false
+ if rr, err := ParseRawRequest(raw); err == nil {
+ exerciseFuzzRequestResponse(rr)
+ parsed = true
+ }
+ if rr, err := ParseRawRequestWithURL(raw, targetURL); err == nil {
+ exerciseFuzzRequestResponse(rr)
+ parsed = true
+ }
+ if rr, err := ParseRawRequest(string(data)); err == nil {
+ exerciseFuzzRequestResponse(rr)
+ parsed = true
+ }
+ if rr, err := ParseRawRequestWithURL(string(data), targetURL); err == nil {
+ exerciseFuzzRequestResponse(rr)
+ parsed = true
+ }
+
+ return parsed
+}
+
+func rawRequestFromFuzzData(data []byte) (string, string, bool) {
+ if len(data) == 0 || len(data) > fuzzMaxInputSize {
+ return "", "", false
+ }
+
+ candidate := newFuzzRawRequestCandidate(data)
+ candidate.applyLines(splitFuzzLines(data))
+ return candidate.build(), candidate.targetURL, true
+}
+
+func newFuzzRawRequestCandidate(data []byte) *fuzzRawRequestCandidate {
+ method := fuzzMethods[int(fuzzByteAt(data, 0))%len(fuzzMethods)]
+ path := fuzzPaths[int(fuzzByteAt(data, 1))%len(fuzzPaths)]
+ host := fuzzHosts[int(fuzzByteAt(data, 2))%len(fuzzHosts)]
+
+ return &fuzzRawRequestCandidate{
+ method: method,
+ path: path,
+ host: host,
+ targetURL: "https://" + host + path,
+ headers: []fuzzHeader{
+ {key: "User-Agent", value: "nuclei-fuzz"},
+ },
+ body: fuzzBody(string(data)),
+ }
+}
+
+func (candidate *fuzzRawRequestCandidate) applyLines(lines []string) {
+ for _, line := range lines {
+ key, value, ok := cutFuzzKV(line)
+ if !ok {
+ candidate.body = fuzzBody(line)
+ continue
+ }
+
+ switch key {
+ case "method":
+ candidate.method = fuzzMethod(value, candidate.method)
+ case "path":
+ candidate.path = fuzzRelativePath(value, candidate.path)
+ case "host":
+ candidate.host = fuzzHost(value, candidate.host)
+ case "url", "target-url":
+ candidate.targetURL = fuzzAbsoluteURL(value, candidate.targetURL)
+ case "header":
+ candidate.addHeader(value)
+ case "body":
+ candidate.body = fuzzBody(value)
+ }
+ }
+}
+
+func (candidate *fuzzRawRequestCandidate) addHeader(value string) {
+ key, headerValue, ok := strings.Cut(value, ":")
+ if !ok {
+ key, headerValue, ok = strings.Cut(value, "=")
+ }
+ if !ok {
+ return
+ }
+
+ key = fuzzHeaderKey(key)
+ if key == "" || strings.EqualFold(key, "Host") || len(candidate.headers) >= fuzzMaxHeaders {
+ return
+ }
+ candidate.headers = append(candidate.headers, fuzzHeader{key: key, value: fuzzHeaderValue(headerValue)})
+}
+
+func (candidate *fuzzRawRequestCandidate) build() string {
+ var builder strings.Builder
+ fmt.Fprintf(&builder, "%s %s HTTP/1.1\r\n", candidate.method, candidate.path)
+ fmt.Fprintf(&builder, "Host: %s\r\n", candidate.host)
+ for _, header := range candidate.headers {
+ fmt.Fprintf(&builder, "%s: %s\r\n", header.key, header.value)
+ }
+ builder.WriteString("\r\n")
+ builder.WriteString(candidate.body)
+ return builder.String()
+}
+
+func exerciseFuzzRequestResponse(rr *RequestResponse) {
+ if rr == nil {
+ panic("nil request response")
+ }
+ if rr.Request == nil {
+ panic("nil parsed request")
+ }
+ _ = rr.Clone()
+ _ = rr.ID()
+ _, _ = rr.MarshalJSON()
+}
+
+func splitFuzzLines(data []byte) []string {
+ fields := strings.FieldsFunc(string(data), func(r rune) bool {
+ return r == '\n' || r == '\r' || r == ';'
+ })
+ if len(fields) > fuzzMaxHeaders*4 {
+ fields = fields[:fuzzMaxHeaders*4]
+ }
+
+ lines := make([]string, 0, len(fields))
+ for _, field := range fields {
+ field = fuzzTrim(field)
+ if field != "" {
+ lines = append(lines, field)
+ }
+ }
+ return lines
+}
+
+func cutFuzzKV(line string) (string, string, bool) {
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ key, value, ok = strings.Cut(line, ":")
+ }
+ if !ok {
+ return "", "", false
+ }
+ return strings.ToLower(fuzzTrim(key)), fuzzTrim(value), true
+}
+
+func fuzzByteAt(data []byte, index int) byte {
+ if len(data) == 0 {
+ return 0
+ }
+ return data[index%len(data)]
+}
+
+func fuzzMethod(value, fallback string) string {
+ value = strings.ToUpper(fuzzToken(value, 16))
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+func fuzzRelativePath(value, fallback string) string {
+ value = fuzzTrim(value)
+ if value == "" {
+ return fallback
+ }
+ if len(value) > fuzzMaxValueBytes {
+ value = value[:fuzzMaxValueBytes]
+ }
+ if strings.HasPrefix(value, "?") || strings.HasPrefix(value, "/") {
+ return value
+ }
+ return "/" + value
+}
+
+func fuzzAbsoluteURL(value, fallback string) string {
+ value = fuzzTrim(value)
+ if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
+ return value
+ }
+ host := fuzzHost(value, "")
+ if host == "" {
+ return fallback
+ }
+ return "https://" + host + "/"
+}
+
+func fuzzHost(value, fallback string) string {
+ value = strings.ToLower(fuzzTrim(value))
+ var builder strings.Builder
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ builder.WriteRune(r)
+ case r >= '0' && r <= '9':
+ builder.WriteRune(r)
+ case r == '.' || r == '-' || r == ':':
+ builder.WriteRune(r)
+ }
+ if builder.Len() >= 128 {
+ break
+ }
+ }
+ if builder.Len() == 0 {
+ return fallback
+ }
+ return builder.String()
+}
+
+func fuzzHeaderKey(value string) string {
+ return fuzzToken(value, 64)
+}
+
+func fuzzHeaderValue(value string) string {
+ return fuzzTrim(value)
+}
+
+func fuzzBody(value string) string {
+ value = strings.ReplaceAll(value, "\x00", "")
+ if len(value) > fuzzMaxValueBytes {
+ value = value[:fuzzMaxValueBytes]
+ }
+ return value
+}
+
+func fuzzToken(value string, limit int) string {
+ value = fuzzTrim(value)
+ var builder strings.Builder
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ builder.WriteRune(r - 'a' + 'A')
+ case r >= 'A' && r <= 'Z':
+ builder.WriteRune(r)
+ case r >= '0' && r <= '9':
+ builder.WriteRune(r)
+ case r == '-':
+ builder.WriteRune(r)
+ }
+ if builder.Len() >= limit {
+ break
+ }
+ }
+ return builder.String()
+}
+
+func fuzzTrim(value string) string {
+ value = strings.TrimSpace(strings.NewReplacer("\x00", "", "\r", " ", "\n", " ").Replace(value))
+ if len(value) > fuzzMaxValueBytes {
+ value = value[:fuzzMaxValueBytes]
+ }
+ return value
+}
diff --git a/pkg/input/types/fuzz_harness_test.go b/pkg/input/types/fuzz_harness_test.go
new file mode 100644
index 0000000000..1953b6bbc6
--- /dev/null
+++ b/pkg/input/types/fuzz_harness_test.go
@@ -0,0 +1,48 @@
+package types
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestRawRequestFromFuzzDataSeedCorpus(t *testing.T) {
+ entries, err := os.ReadDir("testdata/gofuzz-corpus")
+ require.NoError(t, err)
+ require.NotEmpty(t, entries)
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ path := filepath.Join("testdata/gofuzz-corpus", entry.Name())
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ require.Truef(t, fuzzRawRequestParsing(data), "seed %s should exercise the raw request parser fuzz path", entry.Name())
+
+ raw, targetURL, ok := rawRequestFromFuzzData(data)
+ require.Truef(t, ok, "seed %s should decode into a raw request", entry.Name())
+ require.NotEmpty(t, raw)
+ require.NotEmpty(t, targetURL)
+
+ rr, err := ParseRawRequest(raw)
+ require.NoErrorf(t, err, "seed %s generated raw request should parse", entry.Name())
+ exerciseFuzzRequestResponse(rr)
+
+ rr, err = ParseRawRequestWithURL(raw, targetURL)
+ require.NoErrorf(t, err, "seed %s generated raw request should parse with URL", entry.Name())
+ exerciseFuzzRequestResponse(rr)
+ }
+}
+
+func TestRawRequestFromFuzzDataRejectsOversizeInput(t *testing.T) {
+ data := make([]byte, fuzzMaxInputSize+1)
+ raw, targetURL, ok := rawRequestFromFuzzData(data)
+ require.False(t, ok)
+ require.Empty(t, raw)
+ require.Empty(t, targetURL)
+}
diff --git a/pkg/input/types/testdata/gofuzz-corpus/burp-post.seed b/pkg/input/types/testdata/gofuzz-corpus/burp-post.seed
new file mode 100644
index 0000000000..026453e248
--- /dev/null
+++ b/pkg/input/types/testdata/gofuzz-corpus/burp-post.seed
@@ -0,0 +1,6 @@
+method=POST
+path=/submit?debug=true
+host=127.0.0.1:8080
+header=Origin: https://example.com
+header=Content-Type: application/x-www-form-urlencoded
+body=username=admin&password=login
diff --git a/pkg/input/types/testdata/gofuzz-corpus/get.seed b/pkg/input/types/testdata/gofuzz-corpus/get.seed
new file mode 100644
index 0000000000..0af12a7693
--- /dev/null
+++ b/pkg/input/types/testdata/gofuzz-corpus/get.seed
@@ -0,0 +1,4 @@
+method=GET
+path=/api/v1/users?id=1
+host=example.com
+header=Accept: application/json
diff --git a/pkg/input/types/testdata/gofuzz-corpus/json-body.seed b/pkg/input/types/testdata/gofuzz-corpus/json-body.seed
new file mode 100644
index 0000000000..7b27983689
--- /dev/null
+++ b/pkg/input/types/testdata/gofuzz-corpus/json-body.seed
@@ -0,0 +1,5 @@
+method=POST
+path=/api/v1/login
+host=scanme.sh
+header=Content-Type: application/json
+body={"username":"admin","password":"admin"}
diff --git a/pkg/input/types/testdata/gofuzz-corpus/url-override.seed b/pkg/input/types/testdata/gofuzz-corpus/url-override.seed
new file mode 100644
index 0000000000..a748ea1895
--- /dev/null
+++ b/pkg/input/types/testdata/gofuzz-corpus/url-override.seed
@@ -0,0 +1,6 @@
+method=PUT
+path=/openapi/generated
+host=api.example.com
+url=https://target.example.org/base
+header=X-Request-ID: fuzz
+body={"ok":true}
diff --git a/pkg/js/compiler/compiler.go b/pkg/js/compiler/compiler.go
index 5ba6646760..177967b33e 100644
--- a/pkg/js/compiler/compiler.go
+++ b/pkg/js/compiler/compiler.go
@@ -5,7 +5,7 @@ import (
"context"
"fmt"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/utils/errkit"
stringsutil "github.com/projectdiscovery/utils/strings"
diff --git a/pkg/js/compiler/compiler_test.go b/pkg/js/compiler/compiler_test.go
index 9b2219ae6c..5603da9f6c 100644
--- a/pkg/js/compiler/compiler_test.go
+++ b/pkg/js/compiler/compiler_test.go
@@ -11,7 +11,7 @@ import (
"testing"
"time"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/gologger/levels"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
diff --git a/pkg/js/compiler/non-pool.go b/pkg/js/compiler/non-pool.go
index 75218e056d..92fa73fe87 100644
--- a/pkg/js/compiler/non-pool.go
+++ b/pkg/js/compiler/non-pool.go
@@ -4,7 +4,7 @@ import (
"context"
"sync"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
syncutil "github.com/projectdiscovery/utils/sync"
)
diff --git a/pkg/js/compiler/pool.go b/pkg/js/compiler/pool.go
index 6648a3711f..d9f38e2be9 100644
--- a/pkg/js/compiler/pool.go
+++ b/pkg/js/compiler/pool.go
@@ -8,9 +8,9 @@ import (
"reflect"
"sync"
- "github.com/Mzack9999/goja"
- "github.com/Mzack9999/goja_nodejs/console"
- "github.com/Mzack9999/goja_nodejs/require"
+ "github.com/projectdiscovery/goja"
+ "github.com/projectdiscovery/goja_nodejs/console"
+ "github.com/projectdiscovery/goja_nodejs/require"
"github.com/projectdiscovery/gologger"
stringsutil "github.com/projectdiscovery/utils/strings"
syncutil "github.com/projectdiscovery/utils/sync"
diff --git a/pkg/js/compiler/session.go b/pkg/js/compiler/session.go
index 01ba70aea1..f5679d524e 100644
--- a/pkg/js/compiler/session.go
+++ b/pkg/js/compiler/session.go
@@ -5,7 +5,7 @@ import (
"fmt"
"time"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
)
// sessionState represents the various states a session can be in during its lifecycle.
diff --git a/pkg/js/generated/go/libbytes/bytes.go b/pkg/js/generated/go/libbytes/bytes.go
index 882bedc42d..5559462dbd 100644
--- a/pkg/js/generated/go/libbytes/bytes.go
+++ b/pkg/js/generated/go/libbytes/bytes.go
@@ -3,7 +3,7 @@ package bytes
import (
lib_bytes "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/bytes"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libdcerpc/dcerpc.go b/pkg/js/generated/go/libdcerpc/dcerpc.go
index c5693f0c58..54254f4f27 100644
--- a/pkg/js/generated/go/libdcerpc/dcerpc.go
+++ b/pkg/js/generated/go/libdcerpc/dcerpc.go
@@ -3,7 +3,7 @@ package dcerpc
import (
lib_dcerpc "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/dcerpc"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libdcom/dcom.go b/pkg/js/generated/go/libdcom/dcom.go
index 99fb18bd20..394b329dbe 100644
--- a/pkg/js/generated/go/libdcom/dcom.go
+++ b/pkg/js/generated/go/libdcom/dcom.go
@@ -3,7 +3,7 @@ package dcom
import (
lib_dcom "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/dcom"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libfs/fs.go b/pkg/js/generated/go/libfs/fs.go
index fd1cd76cd9..a53e7e91a4 100644
--- a/pkg/js/generated/go/libfs/fs.go
+++ b/pkg/js/generated/go/libfs/fs.go
@@ -3,7 +3,7 @@ package fs
import (
lib_fs "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/fs"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libgoconsole/goconsole.go b/pkg/js/generated/go/libgoconsole/goconsole.go
index 8f218c216e..dab36e3860 100644
--- a/pkg/js/generated/go/libgoconsole/goconsole.go
+++ b/pkg/js/generated/go/libgoconsole/goconsole.go
@@ -3,7 +3,7 @@ package goconsole
import (
lib_goconsole "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/goconsole"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libikev2/ikev2.go b/pkg/js/generated/go/libikev2/ikev2.go
index 453ffaa9c6..7a3f59523c 100644
--- a/pkg/js/generated/go/libikev2/ikev2.go
+++ b/pkg/js/generated/go/libikev2/ikev2.go
@@ -3,7 +3,7 @@ package ikev2
import (
lib_ikev2 "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/ikev2"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libkerberos/kerberos.go b/pkg/js/generated/go/libkerberos/kerberos.go
index 66701c2efc..144f79a060 100644
--- a/pkg/js/generated/go/libkerberos/kerberos.go
+++ b/pkg/js/generated/go/libkerberos/kerberos.go
@@ -3,7 +3,7 @@ package kerberos
import (
lib_kerberos "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/kerberos"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libkrbforge/krbforge.go b/pkg/js/generated/go/libkrbforge/krbforge.go
index 56fa35542a..e8d17f224c 100644
--- a/pkg/js/generated/go/libkrbforge/krbforge.go
+++ b/pkg/js/generated/go/libkrbforge/krbforge.go
@@ -3,7 +3,7 @@ package krbforge
import (
lib_krbforge "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/krbforge"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libkrbroast/krbroast.go b/pkg/js/generated/go/libkrbroast/krbroast.go
index 5892868ee6..4df823aa65 100644
--- a/pkg/js/generated/go/libkrbroast/krbroast.go
+++ b/pkg/js/generated/go/libkrbroast/krbroast.go
@@ -3,7 +3,7 @@ package krbroast
import (
lib_krbroast "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/krbroast"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libldap/ldap.go b/pkg/js/generated/go/libldap/ldap.go
index b0c8de6f3d..da1b6b42f5 100644
--- a/pkg/js/generated/go/libldap/ldap.go
+++ b/pkg/js/generated/go/libldap/ldap.go
@@ -3,7 +3,7 @@ package ldap
import (
lib_ldap "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/ldap"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libmssql/mssql.go b/pkg/js/generated/go/libmssql/mssql.go
index 252fff6bcb..1248819148 100644
--- a/pkg/js/generated/go/libmssql/mssql.go
+++ b/pkg/js/generated/go/libmssql/mssql.go
@@ -3,7 +3,7 @@ package mssql
import (
lib_mssql "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/mssql"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libmysql/mysql.go b/pkg/js/generated/go/libmysql/mysql.go
index b4fa3723ed..48549d7ecf 100644
--- a/pkg/js/generated/go/libmysql/mysql.go
+++ b/pkg/js/generated/go/libmysql/mysql.go
@@ -3,7 +3,7 @@ package mysql
import (
lib_mysql "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/mysql"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libnet/net.go b/pkg/js/generated/go/libnet/net.go
index dd9f5e8b38..c056a9ff8d 100644
--- a/pkg/js/generated/go/libnet/net.go
+++ b/pkg/js/generated/go/libnet/net.go
@@ -3,7 +3,7 @@ package net
import (
lib_net "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/net"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/liboracle/oracle.go b/pkg/js/generated/go/liboracle/oracle.go
index d579c34740..e0d7a54740 100644
--- a/pkg/js/generated/go/liboracle/oracle.go
+++ b/pkg/js/generated/go/liboracle/oracle.go
@@ -3,7 +3,7 @@ package oracle
import (
lib_oracle "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/oracle"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libpop3/pop3.go b/pkg/js/generated/go/libpop3/pop3.go
index 6c51c51bf1..0325c09f95 100644
--- a/pkg/js/generated/go/libpop3/pop3.go
+++ b/pkg/js/generated/go/libpop3/pop3.go
@@ -3,7 +3,7 @@ package pop3
import (
lib_pop3 "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/pop3"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libpostgres/postgres.go b/pkg/js/generated/go/libpostgres/postgres.go
index 7d931f2bed..592be64aad 100644
--- a/pkg/js/generated/go/libpostgres/postgres.go
+++ b/pkg/js/generated/go/libpostgres/postgres.go
@@ -3,7 +3,7 @@ package postgres
import (
lib_postgres "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/postgres"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/librdp/rdp.go b/pkg/js/generated/go/librdp/rdp.go
index f8ff4bf975..7abde015f1 100644
--- a/pkg/js/generated/go/librdp/rdp.go
+++ b/pkg/js/generated/go/librdp/rdp.go
@@ -3,7 +3,7 @@ package rdp
import (
lib_rdp "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/rdp"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libredis/redis.go b/pkg/js/generated/go/libredis/redis.go
index 81f997337e..77f10adc4e 100644
--- a/pkg/js/generated/go/libredis/redis.go
+++ b/pkg/js/generated/go/libredis/redis.go
@@ -3,7 +3,7 @@ package redis
import (
lib_redis "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/redis"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/librsync/rsync.go b/pkg/js/generated/go/librsync/rsync.go
index 7d1b71b8ee..568bfcab2d 100644
--- a/pkg/js/generated/go/librsync/rsync.go
+++ b/pkg/js/generated/go/librsync/rsync.go
@@ -3,7 +3,7 @@ package rsync
import (
lib_rsync "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/rsync"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libscmr/scmr.go b/pkg/js/generated/go/libscmr/scmr.go
index c6f756402b..927d496c56 100644
--- a/pkg/js/generated/go/libscmr/scmr.go
+++ b/pkg/js/generated/go/libscmr/scmr.go
@@ -3,7 +3,7 @@ package scmr
import (
lib_scmr "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/scmr"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libsecretsdump/secretsdump.go b/pkg/js/generated/go/libsecretsdump/secretsdump.go
index a6791359b5..9ecbabaf72 100644
--- a/pkg/js/generated/go/libsecretsdump/secretsdump.go
+++ b/pkg/js/generated/go/libsecretsdump/secretsdump.go
@@ -3,7 +3,7 @@ package secretsdump
import (
lib_secretsdump "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/secretsdump"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libsmb/smb.go b/pkg/js/generated/go/libsmb/smb.go
index 7218355119..f6cbff1938 100644
--- a/pkg/js/generated/go/libsmb/smb.go
+++ b/pkg/js/generated/go/libsmb/smb.go
@@ -3,7 +3,7 @@ package smb
import (
lib_smb "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/smb"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libsmtp/smtp.go b/pkg/js/generated/go/libsmtp/smtp.go
index b17e26004a..9987dfad27 100644
--- a/pkg/js/generated/go/libsmtp/smtp.go
+++ b/pkg/js/generated/go/libsmtp/smtp.go
@@ -3,7 +3,7 @@ package smtp
import (
lib_smtp "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/smtp"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libssh/ssh.go b/pkg/js/generated/go/libssh/ssh.go
index e71eeffe45..672559426c 100644
--- a/pkg/js/generated/go/libssh/ssh.go
+++ b/pkg/js/generated/go/libssh/ssh.go
@@ -3,7 +3,7 @@ package ssh
import (
lib_ssh "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/ssh"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libstructs/structs.go b/pkg/js/generated/go/libstructs/structs.go
index a817bb3352..7cc375d174 100644
--- a/pkg/js/generated/go/libstructs/structs.go
+++ b/pkg/js/generated/go/libstructs/structs.go
@@ -3,7 +3,7 @@ package structs
import (
lib_structs "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/structs"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libtelnet/telnet.go b/pkg/js/generated/go/libtelnet/telnet.go
index 45672b4422..0c3672992e 100644
--- a/pkg/js/generated/go/libtelnet/telnet.go
+++ b/pkg/js/generated/go/libtelnet/telnet.go
@@ -3,7 +3,7 @@ package telnet
import (
lib_telnet "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/telnet"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libtsch/tsch.go b/pkg/js/generated/go/libtsch/tsch.go
index 7a73374e11..7d16078102 100644
--- a/pkg/js/generated/go/libtsch/tsch.go
+++ b/pkg/js/generated/go/libtsch/tsch.go
@@ -3,7 +3,7 @@ package tsch
import (
lib_tsch "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/tsch"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libvnc/vnc.go b/pkg/js/generated/go/libvnc/vnc.go
index fa060ec144..143808ea2d 100644
--- a/pkg/js/generated/go/libvnc/vnc.go
+++ b/pkg/js/generated/go/libvnc/vnc.go
@@ -3,7 +3,7 @@ package vnc
import (
lib_vnc "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/vnc"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/generated/go/libwmi/wmi.go b/pkg/js/generated/go/libwmi/wmi.go
index 5d7e9f6f13..1beb0ad2f7 100644
--- a/pkg/js/generated/go/libwmi/wmi.go
+++ b/pkg/js/generated/go/libwmi/wmi.go
@@ -3,7 +3,7 @@ package wmi
import (
lib_wmi "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/wmi"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/global/helpers.go b/pkg/js/global/helpers.go
index a51e3a4688..7c9a46d8cd 100644
--- a/pkg/js/global/helpers.go
+++ b/pkg/js/global/helpers.go
@@ -3,7 +3,7 @@ package global
import (
"encoding/base64"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
diff --git a/pkg/js/global/scripts.go b/pkg/js/global/scripts.go
index 8203f02f57..72f35be28d 100644
--- a/pkg/js/global/scripts.go
+++ b/pkg/js/global/scripts.go
@@ -10,7 +10,7 @@ import (
"reflect"
"time"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/logrusorgru/aurora/v4"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
diff --git a/pkg/js/global/scripts_test.go b/pkg/js/global/scripts_test.go
index 1b721da630..1f893b10c1 100644
--- a/pkg/js/global/scripts_test.go
+++ b/pkg/js/global/scripts_test.go
@@ -3,9 +3,9 @@ package global
import (
"testing"
- "github.com/Mzack9999/goja"
- "github.com/Mzack9999/goja_nodejs/console"
- "github.com/Mzack9999/goja_nodejs/require"
+ "github.com/projectdiscovery/goja"
+ "github.com/projectdiscovery/goja_nodejs/console"
+ "github.com/projectdiscovery/goja_nodejs/require"
)
func TestScriptsRuntime(t *testing.T) {
diff --git a/pkg/js/gojs/gojs.go b/pkg/js/gojs/gojs.go
index f24413efbf..995cd8eb3e 100644
--- a/pkg/js/gojs/gojs.go
+++ b/pkg/js/gojs/gojs.go
@@ -6,8 +6,8 @@ import (
"reflect"
"sync"
- "github.com/Mzack9999/goja"
- "github.com/Mzack9999/goja_nodejs/require"
+ "github.com/projectdiscovery/goja"
+ "github.com/projectdiscovery/goja_nodejs/require"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
)
diff --git a/pkg/js/gojs/set.go b/pkg/js/gojs/set.go
index aa1afd79b4..6630455272 100644
--- a/pkg/js/gojs/set.go
+++ b/pkg/js/gojs/set.go
@@ -4,7 +4,7 @@ import (
"context"
"reflect"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/utils/errkit"
)
diff --git a/pkg/js/libs/bytes/buffer.go b/pkg/js/libs/bytes/buffer.go
index 87a5f5cd14..64b9ab4046 100644
--- a/pkg/js/libs/bytes/buffer.go
+++ b/pkg/js/libs/bytes/buffer.go
@@ -3,7 +3,7 @@ package bytes
import (
"encoding/hex"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/libs/structs"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
)
diff --git a/pkg/js/libs/dcerpc/dcerpc.go b/pkg/js/libs/dcerpc/dcerpc.go
index 39b8ee95ec..9183d758fa 100644
--- a/pkg/js/libs/dcerpc/dcerpc.go
+++ b/pkg/js/libs/dcerpc/dcerpc.go
@@ -26,7 +26,7 @@ import (
gpsession "github.com/Mzack9999/goimpacket/pkg/session"
gpsmb "github.com/Mzack9999/goimpacket/pkg/smb"
gpsmbexec "github.com/Mzack9999/goimpacket/pkg/smbexec"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
diff --git a/pkg/js/libs/dcom/dcom.go b/pkg/js/libs/dcom/dcom.go
index 0725e9783c..9ec90a2fc5 100644
--- a/pkg/js/libs/dcom/dcom.go
+++ b/pkg/js/libs/dcom/dcom.go
@@ -1,7 +1,7 @@
package dcom
import (
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/libs/goexec"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
)
diff --git a/pkg/js/libs/goconsole/log.go b/pkg/js/libs/goconsole/log.go
index e5b16f8d77..097fdc4e77 100644
--- a/pkg/js/libs/goconsole/log.go
+++ b/pkg/js/libs/goconsole/log.go
@@ -1,7 +1,7 @@
package goconsole
import (
- "github.com/Mzack9999/goja_nodejs/console"
+ "github.com/projectdiscovery/goja_nodejs/console"
"github.com/projectdiscovery/gologger"
)
diff --git a/pkg/js/libs/kerberos/kerberosx.go b/pkg/js/libs/kerberos/kerberosx.go
index c049f10243..3e3b503238 100644
--- a/pkg/js/libs/kerberos/kerberosx.go
+++ b/pkg/js/libs/kerberos/kerberosx.go
@@ -3,7 +3,7 @@ package kerberos
import (
"strings"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
kclient "github.com/jcmturner/gokrb5/v8/client"
kconfig "github.com/jcmturner/gokrb5/v8/config"
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
diff --git a/pkg/js/libs/krbforge/krbforge.go b/pkg/js/libs/krbforge/krbforge.go
index 8d6963e3bb..6125a23a80 100644
--- a/pkg/js/libs/krbforge/krbforge.go
+++ b/pkg/js/libs/krbforge/krbforge.go
@@ -13,6 +13,12 @@ import (
"path/filepath"
gpkrb "github.com/Mzack9999/goimpacket/pkg/kerberos"
+ "github.com/projectdiscovery/goja"
+ "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
+ "github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
+ "github.com/projectdiscovery/nuclei/v3/pkg/types"
+ filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
)
// TicketRequest mirrors gopacket's TicketConfig with json-friendly tags.
@@ -43,34 +49,52 @@ type Ticket struct {
// CreateGoldenTicket forges a TGT for the supplied user against the given
// realm using the krbtgt NT hash (or AES key). It returns the ASN.1-encoded
// ticket and the session key. If req.OutputFile is empty no file is written;
-// pass an absolute path to also persist a ccache.
+// pass an output path allowed by the nuclei file sandbox to also persist a
+// ccache. Use -allow-local-file-access to allow writing outside the sandbox.
//
// @example
// ```javascript
// const krb = require('nuclei/krbforge');
-// const t = krb.CreateGoldenTicket({
-// username: 'Administrator',
-// domain: 'acme.local',
-// domain_sid: 'S-1-5-21-1004336348-1177238915-682003330',
-// nthash: '31d6cfe0d16ae931b73c59d7e0c089c0',
-// });
+//
+// const t = krb.CreateGoldenTicket({
+// username: 'Administrator',
+// domain: 'acme.local',
+// domain_sid: 'S-1-5-21-1004336348-1177238915-682003330',
+// nthash: '31d6cfe0d16ae931b73c59d7e0c089c0',
+// });
+//
// log(t.ticket_hex);
// ```
-func CreateGoldenTicket(req TicketRequest) (*Ticket, error) {
- cfg := buildConfig(req, "")
- if cfg.OutputFile == "" {
- cfg.OutputFile = "-"
+func CreateGoldenTicket(call goja.FunctionCall, vm *goja.Runtime) goja.Value {
+ nj := utils.NewNucleiJS(vm)
+ nj.ObjectSig = "CreateGoldenTicket(request)"
+
+ req, err := exportTicketRequest(vm, call.Argument(0))
+ if err != nil {
+ nj.ThrowError(err)
+ return goja.Undefined()
}
- res, err := gpkrb.CreateTicket(cfg)
+
+ ticket, err := createGoldenTicket(nj.ExecutionId(), req)
+ if err != nil {
+ nj.ThrowError(err)
+ return goja.Undefined()
+ }
+
+ return vm.ToValue(ticket)
+}
+
+func createGoldenTicket(executionID string, req TicketRequest) (*Ticket, error) {
+ cfg, err := buildConfig(executionID, req, "")
if err != nil {
return nil, err
}
- return &Ticket{
- HexTicket: hex.EncodeToString(res.Ticket),
- HexKey: hex.EncodeToString(res.SessionKey),
- EncType: res.EncType,
- OutputFile: cfg.OutputFile,
- }, nil
+
+ if cfg.OutputFile == "" {
+ cfg.OutputFile = "-"
+ }
+
+ return createTicket(cfg)
}
// CreateSilverTicket forges a service ticket (TGS) for the supplied SPN. The
@@ -80,24 +104,65 @@ func CreateGoldenTicket(req TicketRequest) (*Ticket, error) {
// @example
// ```javascript
// const krb = require('nuclei/krbforge');
-// const t = krb.CreateSilverTicket({
-// username: 'Administrator',
-// domain: 'acme.local',
-// domain_sid: 'S-1-5-21-1004336348-1177238915-682003330',
-// nthash: '31d6cfe0d16ae931b73c59d7e0c089c0',
-// spn: 'cifs/server01.acme.local',
-// }, '/tmp/silver.ccache');
+//
+// const t = krb.CreateSilverTicket({
+// username: 'Administrator',
+// domain: 'acme.local',
+// domain_sid: 'S-1-5-21-1004336348-1177238915-682003330',
+// nthash: '31d6cfe0d16ae931b73c59d7e0c089c0',
+// spn: 'cifs/server01.acme.local',
+// }, '/tmp/silver.ccache');
+//
// log(t.output_file);
// ```
-func CreateSilverTicket(req TicketRequest, outputFile string) (*Ticket, error) {
+func CreateSilverTicket(call goja.FunctionCall, vm *goja.Runtime) goja.Value {
+ nj := utils.NewNucleiJS(vm)
+ nj.ObjectSig = "CreateSilverTicket(request, outputFile)"
+
+ req, err := exportTicketRequest(vm, call.Argument(0))
+ if err != nil {
+ nj.ThrowError(err)
+ return goja.Undefined()
+ }
+
+ outputFile, err := exportOutputFile(call.Argument(1))
+ if err != nil {
+ nj.ThrowError(err)
+ return goja.Undefined()
+ }
+
+ ticket, err := createSilverTicket(nj.ExecutionId(), req, outputFile)
+ if err != nil {
+ nj.ThrowError(err)
+ return goja.Undefined()
+ }
+
+ return vm.ToValue(ticket)
+}
+
+func createSilverTicket(executionID string, req TicketRequest, outputFile string) (*Ticket, error) {
if req.SPN == "" {
return nil, fmt.Errorf("spn is required for silver ticket")
}
- cfg := buildConfig(req, outputFile)
+
+ cfg, err := buildConfig(executionID, req, outputFile)
+ if err != nil {
+ return nil, err
+ }
+
+ if cfg.OutputFile == "" {
+ cfg.OutputFile = "-"
+ }
+
+ return createTicket(cfg)
+}
+
+func createTicket(cfg *gpkrb.TicketConfig) (*Ticket, error) {
res, err := gpkrb.CreateTicket(cfg)
if err != nil {
return nil, err
}
+
return &Ticket{
HexTicket: hex.EncodeToString(res.Ticket),
HexKey: hex.EncodeToString(res.SessionKey),
@@ -106,16 +171,16 @@ func CreateSilverTicket(req TicketRequest, outputFile string) (*Ticket, error) {
}, nil
}
-func buildConfig(req TicketRequest, outputFile string) *gpkrb.TicketConfig {
+func buildConfig(executionID string, req TicketRequest, outputFile string) (*gpkrb.TicketConfig, error) {
if outputFile == "" {
outputFile = req.OutputFile
}
- if outputFile != "" && outputFile != "-" {
- // reject relative paths to keep the ccache out of CWD
- if !filepath.IsAbs(outputFile) {
- outputFile = filepath.Join(os.TempDir(), outputFile)
- }
+
+ normalizedOutputFile, err := normalizeOutputFile(executionID, outputFile)
+ if err != nil {
+ return nil, err
}
+
return &gpkrb.TicketConfig{
Username: req.Username,
Domain: req.Domain,
@@ -129,6 +194,63 @@ func buildConfig(req TicketRequest, outputFile string) *gpkrb.TicketConfig {
ExtraSIDs: req.ExtraSIDs,
Duration: req.DurationHours,
KVNO: req.KVNO,
- OutputFile: outputFile,
+ OutputFile: normalizedOutputFile,
+ }, nil
+}
+
+func normalizeOutputFile(executionID string, outputFile string) (string, error) {
+ if outputFile == "" || outputFile == "-" {
+ return outputFile, nil
+ }
+
+ if protocolstate.IsLfaAllowed(&types.Options{ExecutionId: executionID}) {
+ // Preserve the existing relative-path behavior when
+ // -allow-local-file-access is enabled: avoid implicit CWD writes by
+ // placing relative ccache paths in temp.
+ if !filepath.IsAbs(outputFile) {
+ outputFile = filepath.Join(os.TempDir(), outputFile)
+ }
+
+ normalized, err := filepath.Abs(outputFile)
+ if err != nil {
+ return "", fmt.Errorf("normalize output file %q: %w", outputFile, err)
+ }
+
+ return normalized, nil
+ }
+
+ normalized := outputFile
+ if !filepath.IsAbs(normalized) {
+ normalized = filepath.Join(config.DefaultConfig.GetTemplateDir(), normalized)
+ }
+
+ normalized, err := filepath.Abs(normalized)
+ if err != nil {
+ return "", fmt.Errorf("normalize output file %q: %w", outputFile, err)
+ }
+
+ if filepathutil.IsPathWithinDirectory(normalized, config.DefaultConfig.GetTemplateDir()) {
+ return normalized, nil
+ }
+
+ return "", fmt.Errorf("path %v is outside nuclei-template directory and -allow-local-file-access is not enabled", outputFile)
+}
+
+func exportTicketRequest(vm *goja.Runtime, value goja.Value) (TicketRequest, error) {
+ var req TicketRequest
+ if err := vm.ExportTo(value, &req); err != nil {
+ return req, fmt.Errorf("invalid TicketRequest: %w", err)
+ }
+ return req, nil
+}
+
+func exportOutputFile(value goja.Value) (string, error) {
+ if goja.IsUndefined(value) || goja.IsNull(value) {
+ return "", nil
+ }
+ outputFile, ok := value.Export().(string)
+ if !ok {
+ return "", fmt.Errorf("outputFile must be a string")
}
+ return outputFile, nil
}
diff --git a/pkg/js/libs/krbforge/krbforge_test.go b/pkg/js/libs/krbforge/krbforge_test.go
new file mode 100644
index 0000000000..a311c042eb
--- /dev/null
+++ b/pkg/js/libs/krbforge/krbforge_test.go
@@ -0,0 +1,141 @@
+package krbforge
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/projectdiscovery/goja"
+ "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
+ "github.com/projectdiscovery/nuclei/v3/pkg/types"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildConfigRejectsOutputFileOutsideSandbox(t *testing.T) {
+ setTemplateDir(t)
+ executionID := "deny-" + t.Name()
+ setLocalFileAccess(executionID, false)
+ outsidePath := filepath.Join(t.TempDir(), "ticket.ccache")
+
+ t.Run("request output_file", func(t *testing.T) {
+ req := validTicketRequest()
+ req.OutputFile = outsidePath
+
+ _, err := buildConfig(executionID, req, "")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "-allow-local-file-access is not enabled")
+ })
+
+ t.Run("silver outputFile argument", func(t *testing.T) {
+ _, err := buildConfig(executionID, validTicketRequest(), outsidePath)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "-allow-local-file-access is not enabled")
+ })
+}
+
+func TestBuildConfigAllowsOutputFileInsideSandbox(t *testing.T) {
+ templatesDir := setTemplateDir(t)
+ executionID := "sandbox-" + t.Name()
+ setLocalFileAccess(executionID, false)
+
+ outputPath := filepath.Join(templatesDir, "generated", "ticket.ccache")
+ req := validTicketRequest()
+ req.OutputFile = outputPath
+
+ cfg, err := buildConfig(executionID, req, "")
+ require.NoError(t, err)
+ require.Equal(t, outputPath, cfg.OutputFile)
+}
+
+func TestBuildConfigNormalizesRelativeOutputFileInsideSandbox(t *testing.T) {
+ templatesDir := setTemplateDir(t)
+ executionID := "relative-" + t.Name()
+ setLocalFileAccess(executionID, false)
+
+ cfg, err := buildConfig(executionID, validTicketRequest(), filepath.Join("generated", "ticket.ccache"))
+ require.NoError(t, err)
+ require.Equal(t, filepath.Join(templatesDir, "generated", "ticket.ccache"), cfg.OutputFile)
+}
+
+func TestBuildConfigAllowsOutputFileWhenLocalFileAccessEnabled(t *testing.T) {
+ setTemplateDir(t)
+ executionID := "allow-" + t.Name()
+ setLocalFileAccess(executionID, true)
+ outsidePath := filepath.Join(t.TempDir(), "ticket.ccache")
+
+ cfg, err := buildConfig(executionID, validTicketRequest(), outsidePath)
+ require.NoError(t, err)
+ require.Equal(t, outsidePath, cfg.OutputFile)
+}
+
+func TestCreateGoldenTicketRejectsOutputFileOutsideSandboxFromRuntime(t *testing.T) {
+ setTemplateDir(t)
+ executionID := "runtime-" + t.Name()
+ setLocalFileAccess(executionID, false)
+
+ runtime := goja.New()
+ runtime.SetContextValue("executionId", executionID)
+
+ req := validTicketRequest()
+ req.OutputFile = filepath.Join(t.TempDir(), "ticket.ccache")
+
+ var panicValue any
+ func() {
+ defer func() {
+ panicValue = recover()
+ }()
+
+ CreateGoldenTicket(goja.FunctionCall{
+ Arguments: []goja.Value{runtime.ToValue(req)},
+ }, runtime)
+ }()
+
+ require.NotNil(t, panicValue)
+ require.Contains(t, fmt.Sprint(panicValue), "-allow-local-file-access is not enabled")
+}
+
+func TestCreateSilverTicketDoesNotWriteDefaultCCache(t *testing.T) {
+ setTemplateDir(t)
+ executionID := "silver-" + t.Name()
+ setLocalFileAccess(executionID, false)
+
+ cwd := t.TempDir()
+ t.Chdir(cwd)
+
+ ticket, err := createSilverTicket(executionID, validTicketRequest(), "")
+ require.NoError(t, err)
+ require.Equal(t, "-", ticket.OutputFile)
+
+ _, err = os.Stat(filepath.Join(cwd, "Administrator.ccache"))
+ require.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func setTemplateDir(t *testing.T) string {
+ t.Helper()
+ templatesDir := t.TempDir()
+ originalTemplatesDir := config.DefaultConfig.TemplatesDirectory
+ config.DefaultConfig.SetTemplatesDir(templatesDir)
+ t.Cleanup(func() {
+ config.DefaultConfig.SetTemplatesDir(originalTemplatesDir)
+ })
+ return templatesDir
+}
+
+func setLocalFileAccess(executionID string, allowed bool) {
+ protocolstate.SetLfaAllowed(&types.Options{
+ ExecutionId: executionID,
+ AllowLocalFileAccess: allowed,
+ })
+}
+
+func validTicketRequest() TicketRequest {
+ return TicketRequest{
+ Username: "Administrator",
+ Domain: "acme.local",
+ DomainSID: "S-1-5-21-1004336348-1177238915-682003330",
+ NTHash: "31d6cfe0d16ae931b73c59d7e0c089c0",
+ SPN: "cifs/server01.acme.local",
+ }
+}
diff --git a/pkg/js/libs/krbroast/krbroast.go b/pkg/js/libs/krbroast/krbroast.go
index 5ea97859f6..5391b9a1d5 100644
--- a/pkg/js/libs/krbroast/krbroast.go
+++ b/pkg/js/libs/krbroast/krbroast.go
@@ -15,7 +15,7 @@ import (
"fmt"
gpkrb "github.com/Mzack9999/goimpacket/pkg/kerberos"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/libs/dcerpc"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
diff --git a/pkg/js/libs/ldap/ldap.go b/pkg/js/libs/ldap/ldap.go
index d9ae1bcd84..ba9d4b6f4b 100644
--- a/pkg/js/libs/ldap/ldap.go
+++ b/pkg/js/libs/ldap/ldap.go
@@ -7,8 +7,8 @@ import (
"net/url"
"strings"
- "github.com/Mzack9999/goja"
"github.com/go-ldap/ldap/v3"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
)
@@ -84,6 +84,10 @@ func NewClient(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
u, err := url.Parse(ldapUrl)
c.nj.HandleError(err, "invalid ldap url supported schemas are ldap://, ldaps://, ldapi://, and cldap://")
+ if u.Scheme == "" {
+ // default to ldap
+ u.Scheme = "ldap"
+ }
executionId := c.nj.ExecutionId()
dialers := protocolstate.GetDialersWithId(executionId)
@@ -94,44 +98,43 @@ func NewClient(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
dialCtx := c.nj.Context()
var conn net.Conn
if u.Scheme == "ldapi" {
+ const ldapiPolicyHost = "127.0.0.1"
+
+ c.nj.Require(protocolstate.IsHostAllowed(executionId, ldapiPolicyHost), protocolstate.ErrHostDenied.Msgf(ldapiPolicyHost).Error())
if u.Path == "" || u.Path == "/" {
u.Path = "/var/run/slapd/ldapi"
}
conn, err = dialers.Fastdialer.Dial(dialCtx, "unix", u.Path)
c.nj.HandleError(err, "failed to connect to ldap server")
} else {
- host, port, err := net.SplitHostPort(u.Host)
- if err != nil {
- // we assume that error is due to missing port
- host = u.Host
- port = ""
- }
- if u.Scheme == "" {
- // default to ldap
- u.Scheme = "ldap"
- }
-
switch u.Scheme {
- case "cldap":
- if port == "" {
- port = ldap.DefaultLdapPort
- }
- conn, err = dialers.Fastdialer.Dial(dialCtx, "udp", net.JoinHostPort(host, port))
- case "ldap":
- if port == "" {
- port = ldap.DefaultLdapPort
- }
- conn, err = dialers.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, port))
- case "ldaps":
- if port == "" {
- port = ldap.DefaultLdapsPort
- }
- serverName := host
- if c.cfg.ServerName != "" {
- serverName = c.cfg.ServerName
+ case "cldap", "ldap", "ldaps":
+ host, port := u.Hostname(), u.Port()
+ c.nj.Require(host != "", "ldap host cannot be empty")
+ c.nj.Require(protocolstate.IsHostAllowed(executionId, host), protocolstate.ErrHostDenied.Msgf(host).Error())
+
+ switch u.Scheme {
+ case "cldap":
+ if port == "" {
+ port = ldap.DefaultLdapPort
+ }
+ conn, err = dialers.Fastdialer.Dial(dialCtx, "udp", net.JoinHostPort(host, port))
+ case "ldap":
+ if port == "" {
+ port = ldap.DefaultLdapPort
+ }
+ conn, err = dialers.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, port))
+ case "ldaps":
+ if port == "" {
+ port = ldap.DefaultLdapsPort
+ }
+ serverName := host
+ if c.cfg.ServerName != "" {
+ serverName = c.cfg.ServerName
+ }
+ conn, err = dialers.Fastdialer.DialTLSWithConfig(dialCtx, "tcp", net.JoinHostPort(host, port),
+ &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS10, ServerName: serverName})
}
- conn, err = dialers.Fastdialer.DialTLSWithConfig(dialCtx, "tcp", net.JoinHostPort(host, port),
- &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS10, ServerName: serverName})
default:
err = fmt.Errorf("unsupported ldap url schema %v", u.Scheme)
}
diff --git a/pkg/js/libs/ldap/ldap_test.go b/pkg/js/libs/ldap/ldap_test.go
new file mode 100644
index 0000000000..e52325fa87
--- /dev/null
+++ b/pkg/js/libs/ldap/ldap_test.go
@@ -0,0 +1,116 @@
+package ldap
+
+import (
+ "context"
+ "net"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/projectdiscovery/goja"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
+ "github.com/projectdiscovery/nuclei/v3/pkg/types"
+)
+
+func TestNewClientDeniesRestrictedLocalTCPBeforeDial(t *testing.T) {
+ _, err := newLDAPClientForTest(t, &types.Options{
+ RestrictLocalNetworkAccess: true,
+ }, "ldap://127.0.0.1")
+
+ requireNetworkPolicyError(t, err, "127.0.0.1")
+ requireRejectedBeforeDial(t, err)
+}
+
+func TestNewClientDeniesLDAPIAsLocalNetworkAccess(t *testing.T) {
+ _, err := newLDAPClientForTest(t, &types.Options{
+ RestrictLocalNetworkAccess: true,
+ }, "ldapi:///var/run/slapd/ldapi")
+
+ requireNetworkPolicyError(t, err, "127.0.0.1")
+ requireRejectedBeforeDial(t, err)
+}
+
+func requireRejectedBeforeDial(t *testing.T, err error) {
+ t.Helper()
+
+ if strings.Contains(err.Error(), "failed to connect to ldap server") {
+ t.Fatalf("ldap target should be rejected by policy before dialing, got %q", err)
+ }
+}
+
+func TestNewClientAllowsTCPWhenNetworkPolicyAllows(t *testing.T) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ _ = listener.Close()
+ }()
+
+ accepted := make(chan net.Conn, 1)
+ go func() {
+ conn, err := listener.Accept()
+ if err == nil {
+ accepted <- conn
+ return
+ }
+ accepted <- nil
+ }()
+
+ client, err := newLDAPClientForTest(t, &types.Options{}, "ldap://"+listener.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = client.conn.Close()
+ })
+
+ select {
+ case conn := <-accepted:
+ if conn == nil {
+ t.Fatal("listener closed before accepting ldap connection")
+ }
+ _ = conn.Close()
+ case <-time.After(time.Second):
+ t.Fatal("ldap constructor did not connect to allowed listener")
+ }
+}
+
+func newLDAPClientForTest(t *testing.T, options *types.Options, ldapURL string) (*Client, error) {
+ t.Helper()
+
+ executionID := "ldap-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
+ options.ExecutionId = executionID
+ if err := protocolstate.Init(options); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ protocolstate.Close(executionID)
+ })
+
+ runtime := goja.New()
+ runtime.SetContextValue("executionId", executionID)
+ runtime.SetContextValue("ctx", context.Background())
+
+ obj, err := runtime.New(runtime.ToValue(NewClient), runtime.ToValue(ldapURL), runtime.ToValue("corp.internal"))
+ if err != nil {
+ return nil, err
+ }
+
+ client, ok := obj.Export().(*Client)
+ if !ok {
+ t.Fatalf("expected *Client export, got %T", obj.Export())
+ }
+ return client, nil
+}
+
+func requireNetworkPolicyError(t *testing.T, err error, target string) {
+ t.Helper()
+
+ if err == nil {
+ t.Fatal("expected network-policy denial, got nil")
+ }
+ if !strings.Contains(err.Error(), "network policy") || !strings.Contains(err.Error(), target) {
+ t.Fatalf("expected network-policy denial for %q, got %q", target, err)
+ }
+}
diff --git a/pkg/js/libs/mssql/mssql.go b/pkg/js/libs/mssql/mssql.go
index d47406eab2..7b4838ca94 100644
--- a/pkg/js/libs/mssql/mssql.go
+++ b/pkg/js/libs/mssql/mssql.go
@@ -68,11 +68,7 @@ func connect(ctx context.Context, executionId string, host string, port int, use
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
- connString := fmt.Sprintf("sqlserver://%s:%s@%s?database=%s&connection+timeout=30",
- url.PathEscape(username),
- url.PathEscape(password),
- target,
- dbName)
+ connString := mssqlConnString(target, username, password, dbName)
db, err := sql.Open("sqlserver", connString)
if err != nil {
@@ -165,8 +161,6 @@ func (c *MSSQLClient) ExecuteQuery(ctx context.Context, host string, port int, u
return nil, protocolstate.ErrHostDenied.Msgf(host)
}
- target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
-
ok, err := c.IsMssql(ctx, host, port)
if err != nil {
return nil, err
@@ -175,11 +169,8 @@ func (c *MSSQLClient) ExecuteQuery(ctx context.Context, host string, port int, u
return nil, fmt.Errorf("not a mssql service")
}
- connString := fmt.Sprintf("sqlserver://%s:%s@%s?database=%s&connection+timeout=30",
- url.PathEscape(username),
- url.PathEscape(password),
- target,
- dbName)
+ target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
+ connString := mssqlConnString(target, username, password, dbName)
db, err := sql.Open("sqlserver", connString)
if err != nil {
@@ -206,3 +197,11 @@ func (c *MSSQLClient) ExecuteQuery(ctx context.Context, host string, port int, u
}
return data, nil
}
+
+func mssqlConnString(target, username, password, dbName string) string {
+ return fmt.Sprintf("sqlserver://%s:%s@%s?database=%s&connection+timeout=30",
+ url.PathEscape(username),
+ url.PathEscape(password),
+ target,
+ url.QueryEscape(dbName))
+}
diff --git a/pkg/js/libs/mssql/mssql_test.go b/pkg/js/libs/mssql/mssql_test.go
new file mode 100644
index 0000000000..5fb07adb06
--- /dev/null
+++ b/pkg/js/libs/mssql/mssql_test.go
@@ -0,0 +1,35 @@
+package mssql
+
+import (
+ "testing"
+
+ "github.com/microsoft/go-mssqldb/msdsn"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConnectionStringDoesNotTreatDatabaseNameAsDriverOptions(t *testing.T) {
+ dbName := "master&encrypt=true&certificate=/tmp/nuclei-mssql-test.pem" +
+ "&authenticator=krb5" +
+ "&krb5-configfile=/tmp/krb5.conf" +
+ "&krb5-keytabfile=/tmp/krb5.keytab" +
+ "&krb5-credcachefile=/tmp/krb5.ccache"
+
+ cfg, err := msdsn.Parse(mssqlConnString("127.0.0.1:1433", "user", "password", dbName))
+ require.NoError(t, err)
+
+ require.Equal(t, dbName, cfg.Database)
+ require.Equal(t, "30", cfg.Parameters["connection timeout"])
+ require.NotContains(t, cfg.Parameters, "encrypt")
+ require.NotContains(t, cfg.Parameters, "certificate")
+ require.NotContains(t, cfg.Parameters, "authenticator")
+ require.NotContains(t, cfg.Parameters, "krb5-configfile")
+ require.NotContains(t, cfg.Parameters, "krb5-keytabfile")
+ require.NotContains(t, cfg.Parameters, "krb5-credcachefile")
+}
+
+func TestConnectionStringKeepsPlainDatabaseName(t *testing.T) {
+ cfg, err := msdsn.Parse(mssqlConnString("127.0.0.1:1433", "user", "password", "master"))
+ require.NoError(t, err)
+
+ require.Equal(t, "master", cfg.Database)
+}
diff --git a/pkg/js/libs/mysql/mysql.go b/pkg/js/libs/mysql/mysql.go
index f387ad7b7a..3f92119d96 100644
--- a/pkg/js/libs/mysql/mysql.go
+++ b/pkg/js/libs/mysql/mysql.go
@@ -2,7 +2,6 @@ package mysql
import (
"context"
- "database/sql"
"fmt"
"io"
"log"
@@ -227,7 +226,7 @@ func (c *MySQLClient) ExecuteQueryWithOpts(ctx context.Context, opts MySQLOption
return nil, err
}
- db, err := sql.Open("mysql", dsn)
+ db, err := openDB(executionId, dsn)
if err != nil {
return nil, err
}
diff --git a/pkg/js/libs/mysql/mysql_private.go b/pkg/js/libs/mysql/mysql_private.go
index e8ec9f4f77..da8b453681 100644
--- a/pkg/js/libs/mysql/mysql_private.go
+++ b/pkg/js/libs/mysql/mysql_private.go
@@ -7,6 +7,10 @@ import (
"net"
"net/url"
"strings"
+
+ "github.com/go-sql-driver/mysql"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
+ "github.com/projectdiscovery/nuclei/v3/pkg/types"
)
type (
@@ -72,9 +76,33 @@ func BuildDSN(opts MySQLOptions) (string, error) {
return dsn.String(), nil
}
+// sandboxDSN enforces the local file access sandbox on a MySQL DSN. The
+// driver's allowAllFiles option lets a malicious server read any local file
+// off the host via LOAD DATA LOCAL INFILE, so it is only honored when -lfa is
+// enabled, mirroring the fs.ReadFile restriction.
+func sandboxDSN(dsn string, lfaAllowed bool) (string, error) {
+ cfg, err := mysql.ParseDSN(dsn)
+ if err != nil {
+ return "", err
+ }
+ if cfg.AllowAllFiles && !lfaAllowed {
+ cfg.AllowAllFiles = false
+ }
+ return cfg.FormatDSN(), nil
+}
+
+// openDB opens a sandboxed MySQL connection from dsn.
+func openDB(executionId, dsn string) (*sql.DB, error) {
+ dsn, err := sandboxDSN(dsn, protocolstate.IsLfaAllowed(&types.Options{ExecutionId: executionId}))
+ if err != nil {
+ return nil, err
+ }
+ return sql.Open("mysql", dsn)
+}
+
// @memo
func connectWithDSN(ctx context.Context, executionId string, dsn string) (bool, error) {
- db, err := sql.Open("mysql", dsn)
+ db, err := openDB(executionId, dsn)
if err != nil {
return false, err
}
diff --git a/pkg/js/libs/mysql/mysql_private_test.go b/pkg/js/libs/mysql/mysql_private_test.go
new file mode 100644
index 0000000000..e48af9a7c8
--- /dev/null
+++ b/pkg/js/libs/mysql/mysql_private_test.go
@@ -0,0 +1,42 @@
+package mysql
+
+import (
+ "testing"
+
+ "github.com/go-sql-driver/mysql"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSandboxDSN(t *testing.T) {
+ t.Run("strips allowAllFiles when lfa disabled", func(t *testing.T) {
+ got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", false)
+ require.NoError(t, err)
+
+ cfg, err := mysql.ParseDSN(got)
+ require.NoError(t, err)
+ require.False(t, cfg.AllowAllFiles)
+ })
+
+ t.Run("keeps allowAllFiles when lfa enabled", func(t *testing.T) {
+ got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", true)
+ require.NoError(t, err)
+
+ cfg, err := mysql.ParseDSN(got)
+ require.NoError(t, err)
+ require.True(t, cfg.AllowAllFiles)
+ })
+
+ t.Run("leaves dsn without allowAllFiles untouched", func(t *testing.T) {
+ got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/", false)
+ require.NoError(t, err)
+
+ cfg, err := mysql.ParseDSN(got)
+ require.NoError(t, err)
+ require.False(t, cfg.AllowAllFiles)
+ })
+
+ t.Run("errors on invalid dsn", func(t *testing.T) {
+ _, err := sandboxDSN("::not-a-dsn::", false)
+ require.Error(t, err)
+ })
+}
diff --git a/pkg/js/libs/oracle/oracle.go b/pkg/js/libs/oracle/oracle.go
index a30e52c4cb..cdf12fb370 100644
--- a/pkg/js/libs/oracle/oracle.go
+++ b/pkg/js/libs/oracle/oracle.go
@@ -5,7 +5,9 @@ import (
"database/sql"
"fmt"
"net"
+ "net/url"
"strconv"
+ "strings"
"time"
"github.com/praetorian-inc/fingerprintx/pkg/plugins"
@@ -84,6 +86,11 @@ func isOracle(ctx context.Context, executionId string, host string, port int) (I
}
func (c *OracleClient) oracleDbInstance(ctx context.Context, connStr string, executionId string) (*goora.OracleConnector, error) {
+ connStr, err := sandboxDSN(executionId, connStr)
+ if err != nil {
+ return nil, err
+ }
+
if c.connector == nil {
connector := goora.NewConnector(connStr)
oraConnector, ok := connector.(*goora.OracleConnector)
@@ -100,6 +107,48 @@ func (c *OracleClient) oracleDbInstance(ctx context.Context, connStr string, exe
return c.connector, nil
}
+func sandboxDSN(executionId string, dsn string) (string, error) {
+ parsed, err := url.Parse(dsn)
+ if err != nil {
+ return "", err
+ }
+
+ query := parsed.Query()
+ changed := false
+ for key, values := range query {
+ if !isOracleTracePathOption(key) {
+ continue
+ }
+ for i, value := range values {
+ if value == "" {
+ continue
+ }
+ normalized, err := protocolstate.NormalizePathWithExecutionId(executionId, value)
+ if err != nil {
+ return "", fmt.Errorf("oracle %s %q: %w", key, value, err)
+ }
+ values[i] = normalized
+ }
+ query[key] = values
+ changed = true
+ }
+ if !changed {
+ return dsn, nil
+ }
+
+ parsed.RawQuery = query.Encode()
+ return parsed.String(), nil
+}
+
+func isOracleTracePathOption(key string) bool {
+ switch strings.ToUpper(strings.TrimSpace(key)) {
+ case "TRACE FILE", "TRACE DIR", "TRACE FOLDER", "TRACE DIRECTORY":
+ return true
+ default:
+ return false
+ }
+}
+
// Connect connects to an Oracle database
// @example
// ```javascript
diff --git a/pkg/js/libs/oracle/oracle_test.go b/pkg/js/libs/oracle/oracle_test.go
new file mode 100644
index 0000000000..18ffaad03d
--- /dev/null
+++ b/pkg/js/libs/oracle/oracle_test.go
@@ -0,0 +1,102 @@
+package oracle
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
+ "github.com/projectdiscovery/nuclei/v3/pkg/types"
+ go_ora "github.com/sijms/go-ora/v2"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSandboxDSNRejectsTraceFileOutsideTemplatesWithoutLFA(t *testing.T) {
+ templatesDir := t.TempDir()
+ restoreOracleTemplatesDir(t, templatesDir)
+
+ executionID := t.Name()
+ protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
+
+ traceFile := filepath.Join(t.TempDir(), "trace.log")
+ dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
+ "TRACE FILE": traceFile,
+ })
+
+ _, err := sandboxDSN(executionID, dsn)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "-lfa is not enabled")
+}
+
+func TestConnectWithDSNRejectsTraceFileBeforeOracleOpen(t *testing.T) {
+ templatesDir := t.TempDir()
+ restoreOracleTemplatesDir(t, templatesDir)
+
+ executionID := t.Name()
+ protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
+
+ traceFile := filepath.Join(t.TempDir(), "trace.log")
+ dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
+ "TRACE FILE": traceFile,
+ })
+
+ ctx := context.WithValue(context.Background(), "executionId", executionID) // nolint:staticcheck
+ _, err := (&OracleClient{}).ConnectWithDSN(ctx, dsn)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "-lfa is not enabled")
+ require.NoFileExists(t, traceFile)
+}
+
+func TestSandboxDSNNormalizesTraceOptionsWithinTemplatesWithoutLFA(t *testing.T) {
+ templatesDir := t.TempDir()
+ restoreOracleTemplatesDir(t, templatesDir)
+
+ executionID := t.Name()
+ protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
+
+ traceFile := filepath.Join(templatesDir, "trace.log")
+ traceDir := filepath.Join(templatesDir, "trace-dir")
+ dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
+ "TRACE FILE": traceFile,
+ "TRACE DIRECTORY": traceDir,
+ })
+
+ got, err := sandboxDSN(executionID, dsn)
+ require.NoError(t, err)
+
+ cfg, err := go_ora.ParseConfig(got)
+ require.NoError(t, err)
+ require.Equal(t, traceFile, cfg.TraceFilePath)
+ require.Equal(t, traceDir, cfg.TraceDir)
+}
+
+func TestSandboxDSNAllowsTraceFileOutsideTemplatesWithLFA(t *testing.T) {
+ templatesDir := t.TempDir()
+ restoreOracleTemplatesDir(t, templatesDir)
+
+ executionID := t.Name()
+ protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: true})
+
+ traceFile := filepath.Join(t.TempDir(), "trace.log")
+ dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
+ "TRACE FILE": traceFile,
+ })
+
+ got, err := sandboxDSN(executionID, dsn)
+ require.NoError(t, err)
+
+ cfg, err := go_ora.ParseConfig(got)
+ require.NoError(t, err)
+ require.Equal(t, traceFile, cfg.TraceFilePath)
+}
+
+func restoreOracleTemplatesDir(t *testing.T, templatesDir string) {
+ t.Helper()
+
+ oldTemplatesDir := config.DefaultConfig.TemplatesDirectory
+ config.DefaultConfig.SetTemplatesDir(templatesDir)
+ t.Cleanup(func() {
+ config.DefaultConfig.SetTemplatesDir(oldTemplatesDir)
+ })
+}
diff --git a/pkg/js/libs/postgres/postgres.go b/pkg/js/libs/postgres/postgres.go
index 1aed17a34d..8edfecf0d9 100644
--- a/pkg/js/libs/postgres/postgres.go
+++ b/pkg/js/libs/postgres/postgres.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net"
+ "net/url"
"strings"
"time"
@@ -125,7 +126,7 @@ func executeQuery(ctx context.Context, executionId string, host string, port int
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
- connStr := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable&executionId=%s", username, password, target, dbName, executionId)
+ connStr := buildPostgresConnURL(username, password, target, dbName, executionId)
db, err := pgwrap.OpenDB(ctx, executionId, connStr)
if err != nil {
return nil, err
@@ -145,6 +146,19 @@ func executeQuery(ctx context.Context, executionId string, host string, port int
return resp, nil
}
+func buildPostgresConnURL(username, password, target, dbName, executionId string) string {
+ values := url.Values{}
+ values.Set("sslmode", "disable")
+ values.Set("executionId", executionId)
+
+ return fmt.Sprintf("postgres://%s@%s/%s?%s",
+ url.UserPassword(username, password).String(),
+ target,
+ url.PathEscape(dbName),
+ values.Encode(),
+ )
+}
+
// ConnectWithDB connects to Postgres database using given credentials and database name.
// If connection is successful, it returns true.
// If connection is unsuccessful, it returns false and error.
diff --git a/pkg/js/libs/postgres/postgres_test.go b/pkg/js/libs/postgres/postgres_test.go
new file mode 100644
index 0000000000..71397521ef
--- /dev/null
+++ b/pkg/js/libs/postgres/postgres_test.go
@@ -0,0 +1,74 @@
+package postgres
+
+import (
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/lib/pq"
+)
+
+func TestBuildPostgresConnectionURLDoesNotAllowDBNameQueryInjection(t *testing.T) {
+ dbName := "testdb?sslrootcert=/etc/passwd&sslmode=verify-ca&junk="
+ connStr := buildPostgresConnURL("postgres", "x", "127.0.0.1:5432", dbName, "exec-1")
+
+ u, err := url.Parse(connStr)
+ if err != nil {
+ t.Fatalf("parse connection URL: %v", err)
+ }
+
+ if got := strings.TrimPrefix(u.Path, "/"); got != dbName {
+ t.Fatalf("database name = %q, want %q", got, dbName)
+ }
+
+ values := u.Query()
+ if got := values.Get("sslmode"); got != "disable" {
+ t.Fatalf("sslmode = %q, want disable", got)
+ }
+ if got := values.Get("executionId"); got != "exec-1" {
+ t.Fatalf("executionId = %q, want exec-1", got)
+ }
+ deniedParams := []string{"sslrootcert", "sslcert", "sslkey", "service", "junk"}
+ for _, denied := range deniedParams {
+ if got := values.Get(denied); got != "" {
+ t.Fatalf("%s was injected with value %q", denied, got)
+ }
+ }
+
+ pqDSN, err := pq.ParseURL(connStr) //nolint:staticcheck // validates lib/pq URL parsing of the generated DSN.
+ if err != nil {
+ t.Fatalf("parse connection URL as lib/pq DSN: %v", err)
+ }
+ if !strings.Contains(pqDSN, "dbname='"+dbName+"'") {
+ t.Fatalf("lib/pq DSN = %q, want dbName preserved as dbname", pqDSN)
+ }
+ for _, denied := range deniedParams {
+ if strings.Contains(pqDSN, " "+denied+"=") {
+ t.Fatalf("%s was injected into lib/pq DSN %q", denied, pqDSN)
+ }
+ }
+}
+
+func TestBuildPostgresConnectionURLEscapesCredentials(t *testing.T) {
+ username := "user:name@example.com"
+ password := "pa:ss@word?x"
+ connStr := buildPostgresConnURL(username, password, "127.0.0.1:5432", "postgres", "exec-1")
+
+ u, err := url.Parse(connStr)
+ if err != nil {
+ t.Fatalf("parse connection URL: %v", err)
+ }
+
+ if got := u.User.Username(); got != username {
+ t.Fatalf("username = %q, want %q", got, username)
+ }
+ if got, _ := u.User.Password(); got != password {
+ t.Fatalf("password = %q, want %q", got, password)
+ }
+ if got := u.Host; got != "127.0.0.1:5432" {
+ t.Fatalf("host = %q, want 127.0.0.1:5432", got)
+ }
+ if got := strings.TrimPrefix(u.Path, "/"); got != "postgres" {
+ t.Fatalf("database name = %q, want postgres", got)
+ }
+}
diff --git a/pkg/js/libs/scmr/scmr.go b/pkg/js/libs/scmr/scmr.go
index c019ff8313..301afe55be 100644
--- a/pkg/js/libs/scmr/scmr.go
+++ b/pkg/js/libs/scmr/scmr.go
@@ -1,7 +1,7 @@
package scmr
import (
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/libs/goexec"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
)
diff --git a/pkg/js/libs/secretsdump/secretsdump.go b/pkg/js/libs/secretsdump/secretsdump.go
index 3b4662775d..2441d519c8 100644
--- a/pkg/js/libs/secretsdump/secretsdump.go
+++ b/pkg/js/libs/secretsdump/secretsdump.go
@@ -18,7 +18,7 @@ import (
gpdrs "github.com/Mzack9999/goimpacket/pkg/dcerpc/drsuapi"
gpsession "github.com/Mzack9999/goimpacket/pkg/session"
gpsmb "github.com/Mzack9999/goimpacket/pkg/smb"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
diff --git a/pkg/js/libs/smtp/smtp.go b/pkg/js/libs/smtp/smtp.go
index 610215fae8..960335ac9b 100644
--- a/pkg/js/libs/smtp/smtp.go
+++ b/pkg/js/libs/smtp/smtp.go
@@ -7,7 +7,7 @@ import (
"strconv"
"time"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/praetorian-inc/fingerprintx/pkg/plugins"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
diff --git a/pkg/js/libs/tsch/tsch.go b/pkg/js/libs/tsch/tsch.go
index 169fe147a8..5d117a9431 100644
--- a/pkg/js/libs/tsch/tsch.go
+++ b/pkg/js/libs/tsch/tsch.go
@@ -1,7 +1,7 @@
package tsch
import (
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/libs/goexec"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
)
diff --git a/pkg/js/libs/wmi/wmi.go b/pkg/js/libs/wmi/wmi.go
index 4a450d01a5..01a6f3fe18 100644
--- a/pkg/js/libs/wmi/wmi.go
+++ b/pkg/js/libs/wmi/wmi.go
@@ -1,7 +1,7 @@
package wmi
import (
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/libs/goexec"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
)
diff --git a/pkg/js/libs/wmi/wmi_test.go b/pkg/js/libs/wmi/wmi_test.go
index 3c2f58c1d1..a490d3cdae 100644
--- a/pkg/js/libs/wmi/wmi_test.go
+++ b/pkg/js/libs/wmi/wmi_test.go
@@ -4,7 +4,7 @@ import (
"context"
"testing"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/libs/goexec"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
)
diff --git a/pkg/js/utils/nucleijs.go b/pkg/js/utils/nucleijs.go
index 857885e0a4..92a2c7b02c 100644
--- a/pkg/js/utils/nucleijs.go
+++ b/pkg/js/utils/nucleijs.go
@@ -7,7 +7,7 @@ import (
"strings"
"sync"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
)
// temporary on demand runtime to throw errors when vm is not available
diff --git a/pkg/js/utils/nucleijs_test.go b/pkg/js/utils/nucleijs_test.go
index fd2c7a3079..b38e18b432 100644
--- a/pkg/js/utils/nucleijs_test.go
+++ b/pkg/js/utils/nucleijs_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"time"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/stretchr/testify/require"
)
diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go
index 56f09924b5..78bd627fe0 100644
--- a/pkg/model/model_test.go
+++ b/pkg/model/model_test.go
@@ -7,8 +7,8 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/stretchr/testify/require"
- "gopkg.in/yaml.v2"
)
func TestInfoJsonMarshal(t *testing.T) {
@@ -57,18 +57,18 @@ func TestInfoYamlMarshal(t *testing.T) {
expected := `name: Test Template Name
author:
-- forgedhallpass
-- ice3man
+ - forgedhallpass
+ - ice3man
tags:
-- cve
-- misc
+ - cve
+ - misc
description: Test description
reference: Reference1
severity: high
metadata:
array_key:
- - array_value1
- - array_value2
+ - array_value1
+ - array_value2
map_key:
key1: val1
string_key: string_value
diff --git a/pkg/model/types/severity/severity_test.go b/pkg/model/types/severity/severity_test.go
index 10d2050de3..6c0781d0cb 100644
--- a/pkg/model/types/severity/severity_test.go
+++ b/pkg/model/types/severity/severity_test.go
@@ -3,8 +3,7 @@ package severity
import (
"testing"
- "gopkg.in/yaml.v2"
-
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/stretchr/testify/require"
)
diff --git a/pkg/output/format_json.go b/pkg/output/format_json.go
index 29fd41e0ee..620b9e2928 100644
--- a/pkg/output/format_json.go
+++ b/pkg/output/format_json.go
@@ -1,8 +1,6 @@
package output
-import (
- jsoniter "github.com/json-iterator/go"
-)
+import json "github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
// formatJSON formats the output for json based formatting
func (w *StandardWriter) formatJSON(output *ResultEvent) ([]byte, error) {
@@ -10,5 +8,5 @@ func (w *StandardWriter) formatJSON(output *ResultEvent) ([]byte, error) {
output.Request = ""
output.Response = ""
}
- return jsoniter.Marshal(output)
+ return json.Marshal(output)
}
diff --git a/pkg/output/output.go b/pkg/output/output.go
index 7f99cc976c..8934fcebe9 100644
--- a/pkg/output/output.go
+++ b/pkg/output/output.go
@@ -16,12 +16,10 @@ import (
"sync/atomic"
"time"
+ "github.com/logrusorgru/aurora/v4"
"github.com/pkg/errors"
"go.uber.org/multierr"
- jsoniter "github.com/json-iterator/go"
- "github.com/logrusorgru/aurora/v4"
-
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/interactsh/pkg/server"
"github.com/projectdiscovery/nuclei/v3/internal/colorizer"
@@ -34,6 +32,7 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types/nucleierr"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
"github.com/projectdiscovery/utils/errkit"
fileutil "github.com/projectdiscovery/utils/file"
osutils "github.com/projectdiscovery/utils/os"
@@ -424,7 +423,7 @@ func (w *StandardWriter) Request(templatePath, input, requestType string, reques
ts := time.Now()
request.Timestamp = &ts
}
- data, err := jsoniter.Marshal(request)
+ data, err := json.Marshal(request)
if err != nil {
return
}
diff --git a/pkg/protocols/code/code.go b/pkg/protocols/code/code.go
index 691e0a200a..221f0693c2 100644
--- a/pkg/protocols/code/code.go
+++ b/pkg/protocols/code/code.go
@@ -8,7 +8,7 @@ import (
"strings"
"time"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/alecthomas/chroma/quick"
"github.com/ditashi/jsbeautifier-go/jsbeautifier"
diff --git a/pkg/protocols/code/helpers.go b/pkg/protocols/code/helpers.go
index 4e84776109..745704d3be 100644
--- a/pkg/protocols/code/helpers.go
+++ b/pkg/protocols/code/helpers.go
@@ -3,7 +3,7 @@ package code
import (
goruntime "runtime"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
osutils "github.com/projectdiscovery/utils/os"
)
diff --git a/pkg/protocols/common/automaticscan/automaticscan.go b/pkg/protocols/common/automaticscan/automaticscan.go
index bd5dc386d8..e14e1fdbb9 100644
--- a/pkg/protocols/common/automaticscan/automaticscan.go
+++ b/pkg/protocols/common/automaticscan/automaticscan.go
@@ -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,9 @@ 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/nuclei/v3/pkg/utils/yaml"
"github.com/projectdiscovery/retryablehttp-go"
"github.com/projectdiscovery/useragent"
mapsutil "github.com/projectdiscovery/utils/maps"
@@ -34,7 +34,6 @@ import (
syncutil "github.com/projectdiscovery/utils/sync"
unitutils "github.com/projectdiscovery/utils/unit"
wappalyzer "github.com/projectdiscovery/wappalyzergo"
- "gopkg.in/yaml.v2"
)
const (
@@ -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")
}
diff --git a/pkg/protocols/common/contextargs/contextargs.go b/pkg/protocols/common/contextargs/contextargs.go
index 58ff9d0719..8eec9798bf 100644
--- a/pkg/protocols/common/contextargs/contextargs.go
+++ b/pkg/protocols/common/contextargs/contextargs.go
@@ -108,8 +108,15 @@ func (ctx *Context) Add(key string, v interface{}) {
}
}
-// UseNetworkPort updates input with required/default network port for that template
-// but is ignored if input/target contains non-http ports like 80,8080,8081 etc
+// UseNetworkPort updates input with required/default network port for that template.
+// The template port is used when:
+// - the input has no port at all, OR
+// - the input port is a reserved HTTP/DNS port AND the port was not explicitly
+// specified by the user (i.e. the input contains a URL scheme, meaning the
+// port was implied by the scheme, not typed by the operator).
+//
+// When the operator explicitly writes "target:80" (no scheme), that port is
+// intentional (e.g. an SSH service running on port 80) and must not be replaced.
func (ctx *Context) UseNetworkPort(port string, excludePorts string) error {
ignorePorts := reservedPorts
if excludePorts != "" {
@@ -125,8 +132,19 @@ func (ctx *Context) UseNetworkPort(port string, excludePorts string) error {
return err
}
inputPort := target.Port()
- if inputPort == "" || stringsutil.EqualFoldAny(inputPort, ignorePorts...) {
- // replace port with networkPort
+ if inputPort == "" {
+ // No port in input at all — use the template port.
+ target.UpdatePort(port)
+ ctx.MetaInput.Input = target.Host
+ return nil
+ }
+ // The input has an explicit port. Only override a reserved port when the
+ // input included a URL scheme (http:// / https://), which means the port was
+ // implied by the scheme rather than deliberately typed by the operator.
+ // A bare "host:port" form (no scheme) means the operator chose that port
+ // on purpose and we must not overwrite it.
+ hasScheme := strings.Contains(ctx.MetaInput.Input, "://")
+ if hasScheme && stringsutil.EqualFoldAny(inputPort, ignorePorts...) {
target.UpdatePort(port)
ctx.MetaInput.Input = target.Host
}
diff --git a/pkg/protocols/common/contextargs/contextargs_test.go b/pkg/protocols/common/contextargs/contextargs_test.go
new file mode 100644
index 0000000000..493932eabe
--- /dev/null
+++ b/pkg/protocols/common/contextargs/contextargs_test.go
@@ -0,0 +1,251 @@
+package contextargs
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestUseNetworkPort is the behavior matrix for UseNetworkPort.
+//
+// The contract (see issue #7323):
+// - empty template port -> no-op, input untouched
+// - input with no port -> input gets the template port
+// - bare "host:port" (no scheme) -> operator-chosen port, always preserved
+// - "scheme://host:port" -> port is scheme-implied; a reserved
+// (or excluded) port is replaced by the template port, otherwise preserved
+func TestUseNetworkPort(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ templatePort string
+ excludePorts string
+ wantInput string
+ }{
+ // --- empty template port: always a no-op ---
+ {
+ name: "empty template port leaves bare host:port untouched",
+ input: "example.com:9999",
+ templatePort: "",
+ wantInput: "example.com:9999",
+ },
+ {
+ name: "empty template port leaves no-port input untouched",
+ input: "example.com",
+ templatePort: "",
+ wantInput: "example.com",
+ },
+
+ // --- no port in input: template port is applied ---
+ {
+ name: "bare host without port uses template port",
+ input: "example.com",
+ templatePort: "22",
+ wantInput: "example.com:22",
+ },
+ {
+ name: "http scheme without port uses template port",
+ input: "http://example.com",
+ templatePort: "8888",
+ wantInput: "example.com:8888",
+ },
+ {
+ name: "https scheme without port uses template port",
+ input: "https://example.com",
+ templatePort: "9090",
+ wantInput: "example.com:9090",
+ },
+ {
+ name: "http scheme with path and no port uses template port",
+ input: "http://example.com/some/path",
+ templatePort: "8888",
+ wantInput: "example.com:8888",
+ },
+
+ // --- bare host:port (no scheme): operator intent, never replaced ---
+ {
+ // core of issue #7323: SSH (or anything) on port 80.
+ name: "bare host with reserved port 80 is preserved",
+ input: "example.com:80",
+ templatePort: "22",
+ wantInput: "example.com:80",
+ },
+ {
+ name: "bare host with reserved port 443 is preserved",
+ input: "example.com:443",
+ templatePort: "22",
+ wantInput: "example.com:443",
+ },
+ {
+ name: "bare host with reserved port 8080 is preserved",
+ input: "example.com:8080",
+ templatePort: "22",
+ wantInput: "example.com:8080",
+ },
+ {
+ name: "bare host with reserved port 53 is preserved",
+ input: "example.com:53",
+ templatePort: "22",
+ wantInput: "example.com:53",
+ },
+ {
+ name: "bare host with non-reserved port is preserved",
+ input: "example.com:2222",
+ templatePort: "22",
+ wantInput: "example.com:2222",
+ },
+ {
+ name: "bare host with port equal to template port is preserved",
+ input: "example.com:22",
+ templatePort: "22",
+ wantInput: "example.com:22",
+ },
+
+ // --- scheme + reserved port: scheme-implied, replaced ---
+ {
+ name: "http scheme with reserved port 80 is replaced",
+ input: "http://example.com:80",
+ templatePort: "22",
+ wantInput: "example.com:22",
+ },
+ {
+ name: "https scheme with reserved port 443 is replaced",
+ input: "https://example.com:443",
+ templatePort: "22",
+ wantInput: "example.com:22",
+ },
+ {
+ name: "http scheme with reserved port 8080 is replaced",
+ input: "http://example.com:8080",
+ templatePort: "22",
+ wantInput: "example.com:22",
+ },
+
+ // --- scheme + non-reserved port: preserved ---
+ // Note: when the port is preserved the input is left exactly as-is (the
+ // scheme is kept). Only the replace path rewrites to bare "host:port".
+ // getAddress() strips the scheme before dialing, so both forms dial the
+ // same address.
+ {
+ name: "http scheme with non-reserved port is preserved verbatim",
+ input: "http://example.com:9999",
+ templatePort: "22",
+ wantInput: "http://example.com:9999",
+ },
+
+ // --- excludePorts: replaces the default reserved set ---
+ {
+ name: "scheme port in excludePorts is replaced",
+ input: "http://example.com:9090",
+ templatePort: "22",
+ excludePorts: "9090",
+ wantInput: "example.com:22",
+ },
+ {
+ name: "scheme port not in excludePorts is preserved verbatim",
+ input: "http://example.com:9091",
+ templatePort: "22",
+ excludePorts: "9090",
+ wantInput: "http://example.com:9091",
+ },
+ {
+ // excludePorts replaces the reserved list, so 80 is no longer ignored
+ // and the scheme-prefixed input is preserved verbatim.
+ name: "scheme reserved port not in custom excludePorts is preserved verbatim",
+ input: "http://example.com:80",
+ templatePort: "22",
+ excludePorts: "9090",
+ wantInput: "http://example.com:80",
+ },
+ {
+ name: "scheme multiple excludePorts replaces matching",
+ input: "http://example.com:8443",
+ templatePort: "22",
+ excludePorts: "9090,8443",
+ wantInput: "example.com:22",
+ },
+ {
+ // bare host:port is preserved even when the port is in excludePorts.
+ name: "bare host port in excludePorts is still preserved",
+ input: "example.com:9090",
+ templatePort: "22",
+ excludePorts: "9090",
+ wantInput: "example.com:9090",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := NewWithInput(context.Background(), tt.input)
+ err := ctx.UseNetworkPort(tt.templatePort, tt.excludePorts)
+ require.NoError(t, err)
+ require.Equal(t, tt.wantInput, ctx.MetaInput.Input)
+ })
+ }
+}
+
+// TestUseNetworkPortIPv6 keeps IPv6 handling honest: bracketed literals must be
+// preserved and the reserved/scheme rules apply the same way as for hostnames.
+func TestUseNetworkPortIPv6(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ templatePort string
+ wantInput string
+ }{
+ {
+ name: "bare IPv6 with reserved port is preserved",
+ input: "[::1]:80",
+ templatePort: "22",
+ wantInput: "[::1]:80",
+ },
+ {
+ name: "bare IPv6 with non-reserved port is preserved",
+ input: "[2001:db8::1]:2222",
+ templatePort: "22",
+ wantInput: "[2001:db8::1]:2222",
+ },
+ {
+ name: "scheme IPv6 with reserved port is replaced",
+ input: "http://[::1]:80",
+ templatePort: "22",
+ wantInput: "[::1]:22",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := NewWithInput(context.Background(), tt.input)
+ err := ctx.UseNetworkPort(tt.templatePort, "")
+ require.NoError(t, err)
+ require.Equal(t, tt.wantInput, ctx.MetaInput.Input)
+ })
+ }
+}
+
+// TestUseNetworkPortServiceNameExclude verifies excludePorts accepts service
+// names (resolved via portutil), e.g. "http" -> "80".
+func TestUseNetworkPortServiceNameExclude(t *testing.T) {
+ ctx := NewWithInput(context.Background(), "http://example.com:80")
+ err := ctx.UseNetworkPort("22", "http")
+ require.NoError(t, err)
+ require.Equal(t, "example.com:22", ctx.MetaInput.Input)
+}
+
+// TestUseNetworkPortIdempotent ensures repeated application is stable and does
+// not keep mutating the input.
+func TestUseNetworkPortIdempotent(t *testing.T) {
+ ctx := NewWithInput(context.Background(), "example.com")
+ for i := 0; i < 3; i++ {
+ require.NoError(t, ctx.UseNetworkPort("22", ""))
+ require.Equal(t, "example.com:22", ctx.MetaInput.Input)
+ }
+
+ // A preserved bare explicit port must stay stable too.
+ ctx = NewWithInput(context.Background(), "example.com:80")
+ for i := 0; i < 3; i++ {
+ require.NoError(t, ctx.UseNetworkPort("22", ""))
+ require.Equal(t, "example.com:80", ctx.MetaInput.Input)
+ }
+}
diff --git a/pkg/protocols/common/contextargs/metainput.go b/pkg/protocols/common/contextargs/metainput.go
index e0be4c536f..9d2f009e81 100644
--- a/pkg/protocols/common/contextargs/metainput.go
+++ b/pkg/protocols/common/contextargs/metainput.go
@@ -8,8 +8,8 @@ import (
"strings"
"sync"
- jsoniter "github.com/json-iterator/go"
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
urlutil "github.com/projectdiscovery/utils/url"
"github.com/segmentio/ksuid"
)
@@ -35,7 +35,7 @@ func NewMetaInput() *MetaInput {
func (metaInput *MetaInput) marshalToBuffer() (bytes.Buffer, error) {
var b bytes.Buffer
- err := jsoniter.NewEncoder(&b).Encode(metaInput)
+ err := json.NewEncoder(&b).Encode(metaInput)
return b, err
}
@@ -138,7 +138,7 @@ func (metaInput *MetaInput) MustMarshalBytes() []byte {
}
func (metaInput *MetaInput) Unmarshal(data string) error {
- return jsoniter.NewDecoder(strings.NewReader(data)).Decode(metaInput)
+ return json.NewDecoder(strings.NewReader(data)).Decode(metaInput)
}
func (metaInput *MetaInput) Clone() *MetaInput {
diff --git a/pkg/protocols/common/contextargs/metainput_test.go b/pkg/protocols/common/contextargs/metainput_test.go
new file mode 100644
index 0000000000..2c60c1a98a
--- /dev/null
+++ b/pkg/protocols/common/contextargs/metainput_test.go
@@ -0,0 +1,22 @@
+package contextargs
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestMetaInputMarshalAndUnmarshalString(t *testing.T) {
+ input := NewMetaInput()
+ input.Input = "https://example.com"
+ input.CustomIP = "192.0.2.10"
+
+ encoded, err := input.MarshalString()
+ require.NoError(t, err)
+ require.Equal(t, "{\"input\":\"https://example.com\",\"customIP\":\"192.0.2.10\"}\n", encoded)
+
+ decoded := NewMetaInput()
+ require.NoError(t, decoded.Unmarshal(encoded))
+ require.Equal(t, input.Input, decoded.Input)
+ require.Equal(t, input.CustomIP, decoded.CustomIP)
+}
diff --git a/pkg/protocols/common/generators/generators_test.go b/pkg/protocols/common/generators/generators_test.go
index c478995525..e279f7e02c 100644
--- a/pkg/protocols/common/generators/generators_test.go
+++ b/pkg/protocols/common/generators/generators_test.go
@@ -4,11 +4,10 @@ import (
"strings"
"testing"
- "github.com/stretchr/testify/require"
- "gopkg.in/yaml.v2"
-
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
+ "github.com/stretchr/testify/require"
)
func TestBatteringRamGenerator(t *testing.T) {
diff --git a/pkg/protocols/common/helpers/responsehighlighter/hexdump.go b/pkg/protocols/common/helpers/responsehighlighter/hexdump.go
index 36d60c34da..ff76f5b959 100644
--- a/pkg/protocols/common/helpers/responsehighlighter/hexdump.go
+++ b/pkg/protocols/common/helpers/responsehighlighter/hexdump.go
@@ -82,7 +82,7 @@ func highlightAsciiSection(hexDump HighlightableHexDump, snippetToColor string)
if IsASCIIPrintable(v) {
value = regexp.QuoteMeta(string(v))
} else {
- value = "."
+ value = `\.`
}
snippetCharactersMatchPattern += fmt.Sprintf(`(%s\n*)`, value)
}
diff --git a/pkg/protocols/common/helpers/responsehighlighter/response_highlighter_test.go b/pkg/protocols/common/helpers/responsehighlighter/response_highlighter_test.go
index 59014a0851..f633ee1af1 100644
--- a/pkg/protocols/common/helpers/responsehighlighter/response_highlighter_test.go
+++ b/pkg/protocols/common/helpers/responsehighlighter/response_highlighter_test.go
@@ -3,6 +3,7 @@ package responsehighlighter
import (
"encoding/hex"
"testing"
+ "time"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
"github.com/stretchr/testify/require"
@@ -108,3 +109,29 @@ start ValueToMatch-2.1 end
result := Highlight(&operatorResult, input, false, false)
require.Equal(t, expected, result)
}
+
+func TestHexDumpHighlightDoesNotExplode(t *testing.T) {
+ // Two binary matchers of different lengths,
+ // both consisting entirely of non-printable bytes
+ op := &operators.Result{
+ Matches: map[string][]string{
+ "long": {"\x05\x00\x00\x01"},
+ "short": {"\x05\x00"},
+ },
+ }
+ response := []byte{0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}
+ dump := hex.Dump(response)
+
+ done := make(chan int, 1)
+ go func() { done <- len(Highlight(op, dump, false, true)) }()
+ select {
+ case n := <-done:
+ // Sanity: output should stay within a few KB for a single-row
+ // hex dump; before the fix it grew past 1 GB in <100 ms.
+ if n > 10_000 {
+ t.Fatalf("Highlight output suspiciously large: %d bytes", n)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("Highlight hung — exponential ReplaceAll regression")
+ }
+}
diff --git a/pkg/protocols/common/hosterrorscache/hosterrorscache.go b/pkg/protocols/common/hosterrorscache/hosterrorscache.go
index 571e2af92f..89512e580f 100644
--- a/pkg/protocols/common/hosterrorscache/hosterrorscache.go
+++ b/pkg/protocols/common/hosterrorscache/hosterrorscache.go
@@ -173,6 +173,14 @@ func (c *Cache) MarkFailed(protoType string, ctx *contextargs.Context, err error
// MarkFailedOrRemove marks a host as failed previously or removes it
func (c *Cache) MarkFailedOrRemove(protoType string, ctx *contextargs.Context, err error) {
+ // A failure that occurs because the caller's context was cancelled or hit its
+ // deadline is not the host's fault (e.g. the parent scan was cancelled). Such
+ // errors classify as temporary/deadline and would otherwise be counted, so
+ // ignore them. A nil error (success) still resets the host below.
+ if err != nil && ctx != nil && ctx.Context() != nil && ctx.Context().Err() != nil {
+ return
+ }
+
if err != nil && !c.checkError(protoType, err) {
return
}
@@ -280,7 +288,7 @@ func (c *Cache) GetKeyFromContext(ctx *contextargs.Context, err error) string {
return finalValue
}
-var reCheckError = regexp.MustCompile(`(no address found for host|could not resolve host|connection refused|connection reset by peer|could not connect to any address found for host|timeout awaiting response headers)`)
+var reCheckError = regexp.MustCompile(`(no address found for host|could not resolve host|connection refused|connection reset by peer|could not connect to any address found for host|timeout awaiting response headers|i/o timeout)`)
// checkError checks if an error represents a type that should be
// added to the host skipping table.
@@ -300,8 +308,11 @@ func (c *Cache) checkError(protoType string, err error) bool {
// and are due to template logic
return false
case errkit.ErrKindNetworkTemporary:
- // these should not be counted as host errors
- return false
+ // a single temporary error (timeout, i/o reset) is transient, but a host
+ // that produces them on every request with no success in between is
+ // unresponsive. Count it; MarkFailedOrRemove resets the host on the next
+ // successful response, so only consecutive failures reach MaxHostError.
+ return true
case errkit.ErrKindNetworkPermanent:
// these should be counted as host errors
return true
diff --git a/pkg/protocols/common/hosterrorscache/hosterrorscache_test.go b/pkg/protocols/common/hosterrorscache/hosterrorscache_test.go
index 6478e2c5e8..d5c7e8b8ab 100644
--- a/pkg/protocols/common/hosterrorscache/hosterrorscache_test.go
+++ b/pkg/protocols/common/hosterrorscache/hosterrorscache_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
+ "github.com/projectdiscovery/utils/errkit"
"github.com/stretchr/testify/require"
)
@@ -46,6 +47,78 @@ func TestCacheCheck(t *testing.T) {
})
}
+func TestCacheCheckTimeout(t *testing.T) {
+ // A host that consistently times out (request deadline exceeded) is
+ // unresponsive and must be skipped once MaxHostError consecutive timeouts
+ // are recorded. Production surfaces these as ErrKindNetworkTemporary.
+ cache := New(3, DefaultMaxHostsCount, nil)
+ err := errkit.New("context deadline exceeded (Client.Timeout exceeded while awaiting headers)").
+ SetKind(errkit.ErrKindNetworkTemporary)
+
+ t.Run("flagged after threshold", func(t *testing.T) {
+ ctx := newCtxArgs(t.Name())
+ for i := 1; i <= 3; i++ {
+ cache.MarkFailed(protoType, ctx, err)
+ }
+ require.True(t, cache.Check(protoType, ctx), "host with repeated timeouts must be skipped")
+ })
+
+ t.Run("reset on success keeps a live host", func(t *testing.T) {
+ ctx := newCtxArgs(t.Name())
+ cache.MarkFailed(protoType, ctx, err)
+ cache.MarkFailed(protoType, ctx, err)
+ cache.MarkFailedOrRemove(protoType, ctx, nil) // a successful response resets the host
+ require.False(t, cache.Check(protoType, ctx), "a host that responded must not be skipped")
+ })
+}
+
+func TestCacheCheckRawHTTPTimeout(t *testing.T) {
+ // rawhttp/unsafe templates surface read timeouts as a plain-string error
+ // ("ReadStatusLine: ... i/o timeout") that errkit cannot classify, so it
+ // reaches the regex fallback. A host that produces these on every request
+ // must still be skipped.
+ cache := New(3, DefaultMaxHostsCount, nil)
+ err := errors.New("ReadStatusLine: read tcp 127.0.0.1:60087->127.0.0.1:18080: i/o timeout")
+
+ ctx := newCtxArgs(t.Name())
+ for i := 1; i <= 3; i++ {
+ cache.MarkFailed(protoType, ctx, err)
+ }
+ require.True(t, cache.Check(protoType, ctx), "host with repeated rawhttp i/o timeouts must be skipped")
+}
+
+func TestMarkSkipsParentContextCancellation(t *testing.T) {
+ // A failure that happens because the caller's (parent scan) context was
+ // cancelled or hit its deadline is not the host's fault and must not be
+ // counted. context.DeadlineExceeded otherwise classifies as a temporary
+ // network error and would wrongly accumulate.
+ cache := New(3, DefaultMaxHostsCount, nil)
+ parent, cancel := context.WithCancel(context.Background())
+ cancel()
+ ctx := contextargs.NewWithInput(parent, "cancelled-host")
+ timeout := errkit.New("context deadline exceeded").SetKind(errkit.ErrKindNetworkTemporary)
+
+ for i := 0; i < 5; i++ {
+ cache.MarkFailedOrRemove(protoType, ctx, timeout)
+ }
+ require.False(t, cache.Check(protoType, ctx), "failures under a cancelled parent context must not mark the host")
+}
+
+func TestNonConsecutiveTimeoutsDoNotSkip(t *testing.T) {
+ // A live host that times out intermittently but succeeds in between must not
+ // be skipped: a success resets the count so only consecutive failures reach
+ // the threshold. Guards the property the HTTP path relies on.
+ cache := New(3, DefaultMaxHostsCount, nil)
+ ctx := newCtxArgs(t.Name())
+ timeout := errkit.New("i/o timeout").SetKind(errkit.ErrKindNetworkTemporary)
+
+ cache.MarkFailedOrRemove(protoType, ctx, timeout)
+ cache.MarkFailedOrRemove(protoType, ctx, timeout)
+ cache.MarkFailedOrRemove(protoType, ctx, nil) // successful response resets the host
+ cache.MarkFailedOrRemove(protoType, ctx, timeout)
+ require.False(t, cache.Check(protoType, ctx), "a success between timeouts must reset the count")
+}
+
func TestTrackErrors(t *testing.T) {
cache := New(3, DefaultMaxHostsCount, []string{"custom error"})
diff --git a/pkg/protocols/common/interactsh/interactsh.go b/pkg/protocols/common/interactsh/interactsh.go
index 9ebb932f29..7cbd181d72 100644
--- a/pkg/protocols/common/interactsh/interactsh.go
+++ b/pkg/protocols/common/interactsh/interactsh.go
@@ -12,8 +12,7 @@ import (
"errors"
- "github.com/Mzack9999/gcache"
-
+ "github.com/projectdiscovery/gcache"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/interactsh/pkg/client"
"github.com/projectdiscovery/interactsh/pkg/server"
diff --git a/pkg/protocols/common/protocolstate/dialers.go b/pkg/protocols/common/protocolstate/dialers.go
index 91bdbae514..7e58727011 100644
--- a/pkg/protocols/common/protocolstate/dialers.go
+++ b/pkg/protocols/common/protocolstate/dialers.go
@@ -7,14 +7,15 @@ 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
+ PerHostRateLimitPool any // *httpclientpool.PerHostRateLimitPool
+ HTTPToHTTPSPortTracker any // *httpclientpool.HTTPToHTTPSPortTracker
NetworkPolicy *networkpolicy.NetworkPolicy
LocalFileAccessAllowed bool
RestrictLocalNetworkAccess bool
diff --git a/pkg/protocols/common/protocolstate/httppool.go b/pkg/protocols/common/protocolstate/httppool.go
new file mode 100644
index 0000000000..d85cf62145
--- /dev/null
+++ b/pkg/protocols/common/protocolstate/httppool.go
@@ -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
+ })
+}
diff --git a/pkg/protocols/common/protocolstate/httppool_test.go b/pkg/protocols/common/protocolstate/httppool_test.go
new file mode 100644
index 0000000000..b33bf763c7
--- /dev/null
+++ b/pkg/protocols/common/protocolstate/httppool_test.go
@@ -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")
+}
diff --git a/pkg/protocols/common/protocolstate/js.go b/pkg/protocols/common/protocolstate/js.go
index 79fc654c03..6b51c1e0e2 100644
--- a/pkg/protocols/common/protocolstate/js.go
+++ b/pkg/protocols/common/protocolstate/js.go
@@ -1,8 +1,8 @@
package protocolstate
import (
- "github.com/Mzack9999/goja"
- "github.com/Mzack9999/goja/parser"
+ "github.com/projectdiscovery/goja"
+ "github.com/projectdiscovery/goja/parser"
"github.com/projectdiscovery/gologger"
)
diff --git a/pkg/protocols/common/protocolstate/memguardian_test.go b/pkg/protocols/common/protocolstate/memguardian_test.go
index 7306b81e23..acaf2cdef7 100644
--- a/pkg/protocols/common/protocolstate/memguardian_test.go
+++ b/pkg/protocols/common/protocolstate/memguardian_test.go
@@ -18,6 +18,9 @@ func TestMemGuardianGoroutineLeak(t *testing.T) {
goleak.IgnoreAnyContainingPkg("github.com/go-rod/rod"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/interactsh/pkg/server"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
+ // expirable LRU cache creates a background goroutine for TTL expiration that persists
+ // see: https://github.com/hashicorp/golang-lru/blob/770151e9c8cdfae1797826b7b74c33d6f103fbd8/expirable/expirable_lru.go#L79
+ goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"),
)
// Initialize memguardian if not already initialized
@@ -104,6 +107,9 @@ func TestMemGuardianReset(t *testing.T) {
goleak.IgnoreAnyContainingPkg("github.com/go-rod/rod"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/interactsh/pkg/server"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
+ // expirable LRU cache creates a background goroutine for TTL expiration that persists
+ // see: https://github.com/hashicorp/golang-lru/blob/770151e9c8cdfae1797826b7b74c33d6f103fbd8/expirable/expirable_lru.go#L79
+ goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"),
)
// Ensure clean state
diff --git a/pkg/protocols/common/protocolstate/state.go b/pkg/protocols/common/protocolstate/state.go
index 6cebdbc41b..e577e37b77 100644
--- a/pkg/protocols/common/protocolstate/state.go
+++ b/pkg/protocols/common/protocolstate/state.go
@@ -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,
@@ -283,7 +282,7 @@ func interfaceAddresses(interfaceName string) ([]net.Addr, error) {
return addrs, nil
}
-// Close closes the global shared fastdialer
+// Close closes the global shared fastdialer and associated protocol state resources
func Close(executionId string) {
dialersInstance, ok := dialers.Get(executionId)
if !ok {
@@ -291,7 +290,21 @@ func Close(executionId string) {
}
if dialersInstance != nil {
- dialersInstance.Fastdialer.Close()
+ // Drop all cached HTTP clients/transports and close their idle
+ // keep-alive connections to avoid lingering transport goroutines
+ // after shutdown.
+ if dialersInstance.HTTPClientPool != nil {
+ dialersInstance.HTTPClientPool.Close()
+ }
+ if dialersInstance.Fastdialer != nil {
+ dialersInstance.Fastdialer.Close()
+ }
+ if pool, ok := dialersInstance.PerHostRateLimitPool.(interface{ Close() }); ok && pool != nil {
+ pool.Close()
+ }
+ if tracker, ok := dialersInstance.HTTPToHTTPSPortTracker.(interface{ Purge() }); ok && tracker != nil {
+ tracker.Purge()
+ }
}
dialers.Delete(executionId)
diff --git a/pkg/protocols/common/variables/variables_test.go b/pkg/protocols/common/variables/variables_test.go
index 0089c92c66..1763024c55 100644
--- a/pkg/protocols/common/variables/variables_test.go
+++ b/pkg/protocols/common/variables/variables_test.go
@@ -7,8 +7,8 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/stretchr/testify/require"
- "gopkg.in/yaml.v2"
)
func TestVariablesEvaluate(t *testing.T) {
diff --git a/pkg/protocols/headless/engine/util.go b/pkg/protocols/headless/engine/util.go
index 1fb08838c9..93f6cda821 100644
--- a/pkg/protocols/headless/engine/util.go
+++ b/pkg/protocols/headless/engine/util.go
@@ -1,9 +1,9 @@
package engine
import (
+ "github.com/projectdiscovery/fasttemplate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/expressions"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/marker"
- "github.com/valyala/fasttemplate"
)
// replaceWithValues replaces the template markers with the values
diff --git a/pkg/protocols/http/build_request.go b/pkg/protocols/http/build_request.go
index 079896fa25..89a17a4a8d 100644
--- a/pkg/protocols/http/build_request.go
+++ b/pkg/protocols/http/build_request.go
@@ -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"
@@ -485,9 +484,26 @@ 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() {
+ // Respect the connection reuse policy from the smart analyzer.
+ switch r.request.connectionReusePolicy {
+ case ReuseUnsafe:
+ // Force connection close for requests that must not reuse connections
+ if req.Header.Get("Connection") == "" {
+ req.Header.Set("Connection", "close")
+ }
req.Close = true
+ case ReuseSafe:
+ // Allow connection pooling: drop any explicit close header
+ if strings.EqualFold(req.Header.Get("Connection"), "close") {
+ req.Header.Del("Connection")
+ }
+ default:
+ // Defer to the connection-level keep-alive decision: per-host clients
+ // keep connections alive unless a template explicitly disabled it.
+ if r.request.connConfiguration != nil && r.request.connConfiguration.Connection != nil &&
+ r.request.connConfiguration.Connection.DisableKeepAlive && req.Header.Get("Connection") == "" {
+ req.Close = true
+ }
}
// Check if the user requested a request body
diff --git a/pkg/protocols/http/http.go b/pkg/protocols/http/http.go
index 9bc898a98c..a75542ebe6 100644
--- a/pkg/protocols/http/http.go
+++ b/pkg/protocols/http/http.go
@@ -8,15 +8,13 @@ import (
"time"
"github.com/invopop/jsonschema"
- json "github.com/json-iterator/go"
"github.com/pkg/errors"
"github.com/projectdiscovery/fastdialer/fastdialer"
- _ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/time"
- _ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/xss"
-
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
+ _ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/time"
+ _ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers/xss"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
@@ -25,10 +23,9 @@ 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/json"
"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 +142,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
@@ -159,6 +155,10 @@ type Request struct {
// - "AWS"
Signature SignatureTypeHolder `yaml:"signature,omitempty" json:"signature,omitempty" jsonschema:"title=signature is the http request signature method,description=Signature is the HTTP Request signature Method,enum=AWS"`
+ // connectionReusePolicy stores the analyzed connection reuse policy
+ // This is set during Compile() based on template analysis
+ connectionReusePolicy ConnectionReusePolicy `yaml:"-" json:"-"`
+
// description: |
// SkipSecretFile skips the authentication or authorization configured in the secret file.
SkipSecretFile bool `yaml:"skip-secret-file,omitempty" json:"skip-secret-file,omitempty" jsonschema:"title=bypass secret file,description=Skips the authentication or authorization configured in the secret file"`
@@ -310,13 +310,35 @@ func (request *Request) Compile(options *protocols.ExecutorOptions) error {
return errors.Wrap(err, "validation error")
}
+ // Analyze connection reuse policy to determine if we can safely reuse connections
+ forceHTTP2 := options.Options != nil && options.Options.ForceAttemptHTTP2
+ reusePolicy := request.AnalyzeConnectionReuse(forceHTTP2)
+ request.connectionReusePolicy = reusePolicy
+
+ // Determine if keep-alive should be disabled
+ // If policy is ReuseUnsafe, we must disable keep-alive to preserve existing behavior
+ // Otherwise, use the standard logic (which may enable keep-alive)
+ var disableKeepAlive bool
+ switch reusePolicy {
+ case ReuseUnsafe:
+ // Preserve existing behavior: disable keep-alive for unsafe requests
+ disableKeepAlive = true
+ case ReuseSafe:
+ // Enable keep-alive for safe requests to allow connection pooling
+ disableKeepAlive = false
+ default:
+ // ReuseUnknown: keep-alive stays enabled so the per-host client pool can
+ // reuse connections (matches the default pooling behavior)
+ disableKeepAlive = false
+ }
+
connectionConfiguration := &httpclientpool.Configuration{
Threads: request.Threads,
MaxRedirects: request.MaxRedirects,
NoTimeout: false,
DisableCookie: request.DisableCookie,
Connection: &httpclientpool.ConnectionConfiguration{
- DisableKeepAlive: httputil.ShouldDisableKeepAlive(options.Options),
+ DisableKeepAlive: disableKeepAlive,
},
RedirectFlow: httpclientpool.DontFollowRedirect,
}
@@ -353,13 +375,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,
@@ -545,6 +561,18 @@ const (
SetThreadToCountZero = "set-thread-count-to-zero"
)
+// ConnectionReusePolicy determines whether a request can safely reuse connections
+type ConnectionReusePolicy int
+
+const (
+ // ReuseUnknown indicates the policy hasn't been analyzed yet
+ ReuseUnknown ConnectionReusePolicy = iota
+ // ReuseSafe indicates the request can safely reuse connections (enable connection pooling)
+ ReuseSafe
+ // ReuseUnsafe indicates the request must close connections (preserve existing behavior)
+ ReuseUnsafe
+)
+
func init() {
stats.NewEntry(SetThreadToCountZero, "Setting thread count to 0 for %d templates, dynamic extractors are not supported with payloads yet")
}
@@ -558,3 +586,81 @@ func (r *Request) UpdateOptions(opts *protocols.ExecutorOptions) {
func (request *Request) HasFuzzing() bool {
return len(request.Fuzzing) > 0
}
+
+// AnalyzeConnectionReuse determines if a request can safely reuse connections.
+// Returns ReuseUnsafe if connection closure is required, ReuseSafe otherwise.
+// This analysis ensures backward compatibility by preserving connection-close behavior
+// when necessary while enabling connection pooling for other requests.
+//
+// forceHTTP2 reports whether HTTP/2 may be negotiated (only possible when the
+// user enables it, since the pooled transport sets custom dial hooks). It only
+// affects the time_delay analyzer decision below.
+func (r *Request) AnalyzeConnectionReuse(forceHTTP2 bool) ConnectionReusePolicy {
+ // Priority 0: race and pipeline requests need dedicated connections. Race
+ // uses a one-shot synced body gate that breaks if a connection is reused and
+ // the body is re-read, and reusing a single keep-alive connection would also
+ // serialize the requests and defeat the race.
+ if r.Race || r.Pipeline {
+ return ReuseUnsafe
+ }
+
+ // Priority 1: Check for explicit "Connection: close" header in raw requests
+ for _, raw := range r.Raw {
+ if hasConnectionCloseHeader(raw) {
+ return ReuseUnsafe
+ }
+ }
+
+ // Priority 2: Check for "Connection: close" in regular headers
+ for key, value := range r.Headers {
+ if strings.EqualFold(key, "Connection") && strings.Contains(strings.ToLower(value), "close") {
+ return ReuseUnsafe
+ }
+ }
+
+ // Priority 3: time-based analyzers. The time_delay analyzer measures only the
+ // server-side window (httptrace WroteHeaders -> GotFirstResponseByte), so
+ // connection setup is excluded from the timing. Under HTTP/1.1 net/http never
+ // shares an in-flight connection, so keep-alive reuse saves handshakes without
+ // affecting the measurement -> safe to reuse. Under HTTP/2 concurrent sleeping
+ // probes can multiplex onto one connection and add timing jitter, so force
+ // fresh connections only in that case to keep detection error-free.
+ if r.Analyzer != nil && r.Analyzer.Name == "time_delay" {
+ if forceHTTP2 {
+ return ReuseUnsafe
+ }
+ return ReuseSafe
+ }
+
+ // Default: Safe to reuse - enable connection pooling
+ return ReuseSafe
+}
+
+// hasConnectionCloseHeader checks if a raw HTTP request contains "Connection: close"
+// Case-insensitive check for both "Connection:" and "close"
+func hasConnectionCloseHeader(raw string) bool {
+ rawLower := strings.ToLower(raw)
+ // Check for "connection:" header
+ if !strings.Contains(rawLower, "connection:") {
+ return false
+ }
+ // Check for "close" value after "connection:"
+ // Handle various formats: "Connection: close", "Connection:Close", "Connection: close\r\n", etc.
+ connIdx := strings.Index(rawLower, "connection:")
+ if connIdx == -1 {
+ return false
+ }
+ // Extract the value after "connection:"
+ valueStart := connIdx + len("connection:")
+ // Skip whitespace
+ for valueStart < len(rawLower) && (rawLower[valueStart] == ' ' || rawLower[valueStart] == '\t') {
+ valueStart++
+ }
+ // Check if the value contains "close"
+ value := rawLower[valueStart:]
+ // Find end of line or end of string
+ if newlineIdx := strings.IndexAny(value, "\r\n"); newlineIdx != -1 {
+ value = value[:newlineIdx]
+ }
+ return strings.Contains(value, "close")
+}
diff --git a/pkg/protocols/http/http_test.go b/pkg/protocols/http/http_test.go
index a2b9226281..cf7d92cf57 100644
--- a/pkg/protocols/http/http_test.go
+++ b/pkg/protocols/http/http_test.go
@@ -5,10 +5,11 @@ import (
"github.com/stretchr/testify/require"
+ "github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
+ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
- "github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
)
func TestHTTPCompile(t *testing.T) {
@@ -40,3 +41,67 @@ Accept-Encoding: gzip`},
require.Equal(t, 6, request.Requests(), "could not get correct number of requests")
require.Equal(t, map[string]string{"User-Agent": "test", "Hello": "World"}, request.customHeaders, "could not get correct custom headers")
}
+
+// TestAnalyzeConnectionReuse guards the connection-reuse policy: requests that
+// must not reuse pooled keep-alive connections (race, pipeline, explicit
+// "Connection: close", time-based analyzers) must be flagged ReuseUnsafe, while
+// everything else stays ReuseSafe so dev's per-host pooling keeps connections alive.
+func TestAnalyzeConnectionReuse(t *testing.T) {
+ tests := []struct {
+ name string
+ request *Request
+ forceHTTP2 bool
+ want ConnectionReusePolicy
+ }{
+ {
+ name: "plain request is safe",
+ request: &Request{Path: []string{"{{BaseURL}}"}},
+ want: ReuseSafe,
+ },
+ {
+ name: "raw request without close is safe",
+ request: &Request{Raw: []string{"GET / HTTP/1.1\r\nHost: {{Hostname}}\r\n\r\n"}},
+ want: ReuseSafe,
+ },
+ {
+ name: "race is unsafe",
+ request: &Request{Race: true, RaceNumberRequests: 5},
+ want: ReuseUnsafe,
+ },
+ {
+ name: "pipeline is unsafe",
+ request: &Request{Pipeline: true},
+ want: ReuseUnsafe,
+ },
+ {
+ name: "raw connection close is unsafe",
+ request: &Request{Raw: []string{"GET / HTTP/1.1\r\nHost: {{Hostname}}\r\nConnection: close\r\n\r\n"}},
+ want: ReuseUnsafe,
+ },
+ {
+ name: "header connection close is unsafe",
+ request: &Request{Headers: map[string]string{"Connection": "close"}},
+ want: ReuseUnsafe,
+ },
+ {
+ // time_delay measures the server-side window only, so HTTP/1.1 reuse is
+ // measurement-safe and lets time-based fuzzing reuse connections.
+ name: "time_delay is safe under http1",
+ request: &Request{Analyzer: &analyzers.AnalyzerTemplate{Name: "time_delay"}},
+ want: ReuseSafe,
+ },
+ {
+ // Under HTTP/2 concurrent sleeping probes can multiplex and add jitter,
+ // so fresh connections are required to keep detection error-free.
+ name: "time_delay is unsafe under http2",
+ request: &Request{Analyzer: &analyzers.AnalyzerTemplate{Name: "time_delay"}},
+ forceHTTP2: true,
+ want: ReuseUnsafe,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, tt.request.AnalyzeConnectionReuse(tt.forceHTTP2))
+ })
+ }
+}
diff --git a/pkg/protocols/http/httpclientpool/clientpool.go b/pkg/protocols/http/httpclientpool/clientpool.go
index 19fa300112..9606ba022f 100644
--- a/pkg/protocols/http/httpclientpool/clientpool.go
+++ b/pkg/protocols/http/httpclientpool/clientpool.go
@@ -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,127 @@ 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/ratelimit"
"github.com/projectdiscovery/rawhttp"
"github.com/projectdiscovery/retryablehttp-go"
urlutil "github.com/projectdiscovery/utils/url"
)
+var connStats ConnectionStats
+
+// perHostConnStats buckets connection reuse per normalized host alongside the
+// global connStats counters. It is populated from the same httptrace.GotConn
+// hook in connTrackingTransport, so per-host visibility adds no extra trace
+// plumbing or second round-trip wrapper. Reads are lock-free (sync.Map) and
+// each bucket uses atomics, keeping the hot path cheap.
+var perHostConnStats sync.Map // map[string]*hostConnStat
+
+// ConnectionStats tracks HTTP connection reuse across the scan.
+type ConnectionStats struct {
+ New atomic.Int64
+ Reused atomic.Int64
+}
+
+// hostConnStat holds per-host new/reused connection counters.
+type hostConnStat struct {
+ New atomic.Int64
+ Reused atomic.Int64
+}
+
+// PerHostConnStat is a point-in-time snapshot of a single host's connection reuse.
+type PerHostConnStat struct {
+ Host string
+ New int64
+ Reused int64
+}
+
+// recordHostConn records a connection event for a single host. It is called
+// from the global GotConn hook so the global and per-host views stay in sync.
+func recordHostConn(host string, reused bool) {
+ if host == "" {
+ return
+ }
+ v, ok := perHostConnStats.Load(host)
+ if !ok {
+ v, _ = perHostConnStats.LoadOrStore(host, &hostConnStat{})
+ }
+ hs := v.(*hostConnStat)
+ if reused {
+ hs.Reused.Add(1)
+ } else {
+ hs.New.Add(1)
+ }
+}
+
+// GetPerHostConnectionStats returns a snapshot of per-host connection reuse.
+func GetPerHostConnectionStats() []PerHostConnStat {
+ var out []PerHostConnStat
+ perHostConnStats.Range(func(k, v any) bool {
+ hs := v.(*hostConnStat)
+ out = append(out, PerHostConnStat{
+ Host: k.(string),
+ New: hs.New.Load(),
+ Reused: hs.Reused.Load(),
+ })
+ return true
+ })
+ return out
+}
+
+// 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
+// (both the global totals and the per-host breakdown). Intended to be called at
+// the start of an execution to scope the metrics to a single run.
+func ResetConnectionStats() {
+ connStats.New.Store(0)
+ connStats.Reused.Store(0)
+ perHostConnStats.Range(func(k, _ any) bool {
+ perHostConnStats.Delete(k)
+ return true
+ })
+}
+
+// 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) {
+ // Compute the host key once (URL is already parsed) so the GotConn hook can
+ // update both the global counters and the per-host bucket from one trace.
+ host := normalizeHost(req.URL)
+ trace := &httptrace.ClientTrace{
+ GotConn: func(info httptrace.GotConnInfo) {
+ if info.Reused {
+ connStats.Reused.Add(1)
+ } else {
+ connStats.New.Add(1)
+ }
+ recordHostConn(host, info.Reused)
+ },
+ }
+ 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 +228,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,206 +278,247 @@ 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
- }
-
- return wrappedGet(options, configuration)
+// 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()
- // 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
- retryableHttpOptions.NoAdjustTimeout = true
+ clientKey := configuration.Hash()
+ if host != "" {
+ clientKey += ":" + host
+ }
- if configuration.Threads > 0 || options.ScanStrategy == scanstrategy.HostSpray.String() {
- // Single host
- retryableHttpOptions = retryablehttp.DefaultOptionsSingle
- disableKeepAlives = false
- maxIdleConnsPerHost = 500
- maxConnsPerHost = 500
+ // 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
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
}
- redirectFlow := configuration.RedirectFlow
- maxRedirects := configuration.MaxRedirects
- if options.ShouldFollowHTTPRedirects() {
- // by default we enable general redirects following
- switch {
- case options.FollowHostRedirects:
- redirectFlow = FollowSameHostRedirect
- default:
- redirectFlow = FollowAllRedirect
- }
- if options.MaxRedirects > 0 {
- maxRedirects = options.MaxRedirects
- }
- }
- if options.DisableRedirects {
- options.FollowRedirects = false
- options.FollowHostRedirects = false
- redirectFlow = DontFollowRedirect
- maxRedirects = 0
+ maxIdleConns := 4
+ maxIdleConnsPerHost := 4
+ maxConnsPerHost := 0 // unlimited by default; the SPM handler controls concurrency
+ if configuration.Threads > 0 {
+ maxIdleConnsPerHost = configuration.Threads
+ maxIdleConns = configuration.Threads
}
- // override connection's settings if required
- if configuration.Connection != nil {
- disableKeepAlives = configuration.Connection.DisableKeepAlive
- }
+ disableKeepAlives := configuration.Connection != nil && 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)
- }
- 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,
- }
+ // 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)
- if options.AliveHttpProxy != "" {
- if proxyURL, err := url.Parse(options.AliveHttpProxy); err == nil {
- transport.Proxy = http.ProxyURL(proxyURL)
+ createTransport := func() (http.RoundTripper, error) {
+ // Set the base TLS configuration definition
+ tlsConfig := &tls.Config{
+ Renegotiation: tls.RenegotiateOnceAsClient,
+ InsecureSkipVerify: true,
+ MinVersion: tls.VersionTLS10,
+ ClientSessionCache: sharedTLSSessionCache,
}
- } else if options.AliveSocksProxy != "" {
- socksURL, proxyErr := url.Parse(options.AliveSocksProxy)
- if proxyErr != nil {
- return nil, proxyErr
+
+ if options.SNI != "" {
+ tlsConfig.ServerName = options.SNI
}
- dialer, err := proxy.FromURL(socksURL, proxy.Direct)
+ tlsConfig, err := utils.AddConfiguredClientCertToRequest(tlsConfig, options)
if err != nil {
- return nil, err
+ return nil, errors.Wrap(err, "could not create client certificate")
}
- dc := dialer.(interface {
- DialContext(ctx context.Context, network, addr string) (net.Conn, error)
- })
+ 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
+ }
- 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)
+ dialer, err := proxy.FromURL(socksURL, proxy.Direct)
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)
+
+ 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
}
- tlsConfig.ServerName = host
+ 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 tls.Client(conn, tlsConfig), nil
}
+
+ return &connTrackingTransport{base: transport}, 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")
+ redirectFlow := configuration.RedirectFlow
+ maxRedirects := configuration.MaxRedirects
+
+ if options.ShouldFollowHTTPRedirects() {
+ switch {
+ case options.FollowHostRedirects:
+ redirectFlow = FollowSameHostRedirect
+ default:
+ redirectFlow = FollowAllRedirect
+ }
+ if options.MaxRedirects > 0 {
+ maxRedirects = options.MaxRedirects
}
}
-
- httpclient := &http.Client{
- Transport: transport,
- CheckRedirect: makeCheckRedirectFunc(redirectFlow, maxRedirects),
+ if options.DisableRedirects {
+ options.FollowRedirects = false
+ options.FollowHostRedirects = false
+ redirectFlow = DontFollowRedirect
+ maxRedirects = 0
}
- if !configuration.NoTimeout {
- httpclient.Timeout = options.GetTimeouts().HttpTimeout
- if configuration.Connection != nil && configuration.Connection.CustomMaxTimeout > 0 {
- httpclient.Timeout = configuration.Connection.CustomMaxTimeout
+
+ 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")
+ }
}
+
+ 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
}
- 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 {
+ if hasExplicitJar {
+ rt, err := pool.GetOrCreateTransport(transportKey, createTransport)
+ if err != nil {
return nil, err
}
+ return createClient(rt)
}
- return client, nil
+ // 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)
+}
+
+// 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)
+
+// 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
@@ -452,3 +617,91 @@ func isURLEncoded(s string) bool {
return decoded != s
}
+
+// GetPerHostRateLimiter gets or creates a rate limiter for a specific host
+// Returns nil if per-host rate limiting is not enabled
+func GetPerHostRateLimiter(options *types.Options, hostname string) (*ratelimit.Limiter, error) {
+ if !options.PerHostRateLimit {
+ return nil, nil
+ }
+
+ dialers := protocolstate.GetDialersWithId(options.ExecutionId)
+ if dialers == nil {
+ return nil, fmt.Errorf("dialers not initialized for %s", options.ExecutionId)
+ }
+
+ dialers.Lock()
+ if dialers.PerHostRateLimitPool == nil {
+ // Keep entries for the entire scan duration - no TTL-based eviction during scan
+ // so all hosts are tracked throughout the entire scan, even for very long scans
+ dialers.PerHostRateLimitPool = NewPerHostRateLimitPool(1024, 24*time.Hour, 24*time.Hour, options)
+ }
+ poolAny := dialers.PerHostRateLimitPool
+ dialers.Unlock()
+
+ pool, ok := poolAny.(*PerHostRateLimitPool)
+ if !ok || pool == nil {
+ return nil, nil
+ }
+
+ return pool.GetOrCreate(hostname)
+}
+
+// RecordPerHostRateLimitRequest records a request for pps stats calculation
+func RecordPerHostRateLimitRequest(options *types.Options, hostname string) {
+ if !options.PerHostRateLimit || hostname == "" {
+ return
+ }
+
+ dialers := protocolstate.GetDialersWithId(options.ExecutionId)
+ if dialers == nil {
+ return
+ }
+
+ dialers.Lock()
+ poolAny := dialers.PerHostRateLimitPool
+ dialers.Unlock()
+
+ pool, ok := poolAny.(*PerHostRateLimitPool)
+ if !ok || pool == nil {
+ return
+ }
+
+ pool.RecordRequest(hostname)
+}
+
+// GetHTTPToHTTPSPortTracker gets or creates the HTTP-to-HTTPS port tracker
+func GetHTTPToHTTPSPortTracker(options *types.Options) *HTTPToHTTPSPortTracker {
+ dialers := protocolstate.GetDialersWithId(options.ExecutionId)
+ if dialers == nil {
+ return nil
+ }
+
+ dialers.Lock()
+ if dialers.HTTPToHTTPSPortTracker == nil {
+ dialers.HTTPToHTTPSPortTracker = NewHTTPToHTTPSPortTracker()
+ }
+ trackerAny := dialers.HTTPToHTTPSPortTracker
+ dialers.Unlock()
+
+ tracker, ok := trackerAny.(*HTTPToHTTPSPortTracker)
+ if !ok || tracker == nil {
+ return nil
+ }
+
+ return tracker
+}
+
+// RecordHTTPToHTTPSPortMismatch records that a host:port requires HTTPS
+func RecordHTTPToHTTPSPortMismatch(options *types.Options, hostname string) {
+ if hostname == "" {
+ return
+ }
+
+ tracker := GetHTTPToHTTPSPortTracker(options)
+ if tracker == nil {
+ return
+ }
+
+ tracker.RecordHTTPToHTTPSPort(hostname)
+}
diff --git a/pkg/protocols/http/httpclientpool/clientpool_benchmark_test.go b/pkg/protocols/http/httpclientpool/clientpool_benchmark_test.go
new file mode 100644
index 0000000000..dc2d634c97
--- /dev/null
+++ b/pkg/protocols/http/httpclientpool/clientpool_benchmark_test.go
@@ -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)
+}
diff --git a/pkg/protocols/http/httpclientpool/clientpool_get_test.go b/pkg/protocols/http/httpclientpool/clientpool_get_test.go
new file mode 100644
index 0000000000..467cb42ab3
--- /dev/null
+++ b/pkg/protocols/http/httpclientpool/clientpool_get_test.go
@@ -0,0 +1,210 @@
+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)
+ recordHostConn("example.com", false)
+
+ 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")
+ require.Empty(t, GetPerHostConnectionStats(), "per-host stats must be cleared on reset")
+}
+
+// TestPerHostConnectionStats verifies that the per-host breakdown is recorded
+// alongside the global counters from the shared GotConn hook.
+func TestPerHostConnectionStats(t *testing.T) {
+ ResetConnectionStats()
+ t.Cleanup(ResetConnectionStats)
+
+ recordHostConn("a.example.com", false)
+ recordHostConn("a.example.com", true)
+ recordHostConn("a.example.com", true)
+ recordHostConn("b.example.com", false)
+ recordHostConn("", true) // empty host must be ignored
+
+ stats := GetPerHostConnectionStats()
+ byHost := make(map[string]PerHostConnStat, len(stats))
+ for _, s := range stats {
+ byHost[s.Host] = s
+ }
+
+ require.Len(t, stats, 2, "only non-empty hosts should be tracked")
+ require.Equal(t, int64(1), byHost["a.example.com"].New)
+ require.Equal(t, int64(2), byHost["a.example.com"].Reused)
+ require.Equal(t, int64(1), byHost["b.example.com"].New)
+ require.Equal(t, int64(0), byHost["b.example.com"].Reused)
+}
diff --git a/pkg/protocols/http/httpclientpool/clientpool_pr_perf_test.go b/pkg/protocols/http/httpclientpool/clientpool_pr_perf_test.go
new file mode 100644
index 0000000000..edf26cb3fe
--- /dev/null
+++ b/pkg/protocols/http/httpclientpool/clientpool_pr_perf_test.go
@@ -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)
+ }
+}
diff --git a/pkg/protocols/http/httpclientpool/host_normalize.go b/pkg/protocols/http/httpclientpool/host_normalize.go
new file mode 100644
index 0000000000..46687f0a09
--- /dev/null
+++ b/pkg/protocols/http/httpclientpool/host_normalize.go
@@ -0,0 +1,108 @@
+package httpclientpool
+
+import (
+ "fmt"
+ "net"
+ "strings"
+
+ urlutil "github.com/projectdiscovery/utils/url"
+)
+
+// normalizeHostPort extracts and normalizes "hostname:port" from a URL or
+// host[:port] string. Default ports (80/443) are derived from the scheme when
+// missing. It is shared by the per-host rate limit pool and the HTTP-to-HTTPS
+// port tracker so that both group entries by the same key.
+func normalizeHostPort(rawURL string) string {
+ if rawURL == "" {
+ return ""
+ }
+
+ parsed, err := urlutil.Parse(rawURL)
+ if err != nil {
+ // If parsing fails, try to extract host:port manually
+ return extractHostPort(rawURL)
+ }
+
+ scheme := parsed.Scheme
+ if scheme == "" {
+ scheme = "http"
+ }
+
+ // Extract just the hostname (without port) and port separately
+ hostname := parsed.Hostname()
+ if hostname == "" {
+ // Fallback: try to extract from Host field
+ host := parsed.Host
+ if host != "" {
+ // Split host:port if port is present
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ hostname = h
+ } else {
+ hostname = host
+ }
+ }
+ }
+
+ if hostname == "" {
+ return extractHostPort(rawURL)
+ }
+
+ port := parsed.Port()
+ if port == "" {
+ // Use default ports based on scheme
+ if scheme == "https" {
+ port = "443"
+ } else {
+ port = "80"
+ }
+ }
+
+ // Return just hostname:port (no scheme prefix)
+ return fmt.Sprintf("%s:%s", hostname, port)
+}
+
+// extractHostPort attempts to extract host:port from a string when URL parsing fails
+func extractHostPort(s string) string {
+ original := s
+ scheme := "http"
+
+ // Remove scheme prefix if present
+ if strings.HasPrefix(s, "http://") {
+ s = strings.TrimPrefix(s, "http://")
+ scheme = "http"
+ } else if strings.HasPrefix(s, "https://") {
+ s = strings.TrimPrefix(s, "https://")
+ scheme = "https"
+ }
+
+ // Extract up to first /, ?, #, space, or newline (path/query/fragment separator)
+ if idx := strings.IndexAny(s, "/?# \n\r\t"); idx != -1 {
+ s = s[:idx]
+ }
+
+ if s == "" {
+ return original // Return original if we can't extract anything
+ }
+
+ // Validate and split host:port
+ host, port, err := net.SplitHostPort(s)
+ if err == nil {
+ // Valid host:port format
+ if port == "" {
+ // Port is empty, use default
+ if scheme == "https" {
+ port = "443"
+ } else {
+ port = "80"
+ }
+ }
+ // Return just host:port (no scheme prefix)
+ return fmt.Sprintf("%s:%s", host, port)
+ }
+
+ // No port in string, add default port
+ if scheme == "https" {
+ return fmt.Sprintf("%s:443", s)
+ }
+ return fmt.Sprintf("%s:80", s)
+}
diff --git a/pkg/protocols/http/httpclientpool/http_to_https_tracker.go b/pkg/protocols/http/httpclientpool/http_to_https_tracker.go
new file mode 100644
index 0000000000..4765d595b2
--- /dev/null
+++ b/pkg/protocols/http/httpclientpool/http_to_https_tracker.go
@@ -0,0 +1,129 @@
+package httpclientpool
+
+import (
+ "sync/atomic"
+ "time"
+
+ "github.com/hashicorp/golang-lru/v2/expirable"
+ "github.com/projectdiscovery/gologger"
+)
+
+// HTTPToHTTPSPortTracker tracks host:port combinations that require HTTPS
+// This is used to automatically detect and correct cases where HTTP requests
+// are sent to HTTPS ports (detected via 400 error with specific message).
+//
+// NOTE: detection and correction apply to the standard net/http (retryablehttp)
+// request path only. Unsafe/raw (rawhttp) requests bypass this scheme rewrite.
+type HTTPToHTTPSPortTracker struct {
+ // ports is an LRU discovery cache bounded to prevent memory leaks in long-running engines
+ ports *expirable.LRU[string, struct{}]
+
+ // Statistics
+ totalDetections atomic.Uint64
+ totalCorrections atomic.Uint64
+}
+
+// NewHTTPToHTTPSPortTracker creates a new HTTP-to-HTTPS port tracker
+func NewHTTPToHTTPSPortTracker() *HTTPToHTTPSPortTracker {
+ return &HTTPToHTTPSPortTracker{
+ ports: expirable.NewLRU[string, struct{}](4096, nil, 24*time.Hour),
+ }
+}
+
+// RecordHTTPToHTTPSPort records that a host:port requires HTTPS
+func (t *HTTPToHTTPSPortTracker) RecordHTTPToHTTPSPort(hostPort string) {
+ if hostPort == "" || t.ports == nil {
+ return
+ }
+
+ normalizedHostPort := normalizeHostPort(hostPort)
+ if normalizedHostPort == "" {
+ return
+ }
+
+ if t.ports.Contains(normalizedHostPort) {
+ return // Already recorded, no need to log again
+ }
+ t.ports.Add(normalizedHostPort, struct{}{})
+ t.totalDetections.Add(1)
+
+ gologger.Debug().Msgf("[http-to-https-tracker] Detected HTTP-to-HTTPS port mismatch for %s", normalizedHostPort)
+}
+
+// RequiresHTTPS checks if a host:port requires HTTPS
+func (t *HTTPToHTTPSPortTracker) RequiresHTTPS(hostPort string) bool {
+ if hostPort == "" || t.ports == nil {
+ return false
+ }
+
+ normalizedHostPort := normalizeHostPort(hostPort)
+ if normalizedHostPort == "" {
+ return false
+ }
+
+ _, ok := t.ports.Get(normalizedHostPort)
+ return ok
+}
+
+// RecordCorrection records that an HTTP->HTTPS correction was actually applied
+func (t *HTTPToHTTPSPortTracker) RecordCorrection() {
+ t.totalCorrections.Add(1)
+}
+
+// Evict removes a host:port from the tracker. It is used to self-heal a false
+// positive: when an http->https correction is applied but the https request
+// then fails, the original http scheme is retried and, if the entry was wrong,
+// evicted so subsequent requests (including those from unrelated templates)
+// against the same host:port are not silently broken.
+func (t *HTTPToHTTPSPortTracker) Evict(hostPort string) {
+ if hostPort == "" || t.ports == nil {
+ return
+ }
+
+ normalizedHostPort := normalizeHostPort(hostPort)
+ if normalizedHostPort == "" {
+ return
+ }
+
+ if t.ports.Remove(normalizedHostPort) {
+ gologger.Debug().Msgf("[http-to-https-tracker] Reverted HTTP-to-HTTPS for %s (https attempt failed, falling back to http)", normalizedHostPort)
+ }
+}
+
+// Purge removes all tracked entries from the tracker
+func (t *HTTPToHTTPSPortTracker) Purge() {
+ if t.ports != nil {
+ t.ports.Purge()
+ }
+}
+
+// Stats returns statistics about the tracker
+func (t *HTTPToHTTPSPortTracker) Stats() HTTPToHTTPSPortStats {
+ tracked := 0
+ if t.ports != nil {
+ tracked = t.ports.Len()
+ }
+ return HTTPToHTTPSPortStats{
+ TotalDetections: t.totalDetections.Load(),
+ TotalCorrections: t.totalCorrections.Load(),
+ TrackedPorts: tracked,
+ }
+}
+
+// HTTPToHTTPSPortStats contains statistics about the HTTP-to-HTTPS port tracker
+type HTTPToHTTPSPortStats struct {
+ TotalDetections uint64
+ TotalCorrections uint64
+ TrackedPorts int
+}
+
+// PrintStats prints statistics about the tracker
+func (t *HTTPToHTTPSPortTracker) PrintStats() {
+ stats := t.Stats()
+ if stats.TotalDetections == 0 {
+ return
+ }
+
+ gologger.Info().Msgf("[http-to-https-tracker] HTTP-to-HTTPS port corrections: Detections=%d Corrections=%d TrackedPorts=%d",
+ stats.TotalDetections, stats.TotalCorrections, stats.TrackedPorts)
+}
diff --git a/pkg/protocols/http/httpclientpool/http_to_https_tracker_test.go b/pkg/protocols/http/httpclientpool/http_to_https_tracker_test.go
new file mode 100644
index 0000000000..220eac1ff5
--- /dev/null
+++ b/pkg/protocols/http/httpclientpool/http_to_https_tracker_test.go
@@ -0,0 +1,36 @@
+package httpclientpool
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestHTTPToHTTPSPortTracker_RecordAndRequire(t *testing.T) {
+ tr := NewHTTPToHTTPSPortTracker()
+
+ require.False(t, tr.RequiresHTTPS("http://example.com:8443/path"), "unknown host should not require https")
+
+ tr.RecordHTTPToHTTPSPort("http://example.com:8443/path")
+ require.True(t, tr.RequiresHTTPS("http://example.com:8443/other"), "recorded host:port should require https regardless of path")
+ require.True(t, tr.RequiresHTTPS("https://example.com:8443/"), "lookup must be scheme-independent (keyed by host:port)")
+
+ require.EqualValues(t, 1, tr.Stats().TotalDetections)
+}
+
+// TestHTTPToHTTPSPortTracker_Evict guards the fallback mechanism: a wrongly detected
+// host:port must be removable so a failed https correction can revert to http
+// and stop breaking subsequent (cross-template) requests to the same target.
+func TestHTTPToHTTPSPortTracker_Evict(t *testing.T) {
+ tr := NewHTTPToHTTPSPortTracker()
+
+ tr.RecordHTTPToHTTPSPort("http://example.com:8080/")
+ require.True(t, tr.RequiresHTTPS("http://example.com:8080/"))
+
+ tr.Evict("http://example.com:8080/")
+ require.False(t, tr.RequiresHTTPS("http://example.com:8080/"), "evicted host:port must no longer require https")
+
+ // Evicting unknown / empty values must be safe no-ops.
+ tr.Evict("")
+ tr.Evict("http://not-recorded.example:1234/")
+}
diff --git a/pkg/protocols/http/httpclientpool/perhost_ratelimit_pool.go b/pkg/protocols/http/httpclientpool/perhost_ratelimit_pool.go
new file mode 100644
index 0000000000..b03ad3bacf
--- /dev/null
+++ b/pkg/protocols/http/httpclientpool/perhost_ratelimit_pool.go
@@ -0,0 +1,414 @@
+package httpclientpool
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/hashicorp/golang-lru/v2/expirable"
+ "github.com/projectdiscovery/gologger"
+ "github.com/projectdiscovery/nuclei/v3/pkg/types"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils"
+ "github.com/projectdiscovery/ratelimit"
+)
+
+type PerHostRateLimitPool struct {
+ cache *expirable.LRU[string, *rateLimitEntry]
+ capacity int
+ mu sync.Mutex
+ options *types.Options
+ maxLifetime time.Duration // Maximum lifetime for entries regardless of access
+
+ hits atomic.Uint64
+ misses atomic.Uint64
+ evictions atomic.Uint64
+}
+
+// ppsWindowSize is the number of recent request timestamps tracked per host
+// for pps calculation (heuristic balance between precision and memory)
+const ppsWindowSize = 100
+
+type rateLimitEntry struct {
+ limiter *ratelimit.Limiter
+ createdAt time.Time
+ accessCount atomic.Uint64
+ requestCount atomic.Uint64
+ firstRequestAt atomic.Int64 // UnixNano timestamp
+ lastRequestAt atomic.Int64 // UnixNano timestamp
+
+ // Fixed-size circular buffer of recent request timestamps for pps
+ // calculation: avoids the re-allocations of a sliding slice window
+ requestTimestamps [ppsWindowSize]int64
+ tsHead int // next write position
+ tsCount int // number of valid entries (up to ppsWindowSize)
+ requestMu sync.Mutex
+}
+
+func NewPerHostRateLimitPool(size int, maxIdleTime, maxLifetime time.Duration, options *types.Options) *PerHostRateLimitPool {
+ if size <= 0 {
+ size = 1024
+ }
+ // For global scan tracking, use very long TTL to keep entries for entire scan duration
+ // Default to 24 hours if not specified, which should cover even very long scans
+ if maxIdleTime == 0 {
+ maxIdleTime = 24 * time.Hour
+ }
+ if maxLifetime == 0 {
+ maxLifetime = 24 * time.Hour
+ }
+
+ ttl := maxIdleTime
+ if maxLifetime < maxIdleTime {
+ ttl = maxLifetime
+ }
+
+ pool := &PerHostRateLimitPool{
+ cache: expirable.NewLRU[string, *rateLimitEntry](
+ size,
+ func(key string, value *rateLimitEntry) {
+ if value.limiter != nil {
+ value.limiter.Stop()
+ }
+ gologger.Debug().Msgf("[perhost-ratelimit-pool] Evicted rate limiter for %s (age: %v, accesses: %d)",
+ key, time.Since(value.createdAt), value.accessCount.Load())
+ },
+ ttl,
+ ),
+ capacity: size,
+ options: options,
+ maxLifetime: maxLifetime,
+ }
+
+ pool.cache.Purge()
+
+ return pool
+}
+
+func (p *PerHostRateLimitPool) GetOrCreate(
+ host string,
+) (*ratelimit.Limiter, error) {
+ normalizedHost := normalizeHostPort(host)
+
+ // Try to get entry (this refreshes TTL in expirable LRU)
+ if entry, ok := p.cache.Get(normalizedHost); ok {
+ // Check if entry has exceeded maxLifetime
+ if p.maxLifetime > 0 && time.Since(entry.createdAt) > p.maxLifetime {
+ // Entry is too old, need to evict and recreate
+ // Acquire lock to safely evict
+ p.mu.Lock()
+ // Double-check after acquiring lock (another goroutine might have evicted it)
+ if entry, ok := p.cache.Peek(normalizedHost); ok {
+ // Check maxLifetime again (entry might have been replaced)
+ if time.Since(entry.createdAt) > p.maxLifetime {
+ if entry.limiter != nil {
+ entry.limiter.Stop()
+ }
+ p.cache.Remove(normalizedHost)
+ p.evictions.Add(1)
+ // Fall through to create new entry
+ } else {
+ // Entry was replaced or is now valid
+ entry.accessCount.Add(1)
+ p.hits.Add(1)
+ p.mu.Unlock()
+ return entry.limiter, nil
+ }
+ }
+ // Entry was evicted or doesn't exist, continue to create new one
+ } else {
+ // Entry is valid (not expired by maxLifetime)
+ entry.accessCount.Add(1)
+ p.hits.Add(1)
+ return entry.limiter, nil
+ }
+ } else {
+ // Entry doesn't exist, acquire lock to create
+ p.mu.Lock()
+ }
+
+ // At this point we have the lock and need to create a new entry
+ defer p.mu.Unlock()
+
+ // Double-check after acquiring lock (another goroutine might have created it)
+ if entry, ok := p.cache.Peek(normalizedHost); ok {
+ // Check maxLifetime
+ if p.maxLifetime > 0 && time.Since(entry.createdAt) > p.maxLifetime {
+ // Entry is too old, evict it
+ if entry.limiter != nil {
+ entry.limiter.Stop()
+ }
+ p.cache.Remove(normalizedHost)
+ p.evictions.Add(1)
+ } else {
+ // Entry exists and is valid
+ entry.accessCount.Add(1)
+ p.hits.Add(1)
+ return entry.limiter, nil
+ }
+ }
+
+ p.misses.Add(1)
+
+ // Create new rate limiter for this host
+ limiter := utils.GetRateLimiter(context.Background(), p.options.RateLimit, p.options.RateLimitDuration)
+
+ entry := &rateLimitEntry{
+ limiter: limiter,
+ createdAt: time.Now(),
+ }
+ entry.accessCount.Store(1)
+
+ evicted := p.cache.Add(normalizedHost, entry)
+ if evicted {
+ p.evictions.Add(1)
+ }
+
+ return limiter, nil
+}
+
+func (p *PerHostRateLimitPool) EvictHost(host string) bool {
+ normalizedHost := normalizeHostPort(host)
+
+ // Get entry before removing to stop limiter
+ entry, ok := p.cache.Peek(normalizedHost)
+ if ok && entry != nil && entry.limiter != nil {
+ entry.limiter.Stop()
+ }
+
+ existed := p.cache.Remove(normalizedHost)
+ if existed {
+ p.evictions.Add(1)
+ }
+ return existed
+}
+
+func (p *PerHostRateLimitPool) EvictAll() {
+ keys := p.cache.Keys()
+ for _, key := range keys {
+ if entry, ok := p.cache.Peek(key); ok && entry != nil && entry.limiter != nil {
+ entry.limiter.Stop()
+ }
+ }
+ count := p.cache.Len()
+ p.cache.Purge()
+ p.evictions.Add(uint64(count))
+}
+
+func (p *PerHostRateLimitPool) Size() int {
+ return p.cache.Len()
+}
+
+func (p *PerHostRateLimitPool) Stats() RateLimitPoolStats {
+ return RateLimitPoolStats{
+ Hits: p.hits.Load(),
+ Misses: p.misses.Load(),
+ Evictions: p.evictions.Load(),
+ Size: p.Size(),
+ }
+}
+
+func (p *PerHostRateLimitPool) Close() {
+ p.EvictAll()
+}
+
+type RateLimitPoolStats struct {
+ Hits uint64
+ Misses uint64
+ Evictions uint64
+ Size int
+}
+
+func (p *PerHostRateLimitPool) GetLimiterForHost(host string) (*ratelimit.Limiter, bool) {
+ normalizedHost := normalizeHostPort(host)
+
+ if entry, ok := p.cache.Peek(normalizedHost); ok {
+ return entry.limiter, true
+ }
+ return nil, false
+}
+
+func (p *PerHostRateLimitPool) ListAllLimiters() []string {
+ return p.cache.Keys()
+}
+
+type RateLimitInfo struct {
+ Host string
+ CreatedAt time.Time
+ AccessCount uint64
+ Age time.Duration
+}
+
+func (p *PerHostRateLimitPool) GetRateLimitInfo(host string) *RateLimitInfo {
+ normalizedHost := normalizeHostPort(host)
+
+ entry, ok := p.cache.Peek(normalizedHost)
+ if !ok {
+ return nil
+ }
+
+ now := time.Now()
+
+ return &RateLimitInfo{
+ Host: normalizedHost,
+ CreatedAt: entry.createdAt,
+ AccessCount: entry.accessCount.Load(),
+ Age: now.Sub(entry.createdAt),
+ }
+}
+
+func (p *PerHostRateLimitPool) GetAllRateLimitInfo() []*RateLimitInfo {
+ infos := []*RateLimitInfo{}
+ for _, key := range p.cache.Keys() {
+ if info := p.GetRateLimitInfo(key); info != nil {
+ infos = append(infos, info)
+ }
+ }
+ return infos
+}
+
+func (p *PerHostRateLimitPool) Resize(size int) int {
+ evicted := p.cache.Resize(size)
+ p.capacity = size
+ return evicted
+}
+
+func (p *PerHostRateLimitPool) Cap() int {
+ return p.capacity
+}
+
+// RecordRequest records a request timestamp for a host to calculate pps
+func (p *PerHostRateLimitPool) RecordRequest(host string) {
+ normalizedHost := normalizeHostPort(host)
+ entry, ok := p.cache.Peek(normalizedHost)
+ if !ok || entry == nil {
+ return
+ }
+
+ now := time.Now().UnixNano()
+ entry.requestCount.Add(1)
+
+ // Set first request time if not set
+ if entry.firstRequestAt.Load() == 0 {
+ entry.firstRequestAt.Store(now)
+ }
+ entry.lastRequestAt.Store(now)
+
+ // Track recent timestamps for pps calculation (circular buffer, no re-allocs)
+ entry.requestMu.Lock()
+ entry.requestTimestamps[entry.tsHead] = now
+ entry.tsHead = (entry.tsHead + 1) % ppsWindowSize
+ if entry.tsCount < ppsWindowSize {
+ entry.tsCount++
+ }
+ entry.requestMu.Unlock()
+}
+
+// calculatePPS calculates requests per second for a host based on recent requests
+func (p *PerHostRateLimitPool) calculatePPS(entry *rateLimitEntry) float64 {
+ if entry == nil {
+ return 0
+ }
+
+ entry.requestMu.Lock()
+ defer entry.requestMu.Unlock()
+
+ if entry.tsCount < 2 {
+ // Need at least 2 requests to calculate pps
+ return 0
+ }
+
+ now := time.Now().UnixNano()
+ // Calculate pps based on requests in the last second, walking the circular
+ // buffer backwards from the most recent timestamp
+ oneSecondAgo := now - int64(time.Second)
+ recentRequests := 0
+ for i := 0; i < entry.tsCount; i++ {
+ idx := (entry.tsHead - 1 - i + 2*ppsWindowSize) % ppsWindowSize
+ if entry.requestTimestamps[idx] >= oneSecondAgo {
+ recentRequests++
+ } else {
+ break
+ }
+ }
+
+ // If we have recent requests, use them; otherwise calculate from total time span
+ if recentRequests > 0 {
+ return float64(recentRequests)
+ }
+
+ // Fallback: calculate average pps from first to last request
+ first := entry.firstRequestAt.Load()
+ last := entry.lastRequestAt.Load()
+ if first == 0 || last == 0 || last <= first {
+ return 0
+ }
+
+ duration := time.Duration(last - first)
+ if duration <= 0 {
+ return 0
+ }
+
+ totalRequests := entry.requestCount.Load()
+ if totalRequests < 2 {
+ return 0
+ }
+
+ return float64(totalRequests) / duration.Seconds()
+}
+
+func (p *PerHostRateLimitPool) PrintStats() {
+ stats := p.Stats()
+ if stats.Size == 0 {
+ return
+ }
+ var hitRate float64
+ if total := stats.Hits + stats.Misses; total > 0 {
+ hitRate = float64(stats.Hits) * 100 / float64(total)
+ }
+ gologger.Info().Msgf("[perhost-ratelimit-pool] Rate limit stats: Hits=%d Misses=%d HitRate=%.1f%% Hosts=%d",
+ stats.Hits, stats.Misses, hitRate, stats.Size)
+}
+
+// PrintPerHostPPSStats prints requests per second for each host
+func (p *PerHostRateLimitPool) PrintPerHostPPSStats() {
+ if p.Size() == 0 {
+ return
+ }
+
+ type hostStat struct {
+ host string
+ pps float64
+ requests uint64
+ age time.Duration
+ }
+
+ // Collect stats under lock, log after releasing it to avoid blocking
+ // concurrent GetOrCreate/RecordRequest callers during slow I/O
+ p.mu.Lock()
+ hostStats := []hostStat{}
+ for _, key := range p.cache.Keys() {
+ entry, ok := p.cache.Peek(key)
+ if !ok || entry == nil {
+ continue
+ }
+
+ hostStats = append(hostStats, hostStat{
+ host: key,
+ pps: p.calculatePPS(entry),
+ requests: entry.requestCount.Load(),
+ age: time.Since(entry.createdAt),
+ })
+ }
+ p.mu.Unlock()
+
+ if len(hostStats) == 0 {
+ return
+ }
+
+ gologger.Info().Msgf("[perhost-ratelimit-pool] Per-host requests per second (pps):")
+ for _, stat := range hostStats {
+ gologger.Info().Msgf(" %s: %.2f pps (total: %d requests, age: %v)",
+ stat.host, stat.pps, stat.requests, stat.age.Round(time.Second))
+ }
+}
diff --git a/pkg/protocols/http/race/syncedreadcloser.go b/pkg/protocols/http/race/syncedreadcloser.go
index 9aadf1c325..c7e8c83518 100644
--- a/pkg/protocols/http/race/syncedreadcloser.go
+++ b/pkg/protocols/http/race/syncedreadcloser.go
@@ -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)
diff --git a/pkg/protocols/http/raw/fuzz.go b/pkg/protocols/http/raw/fuzz.go
new file mode 100644
index 0000000000..49eb4cbc02
--- /dev/null
+++ b/pkg/protocols/http/raw/fuzz.go
@@ -0,0 +1,19 @@
+//go:build gofuzz
+// +build gofuzz
+
+package raw
+
+// Fuzz exercises raw HTTP request parsing in safe, unsafe, self-contained, and
+// path-automerge modes.
+func Fuzz(data []byte) int {
+ if len(data) == 0 {
+ return 0
+ }
+ if len(data) > fuzzMaxInputSize {
+ return -1
+ }
+ if !fuzzRawHTTPParsing(data) {
+ return 0
+ }
+ return 1
+}
diff --git a/pkg/protocols/http/raw/fuzz_harness.go b/pkg/protocols/http/raw/fuzz_harness.go
new file mode 100644
index 0000000000..490c2de78b
--- /dev/null
+++ b/pkg/protocols/http/raw/fuzz_harness.go
@@ -0,0 +1,355 @@
+package raw
+
+import (
+ "fmt"
+ "strings"
+
+ urlutil "github.com/projectdiscovery/utils/url"
+)
+
+const (
+ fuzzMaxInputSize = 16 << 10
+ fuzzMaxHeaders = 8
+ fuzzMaxValueBytes = 256
+)
+
+var (
+ fuzzRawMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"}
+ fuzzRawPaths = []string{"/", "", "/admin/login", "/api/v1/users?id=1", "1337?with=param", "http://127.0.0.1/foo?id=1"}
+ fuzzRawHosts = []string{"example.com", "{{Hostname}}", "127.0.0.1", "example.com:8080"}
+ fuzzInputURLs = []string{"https://example.com", "https://example.com/base", "http://target.local:8080/root?x=1", "http://httpbin.org/bar"}
+)
+
+type fuzzRawHeader struct {
+ key string
+ value string
+}
+
+type fuzzRawHTTPCandidate struct {
+ method string
+ path string
+ host string
+ inputURL string
+ unsafe bool
+ disablePathAutomerge bool
+ headers []fuzzRawHeader
+ body string
+}
+
+func fuzzRawHTTPParsing(data []byte) bool {
+ rawRequest, inputURL, unsafe, disablePathAutomerge, ok := rawHTTPRequestFromFuzzData(data)
+ if !ok {
+ return false
+ }
+
+ parsedURL, err := urlutil.Parse(inputURL)
+ if err != nil {
+ return false
+ }
+
+ parsed := false
+ for _, unsafeMode := range fuzzBoolCases(unsafe) {
+ for _, disableAutomerge := range fuzzBoolCases(disablePathAutomerge) {
+ request, parseErr := Parse(rawRequest, parsedURL.Clone(), unsafeMode, disableAutomerge)
+ if parseErr == nil {
+ exerciseFuzzRawRequest(request)
+ parsed = true
+ }
+ }
+
+ request, parseErr := ParseRawRequest(rawRequest, unsafeMode)
+ if parseErr == nil {
+ exerciseFuzzRawRequest(request)
+ parsed = true
+ }
+ }
+
+ if looksLikeRawHTTPRequest(data) {
+ rawInput := string(data)
+ for _, unsafeMode := range []bool{false, true} {
+ request, parseErr := Parse(rawInput, parsedURL.Clone(), unsafeMode, disablePathAutomerge)
+ if parseErr == nil {
+ exerciseFuzzRawRequest(request)
+ parsed = true
+ }
+
+ request, parseErr = ParseRawRequest(rawInput, unsafeMode)
+ if parseErr == nil {
+ exerciseFuzzRawRequest(request)
+ parsed = true
+ }
+ }
+ }
+
+ return parsed
+}
+
+func rawHTTPRequestFromFuzzData(data []byte) (string, string, bool, bool, bool) {
+ if len(data) == 0 || len(data) > fuzzMaxInputSize {
+ return "", "", false, false, false
+ }
+
+ candidate := newFuzzRawHTTPCandidate(data)
+ candidate.applyLines(splitFuzzLines(data))
+ return candidate.build(), candidate.inputURL, candidate.unsafe, candidate.disablePathAutomerge, true
+}
+
+func newFuzzRawHTTPCandidate(data []byte) *fuzzRawHTTPCandidate {
+ flags := fuzzByteAt(data, 1)
+ return &fuzzRawHTTPCandidate{
+ method: fuzzRawMethods[int(fuzzByteAt(data, 0))%len(fuzzRawMethods)],
+ path: fuzzRawPaths[int(fuzzByteAt(data, 2))%len(fuzzRawPaths)],
+ host: fuzzRawHosts[int(fuzzByteAt(data, 3))%len(fuzzRawHosts)],
+ inputURL: fuzzInputURLs[int(fuzzByteAt(data, 4))%len(fuzzInputURLs)],
+ unsafe: flags&0x01 != 0,
+ disablePathAutomerge: flags&0x02 != 0,
+ headers: []fuzzRawHeader{
+ {key: "User-Agent", value: "nuclei-fuzz"},
+ },
+ body: fuzzRawBody(string(data)),
+ }
+}
+
+func (candidate *fuzzRawHTTPCandidate) applyLines(lines []string) {
+ for _, line := range lines {
+ key, value, ok := cutFuzzKV(line)
+ if !ok {
+ candidate.body = fuzzRawBody(line)
+ continue
+ }
+
+ switch key {
+ case "method":
+ candidate.method = fuzzRawMethod(value, candidate.method)
+ case "path":
+ if value == "" {
+ candidate.path = ""
+ } else {
+ candidate.path = fuzzRawPath(value, candidate.path)
+ }
+ case "host":
+ candidate.host = fuzzRawHost(value, candidate.host)
+ case "input-url", "url":
+ candidate.inputURL = fuzzRawInputURL(value, candidate.inputURL)
+ case "unsafe":
+ candidate.unsafe = fuzzRawBool(value, candidate.unsafe)
+ case "disable-automerge", "disable-path-automerge":
+ candidate.disablePathAutomerge = fuzzRawBool(value, candidate.disablePathAutomerge)
+ case "header":
+ candidate.addHeader(value)
+ case "body":
+ candidate.body = fuzzRawBody(value)
+ }
+ }
+}
+
+func (candidate *fuzzRawHTTPCandidate) addHeader(value string) {
+ key, headerValue, ok := strings.Cut(value, ":")
+ if !ok {
+ key, headerValue, ok = strings.Cut(value, "=")
+ }
+ if !ok {
+ return
+ }
+
+ key = fuzzRawHeaderKey(key)
+ if key == "" || strings.EqualFold(key, "Host") || len(candidate.headers) >= fuzzMaxHeaders {
+ return
+ }
+ candidate.headers = append(candidate.headers, fuzzRawHeader{key: key, value: fuzzRawHeaderValue(headerValue)})
+}
+
+func (candidate *fuzzRawHTTPCandidate) build() string {
+ var builder strings.Builder
+ if candidate.path == "" {
+ fmt.Fprintf(&builder, "%s HTTP/1.1\r\n", candidate.method)
+ } else {
+ fmt.Fprintf(&builder, "%s %s HTTP/1.1\r\n", candidate.method, candidate.path)
+ }
+ fmt.Fprintf(&builder, "Host: %s\r\n", candidate.host)
+ for _, header := range candidate.headers {
+ fmt.Fprintf(&builder, "%s: %s\r\n", header.key, header.value)
+ }
+ builder.WriteString("\r\n")
+ builder.WriteString(candidate.body)
+ return builder.String()
+}
+
+func exerciseFuzzRawRequest(request *Request) {
+ if request == nil {
+ panic("nil raw request")
+ }
+ _ = request.FullURL
+ _ = request.Method
+ _ = request.Path
+ _ = request.Data
+ if len(request.UnsafeRawBytes) > 0 {
+ _ = request.TryFillCustomHeaders([]string{"X-Fuzz: 1"})
+ }
+}
+
+func looksLikeRawHTTPRequest(data []byte) bool {
+ line := string(data)
+ if index := strings.IndexAny(line, "\r\n"); index >= 0 {
+ line = line[:index]
+ }
+ parts := strings.Fields(line)
+ return len(parts) >= 3 && strings.HasPrefix(parts[2], "HTTP/")
+}
+
+func fuzzBoolCases(value bool) []bool {
+ if value {
+ return []bool{true, false}
+ }
+ return []bool{false, true}
+}
+
+func splitFuzzLines(data []byte) []string {
+ fields := strings.FieldsFunc(string(data), func(r rune) bool {
+ return r == '\n' || r == '\r' || r == ';'
+ })
+ if len(fields) > fuzzMaxHeaders*4 {
+ fields = fields[:fuzzMaxHeaders*4]
+ }
+
+ lines := make([]string, 0, len(fields))
+ for _, field := range fields {
+ field = fuzzTrim(field)
+ if field != "" {
+ lines = append(lines, field)
+ }
+ }
+ return lines
+}
+
+func cutFuzzKV(line string) (string, string, bool) {
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ key, value, ok = strings.Cut(line, ":")
+ }
+ if !ok {
+ return "", "", false
+ }
+ return strings.ToLower(fuzzTrim(key)), fuzzTrim(value), true
+}
+
+func fuzzByteAt(data []byte, index int) byte {
+ if len(data) == 0 {
+ return 0
+ }
+ return data[index%len(data)]
+}
+
+func fuzzRawMethod(value, fallback string) string {
+ value = strings.ToUpper(fuzzRawToken(value, 16))
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+func fuzzRawPath(value, fallback string) string {
+ value = fuzzTrim(value)
+ if value == "" {
+ return fallback
+ }
+ if len(value) > fuzzMaxValueBytes {
+ value = value[:fuzzMaxValueBytes]
+ }
+ return value
+}
+
+func fuzzRawInputURL(value, fallback string) string {
+ value = fuzzTrim(value)
+ if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
+ return value
+ }
+ host := fuzzRawHost(value, "")
+ if host == "" {
+ return fallback
+ }
+ return "https://" + host + "/"
+}
+
+func fuzzRawHost(value, fallback string) string {
+ value = strings.TrimSpace(strings.NewReplacer("\x00", "", "\r", "", "\n", "", "/", "", "\\", "").Replace(value))
+ if value == "{{Hostname}}" || value == "{{BaseURL}}" {
+ return value
+ }
+
+ value = strings.ToLower(value)
+ var builder strings.Builder
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ builder.WriteRune(r)
+ case r >= '0' && r <= '9':
+ builder.WriteRune(r)
+ case r == '.' || r == '-' || r == ':':
+ builder.WriteRune(r)
+ }
+ if builder.Len() >= 128 {
+ break
+ }
+ }
+ if builder.Len() == 0 {
+ return fallback
+ }
+ return builder.String()
+}
+
+func fuzzRawHeaderKey(value string) string {
+ return fuzzRawToken(value, 64)
+}
+
+func fuzzRawHeaderValue(value string) string {
+ return fuzzTrim(value)
+}
+
+func fuzzRawBody(value string) string {
+ value = strings.ReplaceAll(value, "\x00", "")
+ if len(value) > fuzzMaxValueBytes {
+ value = value[:fuzzMaxValueBytes]
+ }
+ return value
+}
+
+func fuzzRawBool(value string, fallback bool) bool {
+ switch strings.ToLower(fuzzTrim(value)) {
+ case "1", "t", "true", "yes", "y", "on":
+ return true
+ case "0", "f", "false", "no", "n", "off":
+ return false
+ default:
+ return fallback
+ }
+}
+
+func fuzzRawToken(value string, limit int) string {
+ value = fuzzTrim(value)
+ var builder strings.Builder
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ builder.WriteRune(r - 'a' + 'A')
+ case r >= 'A' && r <= 'Z':
+ builder.WriteRune(r)
+ case r >= '0' && r <= '9':
+ builder.WriteRune(r)
+ case r == '-':
+ builder.WriteRune(r)
+ }
+ if builder.Len() >= limit {
+ break
+ }
+ }
+ return builder.String()
+}
+
+func fuzzTrim(value string) string {
+ value = strings.TrimSpace(strings.NewReplacer("\x00", "", "\r", " ", "\n", " ").Replace(value))
+ if len(value) > fuzzMaxValueBytes {
+ value = value[:fuzzMaxValueBytes]
+ }
+ return value
+}
diff --git a/pkg/protocols/http/raw/fuzz_harness_test.go b/pkg/protocols/http/raw/fuzz_harness_test.go
new file mode 100644
index 0000000000..acb93a4b16
--- /dev/null
+++ b/pkg/protocols/http/raw/fuzz_harness_test.go
@@ -0,0 +1,55 @@
+package raw
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ urlutil "github.com/projectdiscovery/utils/url"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRawHTTPRequestFromFuzzDataSeedCorpus(t *testing.T) {
+ entries, err := os.ReadDir("testdata/gofuzz-corpus")
+ require.NoError(t, err)
+ require.NotEmpty(t, entries)
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ path := filepath.Join("testdata/gofuzz-corpus", entry.Name())
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ require.Truef(t, fuzzRawHTTPParsing(data), "seed %s should exercise the raw HTTP parser fuzz path", entry.Name())
+
+ rawRequest, inputURL, unsafe, disablePathAutomerge, ok := rawHTTPRequestFromFuzzData(data)
+ require.Truef(t, ok, "seed %s should decode into a raw request", entry.Name())
+ require.NotEmpty(t, rawRequest)
+ require.NotEmpty(t, inputURL)
+
+ parsedURL, err := urlutil.Parse(inputURL)
+ require.NoErrorf(t, err, "seed %s should generate a valid input URL", entry.Name())
+
+ request, err := Parse(rawRequest, parsedURL, unsafe, disablePathAutomerge)
+ require.NoErrorf(t, err, "seed %s generated raw request should parse", entry.Name())
+ exerciseFuzzRawRequest(request)
+
+ request, err = ParseRawRequest(rawRequest, unsafe)
+ if err == nil {
+ exerciseFuzzRawRequest(request)
+ }
+ }
+}
+
+func TestRawHTTPRequestFromFuzzDataRejectsOversizeInput(t *testing.T) {
+ data := make([]byte, fuzzMaxInputSize+1)
+ rawRequest, inputURL, unsafe, disablePathAutomerge, ok := rawHTTPRequestFromFuzzData(data)
+ require.False(t, ok)
+ require.Empty(t, rawRequest)
+ require.Empty(t, inputURL)
+ require.False(t, unsafe)
+ require.False(t, disablePathAutomerge)
+}
diff --git a/pkg/protocols/http/raw/testdata/gofuzz-corpus/empty-path.seed b/pkg/protocols/http/raw/testdata/gofuzz-corpus/empty-path.seed
new file mode 100644
index 0000000000..a72c62fe32
--- /dev/null
+++ b/pkg/protocols/http/raw/testdata/gofuzz-corpus/empty-path.seed
@@ -0,0 +1,7 @@
+method=GET
+path=
+host={{Hostname}}
+input-url=https://example.com/base?debug=true
+unsafe=false
+disable-automerge=false
+header=Accept: */*
diff --git a/pkg/protocols/http/raw/testdata/gofuzz-corpus/host-reconstruct.seed b/pkg/protocols/http/raw/testdata/gofuzz-corpus/host-reconstruct.seed
new file mode 100644
index 0000000000..05fdd2ddb0
--- /dev/null
+++ b/pkg/protocols/http/raw/testdata/gofuzz-corpus/host-reconstruct.seed
@@ -0,0 +1,7 @@
+method=GET
+path=/manager/html
+host=example.com:8080
+input-url=https://target.example/base
+unsafe=false
+disable-automerge=false
+header=Authorization: Basic dXNlcjpwYXNz
diff --git a/pkg/protocols/http/raw/testdata/gofuzz-corpus/path-automerge.seed b/pkg/protocols/http/raw/testdata/gofuzz-corpus/path-automerge.seed
new file mode 100644
index 0000000000..942f8f3844
--- /dev/null
+++ b/pkg/protocols/http/raw/testdata/gofuzz-corpus/path-automerge.seed
@@ -0,0 +1,8 @@
+method=POST
+path=/login
+host={{Hostname}}
+input-url=https://example.com/app
+unsafe=false
+disable-automerge=false
+header=Content-Type: application/x-www-form-urlencoded
+body=username=admin&password=login
diff --git a/pkg/protocols/http/raw/testdata/gofuzz-corpus/unsafe-full-url.seed b/pkg/protocols/http/raw/testdata/gofuzz-corpus/unsafe-full-url.seed
new file mode 100644
index 0000000000..be6e1b667b
--- /dev/null
+++ b/pkg/protocols/http/raw/testdata/gofuzz-corpus/unsafe-full-url.seed
@@ -0,0 +1,7 @@
+method=GET
+path=http://127.0.0.1/foo?id=123&name=test
+host={{Hostname}}
+input-url=http://httpbin.org/bar
+unsafe=true
+disable-automerge=true
+header=Connection: close
diff --git a/pkg/protocols/http/raw/testdata/gofuzz-corpus/unsafe-relative-query.seed b/pkg/protocols/http/raw/testdata/gofuzz-corpus/unsafe-relative-query.seed
new file mode 100644
index 0000000000..9fd68dc152
--- /dev/null
+++ b/pkg/protocols/http/raw/testdata/gofuzz-corpus/unsafe-relative-query.seed
@@ -0,0 +1,6 @@
+method=GET
+path=1337?with=param
+host={{Hostname}}
+input-url=https://example.com/test.js?x=1
+unsafe=true
+disable-automerge=false
diff --git a/pkg/protocols/http/request.go b/pkg/protocols/http/request.go
index d1ec1cca37..79d9b6e0a9 100644
--- a/pkg/protocols/http/request.go
+++ b/pkg/protocols/http/request.go
@@ -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"
@@ -70,6 +71,22 @@ func (request *Request) Type() templateTypes.ProtocolType {
return templateTypes.HTTPProtocol
}
+// rateLimitTake handles rate limiting, using per-host rate limiter if enabled, otherwise global
+func (request *Request) rateLimitTake(hostname string) {
+ if request.options.Options.PerHostRateLimit && hostname != "" {
+ // Use per-host rate limiter
+ if limiter, err := httpclientpool.GetPerHostRateLimiter(request.options.Options, hostname); err == nil && limiter != nil {
+ limiter.Take()
+ // Record request for pps stats
+ httpclientpool.RecordPerHostRateLimitRequest(request.options.Options, hostname)
+ return
+ }
+ // Fallback to global if per-host fails
+ }
+ // Use global rate limiter (or unlimited if per-host is enabled but hostname is empty)
+ request.options.RateLimitTake()
+}
+
// executeRaceRequest executes race condition request for a URL
func (request *Request) executeRaceRequest(input *contextargs.Context, dynamicValues, previous output.InternalEvent, callback protocols.OutputEventCallback) error {
reqURL := input.MetaInput.Input
@@ -148,37 +165,30 @@ func (request *Request) executeRaceRequest(input *contextargs.Context, dynamicVa
}
}
- // look for unresponsive hosts and cancel inflight requests as well
- spmHandler.SetOnResultCallback(func(err error) {
- // marks this host as unresponsive if applicable
- request.markHostError(input, err)
- if request.isUnresponsiveAddress(input) {
- // stop all inflight requests
- spmHandler.Cancel()
- }
- })
-
for i := 0; i < request.RaceNumberRequests; i++ {
- if spmHandler.FoundFirstMatch() || request.isUnresponsiveAddress(input) {
+ updatedInput := contextargs.GetCopyIfHostOutdated(input, generatedRequests[i].URL())
+ if spmHandler.FoundFirstMatch() || request.isUnresponsiveAddress(updatedInput) {
// stop sending more requests condition is met
break
}
spmHandler.Acquire()
// execute http request
- go func(httpRequest *generatedRequest) {
+ go func(httpRequest *generatedRequest, requestInput *contextargs.Context) {
defer spmHandler.Release()
- if spmHandler.FoundFirstMatch() || request.isUnresponsiveAddress(input) {
+ if spmHandler.FoundFirstMatch() || request.isUnresponsiveAddress(requestInput) {
// stop sending more requests condition is met
return
}
+ err := request.executeRequest(requestInput, httpRequest, previous, false, wrappedCallback, 0)
select {
case <-spmHandler.Done():
return
- case spmHandler.ResultChan <- request.executeRequest(input, httpRequest, previous, false, wrappedCallback, 0):
+ case spmHandler.ResultChan <- err:
+ request.recordHostResultAndCancelIfUnresponsive(requestInput, err, spmHandler.Cancel)
return
}
- }(generatedRequests[i])
+ }(generatedRequests[i], updatedInput)
request.options.Progress.IncrementRequests()
}
spmHandler.Wait()
@@ -230,16 +240,6 @@ func (request *Request) executeParallelHTTP(input *contextargs.Context, dynamicV
}
}
- // look for unresponsive hosts and cancel inflight requests as well
- spmHandler.SetOnResultCallback(func(err error) {
- // marks this host as unresponsive if applicable
- request.markHostError(input, err)
- if request.isUnresponsiveAddress(input) {
- // stop all inflight requests
- spmHandler.Cancel()
- }
- })
-
// bounded worker-pool to avoid spawning one goroutine per payload
type task struct {
req *generatedRequest
@@ -268,14 +268,18 @@ func (request *Request) executeParallelHTTP(input *contextargs.Context, dynamicV
spmHandler.Release()
continue
}
- request.options.RateLimitTake()
+ // Extract hostname for per-host rate limiting (use full URL - normalization happens in rateLimitTake)
+ hostname := t.updatedInput.MetaInput.Input
+ if t.req != nil && t.req.URL() != "" {
+ hostname = t.req.URL()
+ } else if t.req != nil && t.req.request != nil && t.req.request.Request != nil && t.req.request.Request.URL != nil {
+ // Extract from request URL if available
+ hostname = t.req.request.Request.URL.String()
+ }
+ request.rateLimitTake(hostname)
hasInteractMatchers := interactsh.HasMatchers(request.CompiledOperators)
needsRequestEvent := hasInteractMatchers && request.NeedsRequestCondition()
- select {
- case <-spmHandler.Done():
- spmHandler.Release()
- continue
- case spmHandler.ResultChan <- request.executeRequest(t.updatedInput, t.req, make(map[string]interface{}), hasInteractMatchers, func(event *output.InternalWrappedEvent) {
+ err := request.executeRequest(t.updatedInput, t.req, make(map[string]interface{}), hasInteractMatchers, func(event *output.InternalWrappedEvent) {
if (t.hasInteractMarkers || needsRequestEvent) && request.options.Interactsh != nil {
requestData := &interactsh.RequestData{
MakeResultFunc: request.MakeResultEvent,
@@ -289,7 +293,13 @@ func (request *Request) executeParallelHTTP(input *contextargs.Context, dynamicV
request.options.Interactsh.RequestEvent(sliceutil.Dedupe(allOASTUrls), requestData)
}
wrappedCallback(event)
- }, 0):
+ }, 0)
+ select {
+ case <-spmHandler.Done():
+ spmHandler.Release()
+ continue
+ case spmHandler.ResultChan <- err:
+ request.recordHostResultAndCancelIfUnresponsive(t.updatedInput, err, spmHandler.Cancel)
spmHandler.Release()
}
}
@@ -435,16 +445,6 @@ func (request *Request) executeTurboHTTP(input *contextargs.Context, dynamicValu
}
}
- // look for unresponsive hosts and cancel inflight requests as well
- spmHandler.SetOnResultCallback(func(err error) {
- // marks this host as unresponsive if applicable
- request.markHostError(input, err)
- if request.isUnresponsiveAddress(input) {
- // stop all inflight requests
- spmHandler.Cancel()
- }
- })
-
for {
inputData, payloads, ok := generator.nextValue()
if !ok {
@@ -479,19 +479,21 @@ func (request *Request) executeTurboHTTP(input *contextargs.Context, dynamicValu
}
generatedHttpRequest.pipelinedClient = pipeClient
spmHandler.Acquire()
- go func(httpRequest *generatedRequest) {
+ go func(httpRequest *generatedRequest, requestInput *contextargs.Context) {
defer spmHandler.Release()
- if spmHandler.FoundFirstMatch() || request.isUnresponsiveAddress(updatedInput) {
+ if spmHandler.FoundFirstMatch() || request.isUnresponsiveAddress(requestInput) {
// skip if first match is found
return
}
+ err := request.executeRequest(requestInput, httpRequest, previous, false, wrappedCallback, 0)
select {
case <-spmHandler.Done():
return
- case spmHandler.ResultChan <- request.executeRequest(input, httpRequest, previous, false, wrappedCallback, 0):
+ case spmHandler.ResultChan <- err:
+ request.recordHostResultAndCancelIfUnresponsive(requestInput, err, spmHandler.Cancel)
return
}
- }(generatedHttpRequest)
+ }(generatedHttpRequest, updatedInput)
request.options.Progress.IncrementRequests()
}
spmHandler.Wait()
@@ -533,8 +535,6 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
executeFunc := func(data string, payloads, dynamicValue map[string]interface{}) (bool, error) {
hasInteractMatchers := interactsh.HasMatchers(request.CompiledOperators)
- request.options.RateLimitTake()
-
ctx := request.newContext(input)
ctxWithTimeout, cancel := context.WithTimeoutCause(ctx, request.options.Options.GetTimeouts().HttpTimeout, ErrHttpEngineRequestDeadline)
defer cancel()
@@ -553,6 +553,14 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
// but this should be replaced once templateCtx is refactored properly
updatedInput := contextargs.GetCopyIfHostOutdated(input, generatedHttpRequest.URL())
+ // Extract hostname for per-host rate limiting (use generated request URL - normalization happens in rateLimitTake)
+ hostname := input.MetaInput.Input
+ if generatedHttpRequest.URL() != "" {
+ // Use the generated URL directly - the normalization function will extract host:port correctly
+ hostname = generatedHttpRequest.URL()
+ }
+ request.rateLimitTake(hostname)
+
if generatedHttpRequest.customCancelFunction != nil {
defer generatedHttpRequest.customCancelFunction()
}
@@ -601,10 +609,8 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
return true, nil
}
+ request.recordHostResult(updatedInput, execReqErr)
if execReqErr != nil {
- request.markHostError(updatedInput, execReqErr)
-
- // if applicable mark the host as unresponsive
reqKitErr := errkit.FromError(execReqErr)
reqKitErr.Msgf("got err while executing %v", generatedHttpRequest.URL())
@@ -695,6 +701,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
@@ -732,8 +743,8 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
if dumpError != nil {
return dumpError
}
- if generatedRequest.request != nil && generatedRequest.request.URL != nil {
- projectCacheKey = getHTTPProjectCacheScope(dumpedRequest, generatedRequest.request.Scheme, generatedRequest.request.URL.Host)
+ if generatedRequest.request != nil && generatedRequest.request.Request != nil && generatedRequest.request.Request.URL != nil {
+ projectCacheKey = getHTTPProjectCacheScope(dumpedRequest, generatedRequest.request.Scheme, generatedRequest.request.Request.URL.Host)
} else {
projectCacheKey = dumpedRequest
}
@@ -815,7 +826,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 {
@@ -830,37 +849,62 @@ 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
+ connConfig := request.connConfiguration
if input.CookieJar != nil {
- connConfiguration := request.connConfiguration.Clone()
- connConfiguration.Connection.SetCookieJar(input.CookieJar)
- modifiedConfig = connConfiguration
+ 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, clientErr := httpclientpool.Get(request.options.Options, connConfig, hostname)
+ if clientErr != nil {
+ return errors.Wrap(clientErr, "could not get http client")
+ }
+ executingClient = httpclient
+
+ // Check if HTTP-to-HTTPS port correction is needed before sending request.
+ // The correction is keyed by host:port and shared across templates, so a
+ // single wrong detection could otherwise silently break every later request
+ // to that host. We remember that a correction was applied so we can revert
+ // and retry on failure (see below).
+ var httpsCorrectionTracker *httpclientpool.HTTPToHTTPSPortTracker
+ var httpsCorrectionURL string
+ if generatedRequest.request != nil && generatedRequest.request.Request != nil && generatedRequest.request.Request.URL != nil {
+ tracker := httpclientpool.GetHTTPToHTTPSPortTracker(request.options.Options)
+ if tracker != nil {
+ requestURL := generatedRequest.request.Request.URL.String()
+ if tracker.RequiresHTTPS(requestURL) {
+ // Modify request URL scheme from http to https
+ if generatedRequest.request.Scheme == "http" {
+ generatedRequest.request.Scheme = "https"
+ tracker.RecordCorrection()
+ httpsCorrectionTracker = tracker
+ httpsCorrectionURL = requestURL
+ gologger.Debug().Msgf("[http-to-https-tracker] Corrected HTTP to HTTPS for %s", requestURL)
+ }
+ }
}
- httpclient = client
}
resp, err = httpclient.Do(generatedRequest.request)
+
+ // If we forced http->https from a previous detection and the corrected
+ // request failed (e.g. a false positive where the port actually speaks
+ // plain HTTP), revert to the original scheme, evict the bad entry so
+ // other templates hitting the same host:port are not affected, and retry
+ // once. This keeps the optimization while preventing a single
+ // wrong detection from silently dropping findings at scale.
+ if err != nil && httpsCorrectionTracker != nil && generatedRequest.request != nil && generatedRequest.request.Scheme == "https" {
+ generatedRequest.request.Scheme = "http"
+ httpsCorrectionTracker.Evict(httpsCorrectionURL)
+ resp, err = httpclient.Do(generatedRequest.request)
+ }
}
}
// use request url as matched url if empty
@@ -991,8 +1035,26 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
bodyStr := respChain.BodyString()
headersStr := respChain.HeadersString()
+ statusCode := respChain.Response().StatusCode
+
+ // Detect HTTP-to-HTTPS port mismatch (400 error with specific message) so
+ // later requests to the same host:port are auto-upgraded to https.
+ if statusCode == 400 && strings.Contains(bodyStr, "The plain HTTP request was sent to HTTPS port") {
+ var requestURL string
+ if generatedRequest.request != nil && generatedRequest.request.Request != nil && generatedRequest.request.Request.URL != nil {
+ requestURL = generatedRequest.request.Request.URL.String()
+ } else if generatedRequest.rawRequest != nil && generatedRequest.rawRequest.FullURL != "" {
+ requestURL = generatedRequest.rawRequest.FullURL
+ } else if respChain.Request() != nil && respChain.Request().URL != nil {
+ requestURL = respChain.Request().URL.String()
+ }
+ if requestURL != "" {
+ httpclientpool.RecordHTTPToHTTPSPortMismatch(request.options.Options, requestURL)
+ }
+ }
+
// log request stats
- request.options.Output.RequestStatsLog(strconv.Itoa(respChain.Response().StatusCode), fullResponseStr)
+ request.options.Output.RequestStatsLog(strconv.Itoa(statusCode), fullResponseStr)
// save response to projectfile
onceFunc()
@@ -1018,18 +1080,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
+ }
}
}
@@ -1162,6 +1236,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 {
@@ -1302,13 +1386,22 @@ func (request *Request) newContext(input *contextargs.Context) context.Context {
return input.Context()
}
-// markHostError checks if the error is a unreponsive host error and marks it
-func (request *Request) markHostError(input *contextargs.Context, err error) {
- if request.options.HostErrorsCache != nil && err != nil {
+// recordHostResult updates the host-errors cache with the outcome of a request.
+// A failure is counted; a success resets the host, so only consecutive failures
+// with no success in between can mark a host unresponsive.
+func (request *Request) recordHostResult(input *contextargs.Context, err error) {
+ if request.options.HostErrorsCache != nil {
request.options.HostErrorsCache.MarkFailedOrRemove(request.options.ProtocolType.String(), input, err)
}
}
+func (request *Request) recordHostResultAndCancelIfUnresponsive(input *contextargs.Context, err error, cancel func()) {
+ request.recordHostResult(input, err)
+ if request.isUnresponsiveAddress(input) {
+ cancel()
+ }
+}
+
// isUnresponsiveAddress checks if the error is a unreponsive based on its execution history
func (request *Request) isUnresponsiveAddress(input *contextargs.Context) bool {
if request.options.HostErrorsCache != nil {
diff --git a/pkg/protocols/http/request_fuzz.go b/pkg/protocols/http/request_fuzz.go
index 3a7e2cc74a..621a3d1960 100644
--- a/pkg/protocols/http/request_fuzz.go
+++ b/pkg/protocols/http/request_fuzz.go
@@ -181,7 +181,13 @@ func (request *Request) executeGeneratedFuzzingRequest(gr fuzz.GeneratedRequest,
if request.options.HostErrorsCache != nil && request.options.HostErrorsCache.Check(request.options.ProtocolType.String(), input) {
return false
}
- request.options.RateLimitTake()
+ // Extract hostname for per-host rate limiting: prefer the concrete fuzzed
+ // request URL (rules may change host/port), fall back to the input target
+ hostname := input.MetaInput.Input
+ if gr.Request != nil && gr.Request.Request != nil && gr.Request.Request.URL != nil {
+ hostname = gr.Request.Request.URL.String()
+ }
+ request.rateLimitTake(hostname)
req := &generatedRequest{
request: gr.Request,
dynamicValues: gr.DynamicValues,
diff --git a/pkg/protocols/http/request_test.go b/pkg/protocols/http/request_test.go
index 8fbfa64409..2d16816833 100644
--- a/pkg/protocols/http/request_test.go
+++ b/pkg/protocols/http/request_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
+ "sync"
"sync/atomic"
"testing"
"time"
@@ -12,6 +13,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/tarunKoyalwar/goleak"
+ "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/pkg/operators"
@@ -20,7 +22,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
- "github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
)
func TestHTTPExtractMultipleReuse(t *testing.T) {
@@ -277,6 +278,194 @@ func (f *fakeHostErrorsCache) Check(string, *contextargs.Context) bool { return
// IsPermanentErr returns false for tests
func (f *fakeHostErrorsCache) IsPermanentErr(*contextargs.Context, error) bool { return false }
+// spyHostErrorsCache records how the request path interacts with the cache:
+// a mark (non-nil error) versus a reset (nil error). Check returns skip so the
+// request actually executes.
+type spyHostErrorsCache struct {
+ marks atomic.Int32
+ resets atomic.Int32
+ skip bool
+}
+
+func (s *spyHostErrorsCache) SetVerbose(bool) {}
+func (s *spyHostErrorsCache) Close() {}
+func (s *spyHostErrorsCache) Remove(*contextargs.Context) { s.resets.Add(1) }
+func (s *spyHostErrorsCache) MarkFailed(p string, c *contextargs.Context, err error) {
+ s.MarkFailedOrRemove(p, c, err)
+}
+func (s *spyHostErrorsCache) MarkFailedOrRemove(_ string, _ *contextargs.Context, err error) {
+ if err == nil {
+ s.resets.Add(1)
+ } else {
+ s.marks.Add(1)
+ }
+}
+func (s *spyHostErrorsCache) Check(string, *contextargs.Context) bool { return s.skip }
+func (s *spyHostErrorsCache) IsPermanentErr(*contextargs.Context, error) bool { return false }
+
+type addressSpyHostErrorsCache struct {
+ mu sync.Mutex
+ checks []string
+ resets []string
+}
+
+func (s *addressSpyHostErrorsCache) SetVerbose(bool) {}
+func (s *addressSpyHostErrorsCache) Close() {}
+func (s *addressSpyHostErrorsCache) Remove(ctx *contextargs.Context) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.resets = append(s.resets, ctx.MetaInput.Address())
+}
+func (s *addressSpyHostErrorsCache) MarkFailed(string, *contextargs.Context, error) {}
+func (s *addressSpyHostErrorsCache) MarkFailedOrRemove(_ string, ctx *contextargs.Context, err error) {
+ if err == nil {
+ s.Remove(ctx)
+ }
+}
+func (s *addressSpyHostErrorsCache) Check(_ string, ctx *contextargs.Context) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.checks = append(s.checks, ctx.MetaInput.Address())
+ return false
+}
+func (s *addressSpyHostErrorsCache) IsPermanentErr(*contextargs.Context, error) bool {
+ return false
+}
+
+func TestHTTPResetsHostCacheOnSuccess(t *testing.T) {
+ options := testutils.DefaultOptions
+ testutils.Init(options)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = fmt.Fprintf(w, "ok")
+ }))
+ defer ts.Close()
+
+ templateID := "reset-on-success"
+ req := &Request{
+ ID: templateID,
+ Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
+ Path: []string{"{{BaseURL}}/"},
+ Operators: operators.Operators{
+ Matchers: []*matchers.Matcher{{
+ Part: "body",
+ Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
+ Words: []string{"ok"},
+ }},
+ },
+ }
+
+ executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
+ ID: templateID,
+ Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
+ })
+ spy := &spyHostErrorsCache{}
+ executerOpts.HostErrorsCache = spy
+ require.NoError(t, req.Compile(executerOpts))
+
+ metadata := make(output.InternalEvent)
+ previous := make(output.InternalEvent)
+ ctxArgs := contextargs.NewWithInput(context.Background(), ts.URL)
+ err := req.ExecuteWithResults(ctxArgs, metadata, previous, func(event *output.InternalWrappedEvent) {})
+ require.NoError(t, err)
+
+ require.Greater(t, spy.resets.Load(), int32(0), "a successful request must reset the host-errors cache")
+ require.Equal(t, int32(0), spy.marks.Load(), "a successful request must not mark a host error")
+}
+
+func TestParallelHTTPResetsHostCacheOnSuccess(t *testing.T) {
+ options := testutils.DefaultOptions
+ testutils.Init(options)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = fmt.Fprintf(w, "ok")
+ }))
+ defer ts.Close()
+
+ templateID := "parallel-reset-on-success"
+ req := &Request{
+ ID: templateID,
+ Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
+ Path: []string{"{{BaseURL}}/p?x={{v}}"},
+ Threads: 2,
+ Payloads: map[string]interface{}{
+ "v": []string{"1", "2", "3", "4"},
+ },
+ Operators: operators.Operators{
+ Matchers: []*matchers.Matcher{{
+ Part: "body",
+ Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
+ Words: []string{"ok"},
+ }},
+ },
+ }
+
+ executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
+ ID: templateID,
+ Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
+ })
+ spy := &spyHostErrorsCache{}
+ executerOpts.HostErrorsCache = spy
+ require.NoError(t, req.Compile(executerOpts))
+
+ metadata := make(output.InternalEvent)
+ previous := make(output.InternalEvent)
+ ctxArgs := contextargs.NewWithInput(context.Background(), ts.URL)
+ err := req.ExecuteWithResults(ctxArgs, metadata, previous, func(event *output.InternalWrappedEvent) {})
+ require.NoError(t, err)
+
+ require.Greater(t, spy.resets.Load(), int32(0), "successful parallel requests must reset the host-errors cache")
+ require.Equal(t, int32(0), spy.marks.Load(), "successful parallel requests must not mark a host error")
+}
+
+func TestParallelHTTPResetsUpdatedHostCacheKeyOnSuccess(t *testing.T) {
+ options := testutils.DefaultOptions
+ testutils.Init(options)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = fmt.Fprintf(w, "ok")
+ }))
+ defer ts.Close()
+
+ templateID := "parallel-reset-updated-host"
+ req := &Request{
+ ID: templateID,
+ Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
+ Path: []string{ts.URL + "/p?x={{v}}"},
+ Threads: 2,
+ Payloads: map[string]interface{}{
+ "v": []string{"1", "2"},
+ },
+ Operators: operators.Operators{
+ Matchers: []*matchers.Matcher{{
+ Part: "body",
+ Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
+ Words: []string{"ok"},
+ }},
+ },
+ }
+
+ executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
+ ID: templateID,
+ Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
+ })
+ spy := &addressSpyHostErrorsCache{}
+ executerOpts.HostErrorsCache = spy
+ require.NoError(t, req.Compile(executerOpts))
+
+ metadata := make(output.InternalEvent)
+ previous := make(output.InternalEvent)
+ ctxArgs := contextargs.NewWithInput(context.Background(), "http://example.invalid")
+ err := req.ExecuteWithResults(ctxArgs, metadata, previous, func(event *output.InternalWrappedEvent) {})
+ require.NoError(t, err)
+
+ expectedAddress := contextargs.NewWithInput(context.Background(), ts.URL).MetaInput.Address()
+ require.Contains(t, spy.checks, expectedAddress, "generated request host must be checked")
+ require.Contains(t, spy.resets, expectedAddress, "success must reset the generated request host")
+}
+
func TestExecuteParallelHTTP_StopAtFirstMatch(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
@@ -391,6 +580,9 @@ func TestExecuteParallelHTTP_GoroutineLeaks(t *testing.T) {
goleak.IgnoreAnyFunction("github.com/syndtr/goleveldb/leveldb.(*DB).mpoolDrain"),
goleak.IgnoreAnyFunction("github.com/syndtr/goleveldb/leveldb.(*DB).tCompaction"),
goleak.IgnoreAnyFunction("github.com/syndtr/goleveldb/leveldb.(*DB).mCompaction"),
+ // expirable LRU cache creates a background goroutine for TTL expiration that persists
+ // see: https://github.com/hashicorp/golang-lru/blob/770151e9c8cdfae1797826b7b74c33d6f103fbd8/expirable/expirable_lru.go#L79
+ goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"),
)
options := testutils.DefaultOptions
diff --git a/pkg/protocols/http/validate.go b/pkg/protocols/http/validate.go
index 1cdf40e400..6181e8be5e 100644
--- a/pkg/protocols/http/validate.go
+++ b/pkg/protocols/http/validate.go
@@ -7,6 +7,10 @@ func (request *Request) validate() error {
return errors.New("'race' and 'req-condition' can't be used together")
}
+ if len(request.Fuzzing) > 0 && request.NeedsRequestCondition() {
+ return errors.New("'fuzzing' and 'request-condition' can't be used together")
+ }
+
if request.Redirects && request.HostRedirects {
return errors.New("'redirects' and 'host-redirects' can't be used together")
}
diff --git a/pkg/protocols/http/validate_test.go b/pkg/protocols/http/validate_test.go
index 119d7ec084..76ce0da0bf 100644
--- a/pkg/protocols/http/validate_test.go
+++ b/pkg/protocols/http/validate_test.go
@@ -4,6 +4,11 @@ import (
"testing"
"github.com/stretchr/testify/require"
+
+ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
+ "github.com/projectdiscovery/nuclei/v3/pkg/operators"
+ "github.com/projectdiscovery/nuclei/v3/pkg/operators/extractors"
+ "github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
)
func TestValidateRedirectsCombinations(t *testing.T) {
@@ -51,3 +56,33 @@ func TestValidateRedirectsCombinations(t *testing.T) {
require.NoError(t, err)
})
}
+
+func TestValidateFuzzingWithRequestCondition(t *testing.T) {
+ t.Run("matcher dsl", func(t *testing.T) {
+ req := &Request{
+ Fuzzing: []*fuzz.Rule{{}},
+ Operators: operators.Operators{
+ Matchers: []*matchers.Matcher{
+ {DSL: []string{"duration_1 > 18"}},
+ },
+ },
+ }
+ err := req.validate()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "'fuzzing' and 'request-condition' can't be used together")
+ })
+
+ t.Run("extractor part", func(t *testing.T) {
+ req := &Request{
+ Fuzzing: []*fuzz.Rule{{}},
+ Operators: operators.Operators{
+ Extractors: []*extractors.Extractor{
+ {Part: "body_1"},
+ },
+ },
+ }
+ err := req.validate()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "'fuzzing' and 'request-condition' can't be used together")
+ })
+}
diff --git a/pkg/protocols/javascript/js.go b/pkg/protocols/javascript/js.go
index 0c4f648e8d..f5a618cf3d 100644
--- a/pkg/protocols/javascript/js.go
+++ b/pkg/protocols/javascript/js.go
@@ -10,7 +10,7 @@ import (
"sync/atomic"
"time"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/alecthomas/chroma/quick"
"github.com/ditashi/jsbeautifier-go/jsbeautifier"
"github.com/pkg/errors"
diff --git a/pkg/protocols/network/request.go b/pkg/protocols/network/request.go
index 39bd003cce..7a2f2044f0 100644
--- a/pkg/protocols/network/request.go
+++ b/pkg/protocols/network/request.go
@@ -336,8 +336,6 @@ func (request *Request) executeRequestWithPayloads(variables map[string]interfac
dataInBytes = []byte(data)
}
- reqBuilder.Write(dataInBytes)
-
if err := expressions.ContainsUnresolvedVariables(data); err != nil {
gologger.Warning().Msgf("[%s] Could not make network request for %s: %v\n", request.options.TemplateID, actualAddress, err)
return nil
@@ -352,6 +350,8 @@ func (request *Request) executeRequestWithPayloads(variables map[string]interfac
}
}
+ reqBuilder.Write(dataInBytes)
+
if _, err := conn.Write(dataInBytes); err != nil {
request.options.Output.Request(request.options.TemplatePath, address, request.Type().String(), err)
request.options.Progress.IncrementFailedRequestsBy(1)
diff --git a/pkg/protocols/ssl/ssl.go b/pkg/protocols/ssl/ssl.go
index c18eebc645..4ab4818dcf 100644
--- a/pkg/protocols/ssl/ssl.go
+++ b/pkg/protocols/ssl/ssl.go
@@ -9,7 +9,6 @@ import (
"github.com/cespare/xxhash"
"github.com/fatih/structs"
- jsoniter "github.com/json-iterator/go"
"github.com/pkg/errors"
"github.com/projectdiscovery/fastdialer/fastdialer"
@@ -30,6 +29,7 @@ import (
protocolutils "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils"
templateTypes "github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
"github.com/projectdiscovery/tlsx/pkg/tlsx"
"github.com/projectdiscovery/tlsx/pkg/tlsx/clients"
"github.com/projectdiscovery/tlsx/pkg/tlsx/openssl"
@@ -266,7 +266,7 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
}
}
- jsonData, _ := jsoniter.Marshal(response)
+ jsonData, _ := json.Marshal(response)
jsonDataString := string(jsonData)
data := make(map[string]interface{})
diff --git a/pkg/protocols/utils/http/requtils.go b/pkg/protocols/utils/http/requtils.go
index bfc602a055..36bd72c973 100644
--- a/pkg/protocols/utils/http/requtils.go
+++ b/pkg/protocols/utils/http/requtils.go
@@ -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()
-}
diff --git a/pkg/protocols/utils/variables.go b/pkg/protocols/utils/variables.go
index 00f22a118d..b2bb9db312 100644
--- a/pkg/protocols/utils/variables.go
+++ b/pkg/protocols/utils/variables.go
@@ -10,7 +10,7 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
maputil "github.com/projectdiscovery/utils/maps"
urlutil "github.com/projectdiscovery/utils/url"
- "github.com/weppos/publicsuffix-go/publicsuffix"
+ "golang.org/x/net/publicsuffix"
)
// KnownVariables are the variables that are known to input requests
@@ -68,12 +68,11 @@ func GenerateVariablesWithContextArgs(input *contextargs.Context, trailingSlash
// GenerateDNSVariables from a dns name
// This function is used by dns and ssl protocol to generate variables
func GenerateDNSVariables(domain string) map[string]interface{} {
- parsed, err := publicsuffix.Parse(strings.TrimSuffix(domain, "."))
- if err != nil {
+ domainName, sld, tld, trd, ok := splitDomain(domain)
+ if !ok {
return map[string]interface{}{"FQDN": domain}
}
- domainName := strings.Join([]string{parsed.SLD, parsed.TLD}, ".")
dnsVariables := make(map[string]interface{})
for k, v := range KnownVariables {
switch k {
@@ -82,16 +81,36 @@ func GenerateDNSVariables(domain string) map[string]interface{} {
case Rdn:
dnsVariables[v] = domainName
case Dn:
- dnsVariables[v] = parsed.SLD
+ dnsVariables[v] = sld
case Tld:
- dnsVariables[v] = parsed.TLD
+ dnsVariables[v] = tld
case Sd:
- dnsVariables[v] = parsed.TRD
+ dnsVariables[v] = trd
}
}
return dnsVariables
}
+func splitDomain(domain string) (domainName, sld, tld, trd string, ok bool) {
+ normalized := strings.TrimSuffix(domain, ".")
+ domainName, err := publicsuffix.EffectiveTLDPlusOne(normalized)
+ if err != nil {
+ return "", "", "", "", false
+ }
+
+ tld, _ = publicsuffix.PublicSuffix(normalized)
+ sld = strings.TrimSuffix(domainName, "."+tld)
+ if sld == "" || sld == domainName {
+ return "", "", "", "", false
+ }
+
+ trd = strings.TrimSuffix(normalized, "."+domainName)
+ if trd == normalized {
+ trd = ""
+ }
+ return domainName, sld, tld, trd, true
+}
+
// GenerateVariables accepts string or *urlutil.URL object as input
// Returns the map of KnownVariables keys
// This function is used by http, headless, websocket, network and whois protocols to generate protocol variables
diff --git a/pkg/protocols/utils/variables_test.go b/pkg/protocols/utils/variables_test.go
index b83529499d..5b21821261 100644
--- a/pkg/protocols/utils/variables_test.go
+++ b/pkg/protocols/utils/variables_test.go
@@ -65,14 +65,52 @@ func TestHTTPVariables(t *testing.T) {
}
func TestGenerateDNSVariables(t *testing.T) {
- vars := GenerateDNSVariables("www.projectdiscovery.io")
- require.Equal(t, map[string]interface{}{
- "FQDN": "www.projectdiscovery.io",
- "RDN": "projectdiscovery.io",
- "DN": "projectdiscovery",
- "TLD": "io",
- "SD": "www",
- }, vars, "could not get dns variables")
+ testCases := []struct {
+ name string
+ input string
+ expected map[string]interface{}
+ }{
+ {
+ name: "simple domain",
+ input: "www.projectdiscovery.io",
+ expected: map[string]interface{}{
+ "FQDN": "www.projectdiscovery.io",
+ "RDN": "projectdiscovery.io",
+ "DN": "projectdiscovery",
+ "TLD": "io",
+ "SD": "www",
+ },
+ },
+ {
+ name: "multi label public suffix",
+ input: "api.service.example.co.uk",
+ expected: map[string]interface{}{
+ "FQDN": "api.service.example.co.uk",
+ "RDN": "example.co.uk",
+ "DN": "example",
+ "TLD": "co.uk",
+ "SD": "api.service",
+ },
+ },
+ {
+ name: "trailing dot",
+ input: "www.projectdiscovery.io.",
+ expected: map[string]interface{}{
+ "FQDN": "www.projectdiscovery.io.",
+ "RDN": "projectdiscovery.io",
+ "DN": "projectdiscovery",
+ "TLD": "io",
+ "SD": "www",
+ },
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ vars := GenerateDNSVariables(testCase.input)
+ require.Equal(t, testCase.expected, vars, "could not get dns variables")
+ })
+ }
}
func TestGenerateVariablesForDNS(t *testing.T) {
diff --git a/pkg/protocols/whois/whois.go b/pkg/protocols/whois/whois.go
index 60f41719a4..ebd0c856e3 100644
--- a/pkg/protocols/whois/whois.go
+++ b/pkg/protocols/whois/whois.go
@@ -5,10 +5,7 @@ import (
"strings"
"time"
- jsoniter "github.com/json-iterator/go"
"github.com/pkg/errors"
- "github.com/projectdiscovery/rdap"
-
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/extractors"
@@ -24,8 +21,9 @@ import (
protocolutils "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/whois/rdapclientpool"
templateTypes "github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
-
"github.com/projectdiscovery/nuclei/v3/pkg/types"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
+ "github.com/projectdiscovery/rdap"
)
// Request is a request for the WHOIS protocol
@@ -129,7 +127,7 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
default:
response = res.Object
}
- jsonData, _ := jsoniter.Marshal(response)
+ jsonData, _ := json.Marshal(response)
jsonDataString := string(jsonData)
data["type"] = request.Type().String()
diff --git a/pkg/reporting/reporting.go b/pkg/reporting/reporting.go
index 4d99c7a915..005468415f 100644
--- a/pkg/reporting/reporting.go
+++ b/pkg/reporting/reporting.go
@@ -1,28 +1,22 @@
package reporting
import (
+ "errors"
"fmt"
"os"
"strings"
"sync/atomic"
- "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/mongo"
-
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
- json_exporter "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter"
- "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
-
- "go.uber.org/multierr"
- "gopkg.in/yaml.v2"
-
- "errors"
-
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/dedupe"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/es"
+ json_exporter "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter"
+ "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown"
+ "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/mongo"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/pdf"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/sarif"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/splunk"
@@ -32,8 +26,10 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/gitlab"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/jira"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/linear"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/projectdiscovery/utils/errkit"
fileutil "github.com/projectdiscovery/utils/file"
+ "go.uber.org/multierr"
)
var (
diff --git a/pkg/reporting/trackers/github/github.go b/pkg/reporting/trackers/github/github.go
index a8ec5837f0..f4fe8e101c 100644
--- a/pkg/reporting/trackers/github/github.go
+++ b/pkg/reporting/trackers/github/github.go
@@ -9,7 +9,7 @@ import (
"strconv"
"strings"
- "github.com/google/go-github/github"
+ "github.com/google/go-github/v30/github"
"github.com/pkg/errors"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util"
@@ -203,8 +203,8 @@ func (i *Integration) findIssueByTitle(ctx context.Context, title string) (*gith
}
for _, issue := range issues.Issues {
- if issue.Title != nil && *issue.Title == title {
- return &issue, nil
+ if issue != nil && issue.Title != nil && *issue.Title == title {
+ return issue, nil
}
}
diff --git a/pkg/scan/charts/charts.go b/pkg/scan/charts/charts.go
index fde60422c0..93b8a087da 100644
--- a/pkg/scan/charts/charts.go
+++ b/pkg/scan/charts/charts.go
@@ -2,10 +2,11 @@ package charts
import (
"fmt"
+ "log"
+ "net/http"
"os"
"path/filepath"
- "github.com/labstack/echo/v4"
"github.com/projectdiscovery/nuclei/v3/pkg/scan/events"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
fileutil "github.com/projectdiscovery/utils/file"
@@ -78,12 +79,15 @@ func NewScanEventsCharts(eventsDir string) (*ScanEventsCharts, error) {
// Start starts the nuclei event charts server
func (sc *ScanEventsCharts) Start(addr string) {
- e := echo.New()
- e.HideBanner = true
- e.GET("/concurrency", sc.ConcurrencyVsTime)
- e.GET("/fuzz", sc.TotalRequestsOverTime)
- e.GET("/slow", sc.TopSlowTemplates)
- e.GET("/rps", sc.RequestsVSInterval)
- e.GET("/", sc.AllCharts)
- e.Logger.Fatal(e.Start(addr))
+ log.Fatal(http.ListenAndServe(addr, sc.routes()))
+}
+
+func (sc *ScanEventsCharts) routes() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /concurrency", sc.ConcurrencyVsTime)
+ mux.HandleFunc("GET /fuzz", sc.TotalRequestsOverTime)
+ mux.HandleFunc("GET /slow", sc.TopSlowTemplates)
+ mux.HandleFunc("GET /rps", sc.RequestsVSInterval)
+ mux.HandleFunc("GET /{$}", sc.AllCharts)
+ return mux
}
diff --git a/pkg/scan/charts/echarts.go b/pkg/scan/charts/echarts.go
index b4e246ae55..97f2569744 100644
--- a/pkg/scan/charts/echarts.go
+++ b/pkg/scan/charts/echarts.go
@@ -2,6 +2,8 @@ package charts
import (
"fmt"
+ "io"
+ "net/http"
"os"
"sort"
"time"
@@ -9,7 +11,6 @@ import (
"github.com/go-echarts/go-echarts/v2/charts"
"github.com/go-echarts/go-echarts/v2/components"
"github.com/go-echarts/go-echarts/v2/opts"
- "github.com/labstack/echo/v4"
"github.com/projectdiscovery/nuclei/v3/pkg/scan/events"
sliceutil "github.com/projectdiscovery/utils/slice"
)
@@ -19,9 +20,9 @@ const (
SpacerHeight = "50px"
)
-func (s *ScanEventsCharts) AllCharts(c echo.Context) error {
- page := s.allCharts(c)
- return page.Render(c.Response().Writer)
+func (s *ScanEventsCharts) AllCharts(w http.ResponseWriter, r *http.Request) {
+ page := s.allCharts(r)
+ renderChart(w, page)
}
func (s *ScanEventsCharts) GenerateHTML(filePath string) error {
@@ -37,16 +38,16 @@ func (s *ScanEventsCharts) GenerateHTML(filePath string) error {
}
// AllCharts generates all the charts for the scan events and returns a page component
-func (s *ScanEventsCharts) allCharts(c echo.Context) *components.Page {
+func (s *ScanEventsCharts) allCharts(r *http.Request) *components.Page {
page := components.NewPage()
page.PageTitle = "Nuclei Charts"
- line1 := s.totalRequestsOverTime(c)
+ line1 := s.totalRequestsOverTime(r)
// line1.SetSpacerHeight(SpacerHeight)
- kline := s.topSlowTemplates(c)
+ kline := s.topSlowTemplates(r)
// kline.SetSpacerHeight(SpacerHeight)
- line2 := s.requestsVSInterval(c)
+ line2 := s.requestsVSInterval(r)
// line2.SetSpacerHeight(SpacerHeight)
- line3 := s.concurrencyVsTime(c)
+ line3 := s.concurrencyVsTime(r)
// line3.SetSpacerHeight(SpacerHeight)
page.AddCharts(line1, kline, line2, line3)
page.SetLayout(components.PageCenterLayout)
@@ -56,13 +57,13 @@ func (s *ScanEventsCharts) allCharts(c echo.Context) *components.Page {
return page
}
-func (s *ScanEventsCharts) TotalRequestsOverTime(c echo.Context) error {
- line := s.totalRequestsOverTime(c)
- return line.Render(c.Response().Writer)
+func (s *ScanEventsCharts) TotalRequestsOverTime(w http.ResponseWriter, r *http.Request) {
+ line := s.totalRequestsOverTime(r)
+ renderChart(w, line)
}
// totalRequestsOverTime generates a line chart showing total requests count over time
-func (s *ScanEventsCharts) totalRequestsOverTime(c echo.Context) *charts.Line {
+func (s *ScanEventsCharts) totalRequestsOverTime(_ *http.Request) *charts.Line {
line := charts.NewLine()
line.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
@@ -122,13 +123,13 @@ func (s *ScanEventsCharts) totalRequestsOverTime(c echo.Context) *charts.Line {
return line
}
-func (s *ScanEventsCharts) TopSlowTemplates(c echo.Context) error {
- kline := s.topSlowTemplates(c)
- return kline.Render(c.Response().Writer)
+func (s *ScanEventsCharts) TopSlowTemplates(w http.ResponseWriter, r *http.Request) {
+ kline := s.topSlowTemplates(r)
+ renderChart(w, kline)
}
// topSlowTemplates generates a Kline chart showing the top slow templates by time taken
-func (s *ScanEventsCharts) topSlowTemplates(c echo.Context) *charts.Kline {
+func (s *ScanEventsCharts) topSlowTemplates(_ *http.Request) *charts.Kline {
kline := charts.NewKLine()
kline.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
@@ -212,13 +213,13 @@ func (s *ScanEventsCharts) topSlowTemplates(c echo.Context) *charts.Kline {
return kline
}
-func (s *ScanEventsCharts) RequestsVSInterval(c echo.Context) error {
- line := s.requestsVSInterval(c)
- return line.Render(c.Response().Writer)
+func (s *ScanEventsCharts) RequestsVSInterval(w http.ResponseWriter, r *http.Request) {
+ line := s.requestsVSInterval(r)
+ renderChart(w, line)
}
// requestsVSInterval generates a line chart showing requests per second over time
-func (s *ScanEventsCharts) requestsVSInterval(c echo.Context) *charts.Line {
+func (s *ScanEventsCharts) requestsVSInterval(r *http.Request) *charts.Line {
line := charts.NewLine()
line.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
@@ -233,8 +234,8 @@ func (s *ScanEventsCharts) requestsVSInterval(c echo.Context) *charts.Line {
var interval time.Duration
- if c != nil {
- interval, _ = time.ParseDuration(c.QueryParam("interval"))
+ if r != nil {
+ interval, _ = time.ParseDuration(r.URL.Query().Get("interval"))
}
if interval <= 3 {
interval = 5 * time.Second
@@ -284,13 +285,13 @@ func (s *ScanEventsCharts) requestsVSInterval(c echo.Context) *charts.Line {
return line
}
-func (s *ScanEventsCharts) ConcurrencyVsTime(c echo.Context) error {
- line := s.concurrencyVsTime(c)
- return line.Render(c.Response().Writer)
+func (s *ScanEventsCharts) ConcurrencyVsTime(w http.ResponseWriter, r *http.Request) {
+ line := s.concurrencyVsTime(r)
+ renderChart(w, line)
}
// concurrencyVsTime generates a line chart showing concurrency (total workers) over time
-func (s *ScanEventsCharts) concurrencyVsTime(c echo.Context) *charts.Line {
+func (s *ScanEventsCharts) concurrencyVsTime(r *http.Request) *charts.Line {
line := charts.NewLine()
line.SetGlobalOptions(
charts.WithTitleOpts(opts.Title{
@@ -306,8 +307,8 @@ func (s *ScanEventsCharts) concurrencyVsTime(c echo.Context) *charts.Line {
})
var interval time.Duration
- if c != nil {
- interval, _ = time.ParseDuration(c.QueryParam("interval"))
+ if r != nil {
+ interval, _ = time.ParseDuration(r.URL.Query().Get("interval"))
}
if interval <= 3 {
interval = 5 * time.Second
@@ -379,3 +380,13 @@ func getCategoryRequestCount(values []events.ScanEvent) map[string][]events.Scan
}
return mx
}
+
+type chartRenderer interface {
+ Render(io.Writer) error
+}
+
+func renderChart(w http.ResponseWriter, chart chartRenderer) {
+ if err := chart.Render(w); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
diff --git a/pkg/templates/capability.go b/pkg/templates/capability.go
new file mode 100644
index 0000000000..1bc2610512
--- /dev/null
+++ b/pkg/templates/capability.go
@@ -0,0 +1,256 @@
+package templates
+
+import (
+ "fmt"
+
+ "github.com/projectdiscovery/nuclei/v3/pkg/types"
+)
+
+// Capability identifies an opt-in capability a template may require.
+type Capability string
+
+const (
+ // CapabilityHeadless requires the -headless flag.
+ CapabilityHeadless Capability = "headless"
+ // CapabilityCode requires the -code flag.
+ CapabilityCode Capability = "code"
+ // CapabilityDAST requires the -dast flag.
+ CapabilityDAST Capability = "dast"
+ // CapabilitySelfContained requires the -enable-self-contained flag.
+ CapabilitySelfContained Capability = "self-contained"
+ // CapabilityGlobalMatchers requires the -enable-global-matchers flag.
+ CapabilityGlobalMatchers Capability = "global-matchers"
+ // CapabilityFile requires the -file flag.
+ CapabilityFile Capability = "file"
+)
+
+type capabilityDefinition struct {
+ capability Capability
+ stat string
+ flag string
+ templateKind string
+ loadBlocking bool
+ enabled func(*types.Options) bool
+ required func(*Template) bool
+}
+
+var capabilityDefinitions = []capabilityDefinition{
+ {
+ capability: CapabilityHeadless,
+ stat: ExcludedHeadlessTemplateStats,
+ flag: "-headless",
+ templateKind: "headless",
+ loadBlocking: true,
+ enabled: func(options *types.Options) bool {
+ return options.Headless
+ },
+ required: func(template *Template) bool {
+ return template.HasHeadlessRequest()
+ },
+ },
+ {
+ capability: CapabilityCode,
+ stat: ExcludedCodeTemplateStats,
+ flag: "-code",
+ templateKind: "code protocol",
+ loadBlocking: true,
+ enabled: func(options *types.Options) bool {
+ return options.EnableCodeTemplates
+ },
+ required: func(template *Template) bool {
+ return template.HasCodeRequest()
+ },
+ },
+ {
+ capability: CapabilityDAST,
+ stat: ExcludedDASTTemplateStats,
+ flag: "-dast",
+ templateKind: "DAST",
+ loadBlocking: true,
+ enabled: func(options *types.Options) bool {
+ return options.DAST
+ },
+ required: func(template *Template) bool {
+ return template.IsFuzzableRequest()
+ },
+ },
+ {
+ capability: CapabilitySelfContained,
+ stat: ExcludedSelfContainedTemplateStats,
+ flag: "-enable-self-contained",
+ templateKind: "self-contained",
+ loadBlocking: true,
+ enabled: func(options *types.Options) bool {
+ return options.EnableSelfContainedTemplates
+ },
+ required: func(template *Template) bool {
+ return template.requiresSelfContained()
+ },
+ },
+ {
+ capability: CapabilityGlobalMatchers,
+ stat: ExcludedGlobalMatchersTemplateStats,
+ flag: "-enable-global-matchers",
+ templateKind: "global matchers",
+ loadBlocking: false,
+ enabled: func(options *types.Options) bool {
+ return options.EnableGlobalMatchersTemplates
+ },
+ required: func(template *Template) bool {
+ return template.requiresGlobalMatchers()
+ },
+ },
+ {
+ capability: CapabilityFile,
+ stat: ExcludedFileTemplateStats,
+ flag: "-file",
+ templateKind: "file",
+ loadBlocking: true,
+ enabled: func(options *types.Options) bool {
+ return options.EnableFileTemplates
+ },
+ required: func(template *Template) bool {
+ return template.HasFileRequest()
+ },
+ },
+}
+
+// AllCapabilities returns all template execution capabilities in evaluation order.
+func AllCapabilities() []Capability {
+ capabilities := make([]Capability, 0, len(capabilityDefinitions))
+ for _, definition := range capabilityDefinitions {
+ capabilities = append(capabilities, definition.capability)
+ }
+
+ return capabilities
+}
+
+// Stat returns the stats key for a missing capability.
+func (capability Capability) Stat() string {
+ definition, _ := capability.definition()
+
+ return definition.stat
+}
+
+// Flag returns the CLI flag enabling the capability.
+func (capability Capability) Flag() string {
+ definition, _ := capability.definition()
+
+ return definition.flag
+}
+
+// TemplateKind returns the template kind label used in missing-flag messages.
+func (capability Capability) TemplateKind() string {
+ definition, found := capability.definition()
+ if !found {
+ return string(capability)
+ }
+
+ return definition.templateKind
+}
+
+func (capability Capability) definition() (capabilityDefinition, bool) {
+ for _, definition := range capabilityDefinitions {
+ if definition.capability == capability {
+ return definition, true
+ }
+ }
+
+ return capabilityDefinition{}, false
+}
+
+// MissingFlagMessage returns a per-template message for a missing capability.
+func (capability Capability) MissingFlagMessage(templatePath string) string {
+ return fmt.Sprintf("%s flag is required for %s template %q.", capability.Flag(), capability.TemplateKind(), templatePath)
+}
+
+// CapabilitySet represents enabled template execution capabilities.
+type CapabilitySet map[Capability]bool
+
+// CapabilitiesFromOptions returns the template capabilities enabled by options.
+func CapabilitiesFromOptions(options *types.Options) CapabilitySet {
+ capabilities := make(CapabilitySet, len(capabilityDefinitions))
+ for _, definition := range capabilityDefinitions {
+ capabilities[definition.capability] = definition.enabled(options)
+ }
+
+ return capabilities
+}
+
+// Has returns true when a capability is enabled.
+func (caps CapabilitySet) Has(capability Capability) bool {
+ return caps[capability]
+}
+
+// RequiredCapabilities returns the opt-in capabilities required by the template.
+func (template *Template) RequiredCapabilities() []Capability {
+ var required []Capability
+
+ for _, definition := range capabilityDefinitions {
+ if definition.required(template) {
+ required = append(required, definition.capability)
+ }
+ }
+
+ return required
+}
+
+func (template *Template) requiresSelfContained() bool {
+ if template.SelfContained {
+ return true
+ }
+
+ for _, request := range template.RequestsHTTP {
+ if request != nil && request.SelfContained {
+ return true
+ }
+ }
+ for _, request := range template.RequestsNetwork {
+ if request != nil && request.SelfContained {
+ return true
+ }
+ }
+ for _, request := range template.RequestsHeadless {
+ if request != nil && request.SelfContained {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (template *Template) requiresGlobalMatchers() bool {
+ for _, request := range template.RequestsHTTP {
+ if request != nil && request.GlobalMatchers {
+ return true
+ }
+ }
+
+ return false
+}
+
+// MissingCapabilities returns all disabled capabilities required by the template.
+func (template *Template) MissingCapabilities(caps CapabilitySet) []Capability {
+ return template.missingCapabilities(caps, false)
+}
+
+// MissingLoadCapabilities returns disabled capabilities that prevent a template
+// from being loaded into the execution store.
+func (template *Template) MissingLoadCapabilities(caps CapabilitySet) []Capability {
+ return template.missingCapabilities(caps, true)
+}
+
+func (template *Template) missingCapabilities(caps CapabilitySet, loadOnly bool) []Capability {
+ var missing []Capability
+
+ for _, definition := range capabilityDefinitions {
+ if loadOnly && !definition.loadBlocking {
+ continue
+ }
+ if definition.required(template) && !caps.Has(definition.capability) {
+ missing = append(missing, definition.capability)
+ }
+ }
+
+ return missing
+}
diff --git a/pkg/templates/compile.go b/pkg/templates/compile.go
index 8a2dbef546..bf61618040 100644
--- a/pkg/templates/compile.go
+++ b/pkg/templates/compile.go
@@ -9,7 +9,7 @@ import (
"github.com/logrusorgru/aurora/v4"
"github.com/pkg/errors"
- "gopkg.in/yaml.v2"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
@@ -250,16 +250,12 @@ func Parse(filePath string, preprocessor Preprocessor, options *protocols.Execut
//
// TODO: support all protocols.
func (template *Template) isGlobalMatchersEnabled() bool {
- if !template.Options.Options.EnableGlobalMatchersTemplates {
+ caps := CapabilitiesFromOptions(template.Options.Options)
+ if !caps.Has(CapabilityGlobalMatchers) {
return false
}
- for _, request := range template.RequestsHTTP {
- if request.GlobalMatchers {
- return true
- }
- }
- return false
+ return template.requiresGlobalMatchers()
}
// parseSelfContainedRequests parses the self contained template requests.
@@ -311,6 +307,7 @@ func (template *Template) compileProtocolRequests(options *protocols.ExecutorOpt
}
var requests []protocols.Request
+ caps := CapabilitiesFromOptions(options.Options)
if template.hasMultipleRequests() {
// when multiple requests are present preserve the order of requests and protocols
@@ -332,7 +329,7 @@ func (template *Template) compileProtocolRequests(options *protocols.ExecutorOpt
if template.HasHTTPRequest() {
requests = append(requests, template.convertRequestToProtocolsRequest(template.RequestsHTTP)...)
}
- if template.HasHeadlessRequest() && options.Options.Headless {
+ if template.HasHeadlessRequest() && caps.Has(CapabilityHeadless) {
requests = append(requests, template.convertRequestToProtocolsRequest(template.RequestsHeadless)...)
}
if template.HasSSLRequest() {
@@ -344,7 +341,7 @@ func (template *Template) compileProtocolRequests(options *protocols.ExecutorOpt
if template.HasWHOISRequest() {
requests = append(requests, template.convertRequestToProtocolsRequest(template.RequestsWHOIS)...)
}
- if template.HasCodeRequest() && options.Options.EnableCodeTemplates {
+ if template.HasCodeRequest() && caps.Has(CapabilityCode) {
requests = append(requests, template.convertRequestToProtocolsRequest(template.RequestsCode)...)
}
if template.HasJavascriptRequest() {
diff --git a/pkg/templates/compile_test.go b/pkg/templates/compile_test.go
index 6369a0154d..6201ce68e5 100644
--- a/pkg/templates/compile_test.go
+++ b/pkg/templates/compile_test.go
@@ -7,10 +7,12 @@ import (
netHttp "net/http"
"net/http/httptest"
"os"
+ "path/filepath"
"testing"
"time"
"github.com/julienschmidt/httprouter"
+ "github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow"
@@ -26,7 +28,7 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/variables"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
- "github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
"github.com/projectdiscovery/nuclei/v3/pkg/workflows"
"github.com/projectdiscovery/ratelimit"
"github.com/stretchr/testify/require"
@@ -205,6 +207,83 @@ func Test_ParseWorkflowWithGlobalMatchers(t *testing.T) {
require.Len(t, got.CompiledWorkflow.Workflows[1].Executers, 0)
}
+func Test_ParseWorkflowAllowsFileAndSelfContainedSubtemplatesWhenEnabled(t *testing.T) {
+ setup()
+ previousFileTemplates := executerOpts.Options.EnableFileTemplates
+ previousSelfContainedTemplates := executerOpts.Options.EnableSelfContainedTemplates
+ defer func() {
+ executerOpts.Options.EnableFileTemplates = previousFileTemplates
+ executerOpts.Options.EnableSelfContainedTemplates = previousSelfContainedTemplates
+ }()
+
+ executerOpts.Options.EnableFileTemplates = true
+ executerOpts.Options.EnableSelfContainedTemplates = true
+
+ got, err := templates.Parse("tests/workflow-capability-gates.yaml", nil, executerOpts)
+ require.NoError(t, err, "could not parse workflow template")
+ require.NotNil(t, got.CompiledWorkflow, "compiled workflow should not be nil")
+ require.Len(t, got.CompiledWorkflow.Workflows, 1)
+
+ workflow := got.CompiledWorkflow.Workflows[0]
+ require.Len(t, workflow.Executers, 1)
+ require.Len(t, workflow.Subtemplates, 1)
+ require.Len(t, workflow.Subtemplates[0].Executers, 1)
+}
+
+func Test_ParseWorkflowRecordsUnsignedCodeSubtemplateOnlyAsCodeSkip(t *testing.T) {
+ setup()
+ previousCodeTemplates := executerOpts.Options.EnableCodeTemplates
+ previousDisableUnsigned := executerOpts.Options.DisableUnsignedTemplates
+ defer func() {
+ executerOpts.Options.EnableCodeTemplates = previousCodeTemplates
+ executerOpts.Options.DisableUnsignedTemplates = previousDisableUnsigned
+ }()
+
+ executerOpts.Options.EnableCodeTemplates = false
+ executerOpts.Options.DisableUnsignedTemplates = false
+
+ dir := t.TempDir()
+ codeTemplatePath := filepath.Join(dir, "unsigned-code.yaml")
+ err := os.WriteFile(codeTemplatePath, []byte(`id: workflow-unsigned-code
+
+info:
+ name: Workflow Unsigned Code
+ author: pdteam
+ severity: info
+
+code:
+ - engine:
+ - sh
+ source: |
+ echo workflow-unsigned-code
+`), 0o600)
+ require.NoError(t, err)
+
+ workflowPath := filepath.Join(dir, "workflow.yaml")
+ err = os.WriteFile(workflowPath, []byte(fmt.Sprintf(`id: workflow-unsigned-code-gate
+
+info:
+ name: Workflow Unsigned Code Gate
+ author: pdteam
+ severity: info
+
+workflows:
+ - template: %q
+`, codeTemplatePath)), 0o600)
+ require.NoError(t, err)
+
+ initialUnverifiedCode := stats.GetValue(templates.SkippedUnverifiedCodeTemplateStats)
+ initialUnverified := stats.GetValue(templates.SkippedUnverifiedTemplateStats)
+
+ got, err := templates.Parse(workflowPath, nil, executerOpts)
+ require.NoError(t, err)
+ require.NotNil(t, got.CompiledWorkflow)
+ require.Len(t, got.CompiledWorkflow.Workflows, 1)
+ require.Empty(t, got.CompiledWorkflow.Workflows[0].Executers)
+ require.Equal(t, initialUnverifiedCode+1, stats.GetValue(templates.SkippedUnverifiedCodeTemplateStats))
+ require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats))
+}
+
func Test_WrongTemplate(t *testing.T) {
setup()
diff --git a/pkg/templates/fuzz.go b/pkg/templates/fuzz.go
new file mode 100644
index 0000000000..131d1345b4
--- /dev/null
+++ b/pkg/templates/fuzz.go
@@ -0,0 +1,19 @@
+//go:build gofuzz
+// +build gofuzz
+
+package templates
+
+// Fuzz exercises YAML and JSON template parsing plus compile-time validation
+// without executing any protocol requests.
+func Fuzz(data []byte) int {
+ if len(data) == 0 {
+ return 0
+ }
+ if len(data) > fuzzMaxInputSize {
+ return -1
+ }
+ if !fuzzTemplateParsing(data) {
+ return 0
+ }
+ return 1
+}
diff --git a/pkg/templates/fuzz_harness.go b/pkg/templates/fuzz_harness.go
new file mode 100644
index 0000000000..1ff84c2a2c
--- /dev/null
+++ b/pkg/templates/fuzz_harness.go
@@ -0,0 +1,423 @@
+package templates
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/projectdiscovery/nuclei/v3/pkg/catalog"
+ "github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
+ "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
+ nucleiTypes "github.com/projectdiscovery/nuclei/v3/pkg/types"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
+ "github.com/projectdiscovery/ratelimit"
+)
+
+const (
+ fuzzMaxInputSize = 16 << 10
+ fuzzMaxValueBytes = 256
+)
+
+var (
+ fuzzTemplateSeverities = []string{"info", "low", "medium", "high", "critical", "unknown"}
+ fuzzTemplateMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"}
+ fuzzTemplatePaths = []string{"/", "/login", "/api/v1/users", "/search?q=nuclei", "/admin/{{id}}"}
+ fuzzTemplateWords = []string{"nuclei", "Example Domain", "HTTP", "success", "admin"}
+ errFuzzHelperDisabled = errors.New("fuzz template helper file loading disabled")
+ fuzzProtocolInit sync.Once
+)
+
+type fuzzTemplateCandidate struct {
+ id string
+ name string
+ author string
+ severity string
+ method string
+ path string
+ matcherWord string
+ useRawRequest bool
+}
+
+func fuzzTemplateParsing(data []byte) bool {
+ if len(data) == 0 || len(data) > fuzzMaxInputSize {
+ return false
+ }
+
+ candidate := newFuzzTemplateCandidate(data)
+ candidate.applyLines(splitFuzzLines(data))
+
+ parsed := exerciseFuzzYAMLTemplate(candidate.yaml())
+ if exerciseFuzzJSONTemplate(candidate.json()) {
+ parsed = true
+ }
+
+ exerciseFuzzDirectTemplateParsers(data)
+ return parsed
+}
+
+func exerciseFuzzYAMLTemplate(data []byte) bool {
+ return exerciseFuzzYAMLTemplateErr(data) == nil
+}
+
+func exerciseFuzzYAMLTemplateErr(data []byte) error {
+ template, err := parseFuzzYAMLTemplate(data)
+ if err != nil {
+ return err
+ }
+ exerciseFuzzParsedTemplate(template)
+ if _, err = compileFuzzTemplate(data); err != nil {
+ return err
+ }
+ return nil
+}
+
+func exerciseFuzzJSONTemplate(data []byte) bool {
+ return exerciseFuzzJSONTemplateErr(data) == nil
+}
+
+func exerciseFuzzJSONTemplateErr(data []byte) error {
+ template, err := parseFuzzJSONTemplate(data)
+ if err != nil {
+ return err
+ }
+ exerciseFuzzParsedTemplate(template)
+ if _, err = compileFuzzTemplate(data); err != nil {
+ return err
+ }
+ return nil
+}
+
+func exerciseFuzzDirectTemplateParsers(data []byte) {
+ if len(bytes.TrimSpace(data)) == 0 {
+ return
+ }
+
+ if template, err := parseFuzzYAMLTemplate(data); err == nil {
+ exerciseFuzzParsedTemplate(template)
+ }
+ if json.Valid(data) {
+ if template, err := parseFuzzJSONTemplate(data); err == nil {
+ exerciseFuzzParsedTemplate(template)
+ }
+ }
+}
+
+func parseFuzzYAMLTemplate(data []byte) (*Template, error) {
+ template := &Template{}
+ if err := yaml.UnmarshalStrict(data, template); err != nil {
+ return nil, err
+ }
+ if err := validateTemplateMandatoryFields(template); err != nil {
+ return nil, err
+ }
+ return template, nil
+}
+
+func parseFuzzJSONTemplate(data []byte) (*Template, error) {
+ template := &Template{}
+ if err := template.unmarshalJSONStrict(data); err != nil {
+ return nil, err
+ }
+ if err := validateTemplateMandatoryFields(template); err != nil {
+ return nil, err
+ }
+ return template, nil
+}
+
+func compileFuzzTemplate(data []byte) (*Template, error) {
+ template, err := parseTemplateNoVerify(data, newFuzzExecutorOptions())
+ if err != nil {
+ return nil, err
+ }
+ if template == nil {
+ return nil, errors.New("nil compiled template")
+ }
+ exerciseFuzzParsedTemplate(template)
+ return template, nil
+}
+
+func exerciseFuzzParsedTemplate(template *Template) {
+ if template == nil {
+ panic("nil template")
+ }
+ _ = template.Type()
+ _ = template.Requests()
+ template.validateAllRequestIDs()
+ template.parseSelfContainedRequests()
+}
+
+func newFuzzExecutorOptions() *protocols.ExecutorOptions {
+ options := nucleiTypes.DefaultOptions()
+ options.NoColor = true
+ options.RateLimit = 1
+ options.RateLimitDuration = time.Second
+ options.BulkSize = 1
+ options.TemplateThreads = 1
+ options.PayloadConcurrency = 1
+ options.TemplateLoadingConcurrency = 1
+ options.ExecutionId = "fuzz-template"
+ options.LoadHelperFileFunction = func(string, string, catalog.Catalog) (io.ReadCloser, error) {
+ return nil, errFuzzHelperDisabled
+ }
+ fuzzProtocolInit.Do(func() {
+ _ = protocolstate.Init(options)
+ _ = protocolinit.Init(options)
+ })
+
+ executorOptions := &protocols.ExecutorOptions{
+ Options: options,
+ Catalog: disk.NewCatalog(""),
+ RateLimiter: ratelimit.New(context.Background(), 1, time.Second),
+ Parser: NewParser(),
+ DoNotCache: true,
+ TemplatePath: "fuzz-template.yaml",
+ }
+ executorOptions.CreateTemplateCtxStore()
+ return executorOptions
+}
+
+func newFuzzTemplateCandidate(data []byte) *fuzzTemplateCandidate {
+ flags := fuzzByteAt(data, 1)
+ return &fuzzTemplateCandidate{
+ id: fuzzTemplateID(string(data)),
+ name: "fuzz template",
+ author: "nuclei-fuzzer",
+ severity: fuzzTemplateSeverities[int(fuzzByteAt(data, 0))%len(fuzzTemplateSeverities)],
+ method: fuzzTemplateMethods[int(fuzzByteAt(data, 2))%len(fuzzTemplateMethods)],
+ path: fuzzTemplatePaths[int(fuzzByteAt(data, 3))%len(fuzzTemplatePaths)],
+ matcherWord: fuzzTemplateWords[int(fuzzByteAt(data, 4))%len(fuzzTemplateWords)],
+ useRawRequest: flags&0x01 != 0,
+ }
+}
+
+func (candidate *fuzzTemplateCandidate) applyLines(lines []string) {
+ for _, line := range lines {
+ key, value, ok := cutFuzzKV(line)
+ if !ok {
+ candidate.matcherWord = fuzzTemplateText(line, candidate.matcherWord)
+ continue
+ }
+
+ switch key {
+ case "id":
+ candidate.id = fuzzTemplateID(value)
+ case "name":
+ candidate.name = fuzzTemplateText(value, candidate.name)
+ case "author":
+ candidate.author = fuzzTemplateID(value)
+ case "severity":
+ candidate.severity = fuzzSeverity(value, candidate.severity)
+ case "method":
+ candidate.method = fuzzMethod(value, candidate.method)
+ case "path":
+ candidate.path = fuzzPath(value, candidate.path)
+ case "matcher", "word":
+ candidate.matcherWord = fuzzTemplateText(value, candidate.matcherWord)
+ case "raw", "use-raw":
+ candidate.useRawRequest = fuzzBool(value, candidate.useRawRequest)
+ }
+ }
+}
+
+func (candidate *fuzzTemplateCandidate) yaml() []byte {
+ var builder strings.Builder
+ fmt.Fprintf(&builder, "id: %s\n", yamlQuote(candidate.id))
+ fmt.Fprintf(&builder, "info:\n name: %s\n author: %s\n severity: %s\n", yamlQuote(candidate.name), yamlQuote(candidate.author), yamlQuote(candidate.severity))
+ builder.WriteString("http:\n - ")
+ if candidate.useRawRequest {
+ builder.WriteString("raw:\n - |\n")
+ for _, line := range strings.Split(candidate.rawRequest(), "\r\n") {
+ if line == "" {
+ builder.WriteString(" \n")
+ continue
+ }
+ fmt.Fprintf(&builder, " %s\n", line)
+ }
+ } else {
+ fmt.Fprintf(&builder, "method: %s\n path:\n - %s\n", yamlQuote(candidate.method), yamlQuote("{{BaseURL}}"+candidate.path))
+ }
+ builder.WriteString(" matchers:\n - type: word\n part: body\n words:\n")
+ fmt.Fprintf(&builder, " - %s\n", yamlQuote(candidate.matcherWord))
+ return []byte(builder.String())
+}
+
+func (candidate *fuzzTemplateCandidate) json() []byte {
+ request := map[string]interface{}{
+ "matchers": []map[string]interface{}{
+ {
+ "type": "word",
+ "part": "body",
+ "words": []string{candidate.matcherWord},
+ },
+ },
+ }
+ if candidate.useRawRequest {
+ request["raw"] = []string{candidate.rawRequest()}
+ } else {
+ request["method"] = candidate.method
+ request["path"] = []string{"{{BaseURL}}" + candidate.path}
+ }
+
+ template := map[string]interface{}{
+ "id": candidate.id,
+ "info": map[string]interface{}{
+ "name": candidate.name,
+ "author": candidate.author,
+ "severity": candidate.severity,
+ },
+ "http": []map[string]interface{}{request},
+ }
+ data, err := json.Marshal(template)
+ if err != nil {
+ panic(err)
+ }
+ return data
+}
+
+func (candidate *fuzzTemplateCandidate) rawRequest() string {
+ path := candidate.path
+ if path == "" {
+ path = "/"
+ }
+ return fmt.Sprintf("%s %s HTTP/1.1\r\nHost: {{Hostname}}\r\nUser-Agent: nuclei-fuzz\r\n\r\n", candidate.method, path)
+}
+
+func splitFuzzLines(data []byte) []string {
+ fields := strings.FieldsFunc(string(data), func(r rune) bool {
+ return r == '\n' || r == '\r' || r == ';'
+ })
+ if len(fields) > 32 {
+ fields = fields[:32]
+ }
+
+ lines := make([]string, 0, len(fields))
+ for _, field := range fields {
+ field = fuzzTrim(field)
+ if field != "" {
+ lines = append(lines, field)
+ }
+ }
+ return lines
+}
+
+func cutFuzzKV(line string) (string, string, bool) {
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ key, value, ok = strings.Cut(line, ":")
+ }
+ if !ok {
+ return "", "", false
+ }
+ return strings.ToLower(fuzzTrim(key)), fuzzTrim(value), true
+}
+
+func yamlQuote(value string) string {
+ data, err := json.Marshal(value)
+ if err != nil {
+ panic(err)
+ }
+ return string(data)
+}
+
+func fuzzByteAt(data []byte, index int) byte {
+ if len(data) == 0 {
+ return 0
+ }
+ return data[index%len(data)]
+}
+
+func fuzzTemplateID(value string) string {
+ value = strings.ToLower(fuzzToken(value, 48))
+ value = strings.Trim(value, "-_")
+ value = strings.ReplaceAll(value, "_-", "-")
+ value = strings.ReplaceAll(value, "-_", "-")
+ if value == "" {
+ return "fuzz-template"
+ }
+ return value
+}
+
+func fuzzTemplateText(value, fallback string) string {
+ value = fuzzTrim(value)
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+func fuzzSeverity(value, fallback string) string {
+ value = strings.ToLower(fuzzTrim(value))
+ for _, severity := range fuzzTemplateSeverities {
+ if value == severity {
+ return value
+ }
+ }
+ return fallback
+}
+
+func fuzzMethod(value, fallback string) string {
+ value = strings.ToUpper(fuzzToken(value, 16))
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+func fuzzPath(value, fallback string) string {
+ value = fuzzTrim(value)
+ if value == "" {
+ return fallback
+ }
+ if !strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "?") {
+ value = "/" + value
+ }
+ return value
+}
+
+func fuzzBool(value string, fallback bool) bool {
+ switch strings.ToLower(fuzzTrim(value)) {
+ case "1", "t", "true", "yes", "y", "on":
+ return true
+ case "0", "f", "false", "no", "n", "off":
+ return false
+ default:
+ return fallback
+ }
+}
+
+func fuzzToken(value string, limit int) string {
+ value = fuzzTrim(value)
+ var builder strings.Builder
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ builder.WriteRune(r)
+ case r >= 'A' && r <= 'Z':
+ builder.WriteRune(r - 'A' + 'a')
+ case r >= '0' && r <= '9':
+ builder.WriteRune(r)
+ case r == '-' || r == '_':
+ builder.WriteRune(r)
+ }
+ if builder.Len() >= limit {
+ break
+ }
+ }
+ return builder.String()
+}
+
+func fuzzTrim(value string) string {
+ value = strings.TrimSpace(strings.NewReplacer("\x00", "", "\r", " ", "\n", " ").Replace(value))
+ if len(value) > fuzzMaxValueBytes {
+ value = value[:fuzzMaxValueBytes]
+ }
+ return value
+}
diff --git a/pkg/templates/fuzz_harness_test.go b/pkg/templates/fuzz_harness_test.go
new file mode 100644
index 0000000000..70170ac8fc
--- /dev/null
+++ b/pkg/templates/fuzz_harness_test.go
@@ -0,0 +1,43 @@
+package templates
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestTemplateFromFuzzDataSeedCorpus(t *testing.T) {
+ entries, err := os.ReadDir("testdata/gofuzz-corpus")
+ require.NoError(t, err)
+ require.NotEmpty(t, entries)
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ path := filepath.Join("testdata/gofuzz-corpus", entry.Name())
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ candidate := newFuzzTemplateCandidate(data)
+ candidate.applyLines(splitFuzzLines(data))
+
+ require.NoErrorf(t, exerciseFuzzYAMLTemplateErr(candidate.yaml()), "seed %s generated YAML should parse and compile", entry.Name())
+ require.NoErrorf(t, exerciseFuzzJSONTemplateErr(candidate.json()), "seed %s generated JSON should parse and compile", entry.Name())
+ }
+}
+
+func TestTemplateFromFuzzDataRejectsOversizeInput(t *testing.T) {
+ data := make([]byte, fuzzMaxInputSize+1)
+ require.False(t, fuzzTemplateParsing(data))
+}
+
+func TestFuzzTemplateHelperFileLoadingDisabled(t *testing.T) {
+ options := newFuzzExecutorOptions()
+ reader, err := options.Options.LoadHelperFile("anything.js", "fuzz-template.yaml", options.Catalog)
+ require.Nil(t, reader)
+ require.ErrorIs(t, err, errFuzzHelperDisabled)
+}
diff --git a/pkg/templates/parser.go b/pkg/templates/parser.go
index 4defadcead..17ef43f8a8 100644
--- a/pkg/templates/parser.go
+++ b/pkg/templates/parser.go
@@ -14,8 +14,6 @@ import (
yamlutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/projectdiscovery/utils/errkit"
fileutil "github.com/projectdiscovery/utils/file"
-
- "gopkg.in/yaml.v2"
)
type Parser struct {
@@ -63,6 +61,16 @@ func (p *Parser) CompiledCache() *Cache {
return p.compiledTemplatesCache
}
+// Purge purges both parsed and compiled template caches
+func (p *Parser) Purge() {
+ if p.parsedTemplatesCache != nil {
+ p.parsedTemplatesCache.Purge()
+ }
+ if p.compiledTemplatesCache != nil {
+ p.compiledTemplatesCache.Purge()
+ }
+}
+
func (p *Parser) ParsedCount() int {
p.Lock()
defer p.Unlock()
@@ -104,7 +112,7 @@ func (p *Parser) LoadTemplate(templatePath string, t any, extraTags []string, ca
validationError := validateTemplateMandatoryFields(template)
if validationError != nil {
- stats.Increment(SyntaxErrorStats)
+ stats.Increment(TemplateSyntaxErrorStats)
return false, errkit.Newf("Could not load template %s: %s", templatePath, validationError)
}
@@ -117,7 +125,7 @@ func (p *Parser) LoadTemplate(templatePath string, t any, extraTags []string, ca
if ret {
validationWarning := validateTemplateOptionalFields(template)
if validationWarning != nil {
- stats.Increment(SyntaxWarningStats)
+ stats.Increment(TemplateSyntaxWarningStats)
checkOpenFileError(validationWarning)
return ret, errkit.Newf("Could not load template %s: %s", templatePath, validationWarning)
}
@@ -172,13 +180,13 @@ func (p *Parser) ParseTemplate(templatePath string, catalog catalog.Catalog) (an
if data != nil {
// Already read and preprocessed
if p.NoStrictSyntax {
- err = yaml.Unmarshal(data, template)
+ err = yamlutil.Unmarshal(data, template)
} else {
- err = yaml.UnmarshalStrict(data, template)
+ err = yamlutil.UnmarshalStrict(data, template)
}
} else {
// Stream directly from reader
- decoder := yaml.NewDecoder(reader)
+ decoder := yamlutil.NewDecoder(reader)
if !p.NoStrictSyntax {
decoder.SetStrict(true)
}
@@ -210,7 +218,7 @@ func (p *Parser) LoadWorkflow(templatePath string, catalog catalog.Catalog) (boo
if len(template.Workflows) > 0 {
if validationError := validateTemplateMandatoryFields(template); validationError != nil {
- stats.Increment(SyntaxErrorStats)
+ stats.Increment(TemplateSyntaxErrorStats)
return false, validationError
}
return true, nil
diff --git a/pkg/templates/parser_stats.go b/pkg/templates/parser_stats.go
index c032201f53..ab08e2d007 100644
--- a/pkg/templates/parser_stats.go
+++ b/pkg/templates/parser_stats.go
@@ -1,18 +1,48 @@
package templates
const (
- SyntaxWarningStats = "syntax-warnings"
- SyntaxErrorStats = "syntax-errors"
- RuntimeWarningsStats = "runtime-warnings"
- SkippedCodeTmplTamperedStats = "unsigned-warnings"
- ExcludedHeadlessTmplStats = "headless-flag-missing-warnings"
- TemplatesExcludedStats = "templates-executed"
- ExcludedCodeTmplStats = "code-flag-missing-warnings"
- ExcludedDastTmplStats = "fuzz-flag-missing-warnings"
- SkippedUnsignedStats = "skipped-unsigned-stats" // tracks loading of unsigned templates
- ExcludedSelfContainedStats = "excluded-self-contained-stats"
- ExcludedFileStats = "excluded-file-stats"
- SkippedRequestSignatureStats = "skipped-request-signature-stats"
+ TemplateRuntimeWarningStats = "template-runtime-warnings"
+ TemplateSyntaxErrorStats = "template-syntax-errors"
+ TemplateSyntaxWarningStats = "template-syntax-warnings"
+
+ SkippedRequestSignatureTemplateStats = "skipped-request-signature-templates"
+ SkippedUnverifiedCodeTemplateStats = "skipped-unverified-code-templates"
+ SkippedUnverifiedTemplateStats = "skipped-unverified-templates"
+
+ ExcludedCodeTemplateStats = "excluded-code-templates"
+ ExcludedDASTTemplateStats = "excluded-dast-templates"
+ ExcludedFileTemplateStats = "excluded-file-templates"
+ ExcludedGlobalMatchersTemplateStats = "excluded-global-matcher-templates"
+ ExcludedHeadlessTemplateStats = "excluded-headless-templates"
+ ExcludedSelfContainedTemplateStats = "excluded-self-contained-templates"
+ ExcludedWeakMatcherTemplateStats = "excluded-weak-matcher-templates"
+)
+
+const (
+ // Deprecated: Use TemplateSyntaxWarningStats instead.
+ SyntaxWarningStats = TemplateSyntaxWarningStats
+ // Deprecated: Use TemplateSyntaxErrorStats instead.
+ SyntaxErrorStats = TemplateSyntaxErrorStats
+ // Deprecated: Use TemplateRuntimeWarningStats instead.
+ RuntimeWarningsStats = TemplateRuntimeWarningStats
+ // Deprecated: Use SkippedUnverifiedCodeTemplateStats instead.
+ SkippedCodeTmplTamperedStats = SkippedUnverifiedCodeTemplateStats
+ // Deprecated: Use ExcludedHeadlessTemplateStats instead.
+ ExcludedHeadlessTmplStats = ExcludedHeadlessTemplateStats
+ // Deprecated: Use ExcludedWeakMatcherTemplateStats instead.
+ TemplatesExcludedStats = ExcludedWeakMatcherTemplateStats
+ // Deprecated: Use ExcludedCodeTemplateStats instead.
+ ExcludedCodeTmplStats = ExcludedCodeTemplateStats
+ // Deprecated: Use ExcludedDASTTemplateStats instead.
+ ExcludedDastTmplStats = ExcludedDASTTemplateStats
+ // Deprecated: Use SkippedUnverifiedTemplateStats instead.
+ SkippedUnsignedStats = SkippedUnverifiedTemplateStats
+ // Deprecated: Use ExcludedSelfContainedTemplateStats instead.
+ ExcludedSelfContainedStats = ExcludedSelfContainedTemplateStats
+ // Deprecated: Use ExcludedFileTemplateStats instead.
+ ExcludedFileStats = ExcludedFileTemplateStats
+ // Deprecated: Use SkippedRequestSignatureTemplateStats instead.
+ SkippedRequestSignatureStats = SkippedRequestSignatureTemplateStats
)
// Deprecated: Use ExcludedDastTmplStats instead.
diff --git a/pkg/templates/parser_test.go b/pkg/templates/parser_test.go
index e7b4df96e9..d911509c0f 100644
--- a/pkg/templates/parser_test.go
+++ b/pkg/templates/parser_test.go
@@ -228,6 +228,126 @@ func TestLoadTemplate(t *testing.T) {
require.NoError(t, laxErr, "lax parser must accept the same template")
})
+ t.Run("strictYAMLRejectsUnknownFields", func(t *testing.T) {
+ const tmpl = `id: yaml-unknown-field
+info:
+ name: strict yaml regression
+ author: anonymous
+ severity: info
+http:
+ - method: GET
+ path:
+ - "{{BaseURL}}"
+ bogus_field: ignore me
+ matchers:
+ - type: word
+ words:
+ - HTTP
+`
+ dir := t.TempDir()
+ strictPath := filepath.Join(dir, "tmpl-strict.yaml")
+ laxPath := filepath.Join(dir, "tmpl-lax.yaml")
+ require.NoError(t, os.WriteFile(strictPath, []byte(tmpl), 0o600))
+ require.NoError(t, os.WriteFile(laxPath, []byte(tmpl), 0o600))
+
+ _, strictErr := NewParser().ParseTemplate(strictPath, disk.NewCatalog(""))
+
+ laxParser := NewParser()
+ laxParser.NoStrictSyntax = true
+ _, laxErr := laxParser.ParseTemplate(laxPath, disk.NewCatalog(""))
+
+ require.Error(t, strictErr, "strict YAML decode must reject unknown fields")
+ require.Contains(t, strictErr.Error(), "bogus_field")
+ require.NoError(t, laxErr, "NoStrictSyntax should allow unknown YAML fields")
+ })
+
+ t.Run("strictYAMLRejectsDuplicateFields", func(t *testing.T) {
+ const tmpl = `id: yaml-duplicate-field
+id: yaml-duplicate-field-overwrite
+info:
+ name: duplicate yaml regression
+ author: anonymous
+ severity: info
+http:
+ - method: GET
+ path:
+ - "{{BaseURL}}"
+ matchers:
+ - type: word
+ words:
+ - HTTP
+`
+ dir := t.TempDir()
+ path := filepath.Join(dir, "tmpl.yaml")
+ require.NoError(t, os.WriteFile(path, []byte(tmpl), 0o600))
+
+ _, err := NewParser().ParseTemplate(path, disk.NewCatalog(""))
+ require.Error(t, err, "strict YAML decode must reject duplicate fields")
+ require.Contains(t, err.Error(), "already")
+ })
+
+ t.Run("laxYAMLAllowsDuplicateFields", func(t *testing.T) {
+ const tmpl = `id: yaml-duplicate-field
+id: yaml-duplicate-field-overwrite
+info:
+ name: duplicate yaml regression
+ author: anonymous
+ severity: info
+http:
+ - method: GET
+ path:
+ - "{{BaseURL}}"
+ matchers:
+ - type: word
+ words:
+ - HTTP
+`
+ dir := t.TempDir()
+ path := filepath.Join(dir, "tmpl.yaml")
+ require.NoError(t, os.WriteFile(path, []byte(tmpl), 0o600))
+
+ laxParser := NewParser()
+ laxParser.NoStrictSyntax = true
+ parsed, err := laxParser.ParseTemplate(path, disk.NewCatalog(""))
+ require.NoError(t, err, "NoStrictSyntax should preserve yaml.v2 duplicate-field behavior")
+
+ template, ok := parsed.(*Template)
+ require.True(t, ok)
+ require.Equal(t, "yaml-duplicate-field-overwrite", template.ID)
+ })
+
+ t.Run("YAMLPreservesMultiProtocolOrder", func(t *testing.T) {
+ const tmpl = `id: yaml-protocol-order
+info:
+ name: protocol order regression
+ author: anonymous
+ severity: info
+dns:
+ - name: "{{FQDN}}"
+ type: cname
+http:
+ - method: GET
+ path:
+ - "{{BaseURL}}"
+ matchers:
+ - type: word
+ words:
+ - HTTP
+`
+ dir := t.TempDir()
+ path := filepath.Join(dir, "tmpl.yaml")
+ require.NoError(t, os.WriteFile(path, []byte(tmpl), 0o600))
+
+ parsed, err := NewParser().ParseTemplate(path, disk.NewCatalog(""))
+ require.NoError(t, err)
+
+ template, ok := parsed.(*Template)
+ require.True(t, ok)
+ require.Len(t, template.RequestsQueue, 2)
+ require.Equal(t, "dns", template.RequestsQueue[0].Type().String())
+ require.Equal(t, "http", template.RequestsQueue[1].Type().String())
+ })
+
t.Run("invalidTemplateID", func(t *testing.T) {
tt := []struct {
id string
diff --git a/pkg/templates/stats.go b/pkg/templates/stats.go
index aa46e88dbd..4f1592569d 100644
--- a/pkg/templates/stats.go
+++ b/pkg/templates/stats.go
@@ -2,17 +2,68 @@ package templates
import "github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
+type templateStatEntry struct {
+ name string
+ description string
+}
+
+var templateStatEntries = []templateStatEntry{
+ {
+ name: TemplateSyntaxWarningStats,
+ description: "Found %d templates with syntax warning (use -validate flag for further examination)",
+ },
+ {
+ name: TemplateSyntaxErrorStats,
+ description: "Found %d templates with syntax error (use -validate flag for further examination)",
+ },
+ {
+ name: TemplateRuntimeWarningStats,
+ description: "Found %d templates with runtime error (use -validate flag for further examination)",
+ },
+ {
+ name: SkippedUnverifiedCodeTemplateStats,
+ description: "Found %d unsigned or tampered code template (carefully examine before using it & use -sign flag to sign them)",
+ },
+ {
+ name: ExcludedHeadlessTemplateStats,
+ description: "Excluded %d headless template[s] (disabled as default), use -headless option to run headless templates.",
+ },
+ {
+ name: ExcludedCodeTemplateStats,
+ description: "Excluded %d code template[s] (disabled as default), use -code option to run code templates.",
+ },
+ {
+ name: ExcludedSelfContainedTemplateStats,
+ description: "Excluded %d self-contained template[s] (disabled as default), use -esc option to run self-contained templates.",
+ },
+ {
+ name: ExcludedGlobalMatchersTemplateStats,
+ description: "Excluded %d global matcher template[s] (disabled as default), use -enable-global-matchers option to run global matcher templates.",
+ },
+ {
+ name: ExcludedFileTemplateStats,
+ description: "Excluded %d file template[s] (disabled as default), use -file option to run file templates.",
+ },
+ {
+ name: ExcludedWeakMatcherTemplateStats,
+ description: "Excluded %d template[s] with known weak matchers / tags excluded from default run using .nuclei-ignore",
+ },
+ {
+ name: ExcludedDASTTemplateStats,
+ description: "Excluded %d dast template[s] (disabled as default), use -dast option to run dast templates.",
+ },
+ {
+ name: SkippedUnverifiedTemplateStats,
+ description: "Skipping %d unsigned template[s]",
+ },
+ {
+ name: SkippedRequestSignatureTemplateStats,
+ description: "Skipping %d templates, HTTP Request signatures can only be used in Signed & Verified templates.",
+ },
+}
+
func init() {
- stats.NewEntry(SyntaxWarningStats, "Found %d templates with syntax warning (use -validate flag for further examination)")
- stats.NewEntry(SyntaxErrorStats, "Found %d templates with syntax error (use -validate flag for further examination)")
- stats.NewEntry(RuntimeWarningsStats, "Found %d templates with runtime error (use -validate flag for further examination)")
- stats.NewEntry(SkippedCodeTmplTamperedStats, "Found %d unsigned or tampered code template (carefully examine before using it & use -sign flag to sign them)")
- stats.NewEntry(ExcludedHeadlessTmplStats, "Excluded %d headless template[s] (disabled as default), use -headless option to run headless templates.")
- stats.NewEntry(ExcludedCodeTmplStats, "Excluded %d code template[s] (disabled as default), use -code option to run code templates.")
- stats.NewEntry(ExcludedSelfContainedStats, "Excluded %d self-contained template[s] (disabled as default), use -esc option to run self-contained templates.")
- stats.NewEntry(ExcludedFileStats, "Excluded %d file template[s] (disabled as default), use -file option to run file templates.")
- stats.NewEntry(TemplatesExcludedStats, "Excluded %d template[s] with known weak matchers / tags excluded from default run using .nuclei-ignore")
- stats.NewEntry(ExcludedDastTmplStats, "Excluded %d dast template[s] (disabled as default), use -dast option to run dast templates.")
- stats.NewEntry(SkippedUnsignedStats, "Skipping %d unsigned template[s]")
- stats.NewEntry(SkippedRequestSignatureStats, "Skipping %d templates, HTTP Request signatures can only be used in Signed & Verified templates.")
+ for _, entry := range templateStatEntries {
+ stats.NewEntry(entry.name, entry.description)
+ }
}
diff --git a/pkg/templates/templates.go b/pkg/templates/templates.go
index 9f98a9fd51..0c5a99ad2a 100644
--- a/pkg/templates/templates.go
+++ b/pkg/templates/templates.go
@@ -24,11 +24,11 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/projectdiscovery/nuclei/v3/pkg/workflows"
"github.com/projectdiscovery/utils/errkit"
fileutil "github.com/projectdiscovery/utils/file"
"go.uber.org/multierr"
- "gopkg.in/yaml.v2"
)
// Template is a YAML input file which defines all the requests and
@@ -633,6 +633,8 @@ func (template *Template) finalizeFromJSON(data []byte) error {
}
// Requirements holds the required options for a template to be enabled.
+//
+// Deprecated: use [Template.RequiredCapabilities] instead.
type Requirements struct {
Headless bool
Code bool
@@ -642,6 +644,8 @@ type Requirements struct {
}
// Requirements returns what options must be enabled for the template to run.
+//
+// Deprecated: use [Template.RequiredCapabilities] instead.
func (template *Template) Requirements() Requirements {
return Requirements{
Headless: template.HasHeadlessRequest(),
@@ -653,6 +657,8 @@ func (template *Template) Requirements() Requirements {
}
// Capabilities represents the enabled options/capabilities.
+//
+// Deprecated: use [CapabilitySet] and [CapabilitiesFromOptions] instead.
type Capabilities struct {
Headless bool
Code bool
@@ -663,28 +669,18 @@ type Capabilities struct {
// IsEnabledFor checks if all template requirements are satisfied by the given
// capabilities.
+//
+// Deprecated: use [Template.MissingCapabilities] instead.
func (template *Template) IsEnabledFor(caps Capabilities) bool {
- reqs := template.Requirements()
-
- if reqs.Headless && !caps.Headless {
- return false
- }
-
- if reqs.Code && !caps.Code {
- return false
- }
-
- if reqs.DAST && !caps.DAST {
- return false
- }
-
- if reqs.SelfContained && !caps.SelfContained {
- return false
- }
+ return len(template.MissingCapabilities(caps.toCapabilitySet())) == 0
+}
- if reqs.File && !caps.File {
- return false
+func (caps Capabilities) toCapabilitySet() CapabilitySet {
+ return CapabilitySet{
+ CapabilityHeadless: caps.Headless,
+ CapabilityCode: caps.Code,
+ CapabilityDAST: caps.DAST,
+ CapabilitySelfContained: caps.SelfContained,
+ CapabilityFile: caps.File,
}
-
- return true
}
diff --git a/pkg/templates/templates_test.go b/pkg/templates/templates_test.go
index a94ebf656e..f82996bfaa 100644
--- a/pkg/templates/templates_test.go
+++ b/pkg/templates/templates_test.go
@@ -4,9 +4,15 @@ import (
"os"
"testing"
+ "github.com/projectdiscovery/nuclei/v3/pkg/fuzz"
+ codeProtocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/code"
+ fileProtocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/file"
+ headlessProtocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless"
+ httpProtocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
+ "github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/stretchr/testify/require"
- "gopkg.in/yaml.v2"
)
func TestCachePoolZeroing(t *testing.T) {
@@ -60,3 +66,193 @@ func TestTemplateStruct(t *testing.T) {
err = yaml.Unmarshal(yamlBin, &yamlTemplate)
require.Nil(t, err, "failed to unmarshal yaml template")
}
+
+func TestCapabilitiesFromOptions(t *testing.T) {
+ options := &types.Options{
+ Headless: true,
+ EnableCodeTemplates: true,
+ DAST: true,
+ EnableSelfContainedTemplates: true,
+ EnableGlobalMatchersTemplates: true,
+ EnableFileTemplates: true,
+ }
+
+ require.Equal(t, CapabilitySet{
+ CapabilityHeadless: true,
+ CapabilityCode: true,
+ CapabilityDAST: true,
+ CapabilitySelfContained: true,
+ CapabilityGlobalMatchers: true,
+ CapabilityFile: true,
+ }, CapabilitiesFromOptions(options))
+}
+
+func TestDeprecatedStatAliases(t *testing.T) {
+ require.Equal(t, TemplateSyntaxWarningStats, SyntaxWarningStats)
+ require.Equal(t, TemplateSyntaxErrorStats, SyntaxErrorStats)
+ require.Equal(t, TemplateRuntimeWarningStats, RuntimeWarningsStats)
+ require.Equal(t, SkippedUnverifiedCodeTemplateStats, SkippedCodeTmplTamperedStats)
+ require.Equal(t, ExcludedHeadlessTemplateStats, ExcludedHeadlessTmplStats)
+ require.Equal(t, ExcludedWeakMatcherTemplateStats, TemplatesExcludedStats)
+ require.Equal(t, ExcludedCodeTemplateStats, ExcludedCodeTmplStats)
+ require.Equal(t, ExcludedDASTTemplateStats, ExcludedDastTmplStats)
+ require.Equal(t, ExcludedDastTmplStats, ExludedDastTmplStats)
+ require.Equal(t, SkippedUnverifiedTemplateStats, SkippedUnsignedStats)
+ require.Equal(t, ExcludedSelfContainedTemplateStats, ExcludedSelfContainedStats)
+ require.Equal(t, ExcludedFileTemplateStats, ExcludedFileStats)
+ require.Equal(t, SkippedRequestSignatureTemplateStats, SkippedRequestSignatureStats)
+}
+
+func TestAllCapabilities(t *testing.T) {
+ require.Equal(t, []Capability{
+ CapabilityHeadless,
+ CapabilityCode,
+ CapabilityDAST,
+ CapabilitySelfContained,
+ CapabilityGlobalMatchers,
+ CapabilityFile,
+ }, AllCapabilities())
+}
+
+func TestCapabilityMetadata(t *testing.T) {
+ tests := []struct {
+ capability Capability
+ expectedStat string
+ expectedFlag string
+ expectedKind string
+ }{
+ {
+ capability: CapabilityHeadless,
+ expectedStat: ExcludedHeadlessTemplateStats,
+ expectedFlag: "-headless",
+ expectedKind: "headless",
+ },
+ {
+ capability: CapabilityCode,
+ expectedStat: ExcludedCodeTemplateStats,
+ expectedFlag: "-code",
+ expectedKind: "code protocol",
+ },
+ {
+ capability: CapabilityDAST,
+ expectedStat: ExcludedDASTTemplateStats,
+ expectedFlag: "-dast",
+ expectedKind: "DAST",
+ },
+ {
+ capability: CapabilitySelfContained,
+ expectedStat: ExcludedSelfContainedTemplateStats,
+ expectedFlag: "-enable-self-contained",
+ expectedKind: "self-contained",
+ },
+ {
+ capability: CapabilityGlobalMatchers,
+ expectedStat: ExcludedGlobalMatchersTemplateStats,
+ expectedFlag: "-enable-global-matchers",
+ expectedKind: "global matchers",
+ },
+ {
+ capability: CapabilityFile,
+ expectedStat: ExcludedFileTemplateStats,
+ expectedFlag: "-file",
+ expectedKind: "file",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(string(test.capability), func(t *testing.T) {
+ require.Equal(t, test.expectedStat, test.capability.Stat())
+ require.Equal(t, test.expectedFlag, test.capability.Flag())
+ require.Equal(t, test.expectedKind, test.capability.TemplateKind())
+ require.Equal(t,
+ test.expectedFlag+" flag is required for "+test.expectedKind+" template \"template.yaml\".",
+ test.capability.MissingFlagMessage("template.yaml"),
+ )
+ })
+ }
+}
+
+func TestTemplateMissingCapabilitiesReturnsAllMissingCapabilities(t *testing.T) {
+ template := &Template{
+ SelfContained: true,
+ RequestsFile: []*fileProtocol.Request{{}},
+ RequestsHeadless: []*headlessProtocol.Request{{Fuzzing: []*fuzz.Rule{{}}}},
+ RequestsCode: []*codeProtocol.Request{{}},
+ RequestsHTTP: []*httpProtocol.Request{{
+ GlobalMatchers: true,
+ }},
+ }
+
+ require.Equal(t, []Capability{
+ CapabilityHeadless,
+ CapabilityCode,
+ CapabilityDAST,
+ CapabilitySelfContained,
+ CapabilityGlobalMatchers,
+ CapabilityFile,
+ }, template.MissingCapabilities(CapabilitySet{}))
+ require.Empty(t, template.MissingCapabilities(CapabilitySet{
+ CapabilityHeadless: true,
+ CapabilityCode: true,
+ CapabilityDAST: true,
+ CapabilitySelfContained: true,
+ CapabilityGlobalMatchers: true,
+ CapabilityFile: true,
+ }))
+}
+
+func TestTemplateMissingLoadCapabilitiesAllowsGlobalMatchers(t *testing.T) {
+ template := &Template{
+ RequestsHTTP: []*httpProtocol.Request{{
+ GlobalMatchers: true,
+ }},
+ }
+
+ require.Equal(t, []Capability{CapabilityGlobalMatchers}, template.MissingCapabilities(CapabilitySet{}))
+ require.Empty(t, template.MissingLoadCapabilities(CapabilitySet{}))
+}
+
+func TestDeprecatedRequirementsAndIsEnabledForWrappers(t *testing.T) {
+ template := &Template{
+ SelfContained: true,
+ RequestsFile: []*fileProtocol.Request{{}},
+ }
+
+ require.Equal(t, Requirements{
+ SelfContained: true,
+ File: true,
+ }, template.Requirements())
+ require.False(t, template.IsEnabledFor(Capabilities{File: true}))
+ require.True(t, template.IsEnabledFor(Capabilities{
+ SelfContained: true,
+ File: true,
+ }))
+}
+
+func TestTemplateMissingCapabilitiesDetectsRequestSelfContained(t *testing.T) {
+ template := &Template{
+ RequestsHTTP: []*httpProtocol.Request{{
+ SelfContained: true,
+ }},
+ }
+
+ require.Equal(t, []Capability{CapabilitySelfContained}, template.MissingCapabilities(CapabilitySet{}))
+ require.Empty(t, template.MissingCapabilities(CapabilitySet{CapabilitySelfContained: true}))
+}
+
+func TestTemplateMissingCapabilitiesIgnoresNilHTTPRequests(t *testing.T) {
+ template := &Template{
+ RequestsHTTP: []*httpProtocol.Request{nil},
+ }
+
+ require.Empty(t, template.MissingCapabilities(CapabilitySet{}))
+}
+
+func TestIsFuzzableRequestIgnoresNilRequests(t *testing.T) {
+ template := &Template{
+ RequestsHTTP: []*httpProtocol.Request{nil},
+ RequestsHeadless: []*headlessProtocol.Request{nil},
+ }
+
+ require.False(t, template.IsFuzzableRequest())
+}
diff --git a/pkg/templates/templates_utils.go b/pkg/templates/templates_utils.go
index 5293f26062..9d57ad946e 100644
--- a/pkg/templates/templates_utils.go
+++ b/pkg/templates/templates_utils.go
@@ -109,7 +109,7 @@ func (t *Template) HasWorkflows() bool {
func (t *Template) IsFuzzableRequest() bool {
if t.HasHTTPRequest() {
for _, request := range t.RequestsHTTP {
- if request.HasFuzzing() {
+ if request != nil && request.HasFuzzing() {
return true
}
}
@@ -117,7 +117,7 @@ func (t *Template) IsFuzzableRequest() bool {
if t.HasHeadlessRequest() {
for _, request := range t.RequestsHeadless {
- if request.HasFuzzing() {
+ if request != nil && request.HasFuzzing() {
return true
}
}
diff --git a/pkg/templates/testdata/gofuzz-corpus/http-json.seed b/pkg/templates/testdata/gofuzz-corpus/http-json.seed
new file mode 100644
index 0000000000..61c0c889ef
--- /dev/null
+++ b/pkg/templates/testdata/gofuzz-corpus/http-json.seed
@@ -0,0 +1,7 @@
+id=http-json-fuzz
+name=HTTP JSON fuzz template
+author=nuclei-fuzzer
+severity=medium
+method=POST
+path=/api/v1/login
+matcher=success
diff --git a/pkg/templates/testdata/gofuzz-corpus/http-yaml.seed b/pkg/templates/testdata/gofuzz-corpus/http-yaml.seed
new file mode 100644
index 0000000000..dcdf424291
--- /dev/null
+++ b/pkg/templates/testdata/gofuzz-corpus/http-yaml.seed
@@ -0,0 +1,7 @@
+id=http-yaml-fuzz
+name=HTTP YAML fuzz template
+author=nuclei-fuzzer
+severity=info
+method=GET
+path=/status
+matcher=HTTP
diff --git a/pkg/templates/testdata/gofuzz-corpus/raw-http.seed b/pkg/templates/testdata/gofuzz-corpus/raw-http.seed
new file mode 100644
index 0000000000..2c2d9afbb9
--- /dev/null
+++ b/pkg/templates/testdata/gofuzz-corpus/raw-http.seed
@@ -0,0 +1,8 @@
+id=raw-http-fuzz
+name=Raw HTTP fuzz template
+author=nuclei-fuzzer
+severity=low
+method=GET
+path=/admin
+raw=true
+matcher=admin
diff --git a/pkg/templates/testdata/gofuzz-corpus/template-path-vars.seed b/pkg/templates/testdata/gofuzz-corpus/template-path-vars.seed
new file mode 100644
index 0000000000..a00cd6becf
--- /dev/null
+++ b/pkg/templates/testdata/gofuzz-corpus/template-path-vars.seed
@@ -0,0 +1,7 @@
+id=template-path-vars
+name=Template path vars
+author=nuclei-fuzzer
+severity=high
+method=PUT
+path=/admin/{{id}}
+matcher=Example Domain
diff --git a/pkg/templates/tests/workflow-capability-gates.yaml b/pkg/templates/tests/workflow-capability-gates.yaml
new file mode 100644
index 0000000000..036b76845d
--- /dev/null
+++ b/pkg/templates/tests/workflow-capability-gates.yaml
@@ -0,0 +1,11 @@
+id: workflow-capability-gates
+
+info:
+ name: Workflow Capability Gates
+ author: pdteam
+ severity: info
+
+workflows:
+ - template: tests/workflow-file-template.yaml
+ subtemplates:
+ - template: tests/workflow-self-contained-template.yaml
diff --git a/pkg/templates/tests/workflow-file-template.yaml b/pkg/templates/tests/workflow-file-template.yaml
new file mode 100644
index 0000000000..4dfd6dce3b
--- /dev/null
+++ b/pkg/templates/tests/workflow-file-template.yaml
@@ -0,0 +1,14 @@
+id: workflow-file-template
+
+info:
+ name: Workflow File Template
+ author: pdteam
+ severity: info
+
+file:
+ - extensions:
+ - all
+ matchers:
+ - type: word
+ words:
+ - "db_password"
diff --git a/pkg/templates/tests/workflow-self-contained-template.yaml b/pkg/templates/tests/workflow-self-contained-template.yaml
new file mode 100644
index 0000000000..41007dff75
--- /dev/null
+++ b/pkg/templates/tests/workflow-self-contained-template.yaml
@@ -0,0 +1,17 @@
+id: workflow-self-contained-template
+
+info:
+ name: Workflow Self Contained Template
+ author: pdteam
+ severity: info
+
+self-contained: true
+
+http:
+ - method: GET
+ path:
+ - "http://127.0.0.1:9999/"
+ matchers:
+ - type: dsl
+ dsl:
+ - "true"
diff --git a/pkg/templates/workflows.go b/pkg/templates/workflows.go
index 73a1400de8..184ce10e37 100644
--- a/pkg/templates/workflows.go
+++ b/pkg/templates/workflows.go
@@ -71,15 +71,19 @@ func parseWorkflowTemplate(workflow *workflows.WorkflowTemplate, preprocessor Pr
}
var workflowTemplates []*Template
+
+ caps := CapabilitiesFromOptions(options.Options)
for _, path := range paths {
template, err := Parse(path, preprocessor, options.Copy())
if err != nil {
gologger.Warning().Msgf("Could not parse workflow template %s: %v\n", path, err)
continue
}
+
if template == nil {
continue
}
+
if template.Executer == nil {
gologger.Warning().Msgf("Could not parse workflow template %s: no executer found\n", path)
continue
@@ -87,30 +91,28 @@ func parseWorkflowTemplate(workflow *workflows.WorkflowTemplate, preprocessor Pr
if options.Options.DisableUnsignedTemplates && !template.Verified {
// skip unverified templates when prompted to do so
- stats.Increment(SkippedUnsignedStats)
+ stats.Increment(SkippedUnverifiedTemplateStats)
continue
}
- if template.UsesRequestSignature() && !template.Verified {
- stats.Increment(SkippedRequestSignatureStats)
+
+ if template.HasCodeRequest() && !template.Verified {
+ // unverified code templates are not allowed in workflows
+ stats.Increment(SkippedUnverifiedCodeTemplateStats)
+ gologger.Warning().Msgf("Skipping unverified code template(s) from workflow: %v\n", path)
continue
}
- if template.HasCodeRequest() {
- if !options.Options.EnableCodeTemplates {
- // NOTE(dwisiswant0): It is safe to continue here during
- // validation mode, because the template has already been parsed
- // and syntax-validated by templates.Parse() above. It only
- // prevents adding to workflow's executer list and suppresses
- // warning messages.
- if !options.Options.Validate {
- gologger.Warning().Msgf("`-code` flag not found, skipping code template from workflow: %v\n", path)
- }
- continue
- } else if !template.Verified {
- // unverified code templates are not allowed in workflows
- gologger.Warning().Msgf("skipping unverified code template from workflow: %v\n", path)
- continue
+ if missingCaps := template.MissingLoadCapabilities(caps); len(missingCaps) > 0 {
+ for _, capability := range missingCaps {
+ stats.Increment(capability.Stat())
+ gologger.Warning().Msgf("Skipping workflow subtemplate: %s", capability.MissingFlagMessage(path))
}
+ continue
+ }
+
+ if template.UsesRequestSignature() && !template.Verified {
+ stats.Increment(SkippedRequestSignatureTemplateStats)
+ continue
}
// increment signed/unsigned counters
diff --git a/pkg/tmplexec/exec.go b/pkg/tmplexec/exec.go
index 1af555429c..7f2e922c1e 100644
--- a/pkg/tmplexec/exec.go
+++ b/pkg/tmplexec/exec.go
@@ -7,7 +7,7 @@ import (
"sync/atomic"
"time"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
@@ -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)
diff --git a/pkg/tmplexec/flow/builtin/dedupe.go b/pkg/tmplexec/flow/builtin/dedupe.go
index 369289db10..9dfa0534f8 100644
--- a/pkg/tmplexec/flow/builtin/dedupe.go
+++ b/pkg/tmplexec/flow/builtin/dedupe.go
@@ -4,7 +4,7 @@ import (
"crypto/md5"
"reflect"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
diff --git a/pkg/tmplexec/flow/flow_executor.go b/pkg/tmplexec/flow/flow_executor.go
index cb9c70b52d..f362925389 100644
--- a/pkg/tmplexec/flow/flow_executor.go
+++ b/pkg/tmplexec/flow/flow_executor.go
@@ -7,7 +7,7 @@ import (
"strings"
"sync/atomic"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
diff --git a/pkg/tmplexec/flow/flow_internal.go b/pkg/tmplexec/flow/flow_internal.go
index c466625168..9f0e697aa6 100644
--- a/pkg/tmplexec/flow/flow_internal.go
+++ b/pkg/tmplexec/flow/flow_internal.go
@@ -4,7 +4,7 @@ import (
"fmt"
"sync/atomic"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/utils/errkit"
diff --git a/pkg/tmplexec/flow/vm.go b/pkg/tmplexec/flow/vm.go
index 4b6f53503f..e43ed5698e 100644
--- a/pkg/tmplexec/flow/vm.go
+++ b/pkg/tmplexec/flow/vm.go
@@ -5,7 +5,7 @@ import (
"reflect"
"sync"
- "github.com/Mzack9999/goja"
+ "github.com/projectdiscovery/goja"
"github.com/logrusorgru/aurora/v4"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
diff --git a/pkg/types/interfaces.go b/pkg/types/interfaces.go
index 21293de5d7..95c1147832 100644
--- a/pkg/types/interfaces.go
+++ b/pkg/types/interfaces.go
@@ -9,7 +9,6 @@ import (
"strconv"
"strings"
- "github.com/asaskevich/govalidator"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
)
@@ -96,7 +95,7 @@ func ToStringNSlice(data interface{}) interface{} {
func ToHexOrString(data interface{}) string {
switch s := data.(type) {
case string:
- if govalidator.IsASCII(s) {
+ if isASCII(s) {
return s
}
return hex.Dump([]byte(s))
@@ -107,6 +106,15 @@ func ToHexOrString(data interface{}) string {
}
}
+func isASCII(s string) bool {
+ for i := 0; i < len(s); i++ {
+ if s[i] > 0x7f {
+ return false
+ }
+ }
+ return true
+}
+
// ToStringSlice casts an interface to a []string type.
func ToStringSlice(i interface{}) []string {
var a []string
diff --git a/pkg/types/types.go b/pkg/types/types.go
index 96afae15ba..28beb4f749 100644
--- a/pkg/types/types.go
+++ b/pkg/types/types.go
@@ -205,6 +205,13 @@ type Options struct {
DebugResponse bool
// DisableHTTPProbe disables http probing feature of input normalization
DisableHTTPProbe bool
+ // PreflightPortScan enables a preflight resolve + TCP portscan and filters targets
+ // before running templates. Disabled by default.
+ PreflightPortScan bool
+ // PerHostRateLimit enables per-host rate limiting for HTTP requests.
+ // When enabled, each host gets its own rate limiter and global rate limit becomes unlimited.
+ // Disabled by default.
+ PerHostRateLimit bool
// LeaveDefaultPorts skips normalization of default ports
LeaveDefaultPorts bool
// AutomaticScan enables automatic tech based template execution
@@ -569,6 +576,8 @@ func (options *Options) Copy() *Options {
DebugRequests: options.DebugRequests,
DebugResponse: options.DebugResponse,
DisableHTTPProbe: options.DisableHTTPProbe,
+ PreflightPortScan: options.PreflightPortScan,
+ PerHostRateLimit: options.PerHostRateLimit,
LeaveDefaultPorts: options.LeaveDefaultPorts,
AutomaticScan: options.AutomaticScan,
Silent: options.Silent,
diff --git a/pkg/utils/insertion_ordered_map.go b/pkg/utils/insertion_ordered_map.go
index f52993d6f5..e916c391cc 100644
--- a/pkg/utils/insertion_ordered_map.go
+++ b/pkg/utils/insertion_ordered_map.go
@@ -5,7 +5,7 @@ import (
"strconv"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
- "gopkg.in/yaml.v2"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
)
type InsertionOrderedStringMap struct {
diff --git a/pkg/utils/insertion_ordered_map_test.go b/pkg/utils/insertion_ordered_map_test.go
index de77ba754f..5ae6933249 100644
--- a/pkg/utils/insertion_ordered_map_test.go
+++ b/pkg/utils/insertion_ordered_map_test.go
@@ -3,8 +3,8 @@ package utils
import (
"testing"
+ "github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/stretchr/testify/require"
- "gopkg.in/yaml.v2"
)
func TestUnmarshalInsertionOrderedMapYAML(t *testing.T) {
diff --git a/pkg/utils/json/json_test.go b/pkg/utils/json/json_test.go
new file mode 100644
index 0000000000..bd3cfad59d
--- /dev/null
+++ b/pkg/utils/json/json_test.go
@@ -0,0 +1,65 @@
+package json
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type logRequestShape struct {
+ Template string `json:"template"`
+ Type string `json:"type"`
+ Input string `json:"input"`
+ Timestamp *string `json:"timestamp,omitempty"`
+ Address string `json:"address"`
+ Error string `json:"error"`
+ Kind string `json:"kind,omitempty"`
+ Attrs interface{} `json:"attrs,omitempty"`
+}
+
+func TestMarshalPreservesStructFieldOutputShape(t *testing.T) {
+ output, err := Marshal(logRequestShape{
+ Template: "path",
+ Type: "http",
+ Input: "input",
+ Address: "input:",
+ Error: "none",
+ })
+ require.NoError(t, err)
+ require.Equal(t, `{"template":"path","type":"http","input":"input","address":"input:","error":"none"}`, string(output))
+}
+
+func TestEncoderAddsTrailingNewline(t *testing.T) {
+ var buffer bytes.Buffer
+ err := NewEncoder(&buffer).Encode(logRequestShape{
+ Template: "path",
+ Type: "http",
+ Input: "input",
+ Address: "input:",
+ Error: "none",
+ })
+ require.NoError(t, err)
+ require.Equal(t, "{\"template\":\"path\",\"type\":\"http\",\"input\":\"input\",\"address\":\"input:\",\"error\":\"none\"}\n", buffer.String())
+}
+
+func TestMarshalEscapesHTML(t *testing.T) {
+ output, err := Marshal(map[string]string{"value": "&"})
+ require.NoError(t, err)
+ require.Equal(t, `{"value":"\u003ctag\u003e\u0026"}`, string(output))
+}
+
+func TestMapRoundTripPreservesDecodedValues(t *testing.T) {
+ output, err := Marshal(map[string]interface{}{
+ "foo": "bar",
+ "number": float64(2),
+ "nested": map[string]interface{}{"ok": true},
+ })
+ require.NoError(t, err)
+
+ var decoded map[string]interface{}
+ require.NoError(t, Unmarshal(output, &decoded))
+ require.Equal(t, "bar", decoded["foo"])
+ require.Equal(t, float64(2), decoded["number"])
+ require.Equal(t, map[string]interface{}{"ok": true}, decoded["nested"])
+}
diff --git a/pkg/utils/yaml/preprocess.go b/pkg/utils/yaml/preprocess.go
index 64e198926b..2e67e71d25 100644
--- a/pkg/utils/yaml/preprocess.go
+++ b/pkg/utils/yaml/preprocess.go
@@ -3,9 +3,10 @@ package yaml
import (
"bytes"
"errors"
+ "fmt"
"os"
+ "path/filepath"
"regexp"
- "strings"
"github.com/projectdiscovery/nuclei/v3/pkg/templates/extensions"
fileutil "github.com/projectdiscovery/utils/file"
@@ -14,62 +15,115 @@ import (
var reImportsPattern = regexp.MustCompile(`(?m)# !include:(.+.yaml)`)
+const maxIncludeDepth = 32
+
// StrictSyntax determines if pre-processing directives should be observed
var StrictSyntax bool
// PreProcess all include directives
func PreProcess(data []byte) ([]byte, error) {
+ return preProcess(data, make(map[string]struct{}), 0)
+}
+
+func preProcess(data []byte, includeStack map[string]struct{}, depth int) ([]byte, error) {
// find all matches like !include:path\n
- importMatches := reImportsPattern.FindAllSubmatch(data, -1)
+ // FindAllSubmatchIndex is used (instead of FindAllSubmatch) so each match
+ // carries its own offset; relying on bytes.Index would always resolve to the
+ // first occurrence and incorrectly pad repeated include directives.
+ importMatches := reImportsPattern.FindAllSubmatchIndex(data, -1)
hasImportDirectives := len(importMatches) > 0
if hasImportDirectives && StrictSyntax {
return data, errors.New("include directive preprocessing is disabled")
}
- var replaceItems []string
+ if !hasImportDirectives {
+ return data, nil
+ }
+
+ // Expand each directive in place using its own offset. A strings.Replacer
+ // cannot be used here because it collapses identical directive lines onto a
+ // single replacement, which would reuse the first occurrence's indentation
+ // for every later occurrence.
+ var out bytes.Buffer
+ lastEnd := 0
for _, match := range importMatches {
- var (
- matchString string
- includeFileName string
- )
- matchBytes := match[0]
- matchString = string(matchBytes)
- if len(match) > 0 {
- includeFileName = string(match[1])
+ matchStart, matchEnd := match[0], match[1]
+
+ var includeFileName string
+ if len(match) > 3 && match[2] >= 0 {
+ includeFileName = string(data[match[2]:match[3]])
}
- // gets the number of tabs/spaces between the last \n and the beginning of the match
- matchIndex := bytes.Index(data, matchBytes)
- lastNewLineIndex := bytes.LastIndex(data[:matchIndex], []byte("\n"))
- padBytes := data[lastNewLineIndex:matchIndex]
-
- // check if the file exists
- if fileutil.FileExists(includeFileName) {
- // and in case replace the comment with it
- includeFileContent, err := os.ReadFile(includeFileName)
- if err != nil {
- return nil, err
- }
- // if it's yaml, tries to preprocess that too recursively
- if stringsutil.HasSuffixAny(includeFileName, extensions.YAML) {
- if subIncludedFileContent, err := PreProcess(includeFileContent); err == nil {
- includeFileContent = subIncludedFileContent
- } else {
- return nil, err
- }
- }
-
- // pad each line of file content with padBytes
- includeFileContent = bytes.ReplaceAll(includeFileContent, []byte("\n"), padBytes)
-
- replaceItems = append(replaceItems, matchString)
- replaceItems = append(replaceItems, string(includeFileContent))
+ // check if the file exists; otherwise leave the directive untouched
+ if !fileutil.FileExists(includeFileName) {
+ continue
+ }
+
+ includeFileContent, err := readIncludedFile(includeFileName, includeStack, depth)
+ if err != nil {
+ return nil, err
+ }
+
+ // Preserve the newline and indentation that should prefix included content lines.
+ lastNewLineIndex := bytes.LastIndex(data[:matchStart], []byte("\n"))
+ var padBytes []byte
+ if lastNewLineIndex < 0 {
+ padBytes = append([]byte("\n"), data[:matchStart]...)
+ } else {
+ padBytes = data[lastNewLineIndex:matchStart]
+ }
+
+ // pad each line of file content with padBytes
+ includeFileContent = bytes.ReplaceAll(includeFileContent, []byte("\n"), padBytes)
+
+ // copy everything up to the directive (including its indentation), then
+ // the expanded content, and resume after the directive.
+ out.Write(data[lastEnd:matchStart])
+ out.Write(includeFileContent)
+ lastEnd = matchEnd
+ }
+ out.Write(data[lastEnd:])
+
+ return out.Bytes(), nil
+}
+
+func readIncludedFile(includeFileName string, includeStack map[string]struct{}, depth int) ([]byte, error) {
+ includePath := includePathKey(includeFileName)
+ if _, ok := includeStack[includePath]; ok {
+ return nil, fmt.Errorf("circular include directive detected: %s", includeFileName)
+ }
+
+ includeStack[includePath] = struct{}{}
+ defer delete(includeStack, includePath)
+
+ includeFileContent, err := os.ReadFile(includeFileName)
+ if err != nil {
+ return nil, err
+ }
+
+ // if it's yaml, tries to preprocess that too recursively
+ if stringsutil.HasSuffixAny(includeFileName, extensions.YAML) {
+ if depth >= maxIncludeDepth {
+ return nil, fmt.Errorf("include directive exceeded maximum include depth of %d", maxIncludeDepth)
+ }
+ includeFileContent, err = preProcess(includeFileContent, includeStack, depth+1)
+ if err != nil {
+ return nil, err
}
}
- replacer := strings.NewReplacer(replaceItems...)
+ return includeFileContent, nil
+}
- return []byte(replacer.Replace(string(data))), nil
+func includePathKey(includeFileName string) string {
+ includePath, err := filepath.Abs(includeFileName)
+ if err != nil {
+ return filepath.Clean(includeFileName)
+ }
+ if evaluatedPath, err := filepath.EvalSymlinks(includePath); err == nil {
+ return evaluatedPath
+ }
+ return includePath
}
diff --git a/pkg/utils/yaml/preprocess_test.go b/pkg/utils/yaml/preprocess_test.go
new file mode 100644
index 0000000000..f543a53565
--- /dev/null
+++ b/pkg/utils/yaml/preprocess_test.go
@@ -0,0 +1,103 @@
+package yaml
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestPreProcessIncludesFileAtStartOfData(t *testing.T) {
+ restoreStrictSyntax(t)
+
+ dir := t.TempDir()
+ includedPath := filepath.Join(dir, "included.yaml")
+ require.NoError(t, os.WriteFile(includedPath, []byte("alpha: one\nbeta: two"), 0o600))
+
+ var (
+ output []byte
+ err error
+ )
+ require.NotPanics(t, func() {
+ output, err = PreProcess([]byte(fmt.Sprintf("# !include:%s\nroot: true\n", includedPath)))
+ })
+ require.NoError(t, err)
+ require.NotContains(t, string(output), "# !include:")
+ require.Contains(t, string(output), "alpha: one\nbeta: two")
+ require.Contains(t, string(output), "root: true")
+}
+
+func TestPreProcessExpandsRepeatedIncludeWithPerOccurrenceIndentation(t *testing.T) {
+ restoreStrictSyntax(t)
+
+ dir := t.TempDir()
+ childPath := filepath.Join(dir, "child.yaml")
+ require.NoError(t, os.WriteFile(childPath, []byte("key: value\nnested: true"), 0o600))
+
+ // The same include directive appears twice at different indentation levels.
+ // Each occurrence must be expanded using its own offset/indentation.
+ data := []byte(fmt.Sprintf("root:\n # !include:%s\nother:\n # !include:%s\n", childPath, childPath))
+
+ var (
+ output []byte
+ err error
+ )
+ require.NotPanics(t, func() {
+ output, err = PreProcess(data)
+ })
+ require.NoError(t, err)
+
+ got := string(output)
+ require.NotContains(t, got, "# !include:")
+ require.Contains(t, got, "root:\n key: value\n nested: true")
+ require.Contains(t, got, "other:\n key: value\n nested: true")
+}
+
+func TestPreProcessRejectsCircularInclude(t *testing.T) {
+ restoreStrictSyntax(t)
+
+ dir := t.TempDir()
+ templatePath := filepath.Join(dir, "self.yaml")
+ template := []byte(fmt.Sprintf("# !include:%s\nid: self\n", templatePath))
+ require.NoError(t, os.WriteFile(templatePath, template, 0o600))
+
+ var err error
+ require.NotPanics(t, func() {
+ _, err = PreProcess(template)
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "circular include")
+}
+
+func TestPreProcessRejectsExcessiveIncludeDepth(t *testing.T) {
+ restoreStrictSyntax(t)
+
+ dir := t.TempDir()
+ paths := make([]string, 40)
+ for i := range paths {
+ paths[i] = filepath.Join(dir, fmt.Sprintf("include-%02d.yaml", i))
+ }
+ for i, path := range paths {
+ content := fmt.Sprintf("id: include-%02d\n", i)
+ if i < len(paths)-1 {
+ content = fmt.Sprintf("# !include:%s\n%s", paths[i+1], content)
+ }
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
+ }
+
+ _, err := PreProcess([]byte(fmt.Sprintf("# !include:%s\nid: root\n", paths[0])))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "maximum include depth")
+}
+
+func restoreStrictSyntax(t *testing.T) {
+ t.Helper()
+
+ previous := StrictSyntax
+ StrictSyntax = false
+ t.Cleanup(func() {
+ StrictSyntax = previous
+ })
+}
diff --git a/pkg/utils/yaml/yaml_decode_wrapper.go b/pkg/utils/yaml/yaml_decode_wrapper.go
index 3bc4fa605a..1034569c7c 100644
--- a/pkg/utils/yaml/yaml_decode_wrapper.go
+++ b/pkg/utils/yaml/yaml_decode_wrapper.go
@@ -1,19 +1,159 @@
package yaml
import (
+ "bytes"
"io"
+ "reflect"
"strings"
"github.com/go-playground/validator/v10"
"github.com/pkg/errors"
- "gopkg.in/yaml.v2"
+ "gopkg.in/yaml.v3"
)
var validate *validator.Validate
+// Marshaler is the YAML marshaling interface used by the project.
+type Marshaler interface {
+ MarshalYAML() (interface{}, error)
+}
+
+// Unmarshaler is the legacy callback-style YAML unmarshaling interface used
+// throughout nuclei. yaml.v3 still supports this shape, but does not export it.
+type Unmarshaler interface {
+ UnmarshalYAML(unmarshal func(interface{}) error) error
+}
+
+// TypeError is returned for YAML type conversion errors.
+type TypeError = yaml.TypeError
+
+// Node is the yaml.v3 syntax tree node type.
+type Node = yaml.Node
+
+// MapItem is a single YAML mapping item.
+type MapItem struct {
+ Key interface{}
+ Value interface{}
+}
+
+// MapSlice preserves mapping key order for compatibility with yaml.v2.
+type MapSlice []MapItem
+
+// Encoder writes YAML documents.
+type Encoder = yaml.Encoder
+
+// Marshal serializes a value to YAML.
+func Marshal(v interface{}) ([]byte, error) {
+ var out bytes.Buffer
+ encoder := yaml.NewEncoder(&out)
+ encoder.SetIndent(2)
+ if err := encoder.Encode(v); err != nil {
+ return nil, err
+ }
+ if err := encoder.Close(); err != nil {
+ return nil, err
+ }
+ return out.Bytes(), nil
+}
+
+// Unmarshal deserializes YAML using yaml.v2-compatible lax duplicate-key
+// behavior. In lax mode yaml.v2 allowed duplicate mapping keys and kept the
+// last value; yaml.v3 rejects duplicates by default, so normalize first.
+func Unmarshal(data []byte, v interface{}) error {
+ return NewDecoder(bytes.NewReader(data)).Decode(v)
+}
+
+// UnmarshalStrict deserializes YAML and rejects unknown struct fields and
+// duplicate mapping keys.
+func UnmarshalStrict(data []byte, v interface{}) error {
+ decoder := NewDecoder(bytes.NewReader(data))
+ decoder.SetStrict(true)
+ return decoder.Decode(v)
+}
+
+// NewEncoder returns a YAML encoder.
+func NewEncoder(w io.Writer) *Encoder {
+ return yaml.NewEncoder(w)
+}
+
+// Decoder reads YAML documents.
+type Decoder struct {
+ decoder *yaml.Decoder
+ strict bool
+}
+
+// NewDecoder returns a YAML decoder.
+func NewDecoder(r io.Reader) *Decoder {
+ return &Decoder{decoder: yaml.NewDecoder(r)}
+}
+
+// SetStrict matches yaml.v2's decoder API.
+func (d *Decoder) SetStrict(strict bool) {
+ d.strict = strict
+ d.decoder.KnownFields(strict)
+}
+
+// KnownFields matches yaml.v3's decoder API.
+func (d *Decoder) KnownFields(enable bool) {
+ d.SetStrict(enable)
+}
+
+// Decode reads the next YAML document into v.
+func (d *Decoder) Decode(v interface{}) error {
+ if d.strict {
+ if err := d.decoder.Decode(v); err != nil {
+ return err
+ }
+ restoreYAMLv2InterfaceMapShape(v)
+ return nil
+ }
+
+ var node yaml.Node
+ if err := d.decoder.Decode(&node); err != nil {
+ return err
+ }
+ normalizeDupMappingKeys(&node)
+ if err := node.Decode(v); err != nil {
+ return err
+ }
+ restoreYAMLv2InterfaceMapShape(v)
+ return nil
+}
+
+// UnmarshalYAML decodes an ordered map from a yaml.v3 node.
+func (m *MapSlice) UnmarshalYAML(node *Node) error {
+ node = unwrapDoc(node)
+ if node == nil || node.Kind == 0 {
+ *m = nil
+ return nil
+ }
+ if node.Kind != yaml.MappingNode {
+ var value interface{}
+ if err := node.Decode(&value); err != nil {
+ return err
+ }
+ return errors.Errorf("cannot unmarshal %T into yaml.MapSlice", value)
+ }
+
+ items := make([]MapItem, 0, len(node.Content)/2)
+ for i := 0; i < len(node.Content); i += 2 {
+ var item MapItem
+ if err := node.Content[i].Decode(&item.Key); err != nil {
+ return err
+ }
+ if err := node.Content[i+1].Decode(&item.Value); err != nil {
+ return err
+ }
+ item.Value = toYAMLv2InterfaceValue(item.Value)
+ items = append(items, item)
+ }
+ *m = items
+ return nil
+}
+
// DecodeAndValidate is a wrapper for yaml Decode adding struct validation
func DecodeAndValidate(r io.Reader, v interface{}) error {
- if err := yaml.NewDecoder(r).Decode(v); err != nil {
+ if err := NewDecoder(r).Decode(v); err != nil {
return err
}
if validate == nil {
@@ -32,3 +172,141 @@ func DecodeAndValidate(r io.Reader, v interface{}) error {
}
return nil
}
+
+func normalizeDupMappingKeys(node *yaml.Node) {
+ node = unwrapDoc(node)
+ if node == nil {
+ return
+ }
+
+ switch node.Kind {
+ case yaml.DocumentNode:
+ for _, child := range node.Content {
+ normalizeDupMappingKeys(child)
+ }
+ case yaml.SequenceNode:
+ for _, child := range node.Content {
+ normalizeDupMappingKeys(child)
+ }
+ case yaml.MappingNode:
+ type pair struct {
+ key *yaml.Node
+ value *yaml.Node
+ }
+ pairs := make([]pair, 0, len(node.Content)/2)
+ indexes := make(map[string]int, len(node.Content)/2)
+ for i := 0; i < len(node.Content); i += 2 {
+ key := node.Content[i]
+ value := node.Content[i+1]
+ normalizeDupMappingKeys(value)
+
+ identity := mappingKeyIdentity(key)
+ if existing, ok := indexes[identity]; ok {
+ pairs[existing].value = value
+ continue
+ }
+ indexes[identity] = len(pairs)
+ pairs = append(pairs, pair{key: key, value: value})
+ }
+
+ node.Content = node.Content[:0]
+ for _, item := range pairs {
+ node.Content = append(node.Content, item.key, item.value)
+ }
+ }
+}
+
+func mappingKeyIdentity(node *yaml.Node) string {
+ if node == nil {
+ return ""
+ }
+ return string(rune(node.Kind)) + "\x00" + node.Value
+}
+
+func unwrapDoc(node *yaml.Node) *yaml.Node {
+ if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) == 1 {
+ return node.Content[0]
+ }
+ return node
+}
+
+func restoreYAMLv2InterfaceMapShape(v interface{}) {
+ value := reflect.ValueOf(v)
+ if !value.IsValid() {
+ return
+ }
+ if value.Kind() != reflect.Pointer || value.IsNil() {
+ return
+ }
+ restoreYAMLv2InterfaceMapShapeValue(value.Elem())
+}
+
+func restoreYAMLv2InterfaceMapShapeValue(value reflect.Value) {
+ if !value.IsValid() {
+ return
+ }
+
+ switch value.Kind() {
+ case reflect.Interface:
+ if value.IsNil() || !value.CanSet() {
+ return
+ }
+ setInterfaceValue(value, toYAMLv2InterfaceValue(value.Interface()))
+ case reflect.Pointer:
+ if !value.IsNil() {
+ restoreYAMLv2InterfaceMapShapeValue(value.Elem())
+ }
+ case reflect.Struct:
+ for i := 0; i < value.NumField(); i++ {
+ field := value.Field(i)
+ if field.CanSet() {
+ restoreYAMLv2InterfaceMapShapeValue(field)
+ }
+ }
+ case reflect.Map:
+ if value.Type().Elem().Kind() != reflect.Interface {
+ return
+ }
+ for _, key := range value.MapKeys() {
+ normalized := toYAMLv2InterfaceValue(value.MapIndex(key).Interface())
+ value.SetMapIndex(key, interfaceValueForMap(value.Type().Elem(), normalized))
+ }
+ case reflect.Slice, reflect.Array:
+ for i := 0; i < value.Len(); i++ {
+ restoreYAMLv2InterfaceMapShapeValue(value.Index(i))
+ }
+ }
+}
+
+func toYAMLv2InterfaceValue(value interface{}) interface{} {
+ switch typed := value.(type) {
+ case map[string]interface{}:
+ converted := make(map[interface{}]interface{}, len(typed))
+ for key, item := range typed {
+ converted[key] = toYAMLv2InterfaceValue(item)
+ }
+ return converted
+ case []interface{}:
+ for i, item := range typed {
+ typed[i] = toYAMLv2InterfaceValue(item)
+ }
+ return typed
+ default:
+ return value
+ }
+}
+
+func setInterfaceValue(dst reflect.Value, value interface{}) {
+ if value == nil {
+ dst.Set(reflect.Zero(dst.Type()))
+ return
+ }
+ dst.Set(reflect.ValueOf(value))
+}
+
+func interfaceValueForMap(elem reflect.Type, value interface{}) reflect.Value {
+ if value == nil {
+ return reflect.Zero(elem)
+ }
+ return reflect.ValueOf(value)
+}
diff --git a/pkg/utils/yaml/yaml_decode_wrapper_test.go b/pkg/utils/yaml/yaml_decode_wrapper_test.go
new file mode 100644
index 0000000000..b01647878d
--- /dev/null
+++ b/pkg/utils/yaml/yaml_decode_wrapper_test.go
@@ -0,0 +1,73 @@
+package yaml
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestUnmarshalStrictRejectsUnknownFields(t *testing.T) {
+ var value struct {
+ Name string `yaml:"name"`
+ }
+
+ err := UnmarshalStrict([]byte("name: test\nunknown: value\n"), &value)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "unknown")
+}
+
+func TestUnmarshalStrictRejectsDuplicateFields(t *testing.T) {
+ var value struct {
+ Name string `yaml:"name"`
+ }
+
+ err := UnmarshalStrict([]byte("name: first\nname: second\n"), &value)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "already")
+}
+
+func TestUnmarshalLaxAllowsDuplicateFields(t *testing.T) {
+ var value struct {
+ Name string `yaml:"name"`
+ }
+
+ err := Unmarshal([]byte("name: first\nname: second\n"), &value)
+ require.NoError(t, err)
+ require.Equal(t, "second", value.Name)
+}
+
+func TestDecoderLaxAllowsDuplicateFields(t *testing.T) {
+ var value struct {
+ Name string `yaml:"name"`
+ }
+
+ err := NewDecoder(strings.NewReader("name: first\nname: second\n")).Decode(&value)
+ require.NoError(t, err)
+ require.Equal(t, "second", value.Name)
+}
+
+func TestUnmarshalPreservesYAMLv2NestedInterfaceMapShape(t *testing.T) {
+ var value map[string]interface{}
+
+ err := Unmarshal([]byte("payload:\n low:\n - one\n"), &value)
+ require.NoError(t, err)
+
+ nested, ok := value["payload"].(map[interface{}]interface{})
+ require.True(t, ok, "nested interface map should keep yaml.v2 map[interface{}]interface{} shape")
+ require.Equal(t, []interface{}{"one"}, nested["low"])
+}
+
+func TestMapSlicePreservesOrder(t *testing.T) {
+ var value MapSlice
+
+ err := Unmarshal([]byte("first: one\nsecond: two\nthird: three\n"), &value)
+ require.NoError(t, err)
+ require.Len(t, value, 3)
+ require.Equal(t, "first", value[0].Key)
+ require.Equal(t, "second", value[1].Key)
+ require.Equal(t, "third", value[2].Key)
+ require.Equal(t, "one", value[0].Value)
+ require.Equal(t, "two", value[1].Value)
+ require.Equal(t, "three", value[2].Value)
+}