mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-08 02:08:33 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a1e040363 | ||
|
|
105b1ba7d0 | ||
|
|
5ae0145cc9 | ||
|
|
c96b397aa0 | ||
|
|
8b3caf4d49 | ||
|
|
cc93090527 | ||
|
|
6ed56ce6af | ||
|
|
e752d0d098 | ||
|
|
9ea561f066 | ||
|
|
e1a30dcb61 |
No files matched your search
@@ -6,6 +6,7 @@ Currently the following formats are implemented -
|
||||
|
||||
- Burp Suite XML Request/Response file
|
||||
- Proxify JSONL output file
|
||||
- Katana JSONL crawl output file
|
||||
- OpenAPI Specification file
|
||||
- Postman Collection file
|
||||
- Swagger Specification file
|
||||
@@ -106,3 +107,16 @@ Swagger specification file is converted from OpenAPI 2.0 format to OpenAPI 3.0 f
|
||||
## Burp XML / Proxify JSONL
|
||||
|
||||
These modules are generic and parse raw requests from these respective tools.
|
||||
|
||||
## Katana JSONL
|
||||
|
||||
This module ingests the JSONL crawl output of the [katana](https://github.com/projectdiscovery/katana) crawler (`katana -jsonl -o crawl.jsonl`), bridging crawling and DAST so a live crawl of a target can directly drive fuzzing (`nuclei -im katana -l crawl.jsonl -dast ...`).
|
||||
|
||||
For each crawled endpoint it produces a fuzzable request:
|
||||
|
||||
- When katana captured the raw request (the common case, including authenticated and non-GET requests), the raw request is parsed as-is, preserving method, headers, body, and all parameters.
|
||||
- When only discrete fields are present (method, endpoint, headers, body), a raw request is synthesized from them and parsed back, so the result has the same shape as every other input format.
|
||||
|
||||
The parser is tolerant of mixed input: blank and malformed lines are skipped with a warning, and a bare URL line (katana's default non-JSONL output) is treated as a `GET` request.
|
||||
|
||||
A single record may be up to 32MB, which is well above a realistic worst case crawl record. Larger records, including a malformed one that never terminates, are drained and skipped with a warning rather than buffered, so one bad line cannot exhaust memory and end the scan.
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ParseReqRespCallback is a callback function for discovered raw requests
|
||||
// ParseReqRespCallback is a callback function for discovered raw requests.
|
||||
// It returns false to ask the format to stop parsing.
|
||||
type ParseReqRespCallback func(rr *types.RequestResponse) bool
|
||||
|
||||
// InputFormatOptions contains options for the input
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// Package katana implements an input format that ingests the JSONL output of
|
||||
// the katana crawler (`katana -jsonl`) and turns each crawled endpoint into a
|
||||
// fuzzable nuclei request. This bridges crawling and DAST: instead of fuzzing
|
||||
// only a proxy-fed list of requests, nuclei can consume a crawl of the target
|
||||
// directly, preserving method, headers, body and parameters discovered on the
|
||||
// wire (including non-GET requests, which a bare URL list cannot express).
|
||||
package katana
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxRecordSize is the maximum size of a single katana JSONL record. Crawled
|
||||
// requests can carry large bodies and raw dumps, so the limit sits well above
|
||||
// the 10MB of a realistic worst case record, while still bounding the memory a
|
||||
// single record can consume. Records above it are skipped, not buffered.
|
||||
MaxRecordSize = 32 * 1024 * 1024
|
||||
|
||||
// readBufferSize is the size of the chunks a record is read in. An oversized or
|
||||
// unterminated record is drained through this buffer, so the memory used while
|
||||
// skipping it stays constant regardless of how long the record is.
|
||||
readBufferSize = 64 * 1024
|
||||
)
|
||||
|
||||
// KatanaFormat is a parser for katana JSONL crawl output.
|
||||
type KatanaFormat struct {
|
||||
opts formats.InputFormatOptions
|
||||
}
|
||||
|
||||
// New creates a new katana JSONL format parser.
|
||||
func New() *KatanaFormat {
|
||||
return &KatanaFormat{}
|
||||
}
|
||||
|
||||
var _ formats.Format = &KatanaFormat{}
|
||||
|
||||
// katanaResult mirrors the relevant subset of katana's output.Result JSON.
|
||||
type katanaResult struct {
|
||||
Request *katanaRequest `json:"request"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// katanaRequest mirrors the relevant subset of katana's navigation.Request JSON.
|
||||
type katanaRequest struct {
|
||||
Method string `json:"method"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// Name returns the name of the format.
|
||||
func (k *KatanaFormat) Name() string {
|
||||
return "katana"
|
||||
}
|
||||
|
||||
// SetOptions sets the options for the input format.
|
||||
func (k *KatanaFormat) SetOptions(options formats.InputFormatOptions) {
|
||||
k.opts = options
|
||||
}
|
||||
|
||||
// Parse parses katana JSONL output and calls the provided callback for each
|
||||
// request it discovers. It is tolerant of mixed input: blank lines are skipped,
|
||||
// and a bare URL line (katana's default non-JSONL output) is treated as a GET.
|
||||
// Records above MaxRecordSize are dropped with a warning so that a malformed or
|
||||
// unterminated record cannot exhaust memory.
|
||||
func (k *KatanaFormat) Parse(input io.Reader, resultsCb formats.ParseReqRespCallback, filePath string) error {
|
||||
reader := bufio.NewReaderSize(input, readBufferSize)
|
||||
for {
|
||||
record, oversized, err := readRecord(reader)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("could not read katana jsonl input: %w", err)
|
||||
}
|
||||
|
||||
if oversized {
|
||||
gologger.Warning().Msgf("katana: skipping record larger than %d bytes\n", MaxRecordSize)
|
||||
} else if len(record) > 0 && !k.handleRecord(record, resultsCb) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readRecord reads a single newline delimited record, keeping at most
|
||||
// MaxRecordSize bytes in memory. A record above the limit is drained chunk by
|
||||
// chunk and reported as oversized so the caller can skip it, instead of being
|
||||
// accumulated in full.
|
||||
func readRecord(reader *bufio.Reader) (record []byte, oversized bool, err error) {
|
||||
for {
|
||||
chunk, err := reader.ReadSlice('\n')
|
||||
if len(chunk) > 0 {
|
||||
// The delimiter is trimmed off the record, so it does not count
|
||||
// towards the limit.
|
||||
size := len(chunk)
|
||||
if chunk[size-1] == '\n' {
|
||||
size--
|
||||
}
|
||||
if oversized || len(record)+size > MaxRecordSize {
|
||||
// Release what was read so far and keep consuming until the record
|
||||
// ends: only the reader buffer stays allocated while draining.
|
||||
oversized, record = true, nil
|
||||
} else {
|
||||
record = append(record, chunk...)
|
||||
}
|
||||
}
|
||||
// A full buffer without a delimiter means the record continues.
|
||||
if errors.Is(err, bufio.ErrBufferFull) {
|
||||
continue
|
||||
}
|
||||
return bytes.TrimSpace(record), oversized, err
|
||||
}
|
||||
}
|
||||
|
||||
// handleRecord parses a single record and reports whether parsing should
|
||||
// continue. A skipped record continues; the callback returns false to stop.
|
||||
func (k *KatanaFormat) handleRecord(record []byte, resultsCb formats.ParseReqRespCallback) bool {
|
||||
if record[0] != '{' {
|
||||
// Bare URL line (katana default output without -jsonl): treat as GET.
|
||||
line := string(record)
|
||||
if !isAbsoluteURL(line) {
|
||||
gologger.Warning().Msg("katana: could not parse line as a URL or JSON record\n")
|
||||
return true
|
||||
}
|
||||
rr, err := k.buildFromComponents(http.MethodGet, line, nil, "")
|
||||
if err != nil {
|
||||
gologger.Warning().Msgf("katana: could not parse url %s: %s\n", line, err)
|
||||
return true
|
||||
}
|
||||
return resultsCb(rr)
|
||||
}
|
||||
|
||||
var result katanaResult
|
||||
if err := json.Unmarshal(record, &result); err != nil {
|
||||
gologger.Warning().Msgf("katana: could not decode jsonl line: %s\n", err)
|
||||
return true
|
||||
}
|
||||
if result.Request == nil || result.Request.Endpoint == "" {
|
||||
gologger.Warning().Msg("katana: invalid record with missing request or endpoint\n")
|
||||
return true
|
||||
}
|
||||
if !isAbsoluteURL(result.Request.Endpoint) {
|
||||
gologger.Warning().Msgf("katana: skipping non http(s) endpoint %s\n", result.Request.Endpoint)
|
||||
return true
|
||||
}
|
||||
rr, err := k.toRequestResponse(result.Request)
|
||||
if err != nil {
|
||||
gologger.Warning().Msgf("katana: could not parse request %s: %s\n", result.Request.Endpoint, err)
|
||||
return true
|
||||
}
|
||||
return resultsCb(rr)
|
||||
}
|
||||
|
||||
// toRequestResponse converts a katana request into nuclei's standard
|
||||
// RequestResponse. It prefers the captured raw request when available and falls
|
||||
// back to synthesizing one from the discovered components otherwise.
|
||||
func (k *KatanaFormat) toRequestResponse(req *katanaRequest) (*types.RequestResponse, error) {
|
||||
method := req.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
if strings.TrimSpace(req.Raw) == "" {
|
||||
return k.buildFromComponents(method, req.Endpoint, req.Headers, req.Body)
|
||||
}
|
||||
rr, err := types.ParseRawRequestWithURL(req.Raw, req.Endpoint)
|
||||
if err != nil || !rawConflicts(req, rr) {
|
||||
return rr, err
|
||||
}
|
||||
// Some katana versions record a form submission as a POST with a body but
|
||||
// keep a raw GET without it (katana issue 876). Rebuild the request from the
|
||||
// structured fields so the method and body survive, and keep the headers the
|
||||
// raw request captured, such as cookies.
|
||||
return k.buildFromComponents(method, req.Endpoint, mergeRawHeaders(rr, req.Headers), req.Body)
|
||||
}
|
||||
|
||||
// rawConflicts reports whether the parsed raw request disagrees with the
|
||||
// structured method or body of the katana request.
|
||||
func rawConflicts(req *katanaRequest, rr *types.RequestResponse) bool {
|
||||
if rr == nil || rr.Request == nil {
|
||||
return false
|
||||
}
|
||||
if req.Method != "" && !strings.EqualFold(req.Method, rr.Request.Method) {
|
||||
return true
|
||||
}
|
||||
return req.Body != "" && rr.Request.Body == ""
|
||||
}
|
||||
|
||||
// mergeRawHeaders returns the headers of the raw request overridden by the
|
||||
// structured headers. Framing headers are dropped, because they describe the
|
||||
// raw body and not the structured one.
|
||||
func mergeRawHeaders(rr *types.RequestResponse, structured map[string]string) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
rr.Request.Headers.Iterate(func(key, value string) bool {
|
||||
if !strings.EqualFold(key, "Content-Length") && !strings.EqualFold(key, "Transfer-Encoding") {
|
||||
headers[http.CanonicalHeaderKey(key)] = value
|
||||
}
|
||||
return true
|
||||
})
|
||||
for key, value := range structured {
|
||||
headers[http.CanonicalHeaderKey(key)] = value
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// buildFromComponents synthesizes a raw HTTP request from discrete fields and
|
||||
// parses it back into a RequestResponse, reusing the well-tested raw parser so
|
||||
// the resulting object is identical in shape to the other input formats.
|
||||
func (k *KatanaFormat) buildFromComponents(method, endpoint string, headers map[string]string, body string) (*types.RequestResponse, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid endpoint: %w", err)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return nil, fmt.Errorf("endpoint %q has no host", endpoint)
|
||||
}
|
||||
|
||||
requestURI := parsed.RequestURI()
|
||||
if requestURI == "" {
|
||||
requestURI = "/"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(method)
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(requestURI)
|
||||
sb.WriteString(" HTTP/1.1\r\n")
|
||||
sb.WriteString("Host: ")
|
||||
sb.WriteString(parsed.Host)
|
||||
sb.WriteString("\r\n")
|
||||
|
||||
// Emit headers in a deterministic order, skipping Host (already written)
|
||||
// and any header with a line break, which would split the raw request.
|
||||
for _, key := range sortedHeaderKeys(headers) {
|
||||
if strings.EqualFold(key, "Host") || strings.ContainsAny(key+headers[key], "\r\n") {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(key)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(headers[key])
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
sb.WriteString("\r\n")
|
||||
if body != "" {
|
||||
sb.WriteString(body)
|
||||
}
|
||||
|
||||
return types.ParseRawRequestWithURL(sb.String(), endpoint)
|
||||
}
|
||||
|
||||
// sortedHeaderKeys returns the header keys in deterministic (sorted) order.
|
||||
func sortedHeaderKeys(headers map[string]string) []string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// isAbsoluteURL reports whether the line is an absolute http(s) URL.
|
||||
func isAbsoluteURL(line string) bool {
|
||||
u, err := url.Parse(line)
|
||||
return err == nil && u.IsAbs() && u.Host != "" && (u.Scheme == "http" || u.Scheme == "https")
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package katana
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKatanaFormatName(t *testing.T) {
|
||||
require.Equal(t, "katana", New().Name())
|
||||
}
|
||||
|
||||
func TestKatanaFormatParse(t *testing.T) {
|
||||
inputFile := "../testdata/katana.jsonl"
|
||||
|
||||
file, err := os.Open(inputFile)
|
||||
require.Nilf(t, err, "error opening katana input file: %v", err)
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
var got []*types.RequestResponse
|
||||
err = New().Parse(file, func(rr *types.RequestResponse) bool {
|
||||
got = append(got, rr)
|
||||
return true
|
||||
}, inputFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 3 JSONL records + 1 bare URL line; malformed and blank lines skipped.
|
||||
require.Len(t, got, 4)
|
||||
|
||||
urls := make([]string, 0, len(got))
|
||||
for _, rr := range got {
|
||||
urls = append(urls, rr.URL.String())
|
||||
}
|
||||
require.ElementsMatch(t, []string{
|
||||
"https://ginandjuice.shop/catalog/product?productId=1",
|
||||
"https://ginandjuice.shop/catalog/subscribe",
|
||||
"https://ginandjuice.shop/login",
|
||||
"https://ginandjuice.shop/about",
|
||||
}, urls)
|
||||
}
|
||||
|
||||
func TestKatanaFormatRawRequestPreserved(t *testing.T) {
|
||||
// A POST with a captured raw request must preserve method and body.
|
||||
rr := parseSingle(t, `{"request":{"method":"POST","endpoint":"https://example.com/sub","raw":"POST /sub HTTP/1.1\r\nHost: example.com\r\nContent-Type: application/x-www-form-urlencoded\r\n\r\nemail=a%40b.com"}}`)
|
||||
require.Equal(t, "POST", rr.Request.Method)
|
||||
require.Equal(t, "email=a%40b.com", rr.Request.Body)
|
||||
require.Equal(t, "https://example.com/sub", rr.URL.String())
|
||||
}
|
||||
|
||||
func TestKatanaFormatRawConflictUsesStructuredRequest(t *testing.T) {
|
||||
// katana issue 876: a POST with a body whose raw request is a GET without it.
|
||||
rr := parseSingle(t, `{"request":{"method":"POST","endpoint":"https://example.com/sub","body":"user=a&pass=b","headers":{"Content-Type":"application/x-www-form-urlencoded"},"raw":"GET /sub HTTP/1.1\r\nHost: example.com\r\nCookie: s=1\r\nContent-Length: 0\r\n\r\n"}}`)
|
||||
require.Equal(t, "POST", rr.Request.Method)
|
||||
require.Equal(t, "user=a&pass=b", rr.Request.Body)
|
||||
require.Equal(t, "https://example.com/sub", rr.URL.String())
|
||||
|
||||
built, err := rr.BuildRequest()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "s=1", built.Header.Get("Cookie"))
|
||||
require.Equal(t, "application/x-www-form-urlencoded", built.Header.Get("Content-Type"))
|
||||
require.Equal(t, int64(len("user=a&pass=b")), built.ContentLength)
|
||||
}
|
||||
|
||||
func TestKatanaFormatComponentSynthesis(t *testing.T) {
|
||||
// No raw request: it must be synthesized from method/endpoint/headers/body.
|
||||
rr := parseSingle(t, `{"request":{"method":"POST","endpoint":"https://example.com/login","headers":{"Content-Type":"application/json"},"body":"{\"u\":\"admin\"}"}}`)
|
||||
require.Equal(t, "POST", rr.Request.Method)
|
||||
require.Equal(t, `{"u":"admin"}`, rr.Request.Body)
|
||||
require.Equal(t, "https://example.com/login", rr.URL.String())
|
||||
|
||||
built, err := rr.BuildRequest()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "application/json", built.Header.Get("Content-Type"))
|
||||
require.Equal(t, "example.com", built.Host)
|
||||
}
|
||||
|
||||
func TestKatanaFormatQueryParamsRetained(t *testing.T) {
|
||||
// Parameter variants must survive so the fuzzer can target them.
|
||||
rr := parseSingle(t, `{"request":{"method":"GET","endpoint":"https://example.com/p?id=1&q=2"}}`)
|
||||
require.Equal(t, "https://example.com/p?id=1&q=2", rr.URL.String())
|
||||
require.Equal(t, "GET", rr.Request.Method)
|
||||
}
|
||||
|
||||
func TestKatanaFormatGracefulSkips(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
"",
|
||||
"garbage",
|
||||
`{"request":null}`,
|
||||
`{"request":{"endpoint":""}}`,
|
||||
`{"request":{"method":"GET","endpoint":"ftp://example.com/file"}}`,
|
||||
`{"request":{"method":"GET","endpoint":"/relative/only"}}`,
|
||||
`{"request":{"method":"GET","endpoint":"https://example.com/ok"}}`,
|
||||
}, "\n")
|
||||
|
||||
var got []*types.RequestResponse
|
||||
err := New().Parse(strings.NewReader(input), func(rr *types.RequestResponse) bool {
|
||||
got = append(got, rr)
|
||||
return true
|
||||
}, "test")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, "https://example.com/ok", got[0].URL.String())
|
||||
}
|
||||
|
||||
func TestKatanaFormatCallbackStops(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
`{"request":{"method":"GET","endpoint":"https://example.com/1"}}`,
|
||||
`{"request":{"method":"GET","endpoint":"https://example.com/2"}}`,
|
||||
}, "\n")
|
||||
|
||||
var count int
|
||||
err := New().Parse(strings.NewReader(input), func(_ *types.RequestResponse) bool {
|
||||
count++
|
||||
return false // request to stop after the first
|
||||
}, "test")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestKatanaFormatLargeRecord(t *testing.T) {
|
||||
largeBody := strings.Repeat("a", 10*1024*1024+1)
|
||||
input := `{"request":{"method":"POST","endpoint":"https://example.com/upload","body":"` + largeBody + `"}}`
|
||||
|
||||
rr := parseSingle(t, input)
|
||||
require.Equal(t, largeBody, rr.Request.Body)
|
||||
}
|
||||
|
||||
func TestKatanaFormatRecordSizeLimit(t *testing.T) {
|
||||
const (
|
||||
prefix = `{"request":{"method":"POST","endpoint":"https://example.com/upload","body":"`
|
||||
suffix = `"}}`
|
||||
)
|
||||
record := func(size int) string {
|
||||
return prefix + strings.Repeat("a", size-len(prefix)-len(suffix)) + suffix
|
||||
}
|
||||
|
||||
// A record right at the limit is parsed, one byte over it is dropped.
|
||||
rr := parseSingle(t, record(MaxRecordSize))
|
||||
require.Len(t, rr.Request.Body, MaxRecordSize-len(prefix)-len(suffix))
|
||||
|
||||
var got []*types.RequestResponse
|
||||
err := New().Parse(strings.NewReader(record(MaxRecordSize+1)), func(rr *types.RequestResponse) bool {
|
||||
got = append(got, rr)
|
||||
return true
|
||||
}, "test")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestKatanaFormatOversizedRecordSkipped(t *testing.T) {
|
||||
// A record above the limit must be dropped while the rest of the file is
|
||||
// still parsed.
|
||||
input := io.MultiReader(
|
||||
strings.NewReader(`{"request":{"method":"POST","endpoint":"https://example.com/upload","body":"`),
|
||||
io.LimitReader(filler{}, MaxRecordSize),
|
||||
strings.NewReader("\"}}\n"+`{"request":{"method":"GET","endpoint":"https://example.com/ok"}}`),
|
||||
)
|
||||
|
||||
var got []*types.RequestResponse
|
||||
err := New().Parse(input, func(rr *types.RequestResponse) bool {
|
||||
got = append(got, rr)
|
||||
return true
|
||||
}, "test")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, "https://example.com/ok", got[0].URL.String())
|
||||
}
|
||||
|
||||
func TestKatanaFormatUnterminatedRecordIsBounded(t *testing.T) {
|
||||
// An unterminated record is drained rather than buffered, so what parsing
|
||||
// costs must not grow with how long the record is: a record sixteen times
|
||||
// over the limit may not cost measurably more than one just over it.
|
||||
small := allocsForUnterminatedRecord(t, 2*MaxRecordSize)
|
||||
large := allocsForUnterminatedRecord(t, 16*MaxRecordSize)
|
||||
|
||||
require.LessOrEqual(t, large, small+MaxRecordSize,
|
||||
"draining a record 16x over the limit allocated %d bytes against %d for 2x", large, small)
|
||||
}
|
||||
|
||||
// allocsForUnterminatedRecord reports the bytes allocated while parsing a
|
||||
// record of the given size that never terminates with a newline.
|
||||
func allocsForUnterminatedRecord(t *testing.T, size int64) uint64 {
|
||||
t.Helper()
|
||||
|
||||
input := io.MultiReader(
|
||||
strings.NewReader(`{"request":{"method":"POST","endpoint":"https://example.com/upload","body":"`),
|
||||
io.LimitReader(filler{}, size),
|
||||
)
|
||||
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
|
||||
var got []*types.RequestResponse
|
||||
err := New().Parse(input, func(rr *types.RequestResponse) bool {
|
||||
got = append(got, rr)
|
||||
return true
|
||||
}, "test")
|
||||
runtime.ReadMemStats(&after)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
|
||||
return after.TotalAlloc - before.TotalAlloc
|
||||
}
|
||||
|
||||
// filler is an endless source of record payload bytes, used to feed oversized
|
||||
// records without materializing them in the test.
|
||||
type filler struct{}
|
||||
|
||||
func (filler) Read(p []byte) (int, error) {
|
||||
for i := range p {
|
||||
p[i] = 'a'
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func parseSingle(t *testing.T, line string) *types.RequestResponse {
|
||||
t.Helper()
|
||||
var got *types.RequestResponse
|
||||
err := New().Parse(strings.NewReader(line), func(rr *types.RequestResponse) bool {
|
||||
got = rr
|
||||
return true
|
||||
}, "test")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
return got
|
||||
}
|
||||
|
||||
func TestKatanaFormatHeaderLineBreaksDropped(t *testing.T) {
|
||||
// A crawled header value with a line break must not inject extra headers.
|
||||
rr := parseSingle(t, `{"request":{"method":"GET","endpoint":"https://example.com/x","headers":{"X-Ok":"1","X-Bad":"a\r\nX-Injected: 1"}}}`)
|
||||
value, ok := rr.Request.Headers.Get("X-Ok")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "1", value)
|
||||
_, ok = rr.Request.Headers.Get("X-Injected")
|
||||
require.False(t, ok)
|
||||
_, ok = rr.Request.Headers.Get("X-Bad")
|
||||
require.False(t, ok)
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{"timestamp":"2024-01-01T00:00:00Z","request":{"method":"GET","endpoint":"https://ginandjuice.shop/catalog/product?productId=1","tag":"a","source":"https://ginandjuice.shop/","raw":"GET /catalog/product?productId=1 HTTP/1.1\r\nHost: ginandjuice.shop\r\nUser-Agent: katana\r\n\r\n"}}
|
||||
{"timestamp":"2024-01-01T00:00:01Z","request":{"method":"POST","endpoint":"https://ginandjuice.shop/catalog/subscribe","body":"email=test%40test.com","raw":"POST /catalog/subscribe HTTP/1.1\r\nHost: ginandjuice.shop\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 21\r\n\r\nemail=test%40test.com"}}
|
||||
{"timestamp":"2024-01-01T00:00:02Z","request":{"method":"POST","endpoint":"https://ginandjuice.shop/login","headers":{"Content-Type":"application/json"},"body":"{\"username\":\"admin\",\"password\":\"x\"}"}}
|
||||
|
||||
not-json-should-be-skipped
|
||||
https://ginandjuice.shop/about
|
||||
@@ -0,0 +1,35 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHttpInputProviderIteratesAllKatanaRecords(t *testing.T) {
|
||||
input := `{"request":{"method":"GET","endpoint":"https://example.com/a?id=1"}}` + "\n" +
|
||||
`{"request":{"method":"POST","endpoint":"https://example.com/login","body":"user=admin"}}` + "\n" +
|
||||
"https://example.com/bare?q=1\n"
|
||||
|
||||
provider, err := NewHttpInputProvider(&HttpMultiFormatOptions{
|
||||
InputMode: "katana",
|
||||
InputContents: input,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), provider.Count())
|
||||
|
||||
var got []string
|
||||
provider.Iterate(func(value *contextargs.MetaInput) bool {
|
||||
got = append(got, value.Input)
|
||||
return true
|
||||
})
|
||||
require.Equal(t, []string{"https://example.com/a?id=1", "https://example.com/login", "https://example.com/bare?q=1"}, got)
|
||||
|
||||
var first []string
|
||||
provider.Iterate(func(value *contextargs.MetaInput) bool {
|
||||
first = append(first, value.Input)
|
||||
return false
|
||||
})
|
||||
require.Len(t, first, 1)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/burp"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/json"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/katana"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/openapi"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/raw"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/swagger"
|
||||
@@ -88,7 +89,7 @@ func NewHttpInputProvider(opts *HttpMultiFormatOptions) (*HttpInputProvider, err
|
||||
|
||||
parseErr := format.Parse(bytes.NewReader(data), func(request *types.RequestResponse) bool {
|
||||
count++
|
||||
return false
|
||||
return true
|
||||
}, opts.InputFile)
|
||||
if parseErr != nil {
|
||||
return nil, errors.Wrap(parseErr, "could not parse input file")
|
||||
@@ -147,6 +148,7 @@ var providersList = []formats.Format{
|
||||
openapi.New(),
|
||||
swagger.New(),
|
||||
raw.New(),
|
||||
katana.New(),
|
||||
}
|
||||
|
||||
// SupportedFormats returns the list of supported formats in comma-separated
|
||||
|
||||
Reference in new issue
Block a user