Files
Dwi Siswanto be40690a29 fix(headless): fail requests whose URL net/url cannot parse
With a request modification action (setheader, addheader, deleteheader,
setmethod or setbody), routingRuleHandler replays every browser request
through nuclei's Go HTTP client. go-rod builds that request with
url.Parse and drops the error, so a URL that Chrome accepts but net/url
rejects, such as a path ending in "100%.png", arrived with a nil URL.
The cookie jar sync then panicked, or httputil.DumpRequestOut did when
cookies were disabled, and the panic killed the whole scan.

Check the URL once on entry and fail such requests in the browser. The
Go client cannot send them, and continuing them in the browser would
drop the template's modification rules and, with -cdp-endpoint, skip
the dial-time address checks of nuclei's network policy.

Fixes: #8015
Reported-by: @l4rm4nd
Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-03 16:13:37 +07:00

173 lines
5.3 KiB
Go

package engine
import (
"fmt"
"net/http"
"net/http/httputil"
"strings"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
)
// routingRuleHandler handles proxy rule for actions related to request/response modification
func (p *Page) routingRuleHandler(httpClient *http.Client) func(ctx *rod.Hijack) {
return func(ctx *rod.Hijack) {
req := ctx.Request.Req()
// go-rod discards url.Parse errors, so a URL that Chrome accepts but
// net/url rejects, such as "/100%.png", arrives as nil. Fail the request:
// the Go client cannot send it, and continuing it in the browser would
// drop the modification rules and, with -cdp-endpoint (no headless
// proxy), the network policy's dial-time address checks.
if req.URL == nil {
gologger.Verbose().Msgf("headless: failed %s request with a URL that net/url cannot parse", req.Method)
ctx.Response.Fail(proto.NetworkErrorReasonFailed)
return
}
// usually browsers don't use chunked transfer encoding, so we set the content-length nevertheless
req.ContentLength = int64(len(ctx.Request.Body()))
// snapshot the rules once: ExecuteActions may still be appending rules
// from another goroutine while this hijack handler runs.
rules := p.rulesSnapshot()
for _, rule := range rules {
if rule.Part != "request" {
continue
}
switch rule.Action {
case ActionSetMethod:
rule.Do(func() {
req.Method = rule.Args["method"]
})
case ActionAddHeader:
req.Header.Add(rule.Args["key"], rule.Args["value"])
case ActionSetHeader:
req.Header.Set(rule.Args["key"], rule.Args["value"])
case ActionDeleteHeader:
req.Header.Del(rule.Args["key"])
case ActionSetBody:
body := rule.Args["body"]
req.ContentLength = int64(len(body))
ctx.Request.SetBody(body)
}
}
// each http request is performed via the native go http client
// we first inject the shared cookies
if !p.options.DisableCookie {
if cookies := p.ctx.CookieJar.Cookies(req.URL); len(cookies) > 0 {
httpClient.Jar.SetCookies(req.URL, cookies)
}
}
// perform the request
if err := ctx.LoadResponse(httpClient, true); err != nil {
gologger.Verbose().Msgf("headless: failed to load response for %s: %s", req.URL, err)
}
if !p.options.DisableCookie {
// retrieve the updated cookies from the native http client and inject them into the shared cookie jar
// keeps existing one if not present
if cookies := httpClient.Jar.Cookies(req.URL); len(cookies) > 0 {
p.ctx.CookieJar.SetCookies(req.URL, cookies)
}
}
for _, rule := range rules {
if rule.Part != "response" {
continue
}
switch rule.Action {
case ActionAddHeader:
ctx.Response.Headers().Add(rule.Args["key"], rule.Args["value"])
case ActionSetHeader:
ctx.Response.Headers().Set(rule.Args["key"], rule.Args["value"])
case ActionDeleteHeader:
ctx.Response.Headers().Del(rule.Args["key"])
case ActionSetBody:
body := rule.Args["body"]
ctx.Response.Headers().Set("Content-Length", fmt.Sprintf("%d", len(body)))
ctx.Response.SetBody(rule.Args["body"])
}
}
// store history
var rawReq string
if raw, err := httputil.DumpRequestOut(req, true); err == nil {
rawReq = string(raw)
}
// attempts to rebuild the response
var rawResp strings.Builder
respPayloads := ctx.Response.Payload()
if respPayloads != nil {
fmt.Fprintf(&rawResp, "HTTP/1.1 %d %s\n", respPayloads.ResponseCode, respPayloads.ResponsePhrase)
for _, header := range respPayloads.ResponseHeaders {
rawResp.WriteString(header.Name + ": " + header.Value + "\n")
}
rawResp.WriteString("\n")
rawResp.WriteString(ctx.Response.Body())
}
// dump request
historyData := HistoryData{
RawRequest: rawReq,
RawResponse: rawResp.String(),
}
p.addToHistory(historyData)
}
}
// routingRuleHandlerNative handles native proxy rule
func (p *Page) routingRuleHandlerNative(e *proto.FetchRequestPaused) error {
// ValidateNFailRequest validates if Local file access is enabled
// and local network access is enables if not it will fail the request
// that don't match the rules
if err := protocolstate.ValidateNFailRequest(p.options.Options, p.page, e); err != nil {
return err
}
body, _ := FetchGetResponseBody(p.page, e)
headers := make(map[string][]string)
for _, h := range e.ResponseHeaders {
headers[h.Name] = []string{h.Value}
}
var statusCode int
if e.ResponseStatusCode != nil {
statusCode = *e.ResponseStatusCode
}
// attempts to rebuild request
var rawReq strings.Builder
fmt.Fprintf(&rawReq, "%s %s %s\n", e.Request.Method, e.Request.URL, "HTTP/1.1")
for _, header := range e.Request.Headers {
fmt.Fprintf(&rawReq, "%s\n", header.String())
}
if e.Request.HasPostData {
fmt.Fprintf(&rawReq, "\n%s\n", e.Request.PostData)
}
// attempts to rebuild the response
var rawResp strings.Builder
fmt.Fprintf(&rawResp, "HTTP/1.1 %d %s\n", statusCode, e.ResponseStatusText)
for _, header := range e.ResponseHeaders {
rawResp.WriteString(header.Name + ": " + header.Value + "\n")
}
rawResp.WriteString("\n")
rawResp.Write(body)
// dump request
historyData := HistoryData{
RawRequest: rawReq.String(),
RawResponse: rawResp.String(),
}
p.addToHistory(historyData)
return FetchContinueRequest(p.page, e)
}