fix(js/net): enforce network policy on proxied target in JS net dial

When an HTTP(S) proxy is configured, the nuclei/net JS library dialed the
proxy through Fastdialer (which validates the proxy host) but tunneled the
connection target inside an HTTP CONNECT without validating it. This let a
template reach a deny-listed destination (-exclude-hosts /
-restrict-local-network-access) that a direct dial would have blocked,
bypassing the network policy.

Validate the target host against the network policy in the proxy branch of
Dial() before contacting the proxy, matching the guard the direct path gets
from Fastdialer. Adds a regression test asserting the proxy is never
contacted for a deny-listed target.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Kemal BazandClaude Opus 4.8 committed 2026-10-18 14:32:26 +03:00
1 parent 6a42a7bf52
commit 1c0f77d28e
2 files changed
+52 -5

No files matched your search

+8
View File
@@ -167,6 +167,14 @@ func Dial(ctx context.Context, protocol, address string) (net.Conn, error) {
if err != nil {
return nil, fmt.Errorf("invalid proxy URL %s: %w", redactProxyURL(proxyURL), err)
}
// Enforce the network policy on the tunneled target. Direct dials go
// through Fastdialer, which validates the destination itself, but the
// proxy path only dials the proxy through Fastdialer and tunnels the
// target inside an HTTP CONNECT, so the target would otherwise skip the
// deny-list (-exclude-hosts / -restrict-local-network-access) checks.
if !protocolstate.IsHostAllowed(executionId, address) {
return nil, fmt.Errorf("host %s is not allowed by network policy", address)
}
switch parsed.Scheme {
case "http", "https":
return dialHTTPProxy(ctx, dialer.Fastdialer.Dial, proxyURL, address, getTimeoutFromContext(ctx))
+44 -5
View File
@@ -221,7 +221,7 @@ func TestDial(t *testing.T) {
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
ctx := context.WithValue(context.Background(), "executionId", opts.ExecutionId) // nolint: staticcheck
ctx = context.WithValue(ctx, "proxyURL", "socks5://127.0.0.1:1080") // nolint: staticcheck
ctx = context.WithValue(ctx, "proxyURL", "socks5://127.0.0.1:1080") // nolint: staticcheck
_, err := Dial(ctx, "tcp", "example.com:80")
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported proxy scheme")
@@ -233,7 +233,7 @@ func TestDial(t *testing.T) {
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
ctx := context.WithValue(context.Background(), "executionId", opts.ExecutionId) // nolint: staticcheck
ctx = context.WithValue(ctx, "proxyURL", "://bad") // nolint: staticcheck
ctx = context.WithValue(ctx, "proxyURL", "://bad") // nolint: staticcheck
_, err := Dial(ctx, "tcp", "example.com:80")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid proxy URL")
@@ -245,12 +245,51 @@ func TestDial(t *testing.T) {
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
ctx := context.WithValue(context.Background(), "executionId", opts.ExecutionId) // nolint: staticcheck
ctx = context.WithValue(ctx, "proxyURL", "http://127.0.0.1:8080") // nolint: staticcheck
ctx = context.WithValue(ctx, "proxyURL", "http://127.0.0.1:8080") // nolint: staticcheck
_, err := Dial(ctx, "udp", "example.com:53")
require.Error(t, err)
require.Contains(t, err.Error(), "does not support protocol")
})
t.Run("proxy path enforces network policy on the target", func(t *testing.T) {
// A proxy dial must not let a template reach a deny-listed target by
// tunnelling it inside an HTTP CONNECT. The policy check has to run on
// the target before the proxy is contacted.
proxyLn, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer func() { _ = proxyLn.Close() }()
proxyHit := make(chan struct{}, 1)
go func() {
conn, err := proxyLn.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
proxyHit <- struct{}{}
}()
opts := &types.Options{
ExecutionId: "net-dial-policy-" + t.Name(),
ExcludeTargets: []string{"127.0.0.2"},
}
require.NoError(t, protocolstate.Init(opts))
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
ctx := context.WithValue(context.Background(), "executionId", opts.ExecutionId) // nolint: staticcheck
ctx = context.WithValue(ctx, "proxyURL", "http://"+proxyLn.Addr().String()) // nolint: staticcheck
_, err = Dial(ctx, "tcp", "127.0.0.2:9999")
require.Error(t, err)
require.Contains(t, err.Error(), "not allowed by network policy")
select {
case <-proxyHit:
t.Fatal("proxy was contacted for a deny-listed target")
default:
}
})
t.Run("open through local http proxy", func(t *testing.T) {
target, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
@@ -299,8 +338,8 @@ func TestDial(t *testing.T) {
require.NoError(t, protocolstate.Init(opts))
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
ctx := context.WithValue(context.Background(), "executionId", opts.ExecutionId) // nolint: staticcheck
ctx = context.WithValue(ctx, "proxyURL", "http://"+proxyLn.Addr().String()) // nolint: staticcheck
ctx := context.WithValue(context.Background(), "executionId", opts.ExecutionId) // nolint: staticcheck
ctx = context.WithValue(ctx, "proxyURL", "http://"+proxyLn.Addr().String()) // nolint: staticcheck
ctx = context.WithValue(ctx, "timeoutVariants", &types.Timeouts{TcpReadTimeout: 5 * time.Second}) // nolint: staticcheck
nconn, err := Open(ctx, "tcp", target.Addr().String())