mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-09 17:43:08 -10:00
The "(Truncated)" marker on dumped responses rebuilt the read-limit
rule from the template max-size and -rsr instead of using the limit
the body was read with. With neither set, the body is cut at the
10 MB default, but the dump was not marked truncated.
Comparing the body length with the effective limit keeps one rule for
both the read and the marker. c918d010c already made -rsr a ceiling
over a smaller template max-size; no release contains it yet, which
is why v3.8.0 still reads the full -rsr limit per response.
Fixes: #8045
Reported-by: @Lidar-Baruch
Signed-off-by: Dwi Siswanto <[email protected]>
2202 lines
73 KiB
Go
2202 lines
73 KiB
Go
package http
|
|
|
|
import (
|
|
"cmp"
|
|
"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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// storedResponseWriter keeps the stored response dumps.
|
|
type storedResponseWriter struct {
|
|
*testutils.MockOutputWriter
|
|
|
|
responses []string
|
|
}
|
|
|
|
func (w *storedResponseWriter) WriteStoreDebugData(_, _, _, data string) {
|
|
if strings.Contains(data, "Dumped HTTP response") {
|
|
w.responses = append(w.responses, data)
|
|
}
|
|
}
|
|
|
|
// TestHTTPResponseTruncatedUsesEffectiveReadLimit checks that a stored response
|
|
// is marked truncated exactly when its body reached the limit it was read with.
|
|
func TestHTTPResponseTruncatedUsesEffectiveReadLimit(t *testing.T) {
|
|
previous := MaxBodyRead
|
|
|
|
t.Cleanup(func() { MaxBodyRead = previous })
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte(strings.Repeat("A", 2000)))
|
|
}))
|
|
defer server.Close()
|
|
|
|
tests := []struct {
|
|
name string
|
|
defaultLimit int // replaces MaxBodyRead when set
|
|
templateMaxSize int
|
|
responseReadSize int
|
|
wantTruncated bool
|
|
}{
|
|
{name: "default limit", defaultLimit: 1000, wantTruncated: true},
|
|
{name: "default limit, negative response-size-read", defaultLimit: 1000, responseReadSize: -1, wantTruncated: true},
|
|
{name: "body under default limit", wantTruncated: false},
|
|
{name: "template max-size", templateMaxSize: 500, wantTruncated: true},
|
|
{name: "response-size-read", responseReadSize: 800, wantTruncated: true},
|
|
{name: "max-size below response-size-read", templateMaxSize: 500, responseReadSize: 3000, wantTruncated: true},
|
|
{name: "limits above body length", templateMaxSize: 4000, responseReadSize: 3000, wantTruncated: false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
MaxBodyRead = cmp.Or(tc.defaultLimit, previous)
|
|
|
|
opts := testutils.DefaultOptions.Copy()
|
|
opts.StoreResponse = true
|
|
opts.ResponseReadSize = tc.responseReadSize
|
|
testutils.Init(opts)
|
|
t.Cleanup(func() { testutils.Cleanup(opts) })
|
|
|
|
request := &Request{
|
|
ID: "test-truncated",
|
|
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"},
|
|
})
|
|
writer := &storedResponseWriter{MockOutputWriter: testutils.NewMockOutputWriter(opts.OmitTemplate)}
|
|
executerOptions.Output = writer
|
|
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.Len(t, writer.responses, 1)
|
|
require.Equal(t, tc.wantTruncated, strings.Contains(writer.responses[0], "(Truncated)"))
|
|
})
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|