Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ require (
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/odvcencio/gotreesitter v0.5.3-0.20260227083844-016686287e7c // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,12 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/odvcencio/gotreesitter v0.5.2 h1:t3rlN8w9kWxHS9IZbMhE8ItO/IkCLQn9755RLAC49VI=
github.com/odvcencio/gotreesitter v0.5.2/go.mod h1:CEjhRXmfP38AHqxwWxNcLaMsLQ3YoGMrwNGle1huZdU=
github.com/odvcencio/gotreesitter v0.5.3-0.20260227072035-f76cb0d8aa95 h1:lMZGc05VRFVD5LU6CIzx3Rpn6clywgN7r76oVntUcnA=
github.com/odvcencio/gotreesitter v0.5.3-0.20260227072035-f76cb0d8aa95/go.mod h1:CEjhRXmfP38AHqxwWxNcLaMsLQ3YoGMrwNGle1huZdU=
github.com/odvcencio/gotreesitter v0.5.3-0.20260227083844-016686287e7c h1:93JqddF+SdRjfiYqef+/z3Bo1NCB8s1llaHVc9JdnKM=
github.com/odvcencio/gotreesitter v0.5.3-0.20260227083844-016686287e7c/go.mod h1:CEjhRXmfP38AHqxwWxNcLaMsLQ3YoGMrwNGle1huZdU=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
Expand Down
97 changes: 97 additions & 0 deletions pkg/fuzz/analyzers/xss/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package xss

import (
"crypto/rand"
"io"
"strings"

"github.com/pkg/errors"
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/analyzers"
)

// Analyzer is an XSS context analyzer for the fuzzer.
// It injects a canary value and uses gotreesitter AST parsing to determine
// the precise HTML/JS/CSS context of each reflection point.
type Analyzer struct{}

var _ analyzers.Analyzer = &Analyzer{}

func init() {
analyzers.RegisterAnalyzer("xss_context", &Analyzer{})
}

// Name returns the name of this analyzer.
func (a *Analyzer) Name() string {
return "xss_context"
}

// ApplyInitialTransformation applies payload transformations.
// For XSS context analysis, we don't transform the initial payload since
// we generate our own canary in Analyze().
func (a *Analyzer) ApplyInitialTransformation(data string, params map[string]interface{}) string {
return analyzers.ApplyPayloadTransformations(data)
}

// Analyze sends a request with a unique canary value and determines the
// XSS context of all reflection points using AST parsing.
func (a *Analyzer) Analyze(options *analyzers.Options) (bool, string, error) {
canary := generateCanary()

gr := options.FuzzGenerated
if err := gr.Component.SetValue(gr.Key, canary); err != nil {
return false, "", errors.Wrap(err, "could not set canary value")
}

rebuilt, err := gr.Component.Rebuild()
if err != nil {
return false, "", errors.Wrap(err, "could not rebuild request with canary")
}

resp, err := options.HttpClient.Do(rebuilt)
if err != nil {
return false, "", errors.Wrap(err, "could not send canary request")
}

body, err := io.ReadAll(resp.Body)

Choose a reason for hiding this comment

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

🟠 Unbounded response body read enables memory exhaustion DoS (CWE-770) — The analyzer reads the entire HTTP response body into memory using io.ReadAll without verifying that response size limits are enforced. While nuclei has MaxBodyRead protections at the protocol layer (default 10MB), the analyzer makes its own HTTP request via options.HttpClient.Do() and directly calls ReadAll on the response body. If the HTTP client is misconfigured or lacks MaxRespBodySize enforcement, a malicious server could return gigabytes of data causing memory exhaustion.

Attack Example
Attacker's malicious server responds to the canary request with Transfer-Encoding: chunked and sends 10GB of data in chunks. The io.ReadAll call attempts to buffer all 10GB in memory, causing the nuclei process to crash with OOM.
Suggested Fix
Wrap resp.Body with io.LimitReader before calling ReadAll: body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyLimit)) where maxBodyLimit is derived from nuclei's MaxBodyRead constant or a configurable analyzer parameter. This ensures the analyzer respects size limits regardless of HTTP client configuration.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fuzz/analyzers/xss/analyzer.go` at line 55, replace the unbounded
`io.ReadAll(resp.Body)` call with a size-limited read: `body, err :=
io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))` to prevent memory
exhaustion from malicious servers returning gigabyte-sized responses. Import the
io package if not already imported. Consider making the 10MB limit configurable
via analyzer parameters.

if err != nil {
return false, "", errors.Wrap(err, "could not read response body")
}

// Quick check: if canary is not reflected at all, no XSS
if !strings.Contains(string(body), canary) {
return false, "", nil
}

// Parse HTML and find all reflection contexts
points := findReflections(body, canary)
if len(points) == 0 {
return false, "", nil
}

// Build details string listing all unique contexts
seen := make(map[XSSContext]bool)
var details []string
for _, p := range points {
if !seen[p.Context] {
seen[p.Context] = true
details = append(details, p.Context.String())
}
}

return true, strings.Join(details, ", "), nil
}

// generateCanary creates a unique canary string unlikely to appear in natural content.
// Format: "gtss" + 8 random alphanumeric characters.
func generateCanary() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
// Fallback to a fixed canary if crypto/rand fails (extremely unlikely)
return "gtss00000000"
}
for i := range b {
b[i] = charset[b[i]%byte(len(charset))]
}
return "gtss" + string(b)
}
Loading