mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-13 05:41:25 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af40c89fd |
No files matched your search
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user