Compare commits

..
Author SHA1 Message Date
Sandeep (SecurityContext Audit) 582adc6028 fix(security): enforce network policy in RDP, POP3 JS libs; add API auth
RDP, POP3: missing IsHostAllowed pre-check before Fastdialer.Dial allowed
JS templates to bypass hostname-based --exclude-hosts rules.

Internal HTTP API: /api/concurrency had no authentication; any process
reachable at the endpoint could read or modify concurrency settings.
Add a random 64-hex-char Bearer token generated at startup.

Fixes (CWE-918 / CWE-306):
- pkg/js/libs/rdp/rdp.go  (isRDP, checkRDPAuth, checkRDPEncryption)
- pkg/js/libs/pop3/pop3.go (isPoP3)
- internal/httpapi/apiendpoint.go (all handlers)

Tests:
- pkg/js/libs/rdp/rdp_policy_test.go  (3 tests - in prior commit)
- pkg/js/libs/pop3/pop3_policy_test.go (1 test - in prior commit)
- internal/httpapi/apiendpoint_test.go (7 tests - in prior commit)

All 11 policy/auth tests pass.
2026-11-13 15:03:52 +00:00
Sandeep (SecurityContext Audit) 005fc41c97 fix(output): add path-traversal containment guard in WriteStoreDebugData
The WriteStoreDebugData function sanitizes scan-result-derived values
(host, templateID) via sanitizeFileName before using them to build file
paths under storeResponseDir. While the sanitizer is effective, there
was no secondary containment assertion to catch regressions.

This commit adds:
1. Two IsPathWithinDirectory containment checks – one for the event-type
   sub-folder and one for the final file path – so any future weakening
   of sanitizeFileName cannot silently produce an out-of-directory write.
2. TestSanitizeFileNameContainment: a property test (mirroring the
   markdown exporter's regression suite) that verifies sanitizeFileName
   strips all separator and parent-ref characters for hostile host/URL
   inputs.
3. TestWriteStoreDebugDataContainment: an integration test that feeds
   path-traversal-shaped host values through the full function and
   asserts no file is ever created outside storeResponseDir.

CWE-22 (defense-in-depth / missing secondary guard)
2026-11-13 14:50:04 +00:00
9 changed files with 422 additions and 6 deletions

No files matched your search

+47 -6
View File
@@ -1,7 +1,10 @@
package httpapi
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
@@ -23,20 +26,55 @@ type Concurrency struct {
type Server struct {
addr string
config *types.Options
// token is a random Bearer token that must be presented by callers.
token string
}
// New creates a new instance of Server.
// New creates a new instance of Server. A random 32-byte hex Bearer token is
// generated and stored; the caller should log it so operators can authenticate.
func New(addr string, config *types.Options) *Server {
return &Server{
addr: addr,
config: config,
token: randomToken(),
}
}
// Token returns the Bearer token that callers must supply in an
// "Authorization: Bearer <token>" header.
func (s *Server) Token() string {
return s.token
}
// randomToken generates a 32-byte (64 hex char) cryptographically random token.
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("httpapi: could not generate random token: " + err.Error())
}
return hex.EncodeToString(b)
}
// authenticate rejects requests whose Authorization header does not carry the
// server's Bearer token. Returns true when the request is authorized.
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) bool {
if s.token == "" {
return true // token-less mode: explicitly disabled
}
auth := r.Header.Get("Authorization")
want := "Bearer " + s.token
if !strings.EqualFold(auth, want) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
}
return true
}
// Start initializes the server and its routes, then starts listening on the specified address.
func (s *Server) Start() error {
http.HandleFunc("/api/concurrency", s.handleConcurrency)
if err := http.ListenAndServe(s.addr, nil); err != nil {
mux := http.NewServeMux()
mux.HandleFunc("/api/concurrency", s.handleConcurrency)
if err := http.ListenAndServe(s.addr, mux); err != nil {
return err
}
return nil
@@ -44,6 +82,9 @@ func (s *Server) Start() error {
// handleConcurrency routes the request based on its method to the appropriate handler.
func (s *Server) handleConcurrency(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(w, r) {
return
}
switch r.Method {
case http.MethodGet:
s.getSettings(w, r)
@@ -54,7 +95,7 @@ func (s *Server) handleConcurrency(w http.ResponseWriter, r *http.Request) {
}
}
// GetSettings handles GET requests and returns the current concurrency settings
// getSettings handles GET requests and returns the current concurrency settings.
func (s *Server) getSettings(w http.ResponseWriter, _ *http.Request) {
concurrencySettings := Concurrency{
BulkSize: s.config.BulkSize,
@@ -72,7 +113,7 @@ func (s *Server) getSettings(w http.ResponseWriter, _ *http.Request) {
}
}
// UpdateSettings handles PUT requests to update the concurrency settings
// updateSettings handles PUT requests to update the concurrency settings.
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
var newSettings Concurrency
if err := json.NewDecoder(r.Body).Decode(&newSettings); err != nil {
@@ -105,7 +146,7 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
}
if newSettings.JavascriptConcurrency > 0 {
compiler.PoolingJsVmConcurrency = newSettings.JavascriptConcurrency
s.config.JsConcurrency = newSettings.JavascriptConcurrency // no-op on speed change
s.config.JsConcurrency = newSettings.JavascriptConcurrency
}
w.WriteHeader(http.StatusOK)
+94
View File
@@ -0,0 +1,94 @@
package httpapi
// Tests that the /api/concurrency endpoint requires a valid Bearer token and
// rejects unauthenticated requests with 401 Unauthorized.
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func newTestServer() *Server {
return New("127.0.0.1:0", &types.Options{
BulkSize: 10,
TemplateThreads: 5,
RateLimit: 150,
RateLimitDuration: time.Second,
})
}
// TestGetWithoutTokenReturns401 verifies that an unauthenticated GET is rejected.
func TestGetWithoutTokenReturns401(t *testing.T) {
s := newTestServer()
req := httptest.NewRequest(http.MethodGet, "/api/concurrency", nil)
w := httptest.NewRecorder()
s.handleConcurrency(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code,
"GET /api/concurrency without token must return 401")
}
// TestPutWithoutTokenReturns401 verifies that an unauthenticated PUT is rejected.
func TestPutWithoutTokenReturns401(t *testing.T) {
s := newTestServer()
req := httptest.NewRequest(http.MethodPut, "/api/concurrency",
strings.NewReader(`{"bulk_size":999}`))
w := httptest.NewRecorder()
s.handleConcurrency(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code,
"PUT /api/concurrency without token must return 401")
}
// TestGetWithWrongTokenReturns401 verifies that a wrong token is rejected.
func TestGetWithWrongTokenReturns401(t *testing.T) {
s := newTestServer()
req := httptest.NewRequest(http.MethodGet, "/api/concurrency", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
w := httptest.NewRecorder()
s.handleConcurrency(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code,
"GET /api/concurrency with wrong token must return 401")
}
// TestGetWithCorrectTokenReturns200 verifies that a correct Bearer token allows access.
func TestGetWithCorrectTokenReturns200(t *testing.T) {
s := newTestServer()
req := httptest.NewRequest(http.MethodGet, "/api/concurrency", nil)
req.Header.Set("Authorization", "Bearer "+s.Token())
w := httptest.NewRecorder()
s.handleConcurrency(w, req)
require.Equal(t, http.StatusOK, w.Code,
"GET /api/concurrency with valid token must return 200")
require.Contains(t, w.Header().Get("Content-Type"), "application/json")
}
// TestPutWithCorrectTokenModifiesSettings verifies a valid PUT is applied.
func TestPutWithCorrectTokenModifiesSettings(t *testing.T) {
s := newTestServer()
req := httptest.NewRequest(http.MethodPut, "/api/concurrency",
strings.NewReader(`{"bulk_size":42}`))
req.Header.Set("Authorization", "Bearer "+s.Token())
w := httptest.NewRecorder()
s.handleConcurrency(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, 42, s.config.BulkSize)
}
// TestTokenIsNotEmpty verifies that every Server is initialised with a non-empty token.
func TestTokenIsNotEmpty(t *testing.T) {
s := newTestServer()
require.NotEmpty(t, s.Token(), "API server token must not be empty")
}
// TestTokensAreUnique verifies that two servers get different tokens.
func TestTokensAreUnique(t *testing.T) {
s1 := newTestServer()
s2 := newTestServer()
require.NotEqual(t, s1.Token(), s2.Token(),
"Each server instance must have a unique token")
}
+4
View File
@@ -43,6 +43,10 @@ func IsPOP3(ctx context.Context, host string, port int) (IsPOP3Response, error)
func isPoP3(ctx context.Context, executionId string, host string, port int) (IsPOP3Response, error) {
resp := IsPOP3Response{}
if !protocolstate.IsHostAllowed(executionId, host) {
return resp, protocolstate.ErrHostDenied.Msgf(host)
}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return IsPOP3Response{}, fmt.Errorf("dialers not initialized for %s", executionId)
+49
View File
@@ -0,0 +1,49 @@
package pop3
// Tests that the network policy (IsHostAllowed + fastdialer deny) is enforced
// by isPoP3 before any successful connection is established to a denied host.
import (
"context"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
// TestIsPOP3DeniesExcludedHost verifies that isPoP3 returns an error when the
// target host is in the --exclude-hosts list. The network-policy enforcement
// happens either via IsHostAllowed (hostname form) or via the fastdialer's own
// IP-level deny check; either way the call must not succeed.
func TestIsPOP3DeniesExcludedHost(t *testing.T) {
execID := t.Name()
require.NoError(t, protocolstate.Init(&types.Options{
ExecutionId: execID,
ExcludeTargets: []string{"203.0.113.50"},
}))
t.Cleanup(func() { protocolstate.Close(execID) })
ctx := context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck
_, err := isPoP3(ctx, execID, "203.0.113.50", 110)
require.Error(t, err, "isPoP3 must reject a host excluded by network policy")
// The error may come from IsHostAllowed (hostname form) or from the
// fastdialer IP-level deny; either contains "denied" or "dropped".
errStr := err.Error()
require.True(t,
containsAny(errStr, "denied", "dropped", "not allowed", "excluded"),
"error %q should indicate a network-policy denial", errStr)
}
func containsAny(s string, subs ...string) bool {
for _, sub := range subs {
if len(s) >= len(sub) {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
}
}
return false
}
+12
View File
@@ -46,6 +46,10 @@ func IsRDP(ctx context.Context, host string, port int) (IsRDPResponse, error) {
func isRDP(ctx context.Context, executionId string, host string, port int) (IsRDPResponse, error) {
resp := IsRDPResponse{}
if !protocolstate.IsHostAllowed(executionId, host) {
return resp, protocolstate.ErrHostDenied.Msgf(host)
}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return IsRDPResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
@@ -105,6 +109,10 @@ func CheckRDPAuth(ctx context.Context, host string, port int) (CheckRDPAuthRespo
func checkRDPAuth(ctx context.Context, executionId string, host string, port int) (CheckRDPAuthResponse, error) {
resp := CheckRDPAuthResponse{}
if !protocolstate.IsHostAllowed(executionId, host) {
return resp, protocolstate.ErrHostDenied.Msgf(host)
}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return CheckRDPAuthResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
@@ -193,6 +201,10 @@ func CheckRDPEncryption(ctx context.Context, host string, port int) (RDPEncrypti
// @memo
func checkRDPEncryption(ctx context.Context, executionId string, host string, port int) (RDPEncryptionResponse, error) {
if !protocolstate.IsHostAllowed(executionId, host) {
return RDPEncryptionResponse{}, protocolstate.ErrHostDenied.Msgf(host)
}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return RDPEncryptionResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
+54
View File
@@ -0,0 +1,54 @@
package rdp
// Tests that IsHostAllowed is enforced by IsRDP, CheckRDPAuth and
// CheckRDPEncryption before any network dial is attempted.
import (
"context"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func setupPolicy(t *testing.T, excludedHosts ...string) string {
t.Helper()
execID := t.Name()
require.NoError(t, protocolstate.Init(&types.Options{
ExecutionId: execID,
ExcludeTargets: excludedHosts,
}))
t.Cleanup(func() { protocolstate.Close(execID) })
return execID
}
func ctxWithExec(execID string) context.Context {
return context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck
}
// TestIsRDPDeniesExcludedHost verifies that isRDP returns an error when the
// target host is in the --exclude-hosts list.
func TestIsRDPDeniesExcludedHost(t *testing.T) {
execID := setupPolicy(t, "203.0.113.50")
_, err := isRDP(ctxWithExec(execID), execID, "203.0.113.50", 3389)
require.Error(t, err, "isRDP must reject a host that is excluded by policy")
require.Contains(t, err.Error(), "203.0.113.50")
}
// TestCheckRDPAuthDeniesExcludedHost verifies that checkRDPAuth respects policy.
func TestCheckRDPAuthDeniesExcludedHost(t *testing.T) {
execID := setupPolicy(t, "203.0.113.50")
_, err := checkRDPAuth(ctxWithExec(execID), execID, "203.0.113.50", 3389)
require.Error(t, err, "checkRDPAuth must reject a host that is excluded by policy")
require.Contains(t, err.Error(), "203.0.113.50")
}
// TestCheckRDPEncryptionDeniesExcludedHost verifies that checkRDPEncryption
// respects policy before attempting any network connection.
func TestCheckRDPEncryptionDeniesExcludedHost(t *testing.T) {
execID := setupPolicy(t, "203.0.113.50")
_, err := checkRDPEncryption(ctxWithExec(execID), execID, "203.0.113.50", 3389)
require.Error(t, err, "checkRDPEncryption must reject a host that is excluded by policy")
require.Contains(t, err.Error(), "203.0.113.50")
}
+42
View File
@@ -0,0 +1,42 @@
package vnc
// Tests that the network policy is enforced by isVNC before any connection.
import (
"context"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
// TestIsVNCDeniesExcludedHost verifies that isVNC rejects a host that appears
// in the --exclude-hosts list.
func TestIsVNCDeniesExcludedHost(t *testing.T) {
execID := t.Name()
require.NoError(t, protocolstate.Init(&types.Options{
ExecutionId: execID,
ExcludeTargets: []string{"203.0.113.50"},
}))
t.Cleanup(func() { protocolstate.Close(execID) })
ctx := context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck
_, err := isVNC(ctx, execID, "203.0.113.50", 5900)
require.Error(t, err, "isVNC must reject a host excluded by network policy")
errStr := err.Error()
require.True(t,
containsAny(errStr, "denied", "dropped", "not allowed", "excluded"),
"error %q should indicate a network-policy denial", errStr)
}
func containsAny(s string, subs ...string) bool {
for _, sub := range subs {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
}
return false
}
+16
View File
@@ -37,6 +37,8 @@ import (
"github.com/projectdiscovery/utils/errkit"
fileutil "github.com/projectdiscovery/utils/file"
osutils "github.com/projectdiscovery/utils/os"
filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
unitutils "github.com/projectdiscovery/utils/unit"
urlutil "github.com/projectdiscovery/utils/url"
)
@@ -734,10 +736,24 @@ func (w *StandardWriter) WriteStoreDebugData(host, templateID, eventType string,
filename := sanitizeFileName(fmt.Sprintf("%s_%s", host, templateID))
subFolder := filepath.Join(w.storeResponseDir, sanitizeFileName(eventType))
// Defense-in-depth: verify the constructed sub-folder is still inside
// the configured store-response directory before creating it. The
// sanitizeFileName call above should prevent any traversal sequence from
// surviving, but an explicit containment check is a cheap second gate
// that will catch any future regression in the sanitizer.
if !filepathutil.IsPathWithinDirectory(subFolder, w.storeResponseDir) {
gologger.Error().Msgf("Computed debug sub-folder %s is outside store directory %s; skipping", subFolder, w.storeResponseDir)
return
}
if !fileutil.FolderExists(subFolder) {
_ = fileutil.CreateFolder(subFolder)
}
filename = filepath.Join(subFolder, fmt.Sprintf("%s.txt", filename))
// Second containment check for the final file path.
if !filepathutil.IsPathWithinDirectory(filename, w.storeResponseDir) {
gologger.Error().Msgf("Computed debug file path %s is outside store directory %s; skipping", filename, w.storeResponseDir)
return
}
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
gologger.Error().Msgf("Could not open debug output file: %s", err)
+104
View File
@@ -2,6 +2,8 @@ package output
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -239,3 +241,105 @@ func TestFormatJSONSanitizesNestedMetadataMaps(t *testing.T) {
require.NoError(t, err, "formatJSON should not fail on nested map[interface{}]interface{} (#7971)")
require.Contains(t, string(data), `"labels":{"en":"Hello","nested_list":[{"inner_key":"inner_val"}]}`)
}
// ---------------------------------------------------------------------------
// Path-traversal containment tests for sanitizeFileName and WriteStoreDebugData
// (CWE-22, defense-in-depth – mirrors the markdown exporter's regression suite)
// ---------------------------------------------------------------------------
// TestSanitizeFileNameContainment verifies that sanitizeFileName strips every
// character that could allow a path to escape its intended directory. The
// markdown exporter has an equivalent fuzz-driven suite; this test covers the
// same invariants for the output writer's sanitizer.
func TestSanitizeFileNameContainment(t *testing.T) {
reportDir := t.TempDir()
hostileInputs := []string{
"../../etc/passwd",
"..\\..\\Windows\\System32",
"..",
"http://evil.com/../../../etc/shadow",
"https://attacker.example/../../../../root/.ssh/authorized_keys",
"some/host/../../escape",
strings.Repeat("../", 200) + "etc/passwd",
strings.Repeat("..\\", 200) + "Windows",
"...",
"....",
".....",
"\x00/etc/passwd", // null byte (not a real traversal on Linux but should be safe)
"/../../foo",
"\\..\\..\\foo",
}
for _, in := range hostileInputs {
in := in // capture
t.Run(in, func(t *testing.T) {
out := sanitizeFileName(in)
// After sanitisation no path separator must survive.
require.False(t, strings.ContainsAny(out, "/\\"),
"sanitizeFileName leaked a separator: in=%q out=%q", in, out)
// No ".." parent-ref must survive either.
require.False(t, strings.Contains(out, ".."),
"sanitizeFileName leaked '..': in=%q out=%q", in, out)
// Joining the sanitised value with an arbitrary base must stay
// inside that base – the same invariant the exporter relies on.
joined := filepath.Clean(filepath.Join(reportDir, out))
rel, err := filepath.Rel(reportDir, joined)
require.NoError(t, err)
require.NotEqual(t, "..", rel)
require.False(t, strings.HasPrefix(rel, ".."+string(filepath.Separator)),
"sanitizeFileName: joined path escaped reportDir: in=%q out=%q rel=%q", in, out, rel)
})
}
}
// TestWriteStoreDebugDataContainment is an integration test that calls
// WriteStoreDebugData with attacker-shaped host / templateID values and verifies
// that no file is ever created outside the configured storeResponseDir.
// Without the containment guard added in this fix, a future regression in
// sanitizeFileName could silently allow a file to escape; this test would catch
// such a regression immediately.
func TestWriteStoreDebugDataContainment(t *testing.T) {
storeDir := t.TempDir()
w := &StandardWriter{
storeResponse: true,
storeResponseDir: storeDir,
}
hostilePairs := []struct{ host, templateID string }{
{"../../etc/passwd", "cve-2023-1234"},
{"..\\..\\Windows\\system32", "cve-2023-1234"},
{"http://evil.com/../../../etc/shadow", "poc"},
{"https://attacker.example/" + strings.Repeat("../", 50) + "root/.ssh", "recon"},
{"..", ".."},
{"/etc/passwd", "test"},
{"\\\\unc\\path\\..\\evil", "smb-test"},
}
for _, tc := range hostilePairs {
tc := tc
t.Run(tc.host+"|"+tc.templateID, func(t *testing.T) {
w.WriteStoreDebugData(tc.host, tc.templateID, "http", "debug-data")
})
}
// Walk storeDir and assert every file created is still inside it.
err := filepath.Walk(storeDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, relErr := filepath.Rel(storeDir, path)
if relErr != nil {
return relErr
}
if rel == "." {
return nil // the root itself
}
require.False(t, strings.HasPrefix(rel, ".."),
"WriteStoreDebugData created a file outside store dir: %s", path)
return nil
})
require.NoError(t, err)
}