Files
nuclei/pkg/protocols/http/request_test.go
2026-10-25 17:32:15 +01:00

2123 lines
70 KiB
Go

package http
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"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"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/extractors"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
"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/ratelimit"
"github.com/projectdiscovery/retryablehttp-go"
)
func executeDynamicHTTPTestRequest(t *testing.T, request *Request, target string) {
t.Helper()
options := testutils.DefaultOptions
testutils.Init(options)
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{
SeverityHolder: severity.Holder{Severity: severity.Low},
Name: "test",
},
})
require.NoError(t, request.Compile(executerOpts))
require.NoError(t, request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), target),
make(output.InternalEvent),
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
))
}
func TestHTTPExtractMultipleReuse(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
templateID := "testing-http"
request := &Request{
ID: templateID,
Raw: []string{
`GET /robots.txt HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:80.0) Gecko/20100101 Firefox/80.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
`,
`GET {{endpoint}} HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:80.0) Gecko/20100101 Firefox/80.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
`,
},
Operators: operators.Operators{
Matchers: []*matchers.Matcher{{
Part: "body",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{"match /a", "match /b", "match /c"},
}},
Extractors: []*extractors.Extractor{{
Part: "body",
Name: "endpoint",
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor},
Regex: []string{"(?m)/([a-zA-Z0-9-_/\\\\]+)"},
Internal: true,
}},
},
IterateAll: true,
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/robots.txt":
_, _ = fmt.Fprintf(w, `User-agent: Googlebot
Disallow: /a
Disallow: /b
Disallow: /c`)
default:
_, _ = fmt.Fprintf(w, `match %v`, r.URL.Path)
}
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: templateID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
err := request.Compile(executerOpts)
require.Nil(t, err, "could not compile network request")
var finalEvent *output.InternalWrappedEvent
var matchCount int
t.Run("test", func(t *testing.T) {
metadata := make(output.InternalEvent)
previous := make(output.InternalEvent)
ctxArgs := contextargs.NewWithInput(context.Background(), ts.URL)
err := request.ExecuteWithResults(ctxArgs, metadata, previous, func(event *output.InternalWrappedEvent) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
matchCount++
}
finalEvent = event
})
require.Nil(t, err, "could not execute network request")
})
require.NotNil(t, finalEvent, "could not get event output from request")
require.Equal(t, 3, matchCount, "could not get correct match count")
}
func TestHTTPExtractMultipleReusePreservesBranchValues(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
request := &Request{
ID: "testing-http-branch-values",
Raw: []string{
"GET /capabilities HTTP/1.1\nHost: {{Hostname}}",
"GET /metadata/{{name}} HTTP/1.1\nHost: {{Hostname}}",
"GET /probe/{{name}}/{{column}} HTTP/1.1\nHost: {{Hostname}}",
},
Operators: operators.Operators{
Extractors: []*extractors.Extractor{
{
Part: "body_1",
Name: "name",
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor},
Regex: []string{`name=([a-z]+)`},
RegexGroup: 1,
Internal: true,
},
{
Part: "body_2",
Name: "column",
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor},
Regex: []string{`column=([a-z0-9]+)`},
RegexGroup: 1,
Internal: true,
},
},
},
IterateAll: true,
}
var mutex sync.Mutex
probes := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/capabilities":
_, _ = fmt.Fprint(w, "name=alpha name=beta")
case "/metadata/alpha":
_, _ = fmt.Fprint(w, "column=a1 column=a2")
case "/metadata/beta":
_, _ = fmt.Fprint(w, "column=b1")
default:
mutex.Lock()
probes[r.URL.Path]++
mutex.Unlock()
}
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{
SeverityHolder: severity.Holder{Severity: severity.Low},
Name: "test",
},
})
require.NoError(t, request.Compile(executerOpts))
err := request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), ts.URL),
make(output.InternalEvent),
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
)
require.NoError(t, err)
require.Equal(t, map[string]int{
"/probe/alpha/a1": 1,
"/probe/alpha/a2": 1,
"/probe/beta/b1": 1,
}, probes)
}
func TestHTTPExtractMultipleReuseReplacesReextractedValue(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
request := &Request{
ID: "testing-http-reextracted-value",
Raw: []string{
"GET /session HTTP/1.1\nHost: {{Hostname}}",
"GET /refresh/{{token}} HTTP/1.1\nHost: {{Hostname}}",
"GET /use/{{token}} HTTP/1.1\nHost: {{Hostname}}",
"GET /final/{{token}} HTTP/1.1\nHost: {{Hostname}}",
},
Operators: operators.Operators{
Extractors: []*extractors.Extractor{
{
Part: "body_1",
Name: "token",
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor},
Regex: []string{`token=([a-z]+)`},
RegexGroup: 1,
Internal: true,
},
{
Part: "body_2",
Name: "token",
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor},
Regex: []string{`token=([a-z]+)`},
RegexGroup: 1,
Internal: true,
},
},
},
IterateAll: true,
}
var mutex sync.Mutex
requests := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mutex.Lock()
requests[r.URL.Path]++
mutex.Unlock()
switch r.URL.Path {
case "/session":
_, _ = fmt.Fprint(w, "token=old")
case "/refresh/old":
_, _ = fmt.Fprint(w, "token=new token=next")
}
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{
SeverityHolder: severity.Holder{Severity: severity.Low},
Name: "test",
},
})
require.NoError(t, request.Compile(executerOpts))
err := request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), ts.URL),
make(output.InternalEvent),
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
)
require.NoError(t, err)
require.Equal(t, map[string]int{
"/session": 1,
"/refresh/old": 1,
"/use/new": 1,
"/use/next": 1,
"/final/new": 1,
"/final/next": 1,
}, requests)
}
func TestExpandDynamicValueContextsReplacesReextractedValueWithoutIteration(t *testing.T) {
parent := dynamicValueContext{
values: map[string]interface{}{"token": "old"},
}
contexts := expandDynamicValueContexts(parent, map[string][]string{"token": {"new", "next"}}, false)
require.Len(t, contexts, 1)
require.Equal(t, "new", contexts[0].values["token"])
}
func TestHTTPExtractMultipleReusePreservesOnceValuesAcrossPayloads(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
request := &Request{
ID: "testing-http-once-payload-values",
Raw: []string{
"@once\nGET /token HTTP/1.1\nHost: {{Hostname}}",
"GET /use/{{token}}/{{item}} HTTP/1.1\nHost: {{Hostname}}",
},
Payloads: map[string]interface{}{
"item": []string{"first", "second"},
},
Operators: operators.Operators{
Extractors: []*extractors.Extractor{{
Part: "body_1",
Name: "token",
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor},
Regex: []string{`token=([a-z]+)`},
RegexGroup: 1,
Internal: true,
}},
},
IterateAll: true,
}
var mutex sync.Mutex
requests := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
_, _ = fmt.Fprint(w, "token=shared")
return
}
mutex.Lock()
requests[r.URL.Path]++
mutex.Unlock()
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{
SeverityHolder: severity.Holder{Severity: severity.Low},
Name: "test",
},
})
require.NoError(t, request.Compile(executerOpts))
err := request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), ts.URL),
make(output.InternalEvent),
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
)
require.NoError(t, err)
require.Equal(t, map[string]int{
"/use/shared/first": 1,
"/use/shared/second": 1,
}, requests)
}
func TestHTTPExtractMultipleReuseDoesNotPersistPayloadValuesBeforeOnce(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
request := &Request{
ID: "testing-http-payload-values-before-once",
Raw: []string{
"GET /seed/{{item}} HTTP/1.1\nHost: {{Hostname}}",
"@once\nGET /token HTTP/1.1\nHost: {{Hostname}}",
"GET /use/{{branch}}/{{token}}/{{item}} HTTP/1.1\nHost: {{Hostname}}",
},
Payloads: map[string]interface{}{
"item": []string{"first", "second"},
},
Operators: operators.Operators{
Extractors: []*extractors.Extractor{
{
Part: "body_1",
Name: "branch",
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor},
Regex: []string{`branch=([a-z]+)`},
RegexGroup: 1,
Internal: true,
},
{
Part: "body_2",
Name: "token",
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor},
Regex: []string{`token=([a-z]+)`},
RegexGroup: 1,
Internal: true,
},
},
},
IterateAll: true,
}
var mutex sync.Mutex
requests := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/seed/"):
_, _ = fmt.Fprintf(w, "branch=%s", strings.TrimPrefix(r.URL.Path, "/seed/"))
case r.URL.Path == "/token":
_, _ = fmt.Fprint(w, "token=shared")
default:
mutex.Lock()
requests[r.URL.Path]++
mutex.Unlock()
}
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{
SeverityHolder: severity.Holder{Severity: severity.Low},
Name: "test",
},
})
require.NoError(t, request.Compile(executerOpts))
err := request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), ts.URL),
make(output.InternalEvent),
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
)
require.NoError(t, err)
require.Equal(t, map[string]int{
"/use/first/shared/first": 1,
"/use/second/shared/second": 1,
}, requests)
}
func TestHTTPExtractMultipleReuseSkipsOnlyMissingBranch(t *testing.T) {
request := &Request{
ID: "testing-http-missing-branch",
Raw: []string{
"GET /capabilities HTTP/1.1\nHost: {{Hostname}}",
"GET /metadata/{{name}} HTTP/1.1\nHost: {{Hostname}}",
"GET /probe/{{name}}/{{column}} HTTP/1.1\nHost: {{Hostname}}",
},
Operators: operators.Operators{Extractors: []*extractors.Extractor{
{Part: "body_1", Name: "name", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`name=([a-z]+)`}, RegexGroup: 1, Internal: true},
{Part: "body_2", Name: "column", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`column=([a-z0-9]+)`}, RegexGroup: 1, Internal: true},
}},
IterateAll: true,
}
var mutex sync.Mutex
probes := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/capabilities":
_, _ = fmt.Fprint(w, "name=alpha name=beta name=gamma")
case "/metadata/beta":
_, _ = fmt.Fprint(w, "column=b1")
case "/metadata/gamma":
_, _ = fmt.Fprint(w, "column=g1")
case "/metadata/alpha":
default:
mutex.Lock()
probes[r.URL.Path]++
mutex.Unlock()
}
}))
defer ts.Close()
executeDynamicHTTPTestRequest(t, request, ts.URL)
require.Equal(t, map[string]int{"/probe/beta/b1": 1, "/probe/gamma/g1": 1}, probes)
}
func TestHTTPExtractMultipleReuseKeepsPayloadFallback(t *testing.T) {
request := &Request{
ID: "testing-http-payload-fallback",
Raw: []string{
"GET /create HTTP/1.1\nHost: {{Hostname}}",
"GET /use/{{rid}}/{{item}} HTTP/1.1\nHost: {{Hostname}}",
},
Payloads: map[string]interface{}{"item": []string{"p1", "p2", "p3"}},
Operators: operators.Operators{Extractors: []*extractors.Extractor{{
Part: "body", Name: "rid", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`rid=([a-z]+)`}, RegexGroup: 1, Internal: true,
}}},
IterateAll: true,
}
var createCount atomic.Int32
var mutex sync.Mutex
uses := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/create" {
if createCount.Add(1) == 1 {
_, _ = fmt.Fprint(w, "rid=abc")
}
return
}
mutex.Lock()
uses[r.URL.Path]++
mutex.Unlock()
}))
defer ts.Close()
executeDynamicHTTPTestRequest(t, request, ts.URL)
require.Equal(t, map[string]int{"/use/abc/p1": 1, "/use/abc/p2": 1, "/use/abc/p3": 1}, uses)
}
func TestHTTPExtractMultipleReuseUsesLatestReextractedValue(t *testing.T) {
request := &Request{
ID: "testing-http-latest-reextracted-value",
Raw: []string{
"GET /one HTTP/1.1\nHost: {{Hostname}}",
"GET /two HTTP/1.1\nHost: {{Hostname}}",
"GET /three HTTP/1.1\nHost: {{Hostname}}",
"GET /use/{{csrf}} HTTP/1.1\nHost: {{Hostname}}",
},
Operators: operators.Operators{Extractors: []*extractors.Extractor{{
Part: "body", Name: "csrf", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`csrf=([a-z]+)`}, RegexGroup: 1, Internal: true,
}}},
}
var mutex sync.Mutex
uses := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/one":
_, _ = fmt.Fprint(w, "csrf=aaa")
case "/two":
_, _ = fmt.Fprint(w, "csrf=bbb")
case "/three":
_, _ = fmt.Fprint(w, "csrf=aaa")
default:
mutex.Lock()
uses[r.URL.Path]++
mutex.Unlock()
}
}))
defer ts.Close()
executeDynamicHTTPTestRequest(t, request, ts.URL)
require.Equal(t, map[string]int{"/use/aaa": 1}, uses)
}
func TestHTTPExtractMultipleReuseRetainsParentValueWhenReextracted(t *testing.T) {
request := &Request{
ID: "testing-http-parent-reextracted-value",
Raw: []string{
"GET /items HTTP/1.1\nHost: {{Hostname}}",
"GET /item/{{id}} HTTP/1.1\nHost: {{Hostname}}",
"GET /delete/{{id}} HTTP/1.1\nHost: {{Hostname}}",
},
Operators: operators.Operators{Extractors: []*extractors.Extractor{{
Part: "body", Name: "id", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`id=([0-9]+)`}, RegexGroup: 1, Internal: true,
}}},
IterateAll: true,
}
var mutex sync.Mutex
deletes := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/items":
_, _ = fmt.Fprint(w, "id=1 id=2")
case "/item/1":
_, _ = fmt.Fprint(w, "id=1 id=9")
case "/item/2":
_, _ = fmt.Fprint(w, "id=2")
default:
mutex.Lock()
deletes[r.URL.Path]++
mutex.Unlock()
}
}))
defer ts.Close()
executeDynamicHTTPTestRequest(t, request, ts.URL)
require.Equal(t, map[string]int{"/delete/1": 1, "/delete/2": 1, "/delete/9": 1}, deletes)
}
func TestHTTPExtractMultipleReuseRunsOnceRequestOnceAcrossBranches(t *testing.T) {
request := &Request{
ID: "testing-http-once-across-branches",
Raw: []string{
"GET /list HTTP/1.1\nHost: {{Hostname}}",
"@once\nGET /token HTTP/1.1\nHost: {{Hostname}}",
"GET /use/{{id}}/{{token}}/{{item}} HTTP/1.1\nHost: {{Hostname}}",
},
Payloads: map[string]interface{}{"item": []string{"x", "y", "z"}},
Operators: operators.Operators{Extractors: []*extractors.Extractor{
{Part: "body_1", Name: "id", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`id=([a-z]+)`}, RegexGroup: 1, Internal: true},
{Part: "body_2", Name: "token", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`token=([a-z0-9]+)`}, RegexGroup: 1, Internal: true},
}},
IterateAll: true,
}
var tokenCount atomic.Int32
var mutex sync.Mutex
uses := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/list":
_, _ = fmt.Fprint(w, "id=a id=b")
case "/token":
_, _ = fmt.Fprintf(w, "token=t%d", tokenCount.Add(1))
default:
mutex.Lock()
uses[r.URL.Path]++
mutex.Unlock()
}
}))
defer ts.Close()
executeDynamicHTTPTestRequest(t, request, ts.URL)
require.EqualValues(t, 1, tokenCount.Load())
require.Equal(t, map[string]int{
"/use/a/t1/x": 1, "/use/b/t1/x": 1,
"/use/a/t1/y": 1, "/use/b/t1/y": 1,
"/use/a/t1/z": 1, "/use/b/t1/z": 1,
}, uses)
}
func TestHTTPExtractMultipleReuseKeepsResponseHistoryPerBranch(t *testing.T) {
request := &Request{
ID: "testing-http-branch-response-history",
Raw: []string{
"GET /items HTTP/1.1\nHost: {{Hostname}}",
"GET /item/{{id}} HTTP/1.1\nHost: {{Hostname}}",
"GET /noop/{{id}} HTTP/1.1\nHost: {{Hostname}}",
"GET /use/{{id}}/{{token}} HTTP/1.1\nHost: {{Hostname}}",
},
Operators: operators.Operators{Extractors: []*extractors.Extractor{
{Part: "body_1", Name: "id", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`id=([a-z]+)`}, RegexGroup: 1, Internal: true},
{Part: "body_2", Name: "token", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`token=([a-z]+)`}, RegexGroup: 1, Internal: true},
}},
IterateAll: true,
}
var mutex sync.Mutex
uses := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/items":
_, _ = fmt.Fprint(w, "id=a id=b")
case "/item/a":
_, _ = fmt.Fprint(w, "token=a")
case "/item/b":
_, _ = fmt.Fprint(w, "token=b")
case "/noop/a", "/noop/b":
default:
mutex.Lock()
uses[r.URL.Path]++
mutex.Unlock()
}
}))
defer ts.Close()
executeDynamicHTTPTestRequest(t, request, ts.URL)
require.Equal(t, map[string]int{"/use/a/a": 1, "/use/b/b": 1}, uses)
}
func TestHTTPExtractMultipleReuseCopiesRenderedPayloadPerBranch(t *testing.T) {
request := &Request{
ID: "testing-http-payload-per-branch",
Raw: []string{
"GET /names HTTP/1.1\nHost: {{Hostname}}",
"GET /x/{{item}} HTTP/1.1\nHost: {{Hostname}}",
},
Payloads: map[string]interface{}{"item": []string{"{{name}}-p"}},
Operators: operators.Operators{Extractors: []*extractors.Extractor{{
Part: "body_1", Name: "name", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`name=([a-z]+)`}, RegexGroup: 1, Internal: true,
}}},
IterateAll: true,
}
var mutex sync.Mutex
uses := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/names" {
_, _ = fmt.Fprint(w, "name=alpha name=beta")
return
}
mutex.Lock()
uses[r.URL.Path]++
mutex.Unlock()
}))
defer ts.Close()
executeDynamicHTTPTestRequest(t, request, ts.URL)
require.Equal(t, map[string]int{"/x/alpha-p": 1, "/x/beta-p": 1}, uses)
}
func TestHTTPExtractMultipleReuseDeduplicatesNewValues(t *testing.T) {
request := &Request{
ID: "testing-http-deduplicate-values",
Raw: []string{"GET /seed HTTP/1.1\nHost: {{Hostname}}", "GET /use/{{v}} HTTP/1.1\nHost: {{Hostname}}"},
Operators: operators.Operators{Extractors: []*extractors.Extractor{
{Part: "body", Name: "v", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`value=([a-z]+)`}, RegexGroup: 1, Internal: true},
{Part: "header", Name: "v", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`X-Value: ([a-z]+)`}, RegexGroup: 1, Internal: true},
}},
IterateAll: true,
}
var useCount atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/seed" {
w.Header().Set("X-Value", "abc")
_, _ = fmt.Fprint(w, "value=abc")
return
}
useCount.Add(1)
}))
defer ts.Close()
executeDynamicHTTPTestRequest(t, request, ts.URL)
require.EqualValues(t, 1, useCount.Load())
}
func TestHTTPExtractMultipleReuseOverridesTemplateContextPerBranch(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
request := &Request{
ID: "testing-http-template-context-branch",
Raw: []string{"GET /seed HTTP/1.1\nHost: {{Hostname}}", "GET /use/{{id}} HTTP/1.1\nHost: {{Hostname}}"},
Operators: operators.Operators{Extractors: []*extractors.Extractor{{
Part: "body_1", Name: "id", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`id=([a-z]+)`}, RegexGroup: 1, Internal: true,
}}},
IterateAll: true,
}
var mutex sync.Mutex
uses := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/seed" {
_, _ = fmt.Fprint(w, "id=alpha id=beta")
return
}
mutex.Lock()
uses[r.URL.Path]++
mutex.Unlock()
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{
SeverityHolder: severity.Holder{Severity: severity.Low},
Name: "test",
},
})
require.NoError(t, request.Compile(executerOpts))
input := contextargs.NewWithInput(context.Background(), ts.URL)
executerOpts.GetTemplateCtx(input.MetaInput).Set("id", "stale")
require.NoError(t, request.ExecuteWithResults(
input,
output.InternalEvent(executerOpts.GetTemplateCtx(input.MetaInput).GetAll()),
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
))
require.Equal(t, map[string]int{"/use/alpha": 1, "/use/beta": 1}, uses)
}
func TestHTTPExtractMultipleReuseIteratesInitialDynamicValues(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
request := &Request{
ID: "testing-http-initial-dynamic-values",
Raw: []string{"GET /use/{{id}} HTTP/1.1\nHost: {{Hostname}}"},
IterateAll: true,
}
var mutex sync.Mutex
uses := make(map[string]int)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mutex.Lock()
uses[r.URL.Path]++
mutex.Unlock()
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{
SeverityHolder: severity.Holder{Severity: severity.Low},
Name: "test",
},
})
require.NoError(t, request.Compile(executerOpts))
require.NoError(t, request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), ts.URL),
output.InternalEvent{"id": []string{"alpha", "beta"}},
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
))
require.Equal(t, map[string]int{"/use/alpha": 1, "/use/beta": 1}, uses)
}
func TestHTTPExtractMultipleReuseStopsOnceBranchesAfterCancellation(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
request := &Request{
ID: "testing-http-once-branch-cancellation",
Raw: []string{
"GET /seed HTTP/1.1\nHost: {{Hostname}}",
"@once\nGET /token/{{missing}} HTTP/1.1\nHost: {{Hostname}}",
},
Operators: operators.Operators{Extractors: []*extractors.Extractor{{
Part: "body_1", Name: "id", Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor}, Regex: []string{`id=([a-z]+)`}, RegexGroup: 1, Internal: true,
}}},
IterateAll: true,
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "id=alpha id=beta")
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{
SeverityHolder: severity.Holder{Severity: severity.Low},
Name: "test",
},
})
executerOpts.RateLimiter.Stop()
executerOpts.RateLimiter = ratelimit.New(context.Background(), 1, 500*time.Millisecond)
t.Cleanup(executerOpts.RateLimiter.Stop)
require.NoError(t, request.Compile(executerOpts))
ctx, cancel := context.WithCancel(context.Background())
cancelTimer := time.AfterFunc(100*time.Millisecond, cancel)
t.Cleanup(func() {
cancelTimer.Stop()
cancel()
})
startedAt := time.Now()
err := request.ExecuteWithResults(
contextargs.NewWithInput(ctx, ts.URL),
make(output.InternalEvent),
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
)
require.ErrorIs(t, err, context.Canceled)
require.Less(t, time.Since(startedAt), 750*time.Millisecond)
}
func TestRequestMatcherSeesBodyPastSaveSize(t *testing.T) {
options := testutils.DefaultOptions.Copy()
options.RequestSaveSize = 32
testutils.Init(options)
t.Cleanup(func() { testutils.Cleanup(options) })
marker := "TAILMARKER"
request := &Request{
ID: "request-save-size",
Method: HTTPMethodTypeHolder{MethodType: HTTPPost},
Path: []string{"{{BaseURL}}"},
Body: strings.Repeat("A", 200) + marker,
Operators: operators.Operators{
Matchers: []*matchers.Matcher{{
Part: "request",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{marker},
}},
},
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
require.NoError(t, request.Compile(executerOpts))
var event *output.InternalWrappedEvent
err := request.ExecuteWithResults(contextargs.NewWithInput(context.Background(), ts.URL), make(output.InternalEvent), make(output.InternalEvent), func(got *output.InternalWrappedEvent) {
if got.OperatorsResult != nil && got.OperatorsResult.Matched {
event = got
}
})
require.NoError(t, err)
require.NotNil(t, event)
require.Contains(t, event.InternalEvent["request"], marker)
require.NotEmpty(t, event.Results)
require.NotContains(t, event.Results[0].Request, marker)
require.Contains(t, event.Results[0].Request, "truncated")
}
func TestDisableTE(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
templateID := "http-disable-transfer-encoding"
// in raw request format
request := &Request{
ID: templateID,
Raw: []string{
`POST / HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:80.0) Gecko/20100101 Firefox/80.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
login=1&username=admin&password=admin
`,
},
Operators: operators.Operators{
Matchers: []*matchers.Matcher{{
Type: matchers.MatcherTypeHolder{MatcherType: matchers.StatusMatcher},
Status: []int{200},
}},
},
}
// in base request format
request2 := &Request{
ID: templateID,
Method: HTTPMethodTypeHolder{MethodType: HTTPPost},
Path: []string{"{{BaseURL}}"},
Body: "login=1&username=admin&password=admin",
Operators: operators.Operators{
Matchers: []*matchers.Matcher{{
Type: matchers.MatcherTypeHolder{MatcherType: matchers.StatusMatcher},
Status: []int{200},
}},
},
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(r.TransferEncoding) > 0 || r.ContentLength <= 0 {
t.Error("Transfer-Encoding header should not be set")
}
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: templateID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
err := request.Compile(executerOpts)
require.Nil(t, err, "could not compile http raw request")
err = request2.Compile(executerOpts)
require.Nil(t, err, "could not compile http base request")
var finalEvent *output.InternalWrappedEvent
var matchCount int
t.Run("test", func(t *testing.T) {
metadata := make(output.InternalEvent)
previous := make(output.InternalEvent)
ctxArgs := contextargs.NewWithInput(context.Background(), ts.URL)
err := request.ExecuteWithResults(ctxArgs, metadata, previous, func(event *output.InternalWrappedEvent) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
matchCount++
}
finalEvent = event
})
require.Nil(t, err, "could not execute network request")
})
t.Run("test2", func(t *testing.T) {
metadata := make(output.InternalEvent)
previous := make(output.InternalEvent)
ctxArgs := contextargs.NewWithInput(context.Background(), ts.URL)
err := request2.ExecuteWithResults(ctxArgs, metadata, previous, func(event *output.InternalWrappedEvent) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
matchCount++
}
finalEvent = event
})
require.Nil(t, err, "could not execute network request")
})
require.NotNil(t, finalEvent, "could not get event output from request")
require.Equal(t, 2, matchCount, "could not get correct match count")
}
// consult @Ice3man543 before making any breaking changes to this test (context: vuln_hash)
func TestReqURLPattern(t *testing.T) {
options := testutils.DefaultOptions
// assume this was a preprocessor
// {{randstr}} => 2eNU2kbrOcUDzhnUL1RGvSo1it7
testutils.Init(options)
templateID := "testing-http"
request := &Request{
ID: templateID,
Raw: []string{
`GET /{{rand_char("abc")}}/{{interactsh-url}}/123?query={{rand_int(1, 10)}}&data=2eNU2kbrOcUDzhnUL1RGvSo1it7 HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:80.0) Gecko/20100101 Firefox/80.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
`,
},
Operators: operators.Operators{
Matchers: []*matchers.Matcher{{
Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher},
DSL: []string{"true"},
}},
},
IterateAll: true,
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// always return 200
w.WriteHeader(200)
_, _ = w.Write([]byte(`match`))
}))
defer ts.Close()
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: templateID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
client, _ := interactsh.New(interactsh.DefaultOptions(executerOpts.Output, nil, executerOpts.Progress))
executerOpts.Interactsh = client
defer client.Close()
executerOpts.ExportReqURLPattern = true
// this is how generated constants are added to template
// generated constants are preprocessors that are executed while loading once
executerOpts.Constants = map[string]interface{}{
"{{randstr}}": "2eNU2kbrOcUDzhnUL1RGvSo1it7",
}
err := request.Compile(executerOpts)
require.Nil(t, err, "could not compile network request")
var finalEvent *output.InternalWrappedEvent
var matchCount int
t.Run("test", func(t *testing.T) {
metadata := make(output.InternalEvent)
previous := make(output.InternalEvent)
ctxArgs := contextargs.NewWithInput(context.Background(), ts.URL)
err := request.ExecuteWithResults(ctxArgs, metadata, previous, func(event *output.InternalWrappedEvent) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
matchCount++
}
finalEvent = event
})
require.Nil(t, err, "could not execute network request")
})
require.NotNil(t, finalEvent, "could not get event output from request")
require.Equal(t, 1, matchCount, "could not get correct match count")
require.NotEmpty(t, finalEvent.Results[0].ReqURLPattern, "could not get req url pattern")
require.Equal(t, `/{{rand_char("abc")}}/{{interactsh-url}}/123?query={{rand_int(1, 10)}}&data={{randstr}}`, finalEvent.Results[0].ReqURLPattern)
}
// fakeHostErrorsCache implements hosterrorscache.CacheInterface minimally for tests
type fakeHostErrorsCache struct{}
func (f *fakeHostErrorsCache) SetVerbose(bool) {}
func (f *fakeHostErrorsCache) Close() {}
func (f *fakeHostErrorsCache) Remove(*contextargs.Context) {}
func (f *fakeHostErrorsCache) MarkFailed(string, *contextargs.Context, error) {}
func (f *fakeHostErrorsCache) MarkFailedOrRemove(string, *contextargs.Context, error) {
}
// Check always returns true to simulate an already unresponsive host
func (f *fakeHostErrorsCache) Check(string, *contextargs.Context) bool { return true }
// 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)
// server that always matches
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "match")
}))
defer ts.Close()
templateID := "parallel-stop-first"
req := &Request{
ID: templateID,
Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
Path: []string{"{{BaseURL}}/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{"match"},
}},
},
StopAtFirstMatch: true,
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: templateID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
err := req.Compile(executerOpts)
require.NoError(t, err)
var matches int32
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) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
atomic.AddInt32(&matches, 1)
}
})
require.NoError(t, err)
require.Equal(t, int32(1), atomic.LoadInt32(&matches), "expected only first match to be processed")
}
func TestExecuteParallelHTTP_StopAtFirstMatchCancelsQueuedRateLimitWaiters(t *testing.T) {
options := testutils.DefaultOptions.Copy()
options.ExecutionId = t.Name()
options.PerHostRateLimit = true
options.PerHostRateLimitPoolSize = -1
options.RateLimit = 1
options.RateLimitDuration = time.Second
options.RestrictLocalNetworkAccess = false
testutils.Init(options)
t.Cleanup(func() { testutils.Cleanup(options) })
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
requests.Add(1)
_, _ = fmt.Fprint(writer, "match")
}))
defer server.Close()
request := &Request{
ID: "parallel-stop-first-rate-limit",
Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
Path: []string{"{{BaseURL}}/p?x={{v}}"},
Threads: 10,
Payloads: map[string]interface{}{
"v": []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
},
Operators: operators.Operators{Matchers: []*matchers.Matcher{{
Part: "body",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{"match"},
}}},
StopAtFirstMatch: true,
}
executerOptions := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
require.NoError(t, request.Compile(executerOptions))
err := request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), server.URL),
make(output.InternalEvent),
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
)
require.NoError(t, err)
require.Equal(t, int32(1), requests.Load())
}
func TestExecuteParallelHTTP_SkipOnUnresponsiveFromCache(t *testing.T) {
options := testutils.DefaultOptions
testutils.Init(options)
// server that would match if reached
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "match")
}))
defer ts.Close()
templateID := "parallel-skip-unresponsive"
req := &Request{
ID: templateID,
Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
Path: []string{"{{BaseURL}}/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{"match"},
}},
},
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: templateID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
// inject fake host errors cache that forces skip
executerOpts.HostErrorsCache = &fakeHostErrorsCache{}
err := req.Compile(executerOpts)
require.NoError(t, err)
var matches int32
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) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
atomic.AddInt32(&matches, 1)
}
})
require.NoError(t, err)
require.Equal(t, int32(0), atomic.LoadInt32(&matches), "expected no matches when host is marked unresponsive")
}
// TestExecuteParallelHTTP_GoroutineLeaks uses goleak to detect goroutine leaks in all HTTP parallel execution scenarios
func TestExecuteParallelHTTP_GoroutineLeaks(t *testing.T) {
defer goleak.VerifyNone(t,
goleak.IgnoreAnyContainingPkg("go.opencensus.io/stats/view"),
goleak.IgnoreAnyContainingPkg("github.com/syndtr/goleveldb"),
goleak.IgnoreAnyContainingPkg("github.com/go-rod/rod"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/interactsh/pkg/server"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/interactsh/pkg/client"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
goleak.IgnoreAnyFunction("github.com/syndtr/goleveldb/leveldb/util.(*BufferPool).drain"),
goleak.IgnoreAnyFunction("github.com/syndtr/goleveldb/leveldb.(*DB).compactionError"),
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"),
// net/http keep-alive connections spawn persistConn read/write loops that are
// owned by the transport's idle-connection pool, not by the parallel executor.
// They are torn down asynchronously on idle timeout, and on slower runners
// (e.g. windows) that teardown can outlast goleak's retry window, so ignore them.
goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
)
options := testutils.DefaultOptions
testutils.Init(options)
defer testutils.Cleanup(options)
// Test Case 1: Normal execution with StopAtFirstMatch
t.Run("StopAtFirstMatch", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Millisecond)
_, _ = fmt.Fprintf(w, "test response")
}))
defer ts.Close()
req := &Request{
ID: "parallel-stop-first-match",
Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
Path: []string{"{{BaseURL}}/test?param={{payload}}"},
Threads: 4,
Payloads: map[string]interface{}{
"payload": []string{"1", "2", "3", "4", "5", "6", "7", "8"},
},
Operators: operators.Operators{
Matchers: []*matchers.Matcher{{
Part: "body",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{"test response"},
}},
},
StopAtFirstMatch: true,
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: "parallel-stop-first-match",
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
err := req.Compile(executerOpts)
require.NoError(t, err)
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)
})
// Test Case 2: Unresponsive host scenario
t.Run("UnresponsiveHost", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "response")
}))
defer ts.Close()
req := &Request{
ID: "parallel-unresponsive",
Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
Path: []string{"{{BaseURL}}/test?param={{payload}}"},
Threads: 3,
Payloads: map[string]interface{}{
"payload": []string{"1", "2", "3", "4", "5"},
},
Operators: operators.Operators{
Matchers: []*matchers.Matcher{{
Part: "body",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{"response"},
}},
},
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: "parallel-unresponsive",
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
executerOpts.HostErrorsCache = &fakeHostErrorsCache{}
err := req.Compile(executerOpts)
require.NoError(t, err)
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)
})
// Test Case 3: Context cancellation scenario
t.Run("ContextCancellation", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
_, _ = fmt.Fprintf(w, "response")
}))
defer ts.Close()
req := &Request{
ID: "parallel-context-cancel",
Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
Path: []string{"{{BaseURL}}/test?param={{payload}}"},
Threads: 3,
Payloads: map[string]interface{}{
"payload": []string{"1", "2", "3", "4", "5"},
},
Operators: operators.Operators{
Matchers: []*matchers.Matcher{{
Part: "body",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{"response"},
}},
},
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: "parallel-context-cancel",
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
err := req.Compile(executerOpts)
require.NoError(t, err)
metadata := make(output.InternalEvent)
previous := make(output.InternalEvent)
ctx, cancel := context.WithCancel(context.Background())
ctxArgs := contextargs.NewWithInput(ctx, ts.URL)
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
err = req.ExecuteWithResults(ctxArgs, metadata, previous, func(event *output.InternalWrappedEvent) {})
require.Error(t, err)
require.Equal(t, context.Canceled, err)
})
}
func TestOutputIP(t *testing.T) {
history := func(entries map[string]string) func(string) string {
return func(hostname string) string { return entries[hostname] }
}
noHistory := history(nil)
tests := []struct {
name string
customIP string
host string
hostname string
getDialedIP func(string) string
want string
}{
{name: "ipv4 literal without dial history", host: "192.0.2.1:8080", hostname: "192.0.2.1", getDialedIP: noHistory, want: "192.0.2.1"},
{name: "ipv4 literal with nil dialer", host: "192.0.2.1", hostname: "192.0.2.1", want: "192.0.2.1"},
{name: "bracketed ipv6 without dial history", host: "[2001:db8::1]", hostname: "[2001:db8:", getDialedIP: noHistory, want: "2001:db8::1"},
{name: "bracketed ipv6 with port without dial history", host: "[2001:db8::1]:443", hostname: "[2001:db8::1]", getDialedIP: noHistory, want: "2001:db8::1"},
{name: "bare ipv6 without dial history", host: "2001:db8::1", hostname: "2001:db8:", getDialedIP: noHistory, want: "2001:db8::1"},
{name: "hostname without dial history", host: "example.com:443", hostname: "example.com", getDialedIP: noHistory, want: ""},
{name: "dial history wins over ip literal", host: "192.0.2.1:80", hostname: "192.0.2.1", getDialedIP: history(map[string]string{"192.0.2.1": "198.51.100.7"}), want: "198.51.100.7"},
{name: "dial history for hostname", host: "example.com", hostname: "example.com", getDialedIP: history(map[string]string{"example.com": "198.51.100.7"}), want: "198.51.100.7"},
{name: "custom ip wins", customIP: "203.0.113.5", host: "192.0.2.1", hostname: "192.0.2.1", getDialedIP: history(map[string]string{"192.0.2.1": "198.51.100.7"}), want: "203.0.113.5"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, outputIP(tt.customIP, tt.host, tt.hostname, tt.getDialedIP))
})
}
}
func TestLooksLikeHTTPToHTTPSMismatch(t *testing.T) {
t.Run("matches genuine nginx/openresty mismatch page", func(t *testing.T) {
body := `<html>
<head><title>400 The plain HTTP request was sent to HTTPS port</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The plain HTTP request was sent to HTTPS port</center>
<hr><center>nginx/1.31.6</center>
</body>
</html>`
resp := &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader(body))}
require.True(t, looksLikeHTTPToHTTPSMismatch(resp))
})
t.Run("does not match an unrelated 400 and leaves the body intact", func(t *testing.T) {
body := "<html><body>400 Bad Request - unrelated app error</body></html>"
resp := &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader(body))}
require.False(t, looksLikeHTTPToHTTPSMismatch(resp))
// downstream readers (matchers, dumps, project cache) must still see
// the full, untouched body after a non-matching peek
remaining, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, body, string(remaining))
})
t.Run("ignores non-400 status codes even if the text is present", func(t *testing.T) {
body := "The plain HTTP request was sent to HTTPS port"
resp := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
require.False(t, looksLikeHTTPToHTTPSMismatch(resp))
})
}
func TestPerHostRateLimitHonorsRequestContextCancellation(t *testing.T) {
options := testutils.DefaultOptions.Copy()
options.ExecutionId = t.Name()
options.PerHostRateLimit = true
options.PerHostRateLimitPoolSize = -1
options.RateLimit = 1
options.RateLimitDuration = 2 * time.Second
options.RestrictLocalNetworkAccess = false
testutils.Init(options)
t.Cleanup(func() { testutils.Cleanup(options) })
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(writer, "ok")
}))
defer server.Close()
request := &Request{
ID: "per-host-rate-limit-cancellation",
Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
Path: []string{"{{BaseURL}}/"},
Operators: operators.Operators{Matchers: []*matchers.Matcher{{
Part: "body",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{"ok"},
}}},
}
executerOptions := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
require.NoError(t, request.Compile(executerOptions))
execute := func(ctx context.Context) error {
return request.ExecuteWithResults(
contextargs.NewWithInput(ctx, server.URL),
make(output.InternalEvent),
make(output.InternalEvent),
func(*output.InternalWrappedEvent) {},
)
}
require.NoError(t, execute(context.Background()))
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- execute(ctx) }()
time.Sleep(100 * time.Millisecond)
cancelledAt := time.Now()
cancel()
err := <-result
cancellationDelay := time.Since(cancelledAt)
t.Logf("request returned %s after its context was cancelled", cancellationDelay.Round(time.Millisecond))
require.ErrorIs(t, err, context.Canceled)
require.Less(t, cancellationDelay, 250*time.Millisecond)
}
func TestDumpedRequestKeepsBodyServerDidNotRead(t *testing.T) {
// The server answers in full, then holds the connection without reading
// the body, so the transport is still writing it when the response arrives.
release := make(chan struct{})
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "2")
_, _ = w.Write([]byte("ok"))
w.(http.Flusher).Flush()
<-release
}))
defer target.Close()
defer close(release)
options := testutils.DefaultOptions.Copy()
options.SetExecutionID(t.Name())
testutils.Init(options)
t.Cleanup(func() { testutils.Cleanup(options) })
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: "dump-body",
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
body := strings.Repeat("A", 4<<20)
requests := []*Request{
{
ID: "post",
Method: HTTPMethodTypeHolder{MethodType: HTTPPost},
Path: []string{"{{BaseURL}}/"},
Body: body,
},
{
ID: "get",
Raw: []string{"GET / HTTP/1.1\nHost: {{Hostname}}\n\n" + body},
},
{
ID: "chunked",
Raw: []string{"POST / HTTP/1.1\nHost: {{Hostname}}\nTransfer-Encoding: chunked\n\n" + body},
},
}
for _, request := range requests {
require.NoError(t, request.Compile(executerOpts))
// how much the transport has written when the response lands varies, so
// a few rounds make a lost body show up reliably
for range 3 {
var dumped string
input := contextargs.NewWithInput(context.Background(), target.URL)
err := request.ExecuteWithResults(input, output.InternalEvent{}, output.InternalEvent{}, func(event *output.InternalWrappedEvent) {
dumped, _ = event.InternalEvent["request"].(string)
})
require.NoError(t, err)
require.True(t, strings.HasSuffix(dumped, "\r\n\r\n"+body), "%s recorded request lost its body: %d of %d bytes", request.ID, len(dumped), len(body))
}
}
}
func TestReuseRequestBodyKeepsExcludedEncoding(t *testing.T) {
body := []byte("original")
for _, tc := range []struct {
name string
method string
header string
contentLength int64
wantLength int64
}{
{name: "post", method: http.MethodPost, contentLength: 1, wantLength: int64(len(body))},
{name: "get", method: http.MethodGet, contentLength: 3, wantLength: 3},
{name: "head", method: http.MethodHead, contentLength: 0, wantLength: 0},
{name: "chunked", method: http.MethodPost, header: "chunked", contentLength: -1, wantLength: -1},
} {
t.Run(tc.name, func(t *testing.T) {
req, err := retryablehttp.NewRequest(tc.method, "https://example.com/", body)
require.NoError(t, err)
req.ContentLength = tc.contentLength
if tc.header != "" {
req.Header.Set("Transfer-Encoding", tc.header)
}
require.NoError(t, reuseRequestBody(req))
require.Equal(t, tc.wantLength, req.ContentLength)
if tc.header != "" {
require.Equal(t, tc.header, req.Header.Get("Transfer-Encoding"))
}
got, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, body, got)
fresh, err := req.GetBody()
require.NoError(t, err)
got, err = io.ReadAll(fresh)
require.NoError(t, err)
require.Equal(t, body, got)
})
}
}
func TestHTTPResponseReadSizeHonoursTemplateMaxSize(t *testing.T) {
previous := testutils.DefaultOptions.ResponseReadSize
t.Cleanup(func() {
testutils.DefaultOptions.ResponseReadSize = previous
})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(strings.Repeat("A", 2000)))
}))
defer ts.Close()
tests := []struct {
name string
templateMaxSize int
globalResponseReadSize int
expectedLen int
}{
{
name: "template max-size smaller than global response-size-read",
templateMaxSize: 500,
globalResponseReadSize: 1000,
expectedLen: 500,
},
{
name: "global response-size-read acts as ceiling when smaller than template max-size",
templateMaxSize: 1500,
globalResponseReadSize: 800,
expectedLen: 800,
},
{
name: "only template max-size specified",
templateMaxSize: 500,
globalResponseReadSize: 0,
expectedLen: 500,
},
{
name: "only global response-size-read specified",
templateMaxSize: 0,
globalResponseReadSize: 600,
expectedLen: 600,
},
{
name: "negative response-size-read keeps template max-size",
templateMaxSize: 500,
globalResponseReadSize: -1,
expectedLen: 500,
},
{
name: "negative response-size-read keeps the default limit",
templateMaxSize: 0,
globalResponseReadSize: -1,
expectedLen: 2000,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
opts := testutils.DefaultOptions
opts.ResponseReadSize = tc.globalResponseReadSize
testutils.Init(opts)
request := &Request{
ID: "test-max-size",
MaxSize: tc.templateMaxSize,
Raw: []string{
"GET / HTTP/1.1\nHost: {{Hostname}}\n\n",
},
}
executerOptions := testutils.NewMockExecuterOptions(opts, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
require.NoError(t, request.Compile(executerOptions))
var receivedBodyLen int
err := request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), ts.URL),
make(output.InternalEvent),
make(output.InternalEvent),
func(event *output.InternalWrappedEvent) {
if event != nil && event.InternalEvent != nil {
if body, ok := event.InternalEvent["body"].(string); ok {
receivedBodyLen = len(body)
}
}
},
)
require.NoError(t, err)
require.Equal(t, tc.expectedLen, receivedBodyLen)
})
}
}
const httpToHTTPSPage = "<html><title>400 The plain HTTP request was sent to HTTPS port</title><center>The plain HTTP request was sent to HTTPS port</center></html>"
// servePlainOrTLS accepts HTTP and TLS on one port. A plaintext request is
// answered by plain, and a TLS handshake by secure. Nuclei's client skips
// certificate verification, so the httptest certificate is enough.
func servePlainOrTLS(t *testing.T, plain, secure http.Handler) (string, *atomic.Int32) {
t.Helper()
certServer := httptest.NewTLSServer(http.NewServeMux())
t.Cleanup(certServer.Close)
require.NotEmpty(t, certServer.TLS.Certificates)
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
var opened []net.Conn
var openedMu sync.Mutex
t.Cleanup(func() {
_ = ln.Close()
openedMu.Lock()
defer openedMu.Unlock()
for _, conn := range opened {
_ = conn.Close()
}
})
tlsHits := &atomic.Int32{}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{certServer.TLS.Certificates[0]}}
go func() {
for {
conn, acceptErr := ln.Accept()
if acceptErr != nil {
return
}
openedMu.Lock()
opened = append(opened, conn)
openedMu.Unlock()
go func(conn net.Conn) {
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
var first [1]byte
if _, readErr := io.ReadFull(conn, first[:]); readErr != nil {
return
}
peeked := &prefixConn{Conn: conn, prefix: append([]byte(nil), first[:]...)}
_ = peeked.SetReadDeadline(time.Time{})
if first[0] == 0x16 {
tlsHits.Add(1)
tlsConn := tls.Server(peeked, tlsConfig)
if handshakeErr := tlsConn.Handshake(); handshakeErr != nil {
return
}
serveOne(tlsConn, secure)
return
}
serveOne(peeked, plain)
}(conn)
}
}()
return "http://" + ln.Addr().String(), tlsHits
}
func mismatchPage() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, httpToHTTPSPage)
})
}
type prefixConn struct {
net.Conn
prefix []byte
}
func (c *prefixConn) Read(p []byte) (int, error) {
if len(c.prefix) > 0 {
n := copy(p, c.prefix)
c.prefix = c.prefix[n:]
return n, nil
}
return c.Conn.Read(p)
}
type oneShotListener struct {
conn net.Conn
ch chan net.Conn
}
func serveOne(conn net.Conn, handler http.Handler) {
ch := make(chan net.Conn, 1)
ch <- conn
close(ch)
_ = (&http.Server{
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 50 * time.Millisecond,
}).Serve(&oneShotListener{conn: conn, ch: ch})
}
func (l *oneShotListener) Accept() (net.Conn, error) {
conn, ok := <-l.ch
if !ok {
return nil, io.EOF
}
return conn, nil
}
func (l *oneShotListener) Close() error { return nil }
func (l *oneShotListener) Addr() net.Addr { return l.conn.LocalAddr() }
func TestHTTPToHTTPSMismatchRetriesOverTLS(t *testing.T) {
options := testutils.DefaultOptions.Copy()
options.ExecutionId = t.Name()
testutils.Init(options)
const page = "cpanel-login-ok"
target, tlsHits := servePlainOrTLS(t, mismatchPage(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
_, _ = io.WriteString(w, page)
}))
request := &Request{
ID: "http-to-https-self-heal",
Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
Path: []string{"{{BaseURL}}/"},
Operators: operators.Operators{Matchers: []*matchers.Matcher{{
Part: "body",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{page},
}}},
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
require.NoError(t, request.Compile(executerOpts))
var matched bool
err := request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), target),
make(output.InternalEvent),
make(output.InternalEvent),
func(event *output.InternalWrappedEvent) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
matched = true
}
},
)
require.NoError(t, err)
require.True(t, matched, "the discovering request must be retried over TLS and matched")
require.Equal(t, int32(1), tlsHits.Load())
}
func TestHTTPToHTTPSMismatchReplaysPostBody(t *testing.T) {
options := testutils.DefaultOptions.Copy()
options.ExecutionId = t.Name()
testutils.Init(options)
const payload = "user=root&pass=secret"
const page = "cpanel-login-ok"
var gotMu sync.Mutex
var gotBody string
var gotLength int64
target, tlsHits := servePlainOrTLS(t, mismatchPage(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, readErr := io.ReadAll(r.Body)
gotMu.Lock()
if readErr == nil {
gotBody = string(body)
gotLength = r.ContentLength
}
gotMu.Unlock()
w.Header().Set("Connection", "close")
_, _ = io.WriteString(w, page)
}))
request := &Request{
ID: "http-to-https-post-body",
Method: HTTPMethodTypeHolder{MethodType: HTTPPost},
Path: []string{"{{BaseURL}}/login"},
Body: payload,
Operators: operators.Operators{Matchers: []*matchers.Matcher{{
Part: "body",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{page},
}}},
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
require.NoError(t, request.Compile(executerOpts))
var matched bool
err := request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), target),
make(output.InternalEvent),
make(output.InternalEvent),
func(event *output.InternalWrappedEvent) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
matched = true
}
},
)
require.NoError(t, err)
require.True(t, matched)
require.Equal(t, int32(1), tlsHits.Load())
gotMu.Lock()
defer gotMu.Unlock()
require.Equal(t, payload, gotBody)
require.Equal(t, int64(len(payload)), gotLength)
}
func TestHTTPToHTTPSUnrelated400IsNotRetried(t *testing.T) {
options := testutils.DefaultOptions.Copy()
options.ExecutionId = t.Name()
testutils.Init(options)
target, tlsHits := servePlainOrTLS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, "unrelated application error")
}), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
_, _ = io.WriteString(w, "should-not-run")
}))
request := &Request{
ID: "http-to-https-unrelated-400",
Method: HTTPMethodTypeHolder{MethodType: HTTPGet},
Path: []string{"{{BaseURL}}/"},
Operators: operators.Operators{Matchers: []*matchers.Matcher{{
Part: "body",
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Words: []string{"unrelated application error"},
}}},
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: request.ID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
require.NoError(t, request.Compile(executerOpts))
var matched bool
err := request.ExecuteWithResults(
contextargs.NewWithInput(context.Background(), target),
make(output.InternalEvent),
make(output.InternalEvent),
func(event *output.InternalWrappedEvent) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
matched = true
}
},
)
require.NoError(t, err)
require.True(t, matched, "an ordinary 400 is still a response the matcher can see")
require.Equal(t, int32(0), tlsHits.Load())
}