mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-10 04:01:31 -10:00
Address review feedback: - Skip Sec-WebSocket-Protocol values containing CR or LF before negotiating them, since gobwas/ws writes subprotocols into the handshake request line and a line break could smuggle extra handshake headers. - Tighten the test server to reject the upgrade unless the client offers the vite-hmr subprotocol, so the test fails if negotiation does not happen. Co-Authored-By: Claude Opus 4.8 <[email protected]>
577 lines
18 KiB
Go
577 lines
18 KiB
Go
package websocket
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gobwas/ws"
|
|
"github.com/gobwas/ws/wsutil"
|
|
"github.com/miekg/dns"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"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/output"
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
|
urlutil "github.com/projectdiscovery/utils/url"
|
|
)
|
|
|
|
// Keep duration assertions above the timer granularity of fast local sockets on Windows.
|
|
const durationObservationDelay = 10 * time.Millisecond
|
|
|
|
// resolveAddress mirrors the path resolution logic in executeRequestWithPayloads.
|
|
// it parses the template address and the input URL then applies the path rule.
|
|
func resolveAddress(templateAddress, inputURL string) (string, error) {
|
|
parsedAddress, err := url.Parse(templateAddress)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parsed, err := urlutil.Parse(inputURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if parsedAddress.Path == "" || parsedAddress.Path == "/" {
|
|
parsedAddress.Path = parsed.Path
|
|
}
|
|
return parsedAddress.String(), nil
|
|
}
|
|
|
|
func TestAddressResolution(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
templateAddress string
|
|
inputURL string
|
|
expected string
|
|
}{
|
|
// template already has a path so we keep it and don't double
|
|
{
|
|
name: "same path in both - no doubling",
|
|
templateAddress: "wss://jenkins.cloud/cli/ws",
|
|
inputURL: "https://jenkins.cloud/cli/ws",
|
|
expected: "wss://jenkins.cloud/cli/ws",
|
|
},
|
|
{
|
|
name: "different paths - template path preserved",
|
|
templateAddress: "wss://example.com/ws/connect",
|
|
inputURL: "https://example.com/api/v1",
|
|
expected: "wss://example.com/ws/connect",
|
|
},
|
|
{
|
|
name: "deep template path preserved",
|
|
templateAddress: "wss://example.com/a/b/c/d",
|
|
inputURL: "https://example.com/x/y",
|
|
expected: "wss://example.com/a/b/c/d",
|
|
},
|
|
{
|
|
name: "template path with trailing slash preserved",
|
|
templateAddress: "wss://example.com/ws/",
|
|
inputURL: "https://example.com/other",
|
|
expected: "wss://example.com/ws/",
|
|
},
|
|
|
|
// when the template has no path we fall back to the input path
|
|
{
|
|
name: "no template path - input path used",
|
|
templateAddress: "wss://example.com",
|
|
inputURL: "https://example.com/api/ws",
|
|
expected: "wss://example.com/api/ws",
|
|
},
|
|
{
|
|
name: "root template path - input path used",
|
|
templateAddress: "wss://example.com/",
|
|
inputURL: "https://example.com/chat/ws",
|
|
expected: "wss://example.com/chat/ws",
|
|
},
|
|
{
|
|
name: "no paths on either side",
|
|
templateAddress: "wss://example.com",
|
|
inputURL: "https://example.com",
|
|
expected: "wss://example.com",
|
|
},
|
|
{
|
|
name: "root template, root input",
|
|
templateAddress: "wss://example.com/",
|
|
inputURL: "https://example.com/",
|
|
expected: "wss://example.com/",
|
|
},
|
|
|
|
// ports should not affect path resolution
|
|
{
|
|
name: "template with port and path",
|
|
templateAddress: "wss://example.com:8443/ws",
|
|
inputURL: "https://example.com:8443/api",
|
|
expected: "wss://example.com:8443/ws",
|
|
},
|
|
{
|
|
name: "template with port, no path - input path used",
|
|
templateAddress: "ws://example.com:9090",
|
|
inputURL: "http://example.com:9090/stream",
|
|
expected: "ws://example.com:9090/stream",
|
|
},
|
|
{
|
|
name: "ws scheme with port and deep input path",
|
|
templateAddress: "ws://example.com:8080",
|
|
inputURL: "http://example.com:8080/api/v2/ws",
|
|
expected: "ws://example.com:8080/api/v2/ws",
|
|
},
|
|
|
|
// query strings should stay with their respective URLs
|
|
{
|
|
name: "template with query string preserved",
|
|
templateAddress: "wss://example.com/ws?token=abc",
|
|
inputURL: "https://example.com/other",
|
|
expected: "wss://example.com/ws?token=abc",
|
|
},
|
|
{
|
|
name: "input query string not leaked when template has path",
|
|
templateAddress: "wss://example.com/ws",
|
|
inputURL: "https://example.com/api?key=secret",
|
|
expected: "wss://example.com/ws",
|
|
},
|
|
{
|
|
name: "no template path - input path used but not query",
|
|
templateAddress: "wss://example.com",
|
|
inputURL: "https://example.com/stream?v=1",
|
|
expected: "wss://example.com/stream",
|
|
},
|
|
|
|
// both ws and wss schemes should behave the same way
|
|
{
|
|
name: "ws scheme template path preserved",
|
|
templateAddress: "ws://example.com/plain",
|
|
inputURL: "http://example.com/other",
|
|
expected: "ws://example.com/plain",
|
|
},
|
|
{
|
|
name: "wss scheme no path - input path used",
|
|
templateAddress: "wss://secure.example.com",
|
|
inputURL: "https://secure.example.com/endpoint",
|
|
expected: "wss://secure.example.com/endpoint",
|
|
},
|
|
|
|
// same logic applies to IP-based targets
|
|
{
|
|
name: "IPv4 template with path",
|
|
templateAddress: "ws://192.168.1.1/ws",
|
|
inputURL: "http://192.168.1.1/api",
|
|
expected: "ws://192.168.1.1/ws",
|
|
},
|
|
{
|
|
name: "IPv4 template no path - input path used",
|
|
templateAddress: "ws://192.168.1.1",
|
|
inputURL: "http://192.168.1.1/metrics/ws",
|
|
expected: "ws://192.168.1.1/metrics/ws",
|
|
},
|
|
{
|
|
name: "IPv4 with port, no path",
|
|
templateAddress: "ws://10.0.0.1:3000",
|
|
inputURL: "http://10.0.0.1:3000/graphql/ws",
|
|
expected: "ws://10.0.0.1:3000/graphql/ws",
|
|
},
|
|
|
|
// patterns from actual templates and bug reports
|
|
{
|
|
name: "jenkins websocket - the original bug report",
|
|
templateAddress: "wss://jenkins-ci.corp.cloud/cli/ws",
|
|
inputURL: "https://jenkins-ci.corp.cloud/cli/ws",
|
|
expected: "wss://jenkins-ci.corp.cloud/cli/ws",
|
|
},
|
|
{
|
|
name: "grafana live websocket",
|
|
templateAddress: "wss://grafana.local/api/live/ws",
|
|
inputURL: "https://grafana.local/api/live/ws",
|
|
expected: "wss://grafana.local/api/live/ws",
|
|
},
|
|
{
|
|
name: "generic host-only template with target path",
|
|
templateAddress: "wss://target.example.com",
|
|
inputURL: "https://target.example.com/socket.io/ws",
|
|
expected: "wss://target.example.com/socket.io/ws",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := resolveAddress(tt.templateAddress, tt.inputURL)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetAddress(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "ws scheme returns host",
|
|
input: "ws://example.com/path",
|
|
want: "example.com",
|
|
},
|
|
{
|
|
name: "wss scheme returns host with port",
|
|
input: "wss://example.com:8443/path",
|
|
want: "example.com:8443",
|
|
},
|
|
{
|
|
name: "ws with non-standard port",
|
|
input: "ws://example.com:9090",
|
|
want: "example.com:9090",
|
|
},
|
|
{
|
|
name: "wss with standard port",
|
|
input: "wss://example.com:443/ws",
|
|
want: "example.com:443",
|
|
},
|
|
{
|
|
name: "ws IPv4",
|
|
input: "ws://192.168.1.1/ws",
|
|
want: "192.168.1.1",
|
|
},
|
|
{
|
|
name: "ws IPv4 with port",
|
|
input: "ws://192.168.1.1:8080/ws",
|
|
want: "192.168.1.1:8080",
|
|
},
|
|
{
|
|
name: "http scheme rejected",
|
|
input: "http://example.com",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "https scheme rejected",
|
|
input: "https://example.com",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "ftp scheme rejected",
|
|
input: "ftp://example.com",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid URL",
|
|
input: "://broken",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := getAddress(tt.input)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebSocketDurationFields(t *testing.T) {
|
|
connHandler := func(conn net.Conn) {
|
|
for {
|
|
msg, op, err := wsutil.ReadClientData(conn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
switch string(msg) {
|
|
case "hello":
|
|
time.Sleep(durationObservationDelay)
|
|
_ = wsutil.WriteServerMessage(conn, op, []byte("world"))
|
|
case "status":
|
|
time.Sleep(durationObservationDelay)
|
|
_ = wsutil.WriteServerMessage(conn, op, []byte("ready"))
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
server := testutils.NewWebsocketServer("", connHandler, func(origin string) bool { return true })
|
|
defer server.Close()
|
|
|
|
options := testutils.DefaultOptions
|
|
testutils.Init(options)
|
|
|
|
target := strings.ReplaceAll(server.URL, "http", "ws")
|
|
request := &Request{
|
|
ID: "duration-ws",
|
|
Address: target,
|
|
Inputs: []*Input{
|
|
{Data: "hello", Name: "first"},
|
|
{Data: "status", Name: "second"},
|
|
},
|
|
}
|
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
|
ID: "testing-websocket-duration",
|
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
|
})
|
|
executerOpts.IsMultiProtocol = true
|
|
require.NoError(t, request.Compile(executerOpts))
|
|
require.Equal(t, "duration-ws", request.GetID())
|
|
|
|
var gotEvent output.InternalEvent
|
|
ctxArgs := contextargs.NewWithInput(context.Background(), target)
|
|
err := request.ExecuteWithResults(ctxArgs, nil, nil, func(event *output.InternalWrappedEvent) {
|
|
gotEvent = event.InternalEvent
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, gotEvent)
|
|
requireWebsocketDurationField(t, gotEvent, "duration")
|
|
requireWebsocketDurationField(t, gotEvent, "duration_1")
|
|
requireWebsocketDurationField(t, gotEvent, "duration_2")
|
|
require.Equal(t, gotEvent["duration_2"], gotEvent["duration"])
|
|
extractor := &extractors.Extractor{
|
|
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.DSLExtractor},
|
|
DSL: []string{"duration_2"},
|
|
}
|
|
require.NoError(t, extractor.CompileExtractors())
|
|
require.NotEmpty(t, request.Extract(gotEvent, extractor))
|
|
|
|
values := executerOpts.GetTemplateCtx(ctxArgs.MetaInput).GetAll()
|
|
require.Equal(t, gotEvent["duration"], values["duration-ws_duration"])
|
|
require.Equal(t, gotEvent["duration_1"], values["duration-ws_duration_1"])
|
|
require.Equal(t, gotEvent["duration_2"], values["duration-ws_duration_2"])
|
|
}
|
|
|
|
func TestWebSocketInternalDSLExtractorHonorsScanOptions(t *testing.T) {
|
|
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
server := &dns.Server{PacketConn: listener, Handler: dns.HandlerFunc(func(writer dns.ResponseWriter, request *dns.Msg) {
|
|
response := new(dns.Msg).SetReply(request)
|
|
for _, question := range request.Question {
|
|
if question.Qtype == dns.TypeA {
|
|
response.Answer = append(response.Answer, &dns.A{
|
|
Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET},
|
|
A: net.ParseIP("8.8.8.8"),
|
|
})
|
|
}
|
|
}
|
|
_ = writer.WriteMsg(response)
|
|
})}
|
|
started := make(chan struct{})
|
|
server.NotifyStartedFunc = func() { close(started) }
|
|
done := make(chan error, 1)
|
|
go func() { done <- server.ActivateAndServe() }()
|
|
<-started
|
|
t.Cleanup(func() { require.NoError(t, server.Shutdown()); require.NoError(t, <-done) })
|
|
|
|
options := testutils.DefaultOptions.Copy()
|
|
options.ExecutionId = t.Name()
|
|
options.InternalResolversList = []string{listener.LocalAddr().String()}
|
|
testutils.Init(options)
|
|
t.Cleanup(func() { protocolstate.Close(options.ExecutionId) })
|
|
|
|
connHandler := func(conn net.Conn) {
|
|
msg, op, err := wsutil.ReadClientData(conn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = wsutil.WriteServerMessage(conn, op, msg)
|
|
}
|
|
wsServer := testutils.NewWebsocketServer("", connHandler, func(origin string) bool { return true })
|
|
defer wsServer.Close()
|
|
|
|
target := strings.ReplaceAll(wsServer.URL, "http", "ws")
|
|
request := &Request{
|
|
Address: target,
|
|
Inputs: []*Input{{Data: "hello", Name: "first"}},
|
|
Operators: operators.Operators{
|
|
Extractors: []*extractors.Extractor{{
|
|
Name: "resolved",
|
|
Internal: true,
|
|
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.DSLExtractor},
|
|
DSL: []string{`resolve("fixture.example")`},
|
|
}},
|
|
},
|
|
}
|
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
|
ID: "testing-websocket-internal-dsl",
|
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
|
})
|
|
require.NoError(t, request.Compile(executerOpts))
|
|
|
|
var gotEvent output.InternalEvent
|
|
ctxArgs := contextargs.NewWithInput(context.Background(), target)
|
|
require.NoError(t, request.ExecuteWithResults(ctxArgs, nil, nil, func(event *output.InternalWrappedEvent) {
|
|
gotEvent = event.InternalEvent
|
|
}))
|
|
require.Equal(t, "8.8.8.8", gotEvent["resolved"])
|
|
}
|
|
|
|
func TestWebSocketNoInputDuration(t *testing.T) {
|
|
server := newDelayedUpgradeWebsocketServer(durationObservationDelay)
|
|
defer server.Close()
|
|
|
|
options := testutils.DefaultOptions
|
|
testutils.Init(options)
|
|
|
|
target := strings.ReplaceAll(server.URL, "http", "ws")
|
|
request := &Request{
|
|
ID: "duration-ws-handshake",
|
|
Address: target,
|
|
}
|
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
|
ID: "testing-websocket-handshake-duration",
|
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
|
})
|
|
require.NoError(t, request.Compile(executerOpts))
|
|
|
|
var gotEvent output.InternalEvent
|
|
ctxArgs := contextargs.NewWithInput(context.Background(), target)
|
|
err := request.ExecuteWithResults(ctxArgs, nil, nil, func(event *output.InternalWrappedEvent) {
|
|
gotEvent = event.InternalEvent
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, gotEvent)
|
|
requireWebsocketDurationField(t, gotEvent, "duration")
|
|
require.NotContains(t, gotEvent, "duration_1")
|
|
}
|
|
|
|
func newDelayedUpgradeWebsocketServer(delay time.Duration) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(delay)
|
|
|
|
conn, _, _, err := ws.UpgradeHTTP(r, w)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
time.Sleep(delay)
|
|
}()
|
|
}))
|
|
}
|
|
|
|
func TestWebSocketCompileHonorsPayloadRandomOrder(t *testing.T) {
|
|
options := testutils.DefaultOptions.Copy()
|
|
options.ExecutionId = t.Name()
|
|
options.PayloadRandomOrder = true
|
|
testutils.Init(options)
|
|
t.Cleanup(func() { protocolstate.Close(options.ExecutionId) })
|
|
|
|
request := &Request{
|
|
Address: "ws://127.0.0.1:1",
|
|
Payloads: map[string]interface{}{
|
|
"username": []string{"a", "b"},
|
|
},
|
|
}
|
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
|
ID: "testing-websocket-payload-order",
|
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
|
})
|
|
require.NoError(t, request.Compile(executerOpts))
|
|
|
|
sawShuffle := false
|
|
for range 24 {
|
|
iterator := request.generator.NewIterator()
|
|
var got []string
|
|
for {
|
|
value, ok := iterator.Value()
|
|
if !ok {
|
|
break
|
|
}
|
|
got = append(got, value["username"].(string))
|
|
}
|
|
if len(got) == 2 && (got[0] != "a" || got[1] != "b") {
|
|
sawShuffle = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, sawShuffle, "websocket payloads must honor payload random order")
|
|
}
|
|
|
|
func requireWebsocketDurationField(t *testing.T, event output.InternalEvent, key string) {
|
|
t.Helper()
|
|
|
|
value, ok := event[key].(float64)
|
|
require.Truef(t, ok, "expected %s to be a float64 duration", key)
|
|
// Local mocks can finish within one timer tick (esp. on Windows), so 0s is valid.
|
|
require.GreaterOrEqual(t, value, float64(0))
|
|
require.Less(t, value, float64(60))
|
|
}
|
|
|
|
// TestWebSocketSubprotocolNegotiation ensures a Sec-WebSocket-Protocol header is
|
|
// negotiated through the dialer. The server only completes the handshake when the
|
|
// client offers the "vite-hmr" subprotocol and echoes it back (as Vite's HMR
|
|
// endpoint does); without routing the header into Dialer.Protocols, gobwas/ws
|
|
// rejects the server-echoed protocol and the handshake fails.
|
|
func TestWebSocketSubprotocolNegotiation(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Require the client to offer the vite-hmr subprotocol: reject the upgrade
|
|
// otherwise, so the test fails if the subprotocol is not negotiated.
|
|
offered := false
|
|
for _, proto := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") {
|
|
if strings.TrimSpace(proto) == "vite-hmr" {
|
|
offered = true
|
|
break
|
|
}
|
|
}
|
|
if !offered {
|
|
http.Error(w, "vite-hmr subprotocol required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
upgrader := ws.HTTPUpgrader{
|
|
Protocol: func(p string) bool { return p == "vite-hmr" },
|
|
}
|
|
conn, _, _, err := upgrader.Upgrade(r, w)
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
defer func() { _ = conn.Close() }()
|
|
for {
|
|
_, op, err := wsutil.ReadClientData(conn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = wsutil.WriteServerMessage(conn, op, []byte("negotiated-ok"))
|
|
}
|
|
}()
|
|
}))
|
|
defer server.Close()
|
|
|
|
options := testutils.DefaultOptions
|
|
testutils.Init(options)
|
|
|
|
target := strings.ReplaceAll(server.URL, "http", "ws")
|
|
request := &Request{
|
|
ID: "subproto-ws",
|
|
Address: target,
|
|
Headers: map[string]string{"Sec-WebSocket-Protocol": "vite-hmr"},
|
|
Inputs: []*Input{{Data: "ping", Name: "first"}},
|
|
}
|
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
|
ID: "testing-websocket-subprotocol",
|
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
|
})
|
|
executerOpts.IsMultiProtocol = true
|
|
require.NoError(t, request.Compile(executerOpts))
|
|
|
|
var gotEvent output.InternalEvent
|
|
ctxArgs := contextargs.NewWithInput(context.Background(), target)
|
|
err := request.ExecuteWithResults(ctxArgs, nil, nil, func(event *output.InternalWrappedEvent) {
|
|
gotEvent = event.InternalEvent
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, gotEvent)
|
|
require.Contains(t, fmt.Sprintf("%v", gotEvent["response"]), "negotiated-ok")
|
|
}
|