mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-08 02:08:33 -10:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3066e87d49 | ||
|
|
2f9ec788d7 | ||
|
|
65af13f607 | ||
|
|
b63510f1eb | ||
|
|
85c362a8e2 | ||
|
|
f7bd7b7608 | ||
|
|
04ed27ddad | ||
|
|
0f42ab5cb3 | ||
|
|
44eb73b023 | ||
|
|
a51960362e | ||
|
|
611104a75f | ||
|
|
6d68c5e283 | ||
|
|
ed4fc56fcf | ||
|
|
52f6850cee | ||
|
|
f50c176a5e | ||
|
|
609e52afe0 | ||
|
|
df8b2bb15d | ||
|
|
c332c6dfa4 | ||
|
|
23c9443b73 | ||
|
|
437e7f3b90 | ||
|
|
77dc5e7b01 | ||
|
|
45ef8963f4 | ||
|
|
a55e66b25a | ||
|
|
596f0eb5ff | ||
|
|
c36ccf80b1 | ||
|
|
6777d4ea2d | ||
|
|
0f38656acc | ||
|
|
895ca210a9 | ||
|
|
08817012c5 | ||
|
|
57393657f8 | ||
|
|
d75f65928d | ||
|
|
5bab1c55bb | ||
|
|
f88573c3e7 | ||
|
|
6414b78429 | ||
|
|
3e5f413a20 |
No files matched your search
@@ -23,7 +23,7 @@ endif
|
||||
|
||||
.PHONY: all build build-stats clean devtools-all devtools-bindgen devtools-scrapefuncs fuzz fuzz-ci fuzz-tools
|
||||
.PHONY: devtools-tsgen docs docgen dsl-docs functional go-build lint lint-strict fuzzplayground syntax-docs
|
||||
.PHONY: integration integration-debug regression jsupdate-all jsupdate-bindgen jsupdate-tsgen memogen scan-charts test test-with-lint
|
||||
.PHONY: integration integration-debug regression jsupdate-all jsupdate-bindgen jsupdate-tsgen memogen scan-charts test test-with-lint test-sandbox-linux
|
||||
.PHONY: tidy ts verify download vet template-validate build-fuzz discover-fuzz-packages
|
||||
|
||||
all: build
|
||||
@@ -99,6 +99,11 @@ integration:
|
||||
integration-debug:
|
||||
$(GOTEST) -tags=integration ./internal/tests/integration -v $(GO_TEST_ARGS) -args $(INTEGRATION_ARGS)
|
||||
|
||||
# Runs the Landlock sandbox tests inside Linux Docker (macOS/Windows hosts).
|
||||
test-sandbox-linux:
|
||||
docker run --rm -e GOTOOLCHAIN=auto -v "$(PWD):/src" -w /src golang:bookworm \
|
||||
go test ./pkg/protocols/common/sandbox/... ./pkg/protocols/code/... -count=1 -run "Landlock|Apply|Supported|DisableSandbox"
|
||||
|
||||
# Opt-in HTTP engine scale regression harness (not part of CI). Stands up many
|
||||
# loopback hosts and asserts finding parity across a diverse template set.
|
||||
# Override host count with NUCLEI_SCALE_HOSTS, e.g. NUCLEI_SCALE_HOSTS=500 make regression
|
||||
|
||||
@@ -246,7 +246,9 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
|
||||
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
|
||||
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
|
||||
+3
-1
@@ -244,7 +244,9 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
|
||||
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
|
||||
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
|
||||
+3
-1
@@ -244,7 +244,9 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
|
||||
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
|
||||
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
|
||||
+3
-1
@@ -261,7 +261,9 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
|
||||
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
|
||||
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
|
||||
+3
-1
@@ -244,7 +244,9 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
|
||||
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
|
||||
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
|
||||
+3
-1
@@ -244,7 +244,9 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
|
||||
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
|
||||
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
|
||||
+3
-1
@@ -244,7 +244,9 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
|
||||
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
|
||||
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
|
||||
+3
-1
@@ -244,7 +244,9 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
|
||||
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
|
||||
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
|
||||
+3
-1
@@ -244,7 +244,9 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
|
||||
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
|
||||
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
|
||||
+8
-1
@@ -381,7 +381,9 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.BoolVar(&options.ZTLS, "ztls", false, "use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default"), //nolint:all
|
||||
flagSet.StringVar(&options.SNI, "sni", "", "tls sni hostname to use (default: input domain name)"),
|
||||
flagSet.DurationVarP(&options.DialerKeepAlive, "dialer-keep-alive", "dka", 0, "keep-alive duration for network requests."),
|
||||
flagSet.BoolVarP(&options.AllowLocalFileAccess, "allow-local-file-access", "lfa", false, "allow all templates in the run to read any file on the system (not just payloads)"),
|
||||
flagSet.BoolVarP(&options.AllowLocalFileAccess, "allow-local-file-access", "lfa", false, "allows local file access from additional allowed directories (cwd and --allowed-paths)"),
|
||||
flagSet.StringSliceVarP(&options.AllowedPaths, "allowed-paths", "ap", nil, "additional filesystem roots allowed with -lfa (repeatable)", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.BoolVar(&options.DisableSandbox, "no-sandbox", false, "disable the OS-level filesystem sandbox and the code template sandbox"),
|
||||
flagSet.BoolVarP(&options.RestrictLocalNetworkAccess, "restrict-local-network-access", "lna", false, "blocks connections to the local / private network"),
|
||||
flagSet.StringVarP(&options.Interface, "interface", "i", "", "network interface to use for network scan"),
|
||||
flagSet.StringVarP(&options.AttackType, "attack-type", "at", "", "type of payload combinations to perform (batteringram,pitchfork,clusterbomb)"),
|
||||
@@ -587,6 +589,11 @@ Additional documentation is available at: https://docs.nuclei.sh/getting-started
|
||||
resetCallback()
|
||||
}
|
||||
|
||||
// The CLI owns its process, so it applies the process-wide OS filesystem
|
||||
// sandbox by default. -no-sandbox (DisableSandbox) still turns it off. SDK
|
||||
// embedders do not get it unless they ask for it.
|
||||
options.EnableProcessSandbox = true
|
||||
|
||||
// when fuzz flag is enabled, set the dast flag to true
|
||||
if fuzzFlag {
|
||||
// backwards compatibility for fuzz flag
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
|
||||
const processSandboxChildEnv = "NUCLEI_PROCESS_SANDBOX_CHILD"
|
||||
|
||||
// TestCLIProcessSandboxWiring checks that the CLI turns the process-wide
|
||||
// sandbox on by default and that -no-sandbox turns it off. readConfig parses
|
||||
// os.Args and touches config dirs, so it runs in an isolated child process.
|
||||
func TestCLIProcessSandboxWiring(t *testing.T) {
|
||||
if os.Getenv(processSandboxChildEnv) == "1" {
|
||||
options = &types.Options{Logger: gologger.DefaultLogger}
|
||||
os.Args = append([]string{os.Args[0], "-disable-update-check"}, strings.Fields(os.Getenv("NUCLEI_TEST_ARGS"))...)
|
||||
readConfig()
|
||||
fmt.Printf("process-sandbox=%v\n", protocolinit.ProcessSandboxEnabled(options))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
args string
|
||||
want string
|
||||
}{
|
||||
{args: "", want: "process-sandbox=true"},
|
||||
{args: "-no-sandbox", want: "process-sandbox=false"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run("args="+test.args, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tempRoot := filepath.Join(root, "tmp")
|
||||
if err := os.Mkdir(tempRoot, 0o700); err != nil {
|
||||
t.Fatalf("create temporary root: %v", err)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestCLIProcessSandboxWiring$", "-test.count=1")
|
||||
cmd.Env = isolatedNucleiStorageEnv(root, tempRoot,
|
||||
processSandboxChildEnv+"=1",
|
||||
"NUCLEI_TEST_ARGS="+test.args,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("child failed: %v\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), test.want) {
|
||||
t.Fatalf("expected %q in child output:\n%s", test.want, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ require (
|
||||
github.com/jhump/protoreflect v1.17.0
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/kitabisa/go-ci v1.0.3
|
||||
github.com/landlock-lsm/go-landlock v0.9.0
|
||||
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/logrusorgru/aurora/v4 v4.0.0
|
||||
@@ -86,7 +87,7 @@ require (
|
||||
github.com/projectdiscovery/gologger v1.1.73
|
||||
github.com/projectdiscovery/gostruct v0.0.2
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20261026122302-786e195b6314
|
||||
github.com/projectdiscovery/hmap v0.0.102
|
||||
github.com/projectdiscovery/httpx v1.12.0
|
||||
github.com/projectdiscovery/interactsh v1.4.1
|
||||
@@ -467,6 +468,7 @@ require (
|
||||
gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
|
||||
mellium.im/sasl v0.3.2 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.2 // indirect
|
||||
)
|
||||
|
||||
@@ -773,6 +773,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/landlock-lsm/go-landlock v0.9.0 h1:2q8G8yx9Hsd5bV+R6PJfgQl0zszNxC8KO+SIqGwfxlw=
|
||||
github.com/landlock-lsm/go-landlock v0.9.0/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
@@ -959,8 +961,8 @@ github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBD
|
||||
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e h1:vxzgQlz2Cy/YvizYDQx9OhucBcmBotfDhbQ4yCY2vfA=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e/go.mod h1:xH7bPwHxUlz1yx9UlVeTF+UVCUaKhTnZgaxHb5z362E=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 h1:AN70bbi6BBs7KpIM9w0LxygUN7uzT/oH+owDIQ+Fz/k=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76/go.mod h1:cWHYnRXoYWHtTpOYyAp5laGYX8GH8ITUhgQaP8G/8FA=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20261026122302-786e195b6314 h1:LxsDXTuF19qS90pVc1/iLY0BNL9ZEyZjBU6g/K0RB/M=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20261026122302-786e195b6314/go.mod h1:RFHYWR70/YBV74h/0P2l0Crht6lHq8zhJfAluVg97OI=
|
||||
github.com/projectdiscovery/hmap v0.0.102 h1:ybPuf7UQvuOLiHLOUbVMvd+PSlItQU5iQpP1Erl8kaU=
|
||||
github.com/projectdiscovery/hmap v0.0.102/go.mod h1:IZ92wXFmxYpdn0euZ7VFmypzOBNDwq8c3yRyIIol6XE=
|
||||
github.com/projectdiscovery/httpx v1.12.0 h1:NN5iDrw7NjPBDlACdjac4lePTQmZ5aOmgOzptMpP44c=
|
||||
@@ -1743,6 +1745,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA=
|
||||
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24=
|
||||
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
|
||||
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
|
||||
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
|
||||
|
||||
@@ -294,6 +294,8 @@ func New(options *types.Options) (*Runner, error) {
|
||||
return nil, errors.Wrap(err, "could not create temporary directory")
|
||||
}
|
||||
runner.tmpDir = tmpDir
|
||||
// The per-scan temp dir is the only temp location templates may access.
|
||||
protocolstate.SetTemporaryDirectory(options.ExecutionId, tmpDir)
|
||||
|
||||
// Cleanup tmpDir only if initialization fails
|
||||
// On successful initialization, Close() method will handle cleanup
|
||||
@@ -537,6 +539,7 @@ func (r *Runner) Close() {
|
||||
}
|
||||
if r.tmpDir != "" {
|
||||
_ = os.RemoveAll(r.tmpDir)
|
||||
protocolstate.SetTemporaryDirectory(r.options.ExecutionId, "")
|
||||
}
|
||||
|
||||
//this is no-op unless nuclei is built with stats build tag
|
||||
|
||||
@@ -39,8 +39,8 @@ var codeTestCases = []integrationCase{
|
||||
{Path: "protocols/code/py-interactsh.yaml", TestCase: &codeSnippet{}, DisableOn: isCodeDisabled},
|
||||
{Path: "protocols/code/ps1-snippet.yaml", TestCase: &codeSnippet{}, DisableOn: func() bool { return !osutils.IsWindows() || isCodeDisabled() }},
|
||||
{Path: "protocols/code/pre-condition.yaml", TestCase: &codePreCondition{}, DisableOn: isCodeDisabled},
|
||||
{Path: "protocols/code/sh-virtual.yaml", TestCase: &codeSnippet{}, DisableOn: func() bool { return !osutils.IsLinux() || isCodeDisabled() || !hasAnyExecutable("docker", "podman") }, Serial: true},
|
||||
{Path: "protocols/code/py-virtual.yaml", TestCase: &codeSnippet{}, DisableOn: func() bool { return !osutils.IsLinux() || isCodeDisabled() || !hasAnyExecutable("docker", "podman") }, Serial: true},
|
||||
{Path: "protocols/code/sh-virtual.yaml", TestCase: &codeVirtualSnippet{}, DisableOn: func() bool { return !osutils.IsLinux() || isCodeDisabled() || !hasAnyExecutable("docker", "podman") }, Serial: true},
|
||||
{Path: "protocols/code/py-virtual.yaml", TestCase: &codeVirtualSnippet{}, DisableOn: func() bool { return !osutils.IsLinux() || isCodeDisabled() || !hasAnyExecutable("docker", "podman") }, Serial: true},
|
||||
{Path: "protocols/code/pwsh-echo.yaml", TestCase: &codeSnippet{}, DisableOn: func() bool { return isCodeDisabled() || !hasAnyExecutable("pwsh", "powershell", "powershell.exe") }},
|
||||
}
|
||||
|
||||
@@ -108,6 +108,12 @@ func ensureSignedIntegrationTemplates() error {
|
||||
}
|
||||
templatesToSign = append(templatesToSign, v.Path)
|
||||
}
|
||||
for _, v := range securityHardeningTestcases {
|
||||
if v.DisableOn != nil && v.DisableOn() {
|
||||
continue
|
||||
}
|
||||
templatesToSign = append(templatesToSign, v.Path)
|
||||
}
|
||||
for _, templatePath := range templatesToSign {
|
||||
if err := templates.SignTemplate(tsigner, fixturePath(templatePath)); err != nil {
|
||||
signedIntegrationTemplatesErr = err
|
||||
@@ -137,6 +143,16 @@ type codeSnippet struct{}
|
||||
|
||||
// Execute executes a test case and returns an error if occurred
|
||||
func (h *codeSnippet) Execute(filePath string) error {
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code", "-no-sandbox")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
type codeVirtualSnippet struct{}
|
||||
|
||||
func (h *codeVirtualSnippet) Execute(filePath string) error {
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -148,7 +164,7 @@ type codePreCondition struct{}
|
||||
|
||||
// Execute executes a test case and returns an error if occurred
|
||||
func (h *codePreCondition) Execute(filePath string) error {
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code", "-esc")
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code", "-no-sandbox", "-esc")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,7 +180,7 @@ type codeFile struct{}
|
||||
|
||||
// Execute executes a test case and returns an error if occurred
|
||||
func (h *codeFile) Execute(filePath string) error {
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code")
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code", "-no-sandbox")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -175,7 +191,7 @@ type codeEnvVar struct{}
|
||||
|
||||
// Execute executes a test case and returns an error if occurred
|
||||
func (h *codeEnvVar) Execute(filePath string) error {
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-V", "baz=baz", "-code")
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-V", "baz=baz", "-code", "-no-sandbox")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ func integrationFamilies() []integrationFamily {
|
||||
{Name: "generic", Cases: genericTestcases},
|
||||
{Name: "dsl", Cases: dslTestcases},
|
||||
{Name: "javascript", Cases: jsTestcases},
|
||||
{Name: "security-hardening", Cases: securityHardeningTestcases},
|
||||
{Name: "matcher-status", Cases: matcherStatusTestcases},
|
||||
{Name: "exporters", Cases: exportersTestCases},
|
||||
{Name: "profile-loader", Cases: profileLoaderTestcases},
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
var securityHardeningTestcases = []integrationCase{
|
||||
{Path: "protocols/javascript/fs-read-deny.yaml", TestCase: &javascriptFSReadDeny{}},
|
||||
{Path: "protocols/javascript/fs-read-deny-lfa.yaml", TestCase: &javascriptFSReadDenyWithLFA{}},
|
||||
{Path: "protocols/javascript/net-deny-excluded.yaml", TestCase: &javascriptNetDenyExcluded{}},
|
||||
{Path: "protocols/javascript/fs-read-allowed-paths.yaml", TestCase: &javascriptFSReadAllowedPaths{}},
|
||||
}
|
||||
|
||||
type javascriptFSReadDeny struct{}
|
||||
|
||||
func (j *javascriptFSReadDeny) Execute(filePath string) error {
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
type javascriptFSReadDenyWithLFA struct{}
|
||||
|
||||
func (j *javascriptFSReadDenyWithLFA) Execute(filePath string) error {
|
||||
denied := "/etc/passwd"
|
||||
if runtime.GOOS == "windows" {
|
||||
sys := os.Getenv("SystemRoot")
|
||||
if sys == "" {
|
||||
sys = `C:\Windows`
|
||||
}
|
||||
denied = filepath.Join(sys, "win.ini")
|
||||
}
|
||||
results, err := runSignedNucleiTemplateAndGetResults(
|
||||
filePath,
|
||||
"127.0.0.1",
|
||||
debug,
|
||||
"-allow-local-file-access",
|
||||
"-var", "DeniedPath="+denied,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
type javascriptNetDenyExcluded struct{}
|
||||
|
||||
func (j *javascriptNetDenyExcluded) Execute(filePath string) error {
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
type javascriptFSReadAllowedPaths struct{}
|
||||
|
||||
func (j *javascriptFSReadAllowedPaths) Execute(filePath string) error {
|
||||
grantedDir, err := os.MkdirTemp("", "nuclei-allowed-paths-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(grantedDir)
|
||||
|
||||
secretPath := filepath.Join(grantedDir, "secret.txt")
|
||||
if err := os.WriteFile(secretPath, []byte("granted-secret"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results, err := runSignedNucleiTemplateAndGetResults(
|
||||
filePath,
|
||||
"127.0.0.1",
|
||||
debug,
|
||||
"-allow-local-file-access",
|
||||
"-ap", grantedDir,
|
||||
"-var", "ReadPath="+secretPath,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
@@ -10,15 +10,12 @@ info:
|
||||
|
||||
code:
|
||||
- engine:
|
||||
- sh
|
||||
- bash
|
||||
- py
|
||||
- python3
|
||||
- python
|
||||
sandbox:
|
||||
working-dir: /tmp
|
||||
image: python:3.14
|
||||
source: |
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
print("hello from python virtual code")
|
||||
|
||||
matchers:
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
id: javascript-fs-read-allowed-paths
|
||||
|
||||
info:
|
||||
name: JavaScript FS Read Allowed Paths
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: reads a file granted via --allowed-paths
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const fs = require('nuclei/fs');
|
||||
let ok = false;
|
||||
let content = "";
|
||||
try {
|
||||
content = fs.ReadFileAsString(ReadPath);
|
||||
ok = content === "granted-secret";
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
}
|
||||
Export(to_json({ ok: ok, content: content }));
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
ReadPath: "{{ReadPath}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- success == true
|
||||
- type: word
|
||||
part: response
|
||||
condition: and
|
||||
words:
|
||||
- '"ok": true'
|
||||
- "granted-secret"
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
id: javascript-fs-read-deny-lfa
|
||||
|
||||
info:
|
||||
name: JavaScript FS Read Deny With LFA
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: confirms -lfa does not bypass absolute paths outside the allowlist
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const fs = require('nuclei/fs');
|
||||
let ok = true;
|
||||
let errMsg = "";
|
||||
try {
|
||||
fs.ReadFileAsString(DeniedPath);
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
errMsg = String(e);
|
||||
}
|
||||
Export(to_json({ ok: ok, err: errMsg }));
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
DeniedPath: "{{DeniedPath}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- success == true
|
||||
- type: word
|
||||
part: response
|
||||
condition: and
|
||||
words:
|
||||
- '"ok": false'
|
||||
- "outside allowed directories"
|
||||
@@ -0,0 +1,31 @@
|
||||
id: javascript-fs-read-deny
|
||||
|
||||
info:
|
||||
name: JavaScript FS Read Deny
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: attempts to read a file outside the sandbox allowlist
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const fs = require('nuclei/fs');
|
||||
let ok = true;
|
||||
let errMsg = "";
|
||||
try {
|
||||
fs.ReadFileAsString('/etc/passwd');
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
errMsg = String(e);
|
||||
}
|
||||
Export(to_json({ ok: ok, err: errMsg }));
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- success == true
|
||||
- type: word
|
||||
part: response
|
||||
words:
|
||||
- '"ok": false'
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
id: javascript-net-deny-excluded
|
||||
|
||||
info:
|
||||
name: JavaScript Net Deny Excluded Host
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: attempts to dial a host blocked by network policy
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const net = require('nuclei/net');
|
||||
let ok = true;
|
||||
let errMsg = "";
|
||||
try {
|
||||
net.Open('tcp', '203.0.113.10:445');
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
errMsg = String(e);
|
||||
}
|
||||
Export(to_json({ ok: ok, err: errMsg }));
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- success == true
|
||||
- type: word
|
||||
part: response
|
||||
condition: and
|
||||
words:
|
||||
- '"ok": false'
|
||||
- "denied address found"
|
||||
@@ -208,7 +208,7 @@ func (h *workflowCodeKeyValueShare) Execute(filePath string) error {
|
||||
// provide the Certificate File that the code templates are signed with
|
||||
certEnvVar := signer.CertEnvVarName + "=" + testCertFile
|
||||
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, []string{certEnvVar}, "-workflows", filePath, "-target", "input", "-code")
|
||||
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, []string{certEnvVar}, "-workflows", filePath, "-target", "input", "-code", "-no-sandbox")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -111,6 +111,12 @@ func WithExtraArgs(args ...string) RunnerOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithAllowLocalFileAccess(allow bool) RunnerOption {
|
||||
return func(r *Runner) {
|
||||
r.AllowLocalFileAccess = allow
|
||||
}
|
||||
}
|
||||
|
||||
var defaultRunner = NewRunner()
|
||||
|
||||
func DefaultRunner() *Runner {
|
||||
|
||||
@@ -73,6 +73,18 @@ For Various use cases like batching etc. you might want to run nuclei in gorouti
|
||||
defer ne.Close()
|
||||
```
|
||||
|
||||
## Filesystem Sandbox
|
||||
|
||||
The nuclei CLI applies an OS-level filesystem sandbox (landlock on Linux) to its whole process. The CLI turns it off with `-no-sandbox`.
|
||||
|
||||
The SDK does not apply it by default. Landlock confines the whole process and cannot be undone, so it would also confine your program. To apply it, pass `nuclei.EnableProcessSandbox()`:
|
||||
|
||||
```go
|
||||
ne, err := nuclei.NewNucleiEngineCtx(ctx, nuclei.EnableProcessSandbox())
|
||||
```
|
||||
|
||||
After the engine starts, your process can only use the nuclei roots (templates, config, cache, temp, outputs, the working directory and allowed paths) and the read-only system paths. Use it only when your process does nothing else on the filesystem. The option has no effect on platforms without landlock. Setting `DisableSandbox` in the engine options turns it off.
|
||||
|
||||
## More Documentation
|
||||
|
||||
For complete documentation of nuclei library, please refer to [godoc](https://pkg.go.dev/github.com/projectdiscovery/nuclei/v3/lib) which contains all available options and methods.
|
||||
|
||||
@@ -411,6 +411,22 @@ func WithSandboxOptions(allowLocalFileAccess bool, restrictLocalNetworkAccess bo
|
||||
}
|
||||
}
|
||||
|
||||
// EnableProcessSandbox applies the OS-level filesystem sandbox (landlock on
|
||||
// Linux) to the whole host process when the engine initializes, the same way
|
||||
// the nuclei CLI does by default. It is off for SDK users because the sandbox
|
||||
// is process-wide and irreversible: after it is applied, the host program can
|
||||
// only reach the nuclei allowlisted roots (templates, config, cache, temp,
|
||||
// outputs, cwd and allowed paths) and the read-only runtime paths, for the
|
||||
// rest of its life. Enable it only when the host process does nothing else
|
||||
// on the filesystem. It has no effect on platforms without landlock, and
|
||||
// DisableSandbox in the engine options still turns it off.
|
||||
func EnableProcessSandbox() NucleiSDKOptions {
|
||||
return func(e *NucleiEngine) error {
|
||||
e.opts.EnableProcessSandbox = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// EnableCodeTemplates allows loading/executing code protocol templates
|
||||
func EnableCodeTemplates() NucleiSDKOptions {
|
||||
return func(e *NucleiEngine) error {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package nuclei
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
|
||||
func TestProcessSandboxIsOptInForSDK(t *testing.T) {
|
||||
e := &NucleiEngine{opts: types.DefaultOptions()}
|
||||
if protocolinit.ProcessSandboxEnabled(e.opts) {
|
||||
t.Fatal("SDK default options enable the process-wide sandbox")
|
||||
}
|
||||
|
||||
if err := EnableProcessSandbox()(e); err != nil {
|
||||
t.Fatalf("EnableProcessSandbox: %v", err)
|
||||
}
|
||||
if !protocolinit.ProcessSandboxEnabled(e.opts) {
|
||||
t.Fatal("EnableProcessSandbox did not enable the process-wide sandbox")
|
||||
}
|
||||
|
||||
e.opts.DisableSandbox = true
|
||||
if protocolinit.ProcessSandboxEnabled(e.opts) {
|
||||
t.Fatal("DisableSandbox did not override EnableProcessSandbox")
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hosterrorscache"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
@@ -279,6 +280,9 @@ func (e *NucleiEngine) closeInternal() {
|
||||
}
|
||||
if e.tmpDir != "" {
|
||||
_ = os.RemoveAll(e.tmpDir)
|
||||
if e.opts != nil {
|
||||
protocolstate.SetTemporaryDirectory(e.opts.ExecutionId, "")
|
||||
}
|
||||
}
|
||||
if e.opts != nil {
|
||||
generators.ClearOptionsPayloadMap(e.opts)
|
||||
|
||||
@@ -271,6 +271,8 @@ func (e *NucleiEngine) init(ctx context.Context) error {
|
||||
}
|
||||
e.tmpDir = tmpDir
|
||||
}
|
||||
// The per-scan temp dir is the only temp location templates may access.
|
||||
protocolstate.SetTemporaryDirectory(e.opts.ExecutionId, e.tmpDir)
|
||||
|
||||
e.executerOpts = &protocols.ExecutorOptions{
|
||||
Output: e.customWriter,
|
||||
|
||||
@@ -46,13 +46,27 @@ func TestNewCompilerConsoleDebug(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func outsideModulePath(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
// os.UserHomeDir (not os.Getenv("HOME")) so this resolves to an absolute,
|
||||
// writable location outside the templates allowlist on every OS. HOME is
|
||||
// unset on GitHub Actions windows-latest, which would otherwise collapse to
|
||||
// a relative path resolved under the templates dir and void the denial test.
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
outsideDir := filepath.Join(home, ".nuclei-test-outside-"+t.Name())
|
||||
require.NoError(t, os.MkdirAll(outsideDir, 0o700))
|
||||
t.Cleanup(func() { _ = os.RemoveAll(outsideDir) })
|
||||
return writeModuleFile(t, outsideDir, name, `module.exports = { value: "outside-secret" };`)
|
||||
}
|
||||
|
||||
func TestRequireLocalFileAccessDenied(t *testing.T) {
|
||||
modulePath := writeModuleFile(t, t.TempDir(), "outside.js", `module.exports = { value: "outside-secret" };`)
|
||||
modulePath := outsideModulePath(t, "outside.js")
|
||||
script := fmt.Sprintf(`var helper = require(%q); ExportAs("value", helper.value); true;`, modulePath)
|
||||
|
||||
result, err := executeScript(t, t.Name(), false, script)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "-lfa is not enabled")
|
||||
require.Contains(t, err.Error(), "outside")
|
||||
require.Equal(t, err.Error(), result["error"])
|
||||
}
|
||||
|
||||
@@ -74,7 +88,13 @@ func TestRequireTemplateModuleAllowedWithoutLFA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireLocalFileAccessAllowed(t *testing.T) {
|
||||
modulePath := writeModuleFile(t, t.TempDir(), "outside.js", `module.exports = { value: "outside-ok" };`)
|
||||
// -lfa grants the working directory; the system temp dir is never granted.
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
moduleDir := filepath.Join(cwd, ".nuclei-module-lfa-test-"+t.Name())
|
||||
require.NoError(t, os.MkdirAll(moduleDir, 0o700))
|
||||
t.Cleanup(func() { _ = os.RemoveAll(moduleDir) })
|
||||
modulePath := writeModuleFile(t, moduleDir, "outside.js", `module.exports = { value: "outside-ok" };`)
|
||||
script := fmt.Sprintf(`var helper = require(%q); ExportAs("value", helper.value); true;`, modulePath)
|
||||
|
||||
result, err := executeScript(t, t.Name(), true, script)
|
||||
@@ -83,7 +103,12 @@ func TestRequireLocalFileAccessAllowed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireDoesNotReusePrivilegedModuleCacheAcrossExecutions(t *testing.T) {
|
||||
modulePath := writeModuleFile(t, t.TempDir(), "outside.js", `module.exports = { value: "outside-ok" };`)
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
moduleDir := filepath.Join(cwd, ".nuclei-module-cache-test-"+t.Name())
|
||||
require.NoError(t, os.MkdirAll(moduleDir, 0o700))
|
||||
t.Cleanup(func() { _ = os.RemoveAll(moduleDir) })
|
||||
modulePath := writeModuleFile(t, moduleDir, "outside.js", `module.exports = { value: "outside-ok" };`)
|
||||
program, err := goja.Compile("", fmt.Sprintf(`require(%q).value`, modulePath), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -103,7 +128,7 @@ func TestRequireDoesNotReusePrivilegedModuleCacheAcrossExecutions(t *testing.T)
|
||||
ExecutionId: denyExecutionID,
|
||||
}, nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "-lfa is not enabled")
|
||||
require.Contains(t, err.Error(), "outside")
|
||||
}
|
||||
|
||||
func TestExecuteWithRuntimeCleansUpAfterCallbackPanic(t *testing.T) {
|
||||
|
||||
@@ -211,7 +211,7 @@ func (c *Client) RpcDump(ctx context.Context) ([]Endpoint, error) {
|
||||
if dialer == nil {
|
||||
return nil, fmt.Errorf("dialers not initialized for execution %s", c.nj.ExecutionId())
|
||||
}
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(c.Host, strconv.Itoa(135)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, c.nj.ExecutionId(), "tcp", net.JoinHostPort(c.Host, strconv.Itoa(135)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial epmapper: %w", err)
|
||||
}
|
||||
|
||||
+10
-7
@@ -2,9 +2,11 @@ package fs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
|
||||
// ListDir lists itemType values within a directory
|
||||
@@ -29,7 +31,10 @@ import (
|
||||
// const items = fs.ListDir('/tmp');
|
||||
// ```
|
||||
func ListDir(ctx context.Context, path string, itemType string) ([]string, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return nil, fmt.Errorf("fs: missing executionId")
|
||||
}
|
||||
finalPath, err := protocolstate.NormalizePathWithExecutionId(executionId, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -60,13 +65,11 @@ func ListDir(ctx context.Context, path string, itemType string) ([]string, error
|
||||
// const content = fs.ReadFile('helpers/usernames.txt');
|
||||
// ```
|
||||
func ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
finalPath, err := protocolstate.NormalizePathWithExecutionId(executionId, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return nil, fmt.Errorf("fs: missing executionId")
|
||||
}
|
||||
bin, err := os.ReadFile(finalPath)
|
||||
return bin, err
|
||||
return protocolstate.ReadFileAllowed(&types.Options{ExecutionId: executionId}, path)
|
||||
}
|
||||
|
||||
// ReadFileAsString reads file contents within permitted paths
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gologger/levels"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadFileAllowsTemplatesPath(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
templatesDir := t.TempDir()
|
||||
secret := []byte("fs-module-secret")
|
||||
path := filepath.Join(templatesDir, "allowed.txt")
|
||||
require.NoError(t, os.WriteFile(path, secret, 0o600))
|
||||
|
||||
old := config.DefaultConfig.TemplatesDirectory
|
||||
config.DefaultConfig.SetTemplatesDir(templatesDir)
|
||||
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
|
||||
|
||||
executionID := t.Name()
|
||||
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
|
||||
ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck
|
||||
|
||||
got, err := ReadFile(ctx, path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secret, got)
|
||||
}
|
||||
|
||||
func TestReadFileRejectsPathOutsideAllowlist(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
templatesDir := t.TempDir()
|
||||
old := config.DefaultConfig.TemplatesDirectory
|
||||
config.DefaultConfig.SetTemplatesDir(templatesDir)
|
||||
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
outside := filepath.Join(home, ".nuclei-fs-module-"+t.Name(), "secret.txt")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(outside), 0o700))
|
||||
require.NoError(t, os.WriteFile(outside, []byte("x"), 0o600))
|
||||
t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(outside)) })
|
||||
|
||||
executionID := t.Name()
|
||||
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
|
||||
ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck
|
||||
|
||||
_, err = ReadFile(ctx, outside)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListDirRejectsOutsideAllowlist(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
templatesDir := t.TempDir()
|
||||
old := config.DefaultConfig.TemplatesDirectory
|
||||
config.DefaultConfig.SetTemplatesDir(templatesDir)
|
||||
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
|
||||
|
||||
executionID := t.Name()
|
||||
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
|
||||
ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck
|
||||
|
||||
_, err := ListDir(ctx, "/etc", "file")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -41,7 +41,8 @@ func NewExecDialer(execID string) *gptr.Dialer {
|
||||
}
|
||||
}
|
||||
|
||||
// DialWithExec performs the fastdialer dial after enforcing host policy.
|
||||
// DialWithExec performs the fastdialer dial after enforcing host policy via the
|
||||
// centralized dial broker (fail-closed when dialers are missing).
|
||||
func DialWithExec(ctx context.Context, execID, network, address string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
@@ -50,11 +51,7 @@ func DialWithExec(ctx context.Context, execID, network, address string) (net.Con
|
||||
if !protocolstate.IsHostAllowed(execID, host) {
|
||||
return nil, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
dialer := protocolstate.GetDialersWithId(execID)
|
||||
if dialer == nil || dialer.Fastdialer == nil {
|
||||
return nil, fmt.Errorf("goimpacket: no fastdialer registered for executionId %q", execID)
|
||||
}
|
||||
return dialer.Fastdialer.Dial(ctx, network, address)
|
||||
return protocolstate.DialAllowedWithExecutionID(ctx, execID, network, address)
|
||||
}
|
||||
|
||||
// ExecutionIDFromCtx pulls the executionId set by nuclei on its goja runtime
|
||||
|
||||
@@ -193,11 +193,25 @@ func TestProtosetOutsideTemplatesDirDenied(t *testing.T) {
|
||||
setTemplateDir(t)
|
||||
executionID := initExec(t, &types.Options{AllowLocalFileAccess: false})
|
||||
|
||||
// write the protoset outside the templates directory
|
||||
outside := filepath.Join(t.TempDir(), "health.protoset")
|
||||
// t.TempDir() lives under os.TempDir(), which is always in the filesystem
|
||||
// allowlist. Create a unique directory under $HOME and skip if HOME itself
|
||||
// is inside that allowlist.
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
absHome, err := filepath.Abs(home)
|
||||
require.NoError(t, err)
|
||||
absTemp, err := filepath.Abs(os.TempDir())
|
||||
require.NoError(t, err)
|
||||
if strings.HasPrefix(absHome+string(os.PathSeparator), absTemp+string(os.PathSeparator)) || absHome == absTemp {
|
||||
t.Skip("$HOME is under the temp root, so it is already allowlisted")
|
||||
}
|
||||
outsideDir, err := os.MkdirTemp(home, "nuclei-grpc-outside-*")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(outsideDir) })
|
||||
outside := filepath.Join(outsideDir, "health.protoset")
|
||||
require.NoError(t, os.WriteFile(outside, healthProtoset(t), 0o600))
|
||||
|
||||
_, _, err := descriptorSource(context.Background(), executionID, nil, outside)
|
||||
_, _, err = descriptorSource(context.Background(), executionID, nil, outside)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "protoset path denied")
|
||||
}
|
||||
|
||||
+11
-14
@@ -4,10 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fullstorydev/grpcurl"
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"google.golang.org/protobuf/types/descriptorpb"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
|
||||
// connConfig holds the low level knobs used to build a gRPC client connection.
|
||||
@@ -34,7 +35,7 @@ type connConfig struct {
|
||||
|
||||
// dialTarget builds a *grpc.ClientConn whose every connection is routed through
|
||||
// nuclei's network policy. The host is validated up front and the actual dial
|
||||
// is delegated to the execution's fastdialer via a custom context dialer, so
|
||||
// is delegated to the execution's fastdialer via DialAllowedWithExecutionID, so
|
||||
// IP/host denylists and RestrictLocalNetworkAccess are always enforced. The
|
||||
// passthrough scheme guarantees the target is handed verbatim to our dialer
|
||||
// (instead of gRPC's built in DNS resolver), keeping resolution and policy
|
||||
@@ -53,13 +54,9 @@ func dialTarget(ctx context.Context, executionID, target string, cfg connConfig)
|
||||
if !protocolstate.IsHostAllowed(executionID, host) {
|
||||
return nil, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
dialers := protocolstate.GetDialersWithId(executionID)
|
||||
if dialers == nil || dialers.Fastdialer == nil {
|
||||
return nil, fmt.Errorf("grpc: dialers not initialized for executionId %q", executionID)
|
||||
}
|
||||
|
||||
contextDialer := func(dialCtx context.Context, addr string) (net.Conn, error) {
|
||||
return dialers.Fastdialer.Dial(dialCtx, "tcp", addr)
|
||||
return protocolstate.DialAllowedWithExecutionID(dialCtx, executionID, "tcp", addr)
|
||||
}
|
||||
|
||||
var creds credentials.TransportCredentials
|
||||
@@ -95,14 +92,14 @@ func dialTarget(ctx context.Context, executionID, target string, cfg connConfig)
|
||||
func descriptorSource(ctx context.Context, executionID string, cc *grpc.ClientConn, protosetFile string) (grpcurl.DescriptorSource, func(), error) {
|
||||
noop := func() {}
|
||||
if strings.TrimSpace(protosetFile) != "" {
|
||||
// resolve through the local-file-access allowlist: unless -lfa is set,
|
||||
// only files inside the nuclei-templates directory are permitted.
|
||||
normalized, err := protocolstate.NormalizePathWithExecutionId(executionID, protosetFile)
|
||||
if err != nil {
|
||||
return nil, noop, fmt.Errorf("protoset path denied: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(normalized)
|
||||
// resolve + read through the filesystem allowlist: unless -lfa /
|
||||
// --allowed-paths expand it, only templates/config/temp roots are
|
||||
// permitted.
|
||||
data, err := protocolstate.ReadFileAllowed(&types.Options{ExecutionId: executionID}, protosetFile)
|
||||
if err != nil {
|
||||
if errors.Is(err, protocolstate.ErrPathDenied) {
|
||||
return nil, noop, fmt.Errorf("protoset path denied: %w", err)
|
||||
}
|
||||
return nil, noop, fmt.Errorf("failed to read protoset file: %w", err)
|
||||
}
|
||||
fds := &descriptorpb.FileDescriptorSet{}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package jslibs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSLibsDoNotDialFastdialerDirectly(t *testing.T) {
|
||||
root := ".."
|
||||
var offenders []string
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != root && d.Name() == "guard" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := string(data)
|
||||
if strings.Contains(content, "Fastdialer.Dial") || strings.Contains(content, "Fastdialer.DialTLS") {
|
||||
offenders = append(offenders, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
requireNoError(t, err)
|
||||
if len(offenders) > 0 {
|
||||
t.Fatalf("js libs must dial via protocolstate.DialAllowed, offenders: %v", offenders)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package jslibs
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// forbiddenSelectors is keyed by the package *identifier* used at the call
|
||||
// site, not the import path. `import "os/exec"` binds the identifier `exec`, so
|
||||
// a direct `exec.Command(...)` yields the selector key "exec.Command" — the
|
||||
// import path form "os/exec.Command" would never match.
|
||||
var forbiddenSelectors = map[string]struct{}{
|
||||
"net.Dial": {},
|
||||
"net.Dialer": {},
|
||||
"exec.Command": {},
|
||||
"os.Open": {},
|
||||
"os.ReadFile": {},
|
||||
"os.WriteFile": {},
|
||||
}
|
||||
|
||||
func TestJSLibsDoNotUseForbiddenSyscalls(t *testing.T) {
|
||||
root := ".."
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != root && d.Name() == "guard" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Inspect every selector expression (pkg.Name), not just call
|
||||
// expressions: this catches type references such as net.Dialer and
|
||||
// composite literals like net.Dialer{}, not only direct calls.
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
pkg, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
key := pkg.Name + "." + sel.Sel.Name
|
||||
if _, blocked := forbiddenSelectors[key]; blocked {
|
||||
t.Fatalf("%s uses forbidden selector %s", path, key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
requireNoError(t, err)
|
||||
}
|
||||
|
||||
func requireNoError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -269,11 +269,6 @@ func (c *Client) do(ctx context.Context, method, rawURL, body string) (*Response
|
||||
return nil, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
|
||||
dialers := protocolstate.GetDialersWithId(executionID)
|
||||
if dialers == nil {
|
||||
return nil, fmt.Errorf("dialers not initialized for %s", executionID)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
MinVersion: tls.VersionTLS10,
|
||||
@@ -290,10 +285,14 @@ func (c *Client) do(ctx context.Context, method, rawURL, body string) (*Response
|
||||
transport := &http.Transport{
|
||||
Proxy: proxy,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialers.Fastdialer.Dial(ctx, network, addr)
|
||||
return protocolstate.DialAllowedWithExecutionID(ctx, executionID, network, addr)
|
||||
},
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialers.Fastdialer.DialTLSWithConfig(ctx, network, addr, tlsConfig)
|
||||
cfg := tlsConfig.Clone()
|
||||
if h, _, err := net.SplitHostPort(addr); err == nil && h != "" && net.ParseIP(h) == nil {
|
||||
cfg.ServerName = h
|
||||
}
|
||||
return protocolstate.DialTLSAllowedWithExecutionID(ctx, executionID, network, addr, cfg)
|
||||
},
|
||||
TLSClientConfig: tlsConfig,
|
||||
ForceAttemptHTTP2: true,
|
||||
|
||||
@@ -81,7 +81,7 @@ func sendToKDCTcp(kclient *Client, msg string) ([]byte, error) {
|
||||
// use that ip address instead of realm/domain for resolving
|
||||
host = kclient.config.ip
|
||||
}
|
||||
tcpConn, err := dialers.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, port))
|
||||
tcpConn, err := protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, port))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("error establishing connection to %s: %v", kdcs[i], err))
|
||||
continue
|
||||
@@ -122,7 +122,7 @@ func sendToKDCUdp(kclient *Client, msg string) ([]byte, error) {
|
||||
// use that ip address instead of realm/domain for resolving
|
||||
host = kclient.config.ip
|
||||
}
|
||||
udpConn, err := dialers.Fastdialer.Dial(dialCtx, "udp", net.JoinHostPort(host, port))
|
||||
udpConn, err := protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "udp", net.JoinHostPort(host, port))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("error establishing connection to %s: %v", kdcs[i], err))
|
||||
continue
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/goja"
|
||||
@@ -19,7 +20,10 @@ func TestCreateTicketDefaultOutput(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("golden=%v/%s", golden, location), func(t *testing.T) {
|
||||
templatesDir := setTemplateDir(t)
|
||||
cwd := templatesDir
|
||||
if location != "sandbox" {
|
||||
switch location {
|
||||
case "outside denied":
|
||||
cwd = outsideAllowedDir(t)
|
||||
case "outside allowed":
|
||||
cwd = t.TempDir()
|
||||
}
|
||||
t.Chdir(cwd)
|
||||
@@ -107,7 +111,7 @@ func TestCreateTicketRejectsDefaultOutputSymlink(t *testing.T) {
|
||||
t.Chdir(cwd)
|
||||
executionID := t.Name()
|
||||
setLocalFileAccess(executionID, false)
|
||||
outside := filepath.Join(t.TempDir(), "ticket.ccache")
|
||||
outside := filepath.Join(outsideAllowedDir(t), "ticket.ccache")
|
||||
require.NoError(t, os.WriteFile(outside, []byte("unchanged"), 0600))
|
||||
if err := os.Symlink(outside, filepath.Join(cwd, "Administrator.ccache")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
@@ -142,7 +146,7 @@ func TestCreateTicketRejectsDefaultOutputHardLink(t *testing.T) {
|
||||
|
||||
func TestCreateGoldenTicketDeniesDefaultOutputFromRuntime(t *testing.T) {
|
||||
setTemplateDir(t)
|
||||
cwd := t.TempDir()
|
||||
cwd := outsideAllowedDir(t)
|
||||
t.Chdir(cwd)
|
||||
executionID := t.Name()
|
||||
setLocalFileAccess(executionID, false)
|
||||
@@ -177,6 +181,27 @@ func TestBuildConfigPreservesIdentity(t *testing.T) {
|
||||
require.Equal(t, req.Domain, cfg.Domain)
|
||||
}
|
||||
|
||||
// outsideAllowedDir returns a new directory outside the file allowlist. The
|
||||
// temp dir is always allowlisted, so t.TempDir() cannot be used for this.
|
||||
func outsideAllowedDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
t.Skip("no home directory to create a path outside the allowlist")
|
||||
}
|
||||
absHome, err := filepath.Abs(home)
|
||||
require.NoError(t, err)
|
||||
absTemp, err := filepath.Abs(os.TempDir())
|
||||
require.NoError(t, err)
|
||||
if absHome == absTemp || strings.HasPrefix(absHome+string(os.PathSeparator), absTemp+string(os.PathSeparator)) {
|
||||
t.Skip("$HOME is under the temp root, so it is already allowlisted")
|
||||
}
|
||||
dir, err := os.MkdirTemp(home, "nuclei-krbforge-outside-*")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
func setTemplateDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
templatesDir := t.TempDir()
|
||||
|
||||
@@ -110,7 +110,7 @@ func NewClient(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
|
||||
if u.Path == "" || u.Path == "/" {
|
||||
u.Path = "/var/run/slapd/ldapi"
|
||||
}
|
||||
conn, err = dialers.Fastdialer.Dial(dialCtx, "unix", u.Path)
|
||||
conn, err = protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "unix", u.Path)
|
||||
c.nj.HandleError(err, "failed to connect to ldap server")
|
||||
} else {
|
||||
switch u.Scheme {
|
||||
@@ -124,12 +124,12 @@ func NewClient(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
|
||||
if port == "" {
|
||||
port = ldap.DefaultLdapPort
|
||||
}
|
||||
conn, err = dialers.Fastdialer.Dial(dialCtx, "udp", net.JoinHostPort(host, port))
|
||||
conn, err = protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "udp", net.JoinHostPort(host, port))
|
||||
case "ldap":
|
||||
if port == "" {
|
||||
port = ldap.DefaultLdapPort
|
||||
}
|
||||
conn, err = dialers.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, port))
|
||||
conn, err = protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, port))
|
||||
case "ldaps":
|
||||
if port == "" {
|
||||
port = ldap.DefaultLdapsPort
|
||||
@@ -138,7 +138,7 @@ func NewClient(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
|
||||
if c.cfg.ServerName != "" {
|
||||
serverName = c.cfg.ServerName
|
||||
}
|
||||
conn, err = dialers.Fastdialer.DialTLSWithConfig(dialCtx, "tcp", net.JoinHostPort(host, port),
|
||||
conn, err = protocolstate.DialTLSAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, port),
|
||||
&tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS10, ServerName: serverName})
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -91,7 +91,10 @@ type (
|
||||
// log(to_json(info));
|
||||
// ```
|
||||
func (c *MSSQLClient) FingerprintMssql(ctx context.Context, host string, port int) (MSSQLInfo, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return MSSQLInfo{}, fmt.Errorf("mssql: missing executionId")
|
||||
}
|
||||
return memoizedfingerprintMssql(ctx, executionId, host, port)
|
||||
}
|
||||
|
||||
@@ -109,12 +112,8 @@ func fingerprintMssql(ctx context.Context, executionId string, host string, port
|
||||
if !protocolstate.IsHostAllowed(executionId, host) {
|
||||
return info, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return info, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
@@ -189,5 +189,5 @@ func mssqlConnString(target, username, password, dbName string) string {
|
||||
url.PathEscape(username),
|
||||
url.PathEscape(password),
|
||||
target,
|
||||
url.QueryEscape(dbName))
|
||||
protocolstate.SanitizeMSSQLDatabaseName(dbName))
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -131,12 +132,7 @@ func fingerprintMySQL(ctx context.Context, executionId string, host string, port
|
||||
// host is not valid according to network policy
|
||||
return info, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return MySQLInfo{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
@@ -220,6 +216,14 @@ func (c *MySQLClient) ExecuteQueryWithOpts(ctx context.Context, opts MySQLOption
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// allowAllFiles is always stripped from the DSN; LOAD DATA LOCAL INFILE
|
||||
// only works for allowlisted files the query names.
|
||||
release, err := protocolstate.RegisterMySQLLocalInfiles(&types.Options{ExecutionId: executionId}, dsn, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
db, err := openDB(executionId, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
@@ -76,24 +76,9 @@ func BuildDSN(opts MySQLOptions) (string, error) {
|
||||
return dsn.String(), nil
|
||||
}
|
||||
|
||||
// sandboxDSN enforces the local file access sandbox on a MySQL DSN. The
|
||||
// driver's allowAllFiles option lets a malicious server read any local file
|
||||
// off the host via LOAD DATA LOCAL INFILE, so it is only honored when -lfa is
|
||||
// enabled, mirroring the fs.ReadFile restriction.
|
||||
func sandboxDSN(dsn string, lfaAllowed bool) (string, error) {
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cfg.AllowAllFiles && !lfaAllowed {
|
||||
cfg.AllowAllFiles = false
|
||||
}
|
||||
return cfg.FormatDSN(), nil
|
||||
}
|
||||
|
||||
// openDB opens a sandboxed MySQL connection from dsn.
|
||||
func openDB(executionId, dsn string) (*sql.DB, error) {
|
||||
dsn, err := sandboxDSN(dsn, protocolstate.IsLfaAllowed(&types.Options{ExecutionId: executionId}))
|
||||
opts := &types.Options{ExecutionId: executionId}
|
||||
dsn, err := protocolstate.SanitizeMySQLDSN(dsn, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSandboxDSN(t *testing.T) {
|
||||
t.Run("strips allowAllFiles when lfa disabled", func(t *testing.T) {
|
||||
got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", false)
|
||||
got, err := protocolstate.SanitizeMySQLDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", &types.Options{})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := mysql.ParseDSN(got)
|
||||
@@ -17,17 +19,17 @@ func TestSandboxDSN(t *testing.T) {
|
||||
require.False(t, cfg.AllowAllFiles)
|
||||
})
|
||||
|
||||
t.Run("keeps allowAllFiles when lfa enabled", func(t *testing.T) {
|
||||
got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", true)
|
||||
t.Run("strips allowAllFiles when lfa enabled", func(t *testing.T) {
|
||||
got, err := protocolstate.SanitizeMySQLDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", &types.Options{AllowLocalFileAccess: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := mysql.ParseDSN(got)
|
||||
require.NoError(t, err)
|
||||
require.True(t, cfg.AllowAllFiles)
|
||||
require.False(t, cfg.AllowAllFiles, "-lfa must never disable the allowlist")
|
||||
})
|
||||
|
||||
t.Run("leaves dsn without allowAllFiles untouched", func(t *testing.T) {
|
||||
got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/", false)
|
||||
got, err := protocolstate.SanitizeMySQLDSN("root:x@nucleitcp(127.0.0.1:3306)/", &types.Options{})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := mysql.ParseDSN(got)
|
||||
@@ -36,7 +38,7 @@ func TestSandboxDSN(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("errors on invalid dsn", func(t *testing.T) {
|
||||
_, err := sandboxDSN("::not-a-dsn::", false)
|
||||
_, err := protocolstate.SanitizeMySQLDSN("::not-a-dsn::", &types.Options{})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
+3
-12
@@ -192,13 +192,13 @@ func Dial(ctx context.Context, protocol, address string) (net.Conn, error) {
|
||||
if !protocolstate.IsHostAllowed(executionId, dialTargetHost(address)) {
|
||||
return nil, fmt.Errorf("host %s is not allowed by network policy", address)
|
||||
}
|
||||
return dialHTTPProxy(ctx, dialer.Fastdialer.Dial, proxyURL, address, getTimeoutFromContext(ctx))
|
||||
return dialHTTPProxy(ctx, protocolstate.DialAllowed, proxyURL, address, getTimeoutFromContext(ctx))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme %q in %s (expected http or https)", parsed.Scheme, redactProxyURL(proxyURL))
|
||||
}
|
||||
}
|
||||
|
||||
return dialer.Fastdialer.Dial(ctx, protocol, address)
|
||||
return protocolstate.DialAllowed(ctx, protocol, address)
|
||||
}
|
||||
|
||||
// Open opens a new connection to the address with a timeout.
|
||||
@@ -233,7 +233,6 @@ func OpenTLS(ctx context.Context, protocol, address string) (*NetConn, error) {
|
||||
c.ServerName = host
|
||||
config = c
|
||||
}
|
||||
|
||||
if proxyURLFromContext(ctx) != "" {
|
||||
conn, err := Dial(ctx, protocol, address)
|
||||
if err != nil {
|
||||
@@ -247,15 +246,7 @@ func OpenTLS(ctx context.Context, protocol, address string) (*NetConn, error) {
|
||||
return &NetConn{conn: tlsConn, timeout: timeout}, nil
|
||||
}
|
||||
|
||||
executionId, err := executionIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return nil, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
conn, err := dialer.Fastdialer.DialTLSWithConfig(ctx, protocol, address, config)
|
||||
conn, err := protocolstate.DialTLSAllowed(ctx, protocol, address, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,15 +5,14 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/praetorian-inc/fingerprintx/pkg/plugins"
|
||||
"github.com/praetorian-inc/fingerprintx/pkg/plugins/services/oracledb"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
goora "github.com/sijms/go-ora/v2"
|
||||
)
|
||||
|
||||
@@ -56,14 +55,9 @@ func (c *OracleClient) IsOracle(ctx context.Context, host string, port int) (IsO
|
||||
// @memo
|
||||
func isOracle(ctx context.Context, executionId string, host string, port int) (IsOracleResponse, error) {
|
||||
resp := IsOracleResponse{}
|
||||
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return IsOracleResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
timeout := 5 * time.Second
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
@@ -86,7 +80,8 @@ func isOracle(ctx context.Context, executionId string, host string, port int) (I
|
||||
}
|
||||
|
||||
func (c *OracleClient) oracleDbInstance(ctx context.Context, connStr string, executionId string) (*goora.OracleConnector, error) {
|
||||
connStr, err := sandboxDSN(executionId, connStr)
|
||||
opts := &types.Options{ExecutionId: executionId}
|
||||
connStr, err := protocolstate.SanitizeOracleDSN(executionId, connStr, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -107,48 +102,6 @@ func (c *OracleClient) oracleDbInstance(ctx context.Context, connStr string, exe
|
||||
return c.connector, nil
|
||||
}
|
||||
|
||||
func sandboxDSN(executionId string, dsn string) (string, error) {
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
changed := false
|
||||
for key, values := range query {
|
||||
if !isOracleTracePathOption(key) {
|
||||
continue
|
||||
}
|
||||
for i, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
normalized, err := protocolstate.NormalizePathWithExecutionId(executionId, value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oracle %s %q: %w", key, value, err)
|
||||
}
|
||||
values[i] = normalized
|
||||
}
|
||||
query[key] = values
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return dsn, nil
|
||||
}
|
||||
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func isOracleTracePathOption(key string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(key)) {
|
||||
case "TRACE FILE", "TRACE DIR", "TRACE FOLDER", "TRACE DIRECTORY":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Connect connects to an Oracle database
|
||||
// @example
|
||||
// ```javascript
|
||||
|
||||
@@ -2,6 +2,7 @@ package oracle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -12,6 +13,17 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// outsideTemplatesPath returns an absolute path that is guaranteed to sit
|
||||
// outside the sandbox allowlist on every OS. A Unix-style "/etc/..." literal is
|
||||
// not absolute on Windows, so it would be resolved relative to the templates
|
||||
// dir and fail with a "does not exist" error before the allowlist check ever
|
||||
// runs. Rooting at the temp volume keeps it drive-qualified on Windows yet
|
||||
// never inside the templates/temp/cwd roots.
|
||||
func outsideTemplatesPath(name string) string {
|
||||
root := filepath.VolumeName(os.TempDir()) + string(os.PathSeparator)
|
||||
return filepath.Join(root, "nuclei-denied", name)
|
||||
}
|
||||
|
||||
func TestSandboxDSNRejectsTraceFileOutsideTemplatesWithoutLFA(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreOracleTemplatesDir(t, templatesDir)
|
||||
@@ -19,14 +31,14 @@ func TestSandboxDSNRejectsTraceFileOutsideTemplatesWithoutLFA(t *testing.T) {
|
||||
executionID := t.Name()
|
||||
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
|
||||
|
||||
traceFile := filepath.Join(t.TempDir(), "trace.log")
|
||||
traceFile := outsideTemplatesPath("nuclei-oracle-trace-test.log")
|
||||
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
|
||||
"TRACE FILE": traceFile,
|
||||
})
|
||||
|
||||
_, err := sandboxDSN(executionID, dsn)
|
||||
_, err := protocolstate.SanitizeOracleDSN(executionID, dsn, &types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "-lfa is not enabled")
|
||||
require.Contains(t, err.Error(), "outside")
|
||||
}
|
||||
|
||||
func TestConnectWithDSNRejectsTraceFileBeforeOracleOpen(t *testing.T) {
|
||||
@@ -36,7 +48,7 @@ func TestConnectWithDSNRejectsTraceFileBeforeOracleOpen(t *testing.T) {
|
||||
executionID := t.Name()
|
||||
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
|
||||
|
||||
traceFile := filepath.Join(t.TempDir(), "trace.log")
|
||||
traceFile := outsideTemplatesPath("nuclei-oracle-connect-trace-test.log")
|
||||
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
|
||||
"TRACE FILE": traceFile,
|
||||
})
|
||||
@@ -44,8 +56,7 @@ func TestConnectWithDSNRejectsTraceFileBeforeOracleOpen(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), "executionId", executionID) // nolint:staticcheck
|
||||
_, err := (&OracleClient{}).ConnectWithDSN(ctx, dsn)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "-lfa is not enabled")
|
||||
require.NoFileExists(t, traceFile)
|
||||
require.Contains(t, err.Error(), "outside")
|
||||
}
|
||||
|
||||
func TestSandboxDSNNormalizesTraceOptionsWithinTemplatesWithoutLFA(t *testing.T) {
|
||||
@@ -62,7 +73,7 @@ func TestSandboxDSNNormalizesTraceOptionsWithinTemplatesWithoutLFA(t *testing.T)
|
||||
"TRACE DIRECTORY": traceDir,
|
||||
})
|
||||
|
||||
got, err := sandboxDSN(executionID, dsn)
|
||||
got, err := protocolstate.SanitizeOracleDSN(executionID, dsn, &types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := go_ora.ParseConfig(got)
|
||||
@@ -71,19 +82,19 @@ func TestSandboxDSNNormalizesTraceOptionsWithinTemplatesWithoutLFA(t *testing.T)
|
||||
require.Equal(t, traceDir, cfg.TraceDir)
|
||||
}
|
||||
|
||||
func TestSandboxDSNAllowsTraceFileOutsideTemplatesWithLFA(t *testing.T) {
|
||||
func TestSandboxDSNAllowsTraceFileWithinAllowedPathsWithLFA(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreOracleTemplatesDir(t, templatesDir)
|
||||
|
||||
executionID := t.Name()
|
||||
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: true})
|
||||
|
||||
traceFile := filepath.Join(t.TempDir(), "trace.log")
|
||||
traceFile := filepath.Join(templatesDir, "trace.log")
|
||||
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
|
||||
"TRACE FILE": traceFile,
|
||||
})
|
||||
|
||||
got, err := sandboxDSN(executionID, dsn)
|
||||
got, err := protocolstate.SanitizeOracleDSN(executionID, dsn, &types.Options{ExecutionId: executionID, AllowLocalFileAccess: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := go_ora.ParseConfig(got)
|
||||
|
||||
@@ -2,7 +2,6 @@ package oracle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
@@ -22,15 +21,7 @@ func (o *oracleCustomDialer) dialWithCtx(ctx context.Context, network, address s
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dialers := protocolstate.GetDialersWithId(o.executionId)
|
||||
if dialers == nil {
|
||||
return nil, fmt.Errorf("dialers not initialized for %s", o.executionId)
|
||||
}
|
||||
if !protocolstate.IsHostAllowed(o.executionId, address) {
|
||||
// host is not valid according to network policy
|
||||
return nil, protocolstate.ErrHostDenied.Msgf(address)
|
||||
}
|
||||
return dialers.Fastdialer.Dial(ctx, network, address)
|
||||
return protocolstate.DialAllowedWithExecutionID(ctx, o.executionId, network, address)
|
||||
}
|
||||
|
||||
func (o *oracleCustomDialer) Dial(network, address string) (net.Conn, error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package pop3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -42,14 +41,13 @@ func IsPOP3(ctx context.Context, host string, port int) (IsPOP3Response, error)
|
||||
// @memo
|
||||
func isPoP3(ctx context.Context, executionId string, host string, port int) (IsPOP3Response, error) {
|
||||
resp := IsPOP3Response{}
|
||||
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return IsPOP3Response{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
if !protocolstate.IsHostAllowed(executionId, host) {
|
||||
// host is not valid according to network policy
|
||||
return resp, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
|
||||
timeout := 5 * time.Second
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func isPostgres(ctx context.Context, executionId string, host string, port int)
|
||||
return false, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func buildPostgresConnURL(username, password, target, dbName, executionId string
|
||||
return fmt.Sprintf("postgres://%s@%s/%s?%s",
|
||||
url.UserPassword(username, password).String(),
|
||||
target,
|
||||
url.PathEscape(dbName),
|
||||
protocolstate.SanitizePostgresDatabaseName(dbName),
|
||||
values.Encode(),
|
||||
)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func connect(ctx context.Context, executionId string, host string, port int, use
|
||||
Password: password,
|
||||
Database: dbName,
|
||||
Dialer: func(dialCtx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Fastdialer.Dial(dialCtx, network, addr)
|
||||
return protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, network, addr)
|
||||
},
|
||||
IdleCheckFrequency: -1,
|
||||
}).WithTimeout(10 * time.Second)
|
||||
|
||||
+4
-17
@@ -46,13 +46,8 @@ 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{}
|
||||
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return IsRDPResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
timeout := 5 * time.Second
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
@@ -105,12 +100,8 @@ 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{}
|
||||
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return CheckRDPAuthResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
timeout := 5 * time.Second
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
@@ -193,10 +184,6 @@ func CheckRDPEncryption(ctx context.Context, host string, port int) (RDPEncrypti
|
||||
|
||||
// @memo
|
||||
func checkRDPEncryption(ctx context.Context, executionId string, host string, port int) (RDPEncryptionResponse, error) {
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return RDPEncryptionResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
resp := RDPEncryptionResponse{}
|
||||
defaultTimeout := 5 * time.Second
|
||||
|
||||
@@ -211,7 +198,7 @@ func checkRDPEncryption(ctx context.Context, executionId string, host string, po
|
||||
|
||||
for name, value := range protocols {
|
||||
dialCtx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
conn, err := dialer.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
cancel()
|
||||
continue
|
||||
@@ -252,7 +239,7 @@ func checkRDPEncryption(ctx context.Context, executionId string, host string, po
|
||||
|
||||
for encryptionLevel, value := range ciphers {
|
||||
dialCtx, cancel := context.WithTimeout(ctx, defaultTimeout)
|
||||
conn, err := dialer.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
cancel()
|
||||
continue
|
||||
|
||||
@@ -35,7 +35,7 @@ func getServerInfo(ctx context.Context, executionId string, host string, port in
|
||||
return "", protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
// create a new client
|
||||
client := newClient(fmt.Sprintf("%s:%d", host, port), "")
|
||||
client := newClient(net.JoinHostPort(host, strconv.Itoa(port)), "")
|
||||
defer func() {
|
||||
_ = client.Close()
|
||||
}()
|
||||
@@ -73,7 +73,7 @@ func connect(ctx context.Context, executionId string, host string, port int, pas
|
||||
return false, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
// create a new client
|
||||
client := newClient(fmt.Sprintf("%s:%d", host, port), password)
|
||||
client := newClient(net.JoinHostPort(host, strconv.Itoa(port)), password)
|
||||
defer func() {
|
||||
_ = client.Close()
|
||||
}()
|
||||
@@ -109,7 +109,7 @@ func getServerInfoAuth(ctx context.Context, executionId string, host string, por
|
||||
return "", protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
// create a new client
|
||||
client := newClient(fmt.Sprintf("%s:%d", host, port), password)
|
||||
client := newClient(net.JoinHostPort(host, strconv.Itoa(port)), password)
|
||||
defer func() {
|
||||
_ = client.Close()
|
||||
}()
|
||||
@@ -144,12 +144,7 @@ func IsAuthenticated(ctx context.Context, host string, port int) (bool, error) {
|
||||
func isAuthenticated(ctx context.Context, executionId string, host string, port int) (bool, error) {
|
||||
plugin := pluginsredis.REDISPlugin{}
|
||||
timeout := 5 * time.Second
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return false, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -180,7 +175,7 @@ func RunLuaScript(ctx context.Context, host string, port int, password string, s
|
||||
return false, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
// create a new client
|
||||
client := newClient(fmt.Sprintf("%s:%d", host, port), password)
|
||||
client := newClient(net.JoinHostPort(host, strconv.Itoa(port)), password)
|
||||
defer func() {
|
||||
_ = client.Close()
|
||||
}()
|
||||
|
||||
@@ -59,7 +59,7 @@ func connectWithFastDialer(ctx context.Context, executionId string, host string,
|
||||
if dialer == nil {
|
||||
return nil, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
return dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
return protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
}
|
||||
|
||||
// IsRsync checks if a host is running a Rsync server.
|
||||
|
||||
+25
-11
@@ -91,7 +91,10 @@ type (
|
||||
// log(to_json(info));
|
||||
// ```
|
||||
func (c *SMBClient) ConnectSMBInfoMode(ctx context.Context, host string, port int) (*smb.SMBLog, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return nil, fmt.Errorf("smb: missing executionId in context")
|
||||
}
|
||||
return memoizedconnectSMBInfoMode(ctx, executionId, host, port)
|
||||
}
|
||||
|
||||
@@ -101,13 +104,9 @@ func connectSMBInfoMode(ctx context.Context, executionId string, host string, po
|
||||
// host is not valid according to network policy
|
||||
return nil, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return nil, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
dialSMBInfo := func(ctx context.Context) (net.Conn, error) {
|
||||
return dialer.Fastdialer.Dial(ctx, "tcp", address)
|
||||
return protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", address)
|
||||
}
|
||||
conn, err := dialSMBInfo(ctx)
|
||||
if err != nil {
|
||||
@@ -148,7 +147,10 @@ func connectSMBInfoMode(ctx context.Context, executionId string, host string, po
|
||||
// log(to_json(metadata));
|
||||
// ```
|
||||
func (c *SMBClient) ListSMBv2Metadata(ctx context.Context, host string, port int) (*plugins.ServiceSMB, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return nil, fmt.Errorf("smb: missing executionId in context")
|
||||
}
|
||||
if !protocolstate.IsHostAllowed(executionId, host) {
|
||||
// host is not valid according to network policy
|
||||
return nil, protocolstate.ErrHostDenied.Msgf(host)
|
||||
@@ -203,7 +205,10 @@ func (c *SMBClient) Authenticate(ctx context.Context, host string, port int, use
|
||||
//
|
||||
// ```
|
||||
func (c *SMBClient) ListShares(ctx context.Context, host string, port int, user, password string) ([]string, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return nil, fmt.Errorf("smb: missing executionId in context")
|
||||
}
|
||||
return memoizedlistShares(ctx, executionId, host, port, user, password)
|
||||
}
|
||||
|
||||
@@ -218,7 +223,10 @@ func (c *SMBClient) ListShares(ctx context.Context, host string, port int, user,
|
||||
// for (const e of entries) { log(e.Name + (e.IsDir ? '/' : '')); }
|
||||
// ```
|
||||
func (c *SMBClient) ListDir(ctx context.Context, host string, port int, user, password, share, dir string) ([]ShareEntry, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return nil, fmt.Errorf("smb: missing executionId in context")
|
||||
}
|
||||
return memoizedlistDir(ctx, executionId, host, port, user, password, share, dir)
|
||||
}
|
||||
|
||||
@@ -232,7 +240,10 @@ func (c *SMBClient) ListDir(ctx context.Context, host string, port int, user, pa
|
||||
// log(body);
|
||||
// ```
|
||||
func (c *SMBClient) ReadFile(ctx context.Context, host string, port int, user, password, share, filePath string) (string, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return "", fmt.Errorf("smb: missing executionId in context")
|
||||
}
|
||||
return memoizedreadFile(ctx, executionId, host, port, user, password, share, filePath)
|
||||
}
|
||||
|
||||
@@ -246,7 +257,10 @@ func (c *SMBClient) ReadFile(ctx context.Context, host string, port int, user, p
|
||||
// for (const e of tree) { log(e.Name); }
|
||||
// ```
|
||||
func (c *SMBClient) ListTree(ctx context.Context, host string, port int, user, password, share, dir string) ([]ShareEntry, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return nil, fmt.Errorf("smb: missing executionId in context")
|
||||
}
|
||||
return memoizedlistTree(ctx, executionId, host, port, user, password, share, dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,7 @@ func collectSMBv2Metadata(ctx context.Context, executionId string, host string,
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return nil, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -27,7 +26,10 @@ const (
|
||||
// const isSMBGhost = smb.DetectSMBGhost('acme.com', 445);
|
||||
// ```
|
||||
func (c *SMBClient) DetectSMBGhost(ctx context.Context, host string, port int) (bool, error) {
|
||||
executionId := ctx.Value("executionId").(string)
|
||||
executionId := protocolstate.ExecutionIDFromContext(ctx)
|
||||
if executionId == "" {
|
||||
return false, errors.New("smb: missing executionId in context")
|
||||
}
|
||||
return memoizeddetectSMBGhost(ctx, executionId, host, port)
|
||||
}
|
||||
|
||||
@@ -38,11 +40,7 @@ func detectSMBGhost(ctx context.Context, executionId string, host string, port i
|
||||
return false, protocolstate.ErrHostDenied.Msgf(host)
|
||||
}
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return false, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", addr)
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", addr)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ func (c *Client) IsSMTP() (SMTPResponse, error) {
|
||||
return SMTPResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(c.nj.Context(), "tcp", net.JoinHostPort(c.host, c.port))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(c.nj.Context(), executionId, "tcp", net.JoinHostPort(c.host, c.port))
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
@@ -141,7 +141,7 @@ func (c *Client) IsOpenRelay(msg *SMTPMessage) (bool, error) {
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(c.host, c.port)
|
||||
conn, err := dialer.Fastdialer.Dial(c.nj.Context(), "tcp", addr)
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(c.nj.Context(), executionId, "tcp", addr)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -219,12 +219,7 @@ func (c *Client) SendMail(msg *SMTPMessage) (bool, error) {
|
||||
// past the network policy, so nothing could bound what it reads or close it on cancel.
|
||||
func (c *Client) sendMail(auth smtp.Auth, msg *SMTPMessage) error {
|
||||
executionId := c.nj.ExecutionId()
|
||||
dialer := protocolstate.GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(c.nj.Context(), "tcp", net.JoinHostPort(c.host, c.port))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(c.nj.Context(), executionId, "tcp", net.JoinHostPort(c.host, c.port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func isTelnet(ctx context.Context, executionId string, host string, port int) (I
|
||||
return IsTelnetResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (c *TelnetClient) Connect(ctx context.Context, host string, port int, usern
|
||||
}
|
||||
|
||||
// Create TCP connection
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func (c *TelnetClient) Info(ctx context.Context, host string, port int) (TelnetI
|
||||
return TelnetInfoResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return TelnetInfoResponse{}, err
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func (c *TelnetClient) GetTelnetNTLMInfo(ctx context.Context, host string, port
|
||||
}
|
||||
|
||||
// Create TCP connection
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func connect(ctx context.Context, executionId string, host string, port int, pas
|
||||
return false, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func isVNC(ctx context.Context, executionId string, host string, port int) (IsVN
|
||||
if dialer == nil {
|
||||
return IsVNCResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
+11
-19
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gozero"
|
||||
"github.com/projectdiscovery/gozero/sandbox"
|
||||
gozerotypes "github.com/projectdiscovery/gozero/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
|
||||
@@ -47,8 +46,11 @@ var (
|
||||
)
|
||||
|
||||
type Sandbox struct {
|
||||
// WorkingDir is kept for backwards-compatible template parsing, but it is
|
||||
// intentionally ignored by the gozero sandbox (Docker or native). Letting
|
||||
// templates choose host/container workdirs reintroduces path-policy footguns.
|
||||
WorkingDir string `yaml:"working-dir,omitempty" json:"working-dir,omitempty" jsonschema:"title=working-dir,description=Working directory"`
|
||||
Image string `yaml:"image,omitempty" json:"image,omitempty" jsonschema:"title=image,description=Image"`
|
||||
Image string `yaml:"image,omitempty" json:"image,omitempty" jsonschema:"title=image,description=Docker image for sandboxed code execution"`
|
||||
}
|
||||
|
||||
// Request is a request for the SSL protocol
|
||||
@@ -266,18 +268,7 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
|
||||
defer cancel()
|
||||
// Note: we use contextutil despite the fact that gozero accepts context as argument
|
||||
gOutput, err := contextutil.ExecFuncWithTwoReturns(ctx, func() (*gozerotypes.Result, error) {
|
||||
if request.useSandbox() {
|
||||
return request.gozero.EvalWithVirtualEnv(
|
||||
ctx, gozero.VirtualEnvDocker,
|
||||
request.src,
|
||||
metaSrc,
|
||||
&sandbox.DockerConfiguration{
|
||||
WorkingDir: request.Sandbox.WorkingDir,
|
||||
Image: request.Sandbox.Image,
|
||||
},
|
||||
)
|
||||
}
|
||||
return request.gozero.Eval(ctx, request.src, metaSrc)
|
||||
return request.evalCode(ctx, metaSrc)
|
||||
})
|
||||
if gOutput == nil {
|
||||
// write error to stderr buff
|
||||
@@ -312,7 +303,12 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
|
||||
sb.WriteString(interpretEnvVars(gOutput.Command, allvars))
|
||||
sb.WriteString("\n")
|
||||
fmt.Fprintf(sb, "\n%v\n%v\n%v\n", dashes, "Command Output:", dashes)
|
||||
sb.WriteString(gOutput.DebugData.String())
|
||||
if gOutput.DebugData != nil {
|
||||
sb.WriteString(gOutput.DebugData.String())
|
||||
} else {
|
||||
sb.WriteString(gOutput.Stdout.String())
|
||||
sb.WriteString(gOutput.Stderr.String())
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("[WRN] Command Output here is stdout+sterr, in response variables they are separate (use -v -svd flags for more details)")
|
||||
return sb.String()
|
||||
@@ -505,10 +501,6 @@ func (r *Request) UpdateOptions(opts *protocols.ExecutorOptions) {
|
||||
r.options.ApplyNewEngineOptions(opts)
|
||||
}
|
||||
|
||||
func (r *Request) useSandbox() bool {
|
||||
return r.Sandbox != nil && r.Sandbox.Image != ""
|
||||
}
|
||||
|
||||
// sanitizeEnvValue removes NUL bytes, which are invalid in a subprocess
|
||||
// environment entry, from a value.
|
||||
func sanitizeEnvValue(value string) string {
|
||||
|
||||
@@ -36,6 +36,7 @@ func executeRequest(t *testing.T, request *Request, info *testutils.TemplateInfo
|
||||
// other tests and make them order-dependent.
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
options.EnableCodeTemplates = true
|
||||
options.DisableSandbox = true
|
||||
testutils.Init(options)
|
||||
|
||||
executerOpts := testutils.NewMockExecuterOptions(options, info)
|
||||
@@ -122,6 +123,7 @@ func executeCodeProtocolWithCallbackOverride(t *testing.T, configure func(*types
|
||||
// code templates are gated behind -code and signature verification, so
|
||||
// enable both for this helper the same way executeRequest does.
|
||||
options.EnableCodeTemplates = true
|
||||
options.DisableSandbox = true
|
||||
options.InteractionsCoolDownPeriod = 0
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gozero"
|
||||
"github.com/projectdiscovery/gozero/confine"
|
||||
gozerotypes "github.com/projectdiscovery/gozero/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/sandbox"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
)
|
||||
|
||||
// ErrNoCodeSandbox is returned when code templates cannot run confined: Docker
|
||||
// is not reachable and no native sandbox works on this host. It fails closed;
|
||||
// the template never runs on the bare host unless the user passes -no-sandbox.
|
||||
var ErrNoCodeSandbox = errkit.New(noCodeSandboxMessage)
|
||||
|
||||
const noCodeSandboxMessage = "code template sandbox unavailable: docker is not reachable and no native sandbox works on this host (bubblewrap on linux, sandbox-exec on macos); use -no-sandbox to run code templates without a sandbox"
|
||||
|
||||
// dockerUnavailable latches once Docker reports it cannot confine, so later
|
||||
// executions go straight to the native sandbox instead of probing a dead
|
||||
// daemon on every request.
|
||||
var dockerUnavailable atomic.Bool
|
||||
|
||||
// noSandboxReported makes the fail-closed error visible once at error level:
|
||||
// per-request execution errors are only logged as warnings, which the
|
||||
// default log level hides.
|
||||
var noSandboxReported atomic.Bool
|
||||
|
||||
// evalCode runs the code template confined. Docker is preferred because the
|
||||
// payload then sees only the image, not the host filesystem. When Docker is
|
||||
// not reachable, the gozero native backend of the OS runs it (bubblewrap on
|
||||
// Linux, Seatbelt on macOS): fresh workspace, no network, scrubbed
|
||||
// environment, and the home directories and runtime sockets are hidden. When
|
||||
// neither works, it fails closed.
|
||||
func (request *Request) evalCode(ctx context.Context, input *gozero.Source) (*gozerotypes.Result, error) {
|
||||
if request.options.Options.DisableSandbox {
|
||||
return request.gozero.Eval(ctx, request.src, input)
|
||||
}
|
||||
|
||||
if !dockerUnavailable.Load() {
|
||||
result, err := request.gozero.EvalWithVirtualEnv(ctx, request.confinementPolicy(confine.BackendDocker), request.src, input)
|
||||
if err == nil || !errors.Is(err, confine.ErrConfinementUnavailable) {
|
||||
return result, err
|
||||
}
|
||||
if dockerUnavailable.CompareAndSwap(false, true) {
|
||||
gologger.Verbose().Msgf("docker sandbox unavailable for code templates, using native sandbox: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := request.gozero.EvalWithVirtualEnv(ctx, request.confinementPolicy(confine.BackendAuto), request.src, input)
|
||||
if err != nil && errors.Is(err, confine.ErrConfinementUnavailable) {
|
||||
reason := err.Error()
|
||||
if runtime.GOOS == "linux" && sandbox.Enforced() {
|
||||
// Landlock forbids mount(2) in this process and its children, and
|
||||
// bubblewrap needs mounts to build the sandbox.
|
||||
reason += " (bubblewrap cannot run under the landlock process sandbox)"
|
||||
}
|
||||
if noSandboxReported.CompareAndSwap(false, true) {
|
||||
gologger.Error().Msgf("%s (%s)", noCodeSandboxMessage, reason)
|
||||
}
|
||||
return nil, errkit.Wrap(ErrNoCodeSandbox, reason)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// confinementPolicy returns the hardened gozero policy for backend. The
|
||||
// gozero defaults are the hardened setting: no network, all capabilities
|
||||
// dropped, no-new-privileges, read-only rootfs, resource limits, hidden home
|
||||
// directories and a scrubbed environment.
|
||||
func (request *Request) confinementPolicy(backend confine.Backend) *confine.Policy {
|
||||
policy := confine.DefaultPolicy()
|
||||
policy.Backend = backend
|
||||
if backend == confine.BackendDocker {
|
||||
// Run the payload as the unprivileged nobody user (65534) inside the
|
||||
// container, so a kernel/runc container escape maps to an unprivileged
|
||||
// host uid rather than root. The native backends keep the caller's uid,
|
||||
// so the workspace stays owned by it.
|
||||
policy.RunAsUID = 65534
|
||||
policy.RunAsGID = 65534
|
||||
// NOTE(max-hardening): Docker+runc shares the host kernel, so this
|
||||
// closes the application-level escape surface (no shell injection, no
|
||||
// unconfined fallback, deny-by-default, non-root) but not a
|
||||
// kernel/runc 0-day escape. For hostile multi-tenant use, run the
|
||||
// daemon on a kernel-isolating runtime (gVisor runsc or Kata microVM).
|
||||
if request.Sandbox != nil && request.Sandbox.Image != "" {
|
||||
policy.DockerImage = request.Sandbox.Image
|
||||
}
|
||||
}
|
||||
if timeout := request.codeTimeout(); timeout > 0 {
|
||||
policy.Timeout = timeout
|
||||
}
|
||||
return &policy
|
||||
}
|
||||
|
||||
func (request *Request) codeTimeout() time.Duration {
|
||||
if request.options == nil || request.options.Options == nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(request.options.Options.GetTimeouts().CodeExecutionTimeout)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package code
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gozero/confine"
|
||||
"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/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func dockerSandboxOrSkip(t *testing.T) {
|
||||
t.Helper()
|
||||
c, err := confine.New(&confine.Policy{Backend: confine.BackendDocker})
|
||||
if err != nil {
|
||||
t.Skipf("docker-backed gozero confinement unavailable: %v", err)
|
||||
}
|
||||
require.NoError(t, c.Close())
|
||||
}
|
||||
|
||||
func executeSandboxedRequest(t *testing.T, request *Request) output.InternalEvent {
|
||||
t.Helper()
|
||||
dockerSandboxOrSkip(t)
|
||||
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
options.EnableCodeTemplates = true
|
||||
options.DisableSandbox = false
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() { testutils.Cleanup(options) })
|
||||
|
||||
info := &testutils.TemplateInfo{
|
||||
ID: "code-gozero-sandbox",
|
||||
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
||||
}
|
||||
executerOpts := testutils.NewMockExecuterOptions(options, info)
|
||||
executerOpts.Verified = true
|
||||
require.NoError(t, request.Compile(executerOpts))
|
||||
|
||||
var gotEvent output.InternalEvent
|
||||
ctxArgs := contextargs.NewWithInput(context.Background(), "")
|
||||
require.NoError(t, request.ExecuteWithResults(ctxArgs, nil, nil, func(event *output.InternalWrappedEvent) {
|
||||
gotEvent = event.InternalEvent
|
||||
}))
|
||||
return gotEvent
|
||||
}
|
||||
|
||||
func TestCodeProtocolGozeroSandboxExecution(t *testing.T) {
|
||||
gotEvent := executeSandboxedRequest(t, &Request{
|
||||
Engine: []string{"sh"},
|
||||
Source: "echo gozero-sandbox-ok",
|
||||
})
|
||||
require.Equal(t, "gozero-sandbox-ok", gotEvent["response"])
|
||||
}
|
||||
|
||||
func TestCodeProtocolConfinementPolicyIsHardened(t *testing.T) {
|
||||
request := &Request{
|
||||
Sandbox: &Sandbox{
|
||||
WorkingDir: "/tmp/ignored",
|
||||
Image: "python:3.14",
|
||||
},
|
||||
}
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
options.GetTimeouts().CodeExecutionTimeout = 3 * time.Second
|
||||
request.options = testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{})
|
||||
|
||||
policy := request.confinementPolicy(confine.BackendDocker)
|
||||
require.Equal(t, confine.BackendDocker, policy.Backend)
|
||||
require.Equal(t, "python:3.14", policy.DockerImage)
|
||||
require.False(t, policy.AllowNetwork)
|
||||
require.False(t, policy.WritableRootfs)
|
||||
require.False(t, policy.AllowCapabilities)
|
||||
require.False(t, policy.AllowNewPrivileges)
|
||||
require.Empty(t, policy.DockerUser)
|
||||
require.Equal(t, 65534, policy.RunAsUID, "payload must run as a non-root uid")
|
||||
require.Equal(t, 65534, policy.RunAsGID, "payload must run as a non-root gid")
|
||||
require.Equal(t, 3*time.Second, policy.Timeout)
|
||||
require.Empty(t, policy.Workspace, "template working-dir must not become a host workspace")
|
||||
|
||||
native := request.confinementPolicy(confine.BackendAuto)
|
||||
require.Equal(t, confine.BackendAuto, native.Backend)
|
||||
require.False(t, native.AllowNetwork)
|
||||
require.False(t, native.AllowCapabilities)
|
||||
require.Empty(t, native.Tmp, "no shared writable temp dir for the payload")
|
||||
require.Empty(t, native.ReadOnlyPaths, "no home path is exposed")
|
||||
require.Equal(t, 3*time.Second, native.Timeout)
|
||||
}
|
||||
|
||||
// TestCodeProtocolNativeFallbackConfinesPayload forces the Docker-unreachable
|
||||
// path and runs a real payload through the gozero native backend (Seatbelt or
|
||||
// bubblewrap): it can write its workspace, and it can neither write the home
|
||||
// directory, read files under it (such as ~/.ssh) nor reach the network.
|
||||
func TestCodeProtocolNativeFallbackConfinesPayload(t *testing.T) {
|
||||
if c, err := confine.New(&confine.Policy{Backend: confine.BackendAuto}); err != nil {
|
||||
t.Skipf("native sandbox unavailable: %v", err)
|
||||
} else {
|
||||
require.NoError(t, c.Close())
|
||||
}
|
||||
prev := dockerUnavailable.Swap(true)
|
||||
t.Cleanup(func() { dockerUnavailable.Store(prev) })
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
canaryDir, err := os.MkdirTemp(home, ".nuclei-native-canary-*")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(canaryDir) })
|
||||
secret := "NUCLEI_NATIVE_SECRET"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(canaryDir, "secret"), []byte(secret), 0o600))
|
||||
homeWrite := filepath.Join(canaryDir, "written")
|
||||
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
options.EnableCodeTemplates = true
|
||||
options.DisableSandbox = false
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() { testutils.Cleanup(options) })
|
||||
request := &Request{
|
||||
Engine: []string{"sh"},
|
||||
Source: fmt.Sprintf(`echo ws > "$HOME/ws-file" && cat "$HOME/ws-file"
|
||||
echo pwned > %[1]q 2>/dev/null; test -f %[1]q && echo HOME_WRITE_OK || echo HOME_WRITE_DENIED
|
||||
cat %[2]q 2>/dev/null || echo HOME_READ_DENIED
|
||||
ls %[3]q >/dev/null 2>&1 && echo SSH_READ_OK || echo SSH_READ_DENIED
|
||||
(exec 3<>/dev/tcp/1.1.1.1/80) 2>/dev/null && echo NET_OK || echo NET_DENIED`, homeWrite, filepath.Join(canaryDir, "secret"), filepath.Join(home, ".ssh")),
|
||||
}
|
||||
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
||||
ID: "code-native-sandbox",
|
||||
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
||||
})
|
||||
executerOpts.Verified = true
|
||||
require.NoError(t, request.Compile(executerOpts))
|
||||
|
||||
var gotEvent output.InternalEvent
|
||||
require.NoError(t, request.ExecuteWithResults(contextargs.NewWithInput(context.Background(), ""), nil, nil, func(event *output.InternalWrappedEvent) {
|
||||
gotEvent = event.InternalEvent
|
||||
}))
|
||||
response, _ := gotEvent["response"].(string)
|
||||
require.Contains(t, response, "ws\n")
|
||||
require.Contains(t, response, "HOME_WRITE_DENIED")
|
||||
require.Contains(t, response, "HOME_READ_DENIED")
|
||||
require.Contains(t, response, "SSH_READ_DENIED")
|
||||
require.NotContains(t, response, secret)
|
||||
require.NoFileExists(t, homeWrite)
|
||||
}
|
||||
|
||||
func TestCodeProtocolGozeroSandboxDeniesHostFileRead(t *testing.T) {
|
||||
secret := "NUCLEI_HOST_SECRET_DO_NOT_LEAK"
|
||||
hostFile := filepath.Join(t.TempDir(), "host-secret")
|
||||
require.NoError(t, os.WriteFile(hostFile, []byte(secret), 0o600))
|
||||
|
||||
gotEvent := executeSandboxedRequest(t, &Request{
|
||||
Engine: []string{"sh"},
|
||||
Source: fmt.Sprintf("cat %q 2>/dev/null || true", hostFile),
|
||||
})
|
||||
|
||||
response, _ := gotEvent["response"].(string)
|
||||
stderr, _ := gotEvent["stderr"].(string)
|
||||
require.NotContains(t, response+stderr, secret)
|
||||
}
|
||||
|
||||
func TestCodeProtocolGozeroSandboxDeniesNetwork(t *testing.T) {
|
||||
gotEvent := executeSandboxedRequest(t, &Request{
|
||||
Engine: []string{"sh"},
|
||||
Source: "wget -q -T 5 -O - http://example.com >/dev/null 2>&1 && echo REACHED || echo BLOCKED",
|
||||
})
|
||||
require.Equal(t, "BLOCKED", gotEvent["response"])
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package protocolinit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/sandbox"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/dns/dnsclientpool"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/signerpool"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network/networkclientpool"
|
||||
@@ -16,6 +19,16 @@ func Init(options *types.Options) error {
|
||||
if err := protocolstate.Init(options); err != nil {
|
||||
return err
|
||||
}
|
||||
// Landlock is process-wide and irreversible. Under `go test` package CWDs
|
||||
// and fixture trees are not in AllowedFileRoots, so applying it would break
|
||||
// unrelated unit tests that open relative paths. Sandbox package tests call
|
||||
// Apply directly.
|
||||
_ = sandbox.Apply(sandbox.Config{
|
||||
AllowedRoots: protocolstate.SandboxFileRoots(options),
|
||||
OwnedRoots: protocolstate.SandboxOwnedFileRoots(options),
|
||||
Disabled: !ProcessSandboxEnabled(options) || testing.Testing(),
|
||||
IncludeRuntime: true,
|
||||
})
|
||||
if err := dnsclientpool.Init(options); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -34,6 +47,15 @@ func Init(options *types.Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessSandboxEnabled reports whether Init applies the process-wide OS
|
||||
// filesystem sandbox. It is opt-in through EnableProcessSandbox, which the
|
||||
// nuclei CLI sets and SDK callers must request explicitly, because the sandbox
|
||||
// confines the whole host process and cannot be undone. -no-sandbox
|
||||
// (DisableSandbox) always turns it off.
|
||||
func ProcessSandboxEnabled(options *types.Options) bool {
|
||||
return options != nil && options.EnableProcessSandbox && !options.DisableSandbox
|
||||
}
|
||||
|
||||
func Close(executionId string) {
|
||||
protocolstate.Close(executionId)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package protocolinit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
|
||||
func TestProcessSandboxEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options *types.Options
|
||||
want bool
|
||||
}{
|
||||
{name: "nil options", options: nil, want: false},
|
||||
{name: "sdk default", options: &types.Options{}, want: false},
|
||||
{name: "cli default", options: &types.Options{EnableProcessSandbox: true}, want: true},
|
||||
{name: "cli no-sandbox", options: &types.Options{EnableProcessSandbox: true, DisableSandbox: true}, want: false},
|
||||
{name: "disable without enable", options: &types.Options{DisableSandbox: true}, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := ProcessSandboxEnabled(test.options); got != test.want {
|
||||
t.Fatalf("ProcessSandboxEnabled() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSandboxEnabledSurvivesCopy(t *testing.T) {
|
||||
options := &types.Options{EnableProcessSandbox: true}
|
||||
if !ProcessSandboxEnabled(options.Copy()) {
|
||||
t.Fatal("Options.Copy dropped EnableProcessSandbox")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package protocolstate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
|
||||
fileutil "github.com/projectdiscovery/utils/file"
|
||||
)
|
||||
|
||||
// AllowedFileRoots returns filesystem roots that template execution may access.
|
||||
// Templates dir and the per-scan temp dir are always included. When -lfa is
|
||||
// enabled the current working directory and any configured AllowedPaths are
|
||||
// added as well. The config dir is never included: it holds the template
|
||||
// signing key and reporting secrets.
|
||||
func AllowedFileRoots(options *types.Options) []string {
|
||||
roots := make([]string, 0, 6)
|
||||
if templateDir := config.DefaultConfig.GetTemplateDir(); templateDir != "" {
|
||||
roots = append(roots, canonicalRoot(templateDir))
|
||||
}
|
||||
// Only the per-scan temp dir nuclei created is allowlisted, never the
|
||||
// shared system temp dir: on Linux that is /tmp, which holds other users'
|
||||
// and other programs' files.
|
||||
if options != nil {
|
||||
if tempDir := GetTemporaryDirectory(options.ExecutionId); tempDir != "" {
|
||||
roots = append(roots, canonicalRoot(tempDir))
|
||||
}
|
||||
}
|
||||
// StoreResponseDir defaults to a relative "output" folder, so it is only a
|
||||
// real root once response storing is actually requested. Adding it
|
||||
// unconditionally would make the sandbox create an unused output dir.
|
||||
if options != nil && options.StoreResponse && options.StoreResponseDir != "" {
|
||||
roots = append(roots, canonicalRoot(options.StoreResponseDir))
|
||||
}
|
||||
if options == nil || !IsLfaAllowed(options) {
|
||||
return uniqueRoots(roots)
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil && cwd != "" {
|
||||
roots = append(roots, canonicalRoot(cwd))
|
||||
}
|
||||
if options != nil {
|
||||
// Merge the paths passed directly on options with any recorded for this
|
||||
// execution id, so execution-id-only callers (that build a bare
|
||||
// *types.Options with just ExecutionId) still honour --allowed-paths.
|
||||
allowed := append([]string(nil), options.AllowedPaths...)
|
||||
allowed = append(allowed, GetAllowedPaths(options.ExecutionId)...)
|
||||
for _, allowedPath := range allowed {
|
||||
if allowedPath == "" {
|
||||
continue
|
||||
}
|
||||
roots = append(roots, canonicalRoot(allowedPath))
|
||||
}
|
||||
}
|
||||
return uniqueRoots(roots)
|
||||
}
|
||||
|
||||
// SandboxFileRoots returns the roots the OS-level sandbox must leave usable.
|
||||
// It is deliberately wider than AllowedFileRoots: landlock restricts the whole
|
||||
// process, so it also has to cover the paths nuclei itself was told to read and
|
||||
// write (target lists, outputs, exports, resume state, template paths) plus the
|
||||
// working directory. Template code does not gain access to any of these, since
|
||||
// javascript/code file access is still checked against AllowedFileRoots.
|
||||
func SandboxFileRoots(options *types.Options) []string {
|
||||
roots := AllowedFileRoots(options)
|
||||
// nuclei itself keeps working files in the system temp dir (the per-scan
|
||||
// dir, the headless browser profile, the dedupe db, update locks), so
|
||||
// the process sandbox must keep it usable. Templates still cannot reach
|
||||
// it: their access is checked against AllowedFileRoots.
|
||||
if tempDir := os.TempDir(); tempDir != "" {
|
||||
roots = append(roots, canonicalRoot(tempDir))
|
||||
}
|
||||
if options == nil {
|
||||
return uniqueRoots(roots)
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil && cwd != "" {
|
||||
roots = append(roots, canonicalRoot(cwd))
|
||||
}
|
||||
// Inputs only: these must already exist. Creating a missing one would
|
||||
// fabricate an empty directory that shadows real template resolution.
|
||||
inputs := []string{options.TargetsFilePath, options.Resume}
|
||||
inputs = append(inputs, options.Templates...)
|
||||
inputs = append(inputs, options.Workflows...)
|
||||
for _, path := range inputs {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
roots = append(roots, canonicalRoot(containingDir(path)))
|
||||
}
|
||||
return uniqueRoots(roots)
|
||||
}
|
||||
|
||||
// SandboxOwnedFileRoots returns directories nuclei manages itself and must be
|
||||
// able to create: its templates, config, state and cache dirs, the go-rod
|
||||
// browser cache, and any output destination the user asked for. They are kept
|
||||
// out of AllowedFileRoots so template code cannot read nuclei's own state.
|
||||
func SandboxOwnedFileRoots(options *types.Options) []string {
|
||||
roots := make([]string, 0, 8)
|
||||
for _, dir := range []string{
|
||||
config.DefaultConfig.GetTemplateDir(),
|
||||
config.DefaultConfig.GetConfigDir(),
|
||||
config.DefaultConfig.GetStateDir(),
|
||||
config.DefaultConfig.GetCacheDir(),
|
||||
launcher.DefaultBrowserDir,
|
||||
} {
|
||||
if dir != "" {
|
||||
roots = append(roots, canonicalRoot(dir))
|
||||
}
|
||||
}
|
||||
if options == nil {
|
||||
return uniqueRoots(roots)
|
||||
}
|
||||
if options.Headless {
|
||||
roots = append(roots, browserProfileRoots()...)
|
||||
}
|
||||
outputs := []string{
|
||||
options.Output,
|
||||
options.JSONExport,
|
||||
options.JSONLExport,
|
||||
options.MarkdownExportDirectory,
|
||||
options.SarifExport,
|
||||
}
|
||||
if options.StoreResponse {
|
||||
outputs = append(outputs, options.StoreResponseDir)
|
||||
}
|
||||
for _, path := range outputs {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
roots = append(roots, canonicalRoot(containingDir(path)))
|
||||
}
|
||||
return uniqueRoots(roots)
|
||||
}
|
||||
|
||||
// browserProfileRoots returns the profile directories chrome falls back to when
|
||||
// it is started without -user-data-dir. go-rod does exactly that when it checks
|
||||
// whether a cached browser still works, so denying these makes chrome's crash
|
||||
// handler abort, go-rod conclude the browser is broken, and re-download it on
|
||||
// every run. Only the landlock sandbox consumes these paths.
|
||||
func browserProfileRoots() []string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil || configDir == "" {
|
||||
return nil
|
||||
}
|
||||
roots := make([]string, 0, 2)
|
||||
for _, name := range []string{"chromium", "google-chrome"} {
|
||||
roots = append(roots, canonicalRoot(filepath.Join(configDir, name)))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// containingDir returns path when it is an existing directory and its parent
|
||||
// otherwise, so a not-yet-created output file still grants its target folder.
|
||||
func containingDir(path string) string {
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
return path
|
||||
}
|
||||
return filepath.Dir(path)
|
||||
}
|
||||
|
||||
func canonicalRoot(path string) string {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = filepath.Clean(path)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
return filepath.Clean(resolved)
|
||||
}
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
|
||||
func uniqueRoots(roots []string) []string {
|
||||
seen := make(map[string]struct{}, len(roots))
|
||||
out := make([]string, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[root]; ok {
|
||||
continue
|
||||
}
|
||||
seen[root] = struct{}{}
|
||||
out = append(out, root)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isPathAllowed(options *types.Options, cleanedPath string) bool {
|
||||
return filepathutil.IsPathWithinAnyDirectory(cleanedPath, AllowedFileRoots(options)...)
|
||||
}
|
||||
|
||||
func resolveAndCleanPath(filePath, baseDir string) (string, error) {
|
||||
return fileutil.ResolveNClean(filePath, baseDir)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package protocolstate_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/goflags"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAllowedFileRootsAlwaysIncludesTemplatesAndScanTemp(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
scanTemp := t.TempDir()
|
||||
protocolstate.SetTemporaryDirectory(t.Name(), scanTemp)
|
||||
t.Cleanup(func() { protocolstate.SetTemporaryDirectory(t.Name(), "") })
|
||||
|
||||
roots := protocolstate.AllowedFileRoots(&types.Options{ExecutionId: t.Name()})
|
||||
require.NotEmpty(t, roots)
|
||||
require.True(t, rootListContains(roots, templatesDir), "templates dir must be in allowlist: %v", roots)
|
||||
require.True(t, rootListContains(roots, scanTemp), "per-scan temp dir must be in allowlist: %v", roots)
|
||||
}
|
||||
|
||||
// The shared system temp dir (/tmp on Linux) holds other users' and other
|
||||
// programs' files, so templates must not reach it. Only the per-scan dir is
|
||||
// allowed, while the process sandbox still keeps the system temp dir usable.
|
||||
func TestSystemTempDirIsNotTemplateReadable(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
scanTemp := t.TempDir()
|
||||
protocolstate.SetTemporaryDirectory(t.Name(), scanTemp)
|
||||
t.Cleanup(func() { protocolstate.SetTemporaryDirectory(t.Name(), "") })
|
||||
|
||||
opts := &types.Options{ExecutionId: t.Name()}
|
||||
roots := protocolstate.AllowedFileRoots(opts)
|
||||
require.False(t, rootListContains(roots, os.TempDir()), "system temp dir must not be in allowlist: %v", roots)
|
||||
|
||||
sibling, err := os.MkdirTemp("", "nuclei-shared-tmp-*")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(sibling) })
|
||||
shared := filepath.Join(sibling, "other-user-file")
|
||||
require.NoError(t, os.WriteFile(shared, []byte("x"), 0o600))
|
||||
_, err = protocolstate.NormalizePath(opts, shared)
|
||||
require.ErrorIs(t, err, protocolstate.ErrPathDenied)
|
||||
|
||||
inScan := filepath.Join(scanTemp, "scan-file")
|
||||
require.NoError(t, os.WriteFile(inScan, []byte("x"), 0o600))
|
||||
_, err = protocolstate.NormalizePath(opts, inScan)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, rootListContains(protocolstate.SandboxFileRoots(opts), os.TempDir()), "process sandbox must keep the system temp dir usable")
|
||||
|
||||
protocolstate.SetTemporaryDirectory(t.Name(), "")
|
||||
require.False(t, rootListContains(protocolstate.AllowedFileRoots(opts), scanTemp), "cleared per-scan temp dir must leave the allowlist")
|
||||
}
|
||||
|
||||
func TestAllowedFileRootsWithLFAIncludesCWD(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
roots := protocolstate.AllowedFileRoots(&types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
AllowLocalFileAccess: true,
|
||||
})
|
||||
require.True(t, rootListContains(roots, cwd), "cwd must be in allowlist when -lfa is enabled")
|
||||
}
|
||||
|
||||
func TestAllowedFileRootsWithAllowedPaths(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
extraDir := t.TempDir()
|
||||
roots := protocolstate.AllowedFileRoots(&types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
AllowLocalFileAccess: true,
|
||||
AllowedPaths: goflags.StringSlice{extraDir},
|
||||
})
|
||||
require.True(t, rootListContains(roots, extraDir))
|
||||
}
|
||||
|
||||
// The OS sandbox restricts the whole process, so it must keep nuclei's own
|
||||
// CLI inputs and outputs reachable even though templates may not read them.
|
||||
// Without this, `nuclei -l targets.txt` fails with permission denied.
|
||||
func TestSandboxFileRootsCoversCLIPathsButAllowlistDoesNot(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
workDir := t.TempDir()
|
||||
targets := filepath.Join(workDir, "targets.txt")
|
||||
require.NoError(t, os.WriteFile(targets, []byte("scanme.sh\n"), 0o600))
|
||||
|
||||
outputDir := t.TempDir()
|
||||
opts := &types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
TargetsFilePath: targets,
|
||||
Output: filepath.Join(outputDir, "results.txt"),
|
||||
}
|
||||
|
||||
sandboxRoots := protocolstate.SandboxFileRoots(opts)
|
||||
require.True(t, rootListContains(sandboxRoots, workDir), "targets file dir must be sandbox-reachable: %v", sandboxRoots)
|
||||
|
||||
ownedRoots := protocolstate.SandboxOwnedFileRoots(opts)
|
||||
require.True(t, rootListContains(ownedRoots, outputDir), "output dir must be sandbox-reachable even before the file exists: %v", ownedRoots)
|
||||
|
||||
// the template-facing allowlist stays narrow
|
||||
allowlist := protocolstate.AllowedFileRoots(opts)
|
||||
require.False(t, rootListContains(allowlist, workDir), "templates must not gain access to the targets dir: %v", allowlist)
|
||||
}
|
||||
|
||||
// Template updates write templates.json under the state dir, so the sandbox
|
||||
// must cover it while templates themselves stay locked out of it.
|
||||
func TestSandboxFileRootsCoversStateAndCacheDirs(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
opts := &types.Options{ExecutionId: t.Name()}
|
||||
roots := protocolstate.SandboxOwnedFileRoots(opts)
|
||||
allowlist := protocolstate.AllowedFileRoots(opts)
|
||||
|
||||
for _, dir := range []string{config.DefaultConfig.GetConfigDir(), config.DefaultConfig.GetStateDir(), config.DefaultConfig.GetCacheDir()} {
|
||||
require.NotEmpty(t, dir)
|
||||
require.True(t, rootListContains(roots, dir), "sandbox must own %s: %v", dir, roots)
|
||||
require.False(t, rootListContains(allowlist, dir), "templates must not reach %s: %v", dir, allowlist)
|
||||
}
|
||||
}
|
||||
|
||||
// The config dir holds the template signing private key, so a template must
|
||||
// not read it even when nothing else denies the path.
|
||||
func TestNormalizePathDeniesSigningKeysDir(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
keyPath := filepath.Join(config.DefaultConfig.GetKeysDir(), "nuclei-user-private-key.pem")
|
||||
_, err := protocolstate.NormalizePath(&types.Options{ExecutionId: t.Name()}, keyPath)
|
||||
require.ErrorIs(t, err, protocolstate.ErrPathDenied)
|
||||
}
|
||||
|
||||
// A missing -t path must never be treated as an owned root. Creating it would
|
||||
// leave an empty directory that template resolution then prefers over the real
|
||||
// templates dir, which silently loads zero templates.
|
||||
func TestSandboxOwnedRootsExcludeTemplateInputPaths(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
opts := &types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
Templates: goflags.StringSlice{"http/cves/"},
|
||||
}
|
||||
|
||||
owned := protocolstate.SandboxOwnedFileRoots(opts)
|
||||
for _, root := range owned {
|
||||
require.NotContains(t, root, filepath.Join("http", "cves"), "template input path must not be an owned root: %v", owned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxOwnedRootsCoverBrowserProfileOnlyForHeadless(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
configDir, err := os.UserConfigDir()
|
||||
require.NoError(t, err)
|
||||
profile := filepath.Join(configDir, "chromium")
|
||||
|
||||
// go-rod starts a cached browser without -user-data-dir to check it still
|
||||
// works, so chrome falls back here. Denying it makes go-rod re-download the
|
||||
// browser on every run.
|
||||
headless := protocolstate.SandboxOwnedFileRoots(&types.Options{ExecutionId: t.Name(), Headless: true})
|
||||
require.Contains(t, headless, profile, "headless runs must own the browser profile dir: %v", headless)
|
||||
|
||||
plain := protocolstate.SandboxOwnedFileRoots(&types.Options{ExecutionId: t.Name()})
|
||||
require.NotContains(t, plain, profile, "non-headless runs must not create a browser profile dir: %v", plain)
|
||||
}
|
||||
|
||||
func TestSandboxFileRootsIncludesWorkingDirectory(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
roots := protocolstate.SandboxFileRoots(&types.Options{ExecutionId: t.Name()})
|
||||
require.True(t, rootListContains(roots, cwd), "cwd must be sandbox-reachable without -lfa: %v", roots)
|
||||
}
|
||||
|
||||
func TestNormalizePathRejectsTraversalOutsideTemplates(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
|
||||
_, err := protocolstate.NormalizePath(opts, "/etc/passwd")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNormalizePathAllowsFileInsideTemplatesViaRelativePath(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
payload := filepath.Join(templatesDir, "helpers", "payload.txt")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(payload), 0o700))
|
||||
require.NoError(t, os.WriteFile(payload, []byte("secret"), 0o600))
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
|
||||
got, err := protocolstate.NormalizePath(opts, "helpers/payload.txt")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, got, "payload.txt")
|
||||
}
|
||||
|
||||
func TestNormalizePathLFADoesNotBypassAbsoluteOutsideRoots(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
opts := &types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
AllowLocalFileAccess: true,
|
||||
}
|
||||
_, err := protocolstate.NormalizePath(opts, "/etc/passwd")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNormalizePathAllowedPathsGrantAccessWithLFA(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
grantedDir := t.TempDir()
|
||||
secretPath := filepath.Join(grantedDir, "secret.txt")
|
||||
require.NoError(t, os.WriteFile(secretPath, []byte("x"), 0o600))
|
||||
|
||||
opts := &types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
AllowLocalFileAccess: true,
|
||||
AllowedPaths: goflags.StringSlice{grantedDir},
|
||||
}
|
||||
got, err := protocolstate.NormalizePath(opts, secretPath)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secretPath, got)
|
||||
}
|
||||
|
||||
func rootListContains(roots []string, target string) bool {
|
||||
cleanTarget, err := filepath.EvalSymlinks(filepath.Clean(target))
|
||||
if err != nil {
|
||||
cleanTarget = filepath.Clean(target)
|
||||
}
|
||||
for _, root := range roots {
|
||||
cleanRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
|
||||
if err != nil {
|
||||
cleanRoot = filepath.Clean(root)
|
||||
}
|
||||
if cleanRoot == cleanTarget {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package protocolstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// ExecutionIDFromContext returns the nuclei execution id stored on ctx.
|
||||
func ExecutionIDFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
if v := ctx.Value("executionId"); v != nil {
|
||||
if id, ok := v.(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DialAllowed dials through the execution-scoped fastdialer which enforces
|
||||
// network policy. All javascript/code native modules must use this helper
|
||||
// instead of calling net.Dial or Fastdialer directly.
|
||||
func DialAllowed(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
executionID := ExecutionIDFromContext(ctx)
|
||||
if executionID == "" {
|
||||
return nil, fmt.Errorf("protocolstate: refusing to dial %s/%s without executionId", network, address)
|
||||
}
|
||||
return DialAllowedWithExecutionID(ctx, executionID, network, address)
|
||||
}
|
||||
|
||||
// DialAllowedWithExecutionID dials using a known execution id.
|
||||
func DialAllowedWithExecutionID(ctx context.Context, executionID, network, address string) (net.Conn, error) {
|
||||
if executionID == "" {
|
||||
return nil, fmt.Errorf("protocolstate: refusing to dial %s/%s without executionId", network, address)
|
||||
}
|
||||
dialer := GetDialersWithId(executionID)
|
||||
if dialer == nil || dialer.Fastdialer == nil {
|
||||
return nil, fmt.Errorf("protocolstate: dialers not initialized for %q", executionID)
|
||||
}
|
||||
return dialer.Fastdialer.Dial(ctx, network, address)
|
||||
}
|
||||
|
||||
// DialTLSAllowed dials TLS through the execution-scoped fastdialer.
|
||||
func DialTLSAllowed(ctx context.Context, network, address string, config *tls.Config) (net.Conn, error) {
|
||||
executionID := ExecutionIDFromContext(ctx)
|
||||
if executionID == "" {
|
||||
return nil, fmt.Errorf("protocolstate: refusing to dial %s/%s without executionId", network, address)
|
||||
}
|
||||
return DialTLSAllowedWithExecutionID(ctx, executionID, network, address, config)
|
||||
}
|
||||
|
||||
// DialTLSAllowedWithExecutionID dials TLS using a known execution id.
|
||||
func DialTLSAllowedWithExecutionID(ctx context.Context, executionID, network, address string, config *tls.Config) (net.Conn, error) {
|
||||
if executionID == "" {
|
||||
return nil, fmt.Errorf("protocolstate: refusing to dial %s/%s without executionId", network, address)
|
||||
}
|
||||
dialer := GetDialersWithId(executionID)
|
||||
if dialer == nil || dialer.Fastdialer == nil {
|
||||
return nil, fmt.Errorf("protocolstate: dialers not initialized for %q", executionID)
|
||||
}
|
||||
return dialer.Fastdialer.DialTLSWithConfig(ctx, network, address, config)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package protocolstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gologger/levels"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func initTestDialers(t *testing.T, opts *types.Options) context.Context {
|
||||
t.Helper()
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
if opts.ExecutionId == "" {
|
||||
opts.ExecutionId = t.Name()
|
||||
}
|
||||
require.NoError(t, protocolstate.Init(opts))
|
||||
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
|
||||
ctx := context.WithValue(context.Background(), "executionId", opts.ExecutionId) //nolint:staticcheck
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestDialAllowedWithExecutionIDRequiresInitializedDialers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, err := protocolstate.DialAllowedWithExecutionID(ctx, "missing", "tcp", "127.0.0.1:80")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "dialers not initialized")
|
||||
}
|
||||
|
||||
func TestDialAllowedRejectsExcludedHost(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
ExecutionId: "dial-exclude-" + t.Name(),
|
||||
ExcludeTargets: []string{"203.0.113.10"},
|
||||
}
|
||||
ctx := initTestDialers(t, opts)
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := protocolstate.DialAllowed(dialCtx, "tcp", "203.0.113.10:445")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDialAllowedAllowsNonExcludedHost(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
ExecutionId: "dial-allow-" + t.Name(),
|
||||
ExcludeTargets: []string{"203.0.113.10"},
|
||||
}
|
||||
ctx := initTestDialers(t, opts)
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 127.0.0.1:1 should fail fast with connection refused, not policy denial.
|
||||
_, err := protocolstate.DialAllowed(dialCtx, "tcp", "127.0.0.1:1")
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, strings.ToLower(err.Error()), "network policy")
|
||||
}
|
||||
|
||||
func TestIsHostAllowedFailsClosedWithInitializedDialersButExcludedHost(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
ExecutionId: "host-deny-" + t.Name(),
|
||||
ExcludeTargets: []string{"203.0.113.10"},
|
||||
}
|
||||
_ = initTestDialers(t, opts)
|
||||
require.False(t, protocolstate.IsHostAllowed(opts.ExecutionId, "203.0.113.10"))
|
||||
}
|
||||
|
||||
func TestIsHostAllowedAllowsNonExcludedHost(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
ExecutionId: "host-allow-" + t.Name(),
|
||||
ExcludeTargets: []string{"203.0.113.10"},
|
||||
}
|
||||
_ = initTestDialers(t, opts)
|
||||
require.True(t, protocolstate.IsHostAllowed(opts.ExecutionId, "127.0.0.1"))
|
||||
}
|
||||
|
||||
func TestExecutionIDFromContext(t *testing.T) {
|
||||
require.Equal(t, "", protocolstate.ExecutionIDFromContext(context.Background()))
|
||||
require.Equal(t, "", protocolstate.ExecutionIDFromContext(nil)) //nolint:staticcheck // deliberately asserting nil-context handling
|
||||
|
||||
ctx := context.WithValue(context.Background(), "executionId", "abc") //nolint:staticcheck
|
||||
require.Equal(t, "abc", protocolstate.ExecutionIDFromContext(ctx))
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package protocolstate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
|
||||
// SanitizeMySQLDSN enforces filesystem sandbox rules on a MySQL DSN. It always
|
||||
// removes allowAllFiles: that option lets the server read any client file
|
||||
// through LOAD DATA LOCAL INFILE, and -lfa must never disable the allowlist.
|
||||
// RegisterMySQLLocalInfiles allows single allowlisted files instead.
|
||||
func SanitizeMySQLDSN(dsn string, options *types.Options) (string, error) {
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg.AllowAllFiles = false
|
||||
return cfg.FormatDSN(), nil
|
||||
}
|
||||
|
||||
// mysqlLocalInfilePattern finds the file literal of LOAD DATA/XML LOCAL INFILE.
|
||||
var mysqlLocalInfilePattern = regexp.MustCompile(`(?is)\bLOAD\s+(?:DATA|XML)\s+(?:(?:LOW_PRIORITY|CONCURRENT)\s+)?LOCAL\s+INFILE\s+(?:'((?:[^'\\]|\\.|'')*)'|"((?:[^"\\]|\\.|"")*)")`)
|
||||
|
||||
var (
|
||||
mysqlLocalFilesMu sync.Mutex
|
||||
mysqlLocalFiles = map[string]int{}
|
||||
// registerMySQLLocalFile and deregisterMySQLLocalFile are the mysql
|
||||
// driver's process-wide file registry, replaceable in tests.
|
||||
registerMySQLLocalFile = mysql.RegisterLocalFile
|
||||
deregisterMySQLLocalFile = mysql.DeregisterLocalFile
|
||||
)
|
||||
|
||||
// RegisterMySQLLocalInfiles allows LOAD DATA LOCAL INFILE for the files that
|
||||
// query names. It does so only when the DSN asks for local files
|
||||
// (allowAllFiles=true), -lfa is enabled, and every file is an absolute path
|
||||
// inside the filesystem allowlist; a file outside it is an error. Each file
|
||||
// is registered with the mysql driver by its exact path, so the server can
|
||||
// only request registered (allowlisted) files, never an arbitrary one. The
|
||||
// caller must call the returned release func after the query.
|
||||
func RegisterMySQLLocalInfiles(options *types.Options, dsn, query string) (func(), error) {
|
||||
noop := func() {}
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
return noop, err
|
||||
}
|
||||
if !cfg.AllowAllFiles || options == nil || !IsLfaAllowed(options) {
|
||||
return noop, nil
|
||||
}
|
||||
var files []string
|
||||
for _, match := range mysqlLocalInfilePattern.FindAllStringSubmatch(query, -1) {
|
||||
path := unescapeMySQLString(match[1])
|
||||
if match[1] == "" {
|
||||
path = unescapeMySQLString(match[2])
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return noop, fmt.Errorf("%w: LOAD DATA LOCAL INFILE path %q must be absolute", ErrPathDenied, path)
|
||||
}
|
||||
if _, err := NormalizePath(options, path); err != nil {
|
||||
return noop, err
|
||||
}
|
||||
files = append(files, path)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return noop, nil
|
||||
}
|
||||
mysqlLocalFilesMu.Lock()
|
||||
for _, file := range files {
|
||||
if mysqlLocalFiles[file] == 0 {
|
||||
registerMySQLLocalFile(file)
|
||||
}
|
||||
mysqlLocalFiles[file]++
|
||||
}
|
||||
mysqlLocalFilesMu.Unlock()
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
mysqlLocalFilesMu.Lock()
|
||||
defer mysqlLocalFilesMu.Unlock()
|
||||
for _, file := range files {
|
||||
mysqlLocalFiles[file]--
|
||||
if mysqlLocalFiles[file] <= 0 {
|
||||
delete(mysqlLocalFiles, file)
|
||||
deregisterMySQLLocalFile(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
// unescapeMySQLString decodes the backslash and doubled-quote escapes of a
|
||||
// MySQL string literal body.
|
||||
func unescapeMySQLString(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c == '\\' && i+1 < len(s) {
|
||||
i++
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
if (c == '\'' || c == '"') && i+1 < len(s) && s[i+1] == c {
|
||||
i++
|
||||
}
|
||||
b.WriteByte(c)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SanitizeOracleDSN normalizes oracle trace-related DSN options through the
|
||||
// filesystem allowlist broker.
|
||||
func SanitizeOracleDSN(executionID, dsn string, options *types.Options) (string, error) {
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if options == nil {
|
||||
options = &types.Options{ExecutionId: executionID}
|
||||
}
|
||||
query := parsed.Query()
|
||||
changed := false
|
||||
for key, values := range query {
|
||||
if !isOracleTracePathOption(key) {
|
||||
continue
|
||||
}
|
||||
for i, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
normalized, err := NormalizePath(options, value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oracle %s %q: %w", key, value, err)
|
||||
}
|
||||
values[i] = normalized
|
||||
}
|
||||
query[key] = values
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return dsn, nil
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func isOracleTracePathOption(key string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(key)) {
|
||||
case "TRACE FILE", "TRACE DIR", "TRACE FOLDER", "TRACE DIRECTORY":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SanitizeMSSQLDatabaseName escapes database names used in MSSQL connection strings.
|
||||
func SanitizeMSSQLDatabaseName(dbName string) string {
|
||||
return url.QueryEscape(dbName)
|
||||
}
|
||||
|
||||
// SanitizePostgresDatabaseName escapes database names used in postgres URLs.
|
||||
func SanitizePostgresDatabaseName(dbName string) string {
|
||||
return url.PathEscape(dbName)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package protocolstate_test
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
go_ora "github.com/sijms/go-ora/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSanitizeMySQLDSNStripsAllowAllFilesWithoutLFA(t *testing.T) {
|
||||
got, err := protocolstate.SanitizeMySQLDSN(
|
||||
"root:secret@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true",
|
||||
&types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := mysql.ParseDSN(got)
|
||||
require.NoError(t, err)
|
||||
require.False(t, cfg.AllowAllFiles)
|
||||
}
|
||||
|
||||
func TestSanitizeMySQLDSNStripsAllowAllFilesWithLFA(t *testing.T) {
|
||||
got, err := protocolstate.SanitizeMySQLDSN(
|
||||
"root:secret@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true",
|
||||
&types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := mysql.ParseDSN(got)
|
||||
require.NoError(t, err)
|
||||
require.False(t, cfg.AllowAllFiles, "-lfa must never disable the allowlist")
|
||||
}
|
||||
|
||||
func TestSanitizeMySQLDSNRejectsInvalidDSN(t *testing.T) {
|
||||
_, err := protocolstate.SanitizeMySQLDSN("::not-a-dsn::", &types.Options{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSanitizeOracleDSNRejectsTraceFileOutsideAllowlist(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
|
||||
"TRACE FILE": "/etc/nuclei-oracle-trace.log",
|
||||
})
|
||||
_, err := protocolstate.SanitizeOracleDSN(t.Name(), dsn, &types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
AllowLocalFileAccess: false,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSanitizeOracleDSNNormalizesTraceFileInsideTemplates(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
traceFile := filepath.Join(templatesDir, "trace.log")
|
||||
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
|
||||
"TRACE FILE": traceFile,
|
||||
})
|
||||
got, err := protocolstate.SanitizeOracleDSN(t.Name(), dsn, &types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
AllowLocalFileAccess: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := go_ora.ParseConfig(got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, traceFile, cfg.TraceFilePath)
|
||||
}
|
||||
|
||||
func TestSanitizeOracleDSNNormalizesTraceDirectory(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
traceDir := filepath.Join(templatesDir, "traces")
|
||||
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
|
||||
"TRACE DIRECTORY": traceDir,
|
||||
})
|
||||
got, err := protocolstate.SanitizeOracleDSN(t.Name(), dsn, &types.Options{
|
||||
ExecutionId: t.Name(),
|
||||
AllowLocalFileAccess: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := go_ora.ParseConfig(got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, traceDir, cfg.TraceDir)
|
||||
}
|
||||
|
||||
func TestSanitizeMSSQLDatabaseNameEscapesInjection(t *testing.T) {
|
||||
malicious := "master;DROP TABLE users--"
|
||||
got := protocolstate.SanitizeMSSQLDatabaseName(malicious)
|
||||
require.NotEqual(t, malicious, got)
|
||||
require.Equal(t, url.QueryEscape(malicious), got)
|
||||
}
|
||||
|
||||
func TestSanitizePostgresDatabaseNameEscapesInjection(t *testing.T) {
|
||||
malicious := "../../etc/passwd"
|
||||
got := protocolstate.SanitizePostgresDatabaseName(malicious)
|
||||
require.NotEqual(t, malicious, got)
|
||||
require.Equal(t, url.PathEscape(malicious), got)
|
||||
}
|
||||
|
||||
// recordMySQLRegistry swaps the mysql driver file registry for a recorder.
|
||||
func recordMySQLRegistry(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
registered := map[string]bool{}
|
||||
restore := protocolstate.SwapMySQLLocalFileRegistry(
|
||||
func(path string) { registered[path] = true },
|
||||
func(path string) { delete(registered, path) },
|
||||
)
|
||||
t.Cleanup(restore)
|
||||
return registered
|
||||
}
|
||||
|
||||
const mysqlLocalFilesDSN = "root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true"
|
||||
|
||||
// sqlPath escapes backslashes the way a MySQL string literal needs them, so
|
||||
// Windows paths survive the round trip.
|
||||
func sqlPath(p string) string {
|
||||
return strings.ReplaceAll(p, `\`, `\\`)
|
||||
}
|
||||
|
||||
func TestRegisterMySQLLocalInfilesAllowsOnlyAllowlistedFiles(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
registered := recordMySQLRegistry(t)
|
||||
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true}
|
||||
|
||||
inside := filepath.Join(templatesDir, "data.csv")
|
||||
require.NoError(t, os.WriteFile(inside, []byte("1\n"), 0o600))
|
||||
release, err := protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, "LOAD DATA LOCAL INFILE '"+sqlPath(inside)+"' INTO TABLE t")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]bool{inside: true}, registered)
|
||||
release()
|
||||
require.Empty(t, registered, "release must deregister the file")
|
||||
|
||||
outside := outsideHomePath(t, ".nuclei-mysql-infile-"+t.Name())
|
||||
_, err = protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, `load data low_priority local infile "`+sqlPath(outside)+`" into table t`)
|
||||
require.ErrorIs(t, err, protocolstate.ErrPathDenied)
|
||||
require.Empty(t, registered)
|
||||
|
||||
_, err = protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, "LOAD DATA LOCAL INFILE 'relative.csv' INTO TABLE t")
|
||||
require.ErrorIs(t, err, protocolstate.ErrPathDenied)
|
||||
require.Empty(t, registered)
|
||||
}
|
||||
|
||||
func TestRegisterMySQLLocalInfilesNeedsLFAAndDSNOptIn(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
registered := recordMySQLRegistry(t)
|
||||
inside := filepath.Join(templatesDir, "data.csv")
|
||||
query := "LOAD DATA LOCAL INFILE '" + sqlPath(inside) + "' INTO TABLE t"
|
||||
|
||||
release, err := protocolstate.RegisterMySQLLocalInfiles(&types.Options{ExecutionId: t.Name()}, mysqlLocalFilesDSN, query)
|
||||
require.NoError(t, err)
|
||||
release()
|
||||
require.Empty(t, registered, "nothing may be registered without -lfa")
|
||||
|
||||
release, err = protocolstate.RegisterMySQLLocalInfiles(&types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true}, "root:x@nucleitcp(127.0.0.1:3306)/", query)
|
||||
require.NoError(t, err)
|
||||
release()
|
||||
require.Empty(t, registered, "nothing may be registered when the DSN does not ask for local files")
|
||||
}
|
||||
|
||||
func TestRegisterMySQLLocalInfilesRefcountsSharedFiles(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
registered := recordMySQLRegistry(t)
|
||||
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true}
|
||||
inside := filepath.Join(templatesDir, "it's.csv")
|
||||
query := `LOAD DATA LOCAL INFILE '` + sqlPath(filepath.Join(templatesDir, "it''s.csv")) + `' INTO TABLE t`
|
||||
|
||||
first, err := protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, query)
|
||||
require.NoError(t, err)
|
||||
second, err := protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, query)
|
||||
require.NoError(t, err)
|
||||
require.True(t, registered[inside], "escaped quote must decode to the real file name")
|
||||
|
||||
first()
|
||||
first()
|
||||
require.True(t, registered[inside], "a concurrent query still needs the file")
|
||||
second()
|
||||
require.Empty(t, registered)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package protocolstate
|
||||
|
||||
// SwapMySQLLocalFileRegistry replaces the mysql driver file registry hooks
|
||||
// for a test and returns a func that restores them.
|
||||
func SwapMySQLLocalFileRegistry(register, deregister func(string)) func() {
|
||||
oldRegister, oldDeregister := registerMySQLLocalFile, deregisterMySQLLocalFile
|
||||
registerMySQLLocalFile, deregisterMySQLLocalFile = register, deregister
|
||||
return func() {
|
||||
registerMySQLLocalFile, deregisterMySQLLocalFile = oldRegister, oldDeregister
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,36 @@
|
||||
package protocolstate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
fileutil "github.com/projectdiscovery/utils/file"
|
||||
mapsutil "github.com/projectdiscovery/utils/maps"
|
||||
)
|
||||
|
||||
// ErrPathDenied is returned when a path is outside the filesystem allowlist.
|
||||
var ErrPathDenied = errors.New("path denied by filesystem allowlist")
|
||||
|
||||
var (
|
||||
// LfaAllowed means local file access is allowed
|
||||
LfaAllowed *mapsutil.SyncLockMap[string, bool]
|
||||
// allowedPathsByExec stores the -allowed-paths allowlist per execution id so
|
||||
// callers that only have an execution id (NormalizePathWithExecutionId,
|
||||
// fs.ReadFile, krbforge.normalizeOutputFile) still honour --allowed-paths
|
||||
// even though they never see the full *types.Options. A plain guarded map is
|
||||
// used because the value is a slice (not comparable, so unusable as a
|
||||
// SyncLockMap value).
|
||||
allowedPathsMu sync.RWMutex
|
||||
allowedPathsByExec = map[string][]string{}
|
||||
// tempDirByExec stores the per-scan temporary directory nuclei created for
|
||||
// an execution id. It replaces the shared system temp dir in the template
|
||||
// filesystem allowlist.
|
||||
tempDirMu sync.RWMutex
|
||||
tempDirByExec = map[string]string{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -39,6 +58,63 @@ func IsLfaAllowed(options *types.Options) bool {
|
||||
|
||||
func SetLfaAllowed(options *types.Options) {
|
||||
_ = LfaAllowed.Set(options.ExecutionId, options.AllowLocalFileAccess)
|
||||
SetAllowedPaths(options)
|
||||
}
|
||||
|
||||
// SetAllowedPaths records the --allowed-paths allowlist for an execution id so
|
||||
// execution-id-only path checks can expand the allowlist the same way callers
|
||||
// that pass full options do.
|
||||
func SetAllowedPaths(options *types.Options) {
|
||||
if options == nil || options.ExecutionId == "" {
|
||||
return
|
||||
}
|
||||
paths := make([]string, 0, len(options.AllowedPaths))
|
||||
for _, p := range options.AllowedPaths {
|
||||
if p != "" {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
allowedPathsMu.Lock()
|
||||
allowedPathsByExec[options.ExecutionId] = paths
|
||||
allowedPathsMu.Unlock()
|
||||
}
|
||||
|
||||
// GetAllowedPaths returns the --allowed-paths allowlist recorded for an
|
||||
// execution id, or nil when none was stored.
|
||||
func GetAllowedPaths(executionId string) []string {
|
||||
if executionId == "" {
|
||||
return nil
|
||||
}
|
||||
allowedPathsMu.RLock()
|
||||
defer allowedPathsMu.RUnlock()
|
||||
return allowedPathsByExec[executionId]
|
||||
}
|
||||
|
||||
// SetTemporaryDirectory records the per-scan temporary directory nuclei
|
||||
// created for an execution id. Templates may access it; the shared system
|
||||
// temp dir (on Linux, /tmp) is not part of the template allowlist.
|
||||
func SetTemporaryDirectory(executionId, dir string) {
|
||||
if executionId == "" {
|
||||
return
|
||||
}
|
||||
tempDirMu.Lock()
|
||||
defer tempDirMu.Unlock()
|
||||
if dir == "" {
|
||||
delete(tempDirByExec, executionId)
|
||||
return
|
||||
}
|
||||
tempDirByExec[executionId] = dir
|
||||
}
|
||||
|
||||
// GetTemporaryDirectory returns the per-scan temporary directory recorded for
|
||||
// an execution id, or an empty string when none was recorded.
|
||||
func GetTemporaryDirectory(executionId string) string {
|
||||
if executionId == "" {
|
||||
return ""
|
||||
}
|
||||
tempDirMu.RLock()
|
||||
defer tempDirMu.RUnlock()
|
||||
return tempDirByExec[executionId]
|
||||
}
|
||||
|
||||
func GetLfaAllowed(options *types.Options) bool {
|
||||
@@ -54,29 +130,26 @@ func NormalizePathWithExecutionId(executionId string, filePath string) (string,
|
||||
return NormalizePath(options, filePath)
|
||||
}
|
||||
|
||||
// Normalizepath normalizes path and returns absolute path
|
||||
// it returns error if path is not allowed
|
||||
// this respects the sandbox rules and only loads files from
|
||||
// allowed directories
|
||||
// NormalizePath normalizes path and returns absolute path.
|
||||
// It returns an error when the resolved path is outside the computed
|
||||
// filesystem allowlist. -lfa expands the allowlist but never disables it.
|
||||
func NormalizePath(options *types.Options, filePath string) (string, error) {
|
||||
// TODO: this should be tied to executionID using *types.Options
|
||||
if IsLfaAllowed(options) {
|
||||
// if local file access is allowed, we can return the absolute path
|
||||
return filePath, nil
|
||||
if filePath == "" {
|
||||
return "", errkit.New("empty file path is not allowed")
|
||||
}
|
||||
cleaned, err := fileutil.ResolveNClean(filePath, config.DefaultConfig.GetTemplateDir())
|
||||
baseDir := config.DefaultConfig.GetTemplateDir()
|
||||
cleaned, err := resolveAndCleanPath(filePath, baseDir)
|
||||
if err != nil {
|
||||
return "", errkit.Wrapf(err, "could not resolve and clean path %v", filePath)
|
||||
}
|
||||
// only allow files inside nuclei-templates directory
|
||||
// even current working directory is not allowed
|
||||
if filepathutil.IsPathWithinDirectory(cleaned, config.DefaultConfig.GetTemplateDir()) {
|
||||
// reject hard-linked regular files, whose inode can alias content
|
||||
// outside the templates directory.
|
||||
if isPathAllowed(options, cleaned) {
|
||||
if filepathutil.IsHardLinkedRegularFile(cleaned) {
|
||||
return "", errkit.Newf("path %v denied (hard link)", filePath)
|
||||
return "", fmt.Errorf("%w: path %v denied (hard link)", ErrPathDenied, filePath)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
return "", errkit.Newf("path %v is outside nuclei-template directory and -lfa is not enabled", filePath)
|
||||
if options != nil && IsLfaAllowed(options) {
|
||||
return "", fmt.Errorf("%w: path %v is outside allowed directories (use --allowed-paths to grant access)", ErrPathDenied, filePath)
|
||||
}
|
||||
return "", fmt.Errorf("%w: path %v is outside nuclei-template directory and -lfa is not enabled", ErrPathDenied, filePath)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package protocolstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// outsideAllowlistPath returns an absolute path outside the sandbox allowlist
|
||||
// on every OS. "/etc/passwd" is not absolute on Windows and would resolve
|
||||
// relative to the templates dir (failing with "does not exist" before the
|
||||
// allowlist check); rooting at the temp volume keeps it drive-qualified yet
|
||||
// outside the templates/temp/cwd roots.
|
||||
func outsideAllowlistPath(name string) string {
|
||||
root := filepath.VolumeName(os.TempDir()) + string(os.PathSeparator)
|
||||
return filepath.Join(root, "nuclei-denied", name)
|
||||
}
|
||||
|
||||
func TestNormalizePathRejectsOutsideAllowlistWithoutLFA(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
old := config.DefaultConfig.TemplatesDirectory
|
||||
config.DefaultConfig.SetTemplatesDir(templatesDir)
|
||||
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
|
||||
|
||||
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
|
||||
_, err := protocolstate.NormalizePath(opts, outsideAllowlistPath("passwd"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNormalizePathAllowsTemplatesDir(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
payloadPath := filepath.Join(templatesDir, "payload.txt")
|
||||
require.NoError(t, os.WriteFile(payloadPath, []byte("ok"), 0o600))
|
||||
old := config.DefaultConfig.TemplatesDirectory
|
||||
config.DefaultConfig.SetTemplatesDir(templatesDir)
|
||||
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
|
||||
|
||||
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
|
||||
got, err := protocolstate.NormalizePath(opts, payloadPath)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, got, templatesDir)
|
||||
}
|
||||
|
||||
func TestNormalizePathLFADoesNotBypassAllowlist(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
old := config.DefaultConfig.TemplatesDirectory
|
||||
config.DefaultConfig.SetTemplatesDir(templatesDir)
|
||||
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
|
||||
|
||||
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true}
|
||||
_, err := protocolstate.NormalizePath(opts, outsideAllowlistPath("passwd"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "allowed directories")
|
||||
}
|
||||
|
||||
func TestIsHostAllowedFailsClosedWithoutDialers(t *testing.T) {
|
||||
require.False(t, protocolstate.IsHostAllowed("missing-execution-id", "127.0.0.1:80"))
|
||||
}
|
||||
|
||||
func TestDialAllowedRequiresExecutionID(t *testing.T) {
|
||||
_, err := protocolstate.DialAllowed(context.Background(), "tcp", "127.0.0.1:80")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package protocolstate
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
|
||||
// ReadFileAllowed reads a file after enforcing filesystem allowlist rules.
|
||||
func ReadFileAllowed(options *types.Options, filePath string) ([]byte, error) {
|
||||
finalPath, err := NormalizePath(options, filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(finalPath)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package protocolstate_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// outsideHomePath builds an absolute, writable path under the user's home
|
||||
// directory, which sits outside the templates/temp allowlist on every OS.
|
||||
// os.UserHomeDir is used instead of os.Getenv("HOME") because HOME is unset on
|
||||
// GitHub Actions windows-latest, where a relative path would resolve against
|
||||
// the test CWD and weaken the negative-path assertions.
|
||||
func outsideHomePath(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
return filepath.Join(append([]string{home}, parts...)...)
|
||||
}
|
||||
|
||||
func TestReadFileAllowedReadsInsideTemplates(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
secret := []byte("template-secret")
|
||||
path := filepath.Join(templatesDir, "secret.txt")
|
||||
require.NoError(t, os.WriteFile(path, secret, 0o600))
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
|
||||
got, err := protocolstate.ReadFileAllowed(opts, path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, secret, got)
|
||||
}
|
||||
|
||||
func TestReadFileAllowedRejectsOutsideTemplates(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
restoreTemplatesDir(t, templatesDir)
|
||||
|
||||
outside := outsideHomePath(t, ".nuclei-fsbroker-outside-"+t.Name(), "secret.txt")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(outside), 0o700))
|
||||
require.NoError(t, os.WriteFile(outside, []byte("x"), 0o600))
|
||||
t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(outside)) })
|
||||
|
||||
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
|
||||
_, err := protocolstate.ReadFileAllowed(opts, outside)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -104,8 +104,8 @@ func isValidHost(options *types.Options, targetUrl string) bool {
|
||||
}
|
||||
|
||||
dialers, ok := dialers.Get(options.ExecutionId)
|
||||
if !ok {
|
||||
return true
|
||||
if !ok || dialers == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
np := dialers.NetworkPolicy
|
||||
@@ -140,16 +140,16 @@ func CanonicalizeHost(host string) string {
|
||||
return h
|
||||
}
|
||||
|
||||
// IsHostAllowed checks if the host is allowed by network policy
|
||||
// IsHostAllowed checks if the host is allowed by network policy.
|
||||
// When dialers or policy are unavailable the check fails closed.
|
||||
func IsHostAllowed(executionId string, targetUrl string) bool {
|
||||
dialers, ok := dialers.Get(executionId)
|
||||
if !ok {
|
||||
return true
|
||||
if !ok || dialers == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
np := dialers.NetworkPolicy
|
||||
if np == nil {
|
||||
// no policy present: deny rather than allow.
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -192,21 +192,18 @@ func initDialers(options *types.Options) error {
|
||||
// dial history is required to get dialed ip of a host
|
||||
opts.WithDialerHistory = true
|
||||
|
||||
networkPolicy, err := networkpolicy.New(*npOptions)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create network policy")
|
||||
}
|
||||
opts.NetworkPolicy = networkPolicy
|
||||
|
||||
// fastdialer now by default fallbacks to ztls when there are tls related errors
|
||||
dialer, err := fastdialer.NewDialer(opts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create dialer")
|
||||
}
|
||||
|
||||
// fail initialization if the network policy cannot be created rather than
|
||||
// continuing with no denylist.
|
||||
networkPolicy, err := networkpolicy.New(*npOptions)
|
||||
if err != nil {
|
||||
// close the already-created dialer to avoid leaking its resources.
|
||||
dialer.Close()
|
||||
return errors.Wrap(err, "could not create network policy")
|
||||
}
|
||||
|
||||
// Per-host HTTP clients and transports are evicted after 90 seconds of
|
||||
// inactivity (checked lazily every 30 seconds). Evicted transports get
|
||||
// their idle connections closed immediately, so connections to
|
||||
@@ -239,11 +236,7 @@ func initDialers(options *types.Options) error {
|
||||
if val := ctx.Value("executionId"); val != nil {
|
||||
executionId = val.(string)
|
||||
}
|
||||
dialer := GetDialersWithId(executionId)
|
||||
if dialer == nil {
|
||||
return nil, fmt.Errorf("dialers not initialized for %s", executionId)
|
||||
}
|
||||
return dialer.Fastdialer.Dial(ctx, "tcp", addr)
|
||||
return DialAllowedWithExecutionID(ctx, executionId, "tcp", addr)
|
||||
})
|
||||
|
||||
StartActiveMemGuardian(context.Background())
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package protocolstate_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gologger/levels"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
)
|
||||
|
||||
func restoreTemplatesDir(t *testing.T, templatesDir string) {
|
||||
t.Helper()
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
old := config.DefaultConfig.TemplatesDirectory
|
||||
config.DefaultConfig.SetTemplatesDir(templatesDir)
|
||||
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package sandbox
|
||||
|
||||
import "github.com/projectdiscovery/utils/errkit"
|
||||
|
||||
var ErrNoAllowedRoots = errkit.New("no allowed filesystem roots configured for sandbox")
|
||||
@@ -0,0 +1,89 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
)
|
||||
|
||||
var (
|
||||
applyMu sync.Mutex
|
||||
// applied is latched only once the sandbox reaches a terminal state:
|
||||
// successfully enforced, or provably unavailable on this platform. Failures
|
||||
// (missing roots, transient apply errors) do not latch, so a later call with
|
||||
// a valid Config can still succeed.
|
||||
applied bool
|
||||
// enforced is set only when the OS-level sandbox is actually in force.
|
||||
enforced bool
|
||||
)
|
||||
|
||||
// Config configures filesystem sandbox enforcement.
|
||||
type Config struct {
|
||||
// AllowedRoots are granted only when they already exist. They cover paths
|
||||
// the user pointed nuclei at (targets, templates, cwd); creating a missing
|
||||
// one would fabricate a directory that shadows real template resolution.
|
||||
AllowedRoots []string
|
||||
// OwnedRoots are directories nuclei manages itself (templates, config,
|
||||
// state, cache, requested outputs). A missing one is created, because
|
||||
// landlock can only reference existing paths and dropping the root would
|
||||
// leave the process unable to create it afterwards.
|
||||
OwnedRoots []string
|
||||
Disabled bool
|
||||
// IncludeRuntime grants read-only access to host paths the nuclei process
|
||||
// itself needs (libc, CA certs, /dev, /proc). It must not be folded into
|
||||
// AllowedRoots: those roots also feed the JS/code filesystem allowlist.
|
||||
IncludeRuntime bool
|
||||
}
|
||||
|
||||
// ResetForTesting clears the apply-once gate. It must only be used from tests.
|
||||
func ResetForTesting() {
|
||||
applyMu.Lock()
|
||||
defer applyMu.Unlock()
|
||||
applied = false
|
||||
enforced = false
|
||||
}
|
||||
|
||||
// Apply enforces the OS-level filesystem sandbox when supported.
|
||||
// It is safe to call multiple times; only the first successful apply wins.
|
||||
// A failed attempt does not latch, so a subsequent call with a valid Config can
|
||||
// still enforce the sandbox.
|
||||
func Apply(cfg Config) error {
|
||||
if cfg.Disabled {
|
||||
return nil
|
||||
}
|
||||
applyMu.Lock()
|
||||
defer applyMu.Unlock()
|
||||
if applied {
|
||||
return nil
|
||||
}
|
||||
if len(cfg.AllowedRoots) == 0 && len(cfg.OwnedRoots) == 0 {
|
||||
gologger.Verbose().Msgf("filesystem sandbox skipped: %v", ErrNoAllowedRoots)
|
||||
return ErrNoAllowedRoots
|
||||
}
|
||||
if !Supported() {
|
||||
gologger.Verbose().Msg("filesystem sandbox is not supported on this platform; relying on Go-level path broker only")
|
||||
applied = true
|
||||
return nil
|
||||
}
|
||||
if err := applyPlatform(cfg.AllowedRoots, cfg.OwnedRoots, cfg.IncludeRuntime); err != nil {
|
||||
gologger.Warning().Msgf("filesystem sandbox not applied: %v", err)
|
||||
return err
|
||||
}
|
||||
applied = true
|
||||
enforced = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enforced reports whether the OS-level filesystem sandbox is in force for
|
||||
// this process. Landlock forbids mount(2) for the rest of the process life, so
|
||||
// tools that need mounts (such as bubblewrap) cannot run once it is enforced.
|
||||
func Enforced() bool {
|
||||
applyMu.Lock()
|
||||
defer applyMu.Unlock()
|
||||
return enforced
|
||||
}
|
||||
|
||||
// Supported reports whether an OS-level filesystem sandbox is available.
|
||||
func Supported() bool {
|
||||
return platformSupported()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
landlock "github.com/landlock-lsm/go-landlock/landlock"
|
||||
llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall"
|
||||
)
|
||||
|
||||
// hostRuntimeRODirs are paths a running nuclei process needs to read (shared
|
||||
// libraries, TLS trust store, kernel interfaces). They are landlock-only and
|
||||
// never added to the template filesystem allowlist. Home, /root and /var stay
|
||||
// out so credentials and user data remain denied.
|
||||
var hostRuntimeRODirs = []string{
|
||||
"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/proc", "/sys", "/opt",
|
||||
}
|
||||
|
||||
// hostRuntimeRWDirs need writes for ordinary operation: /dev/null and friends
|
||||
// are written by almost any process, and /run carries runtime sockets.
|
||||
var hostRuntimeRWDirs = []string{"/dev", "/run"}
|
||||
|
||||
var containerSockets = []string{
|
||||
"/var/run/docker.sock",
|
||||
"/run/docker.sock",
|
||||
}
|
||||
|
||||
// platformSupported probes the running kernel for Landlock support instead of
|
||||
// assuming it is always present on Linux. Without this, Supported() reports
|
||||
// true on kernels that cannot enforce Landlock (e.g. CONFIG_SECURITY_LANDLOCK
|
||||
// disabled, or the LSM not enabled at boot), which combined with BestEffort()
|
||||
// would let Apply() return nil while the sandbox is effectively off.
|
||||
func platformSupported() bool {
|
||||
v, err := llsyscall.LandlockGetABIVersion()
|
||||
return err == nil && v > 0
|
||||
}
|
||||
|
||||
func applyPlatform(roots, owned []string, includeRuntime bool) error {
|
||||
var opts []landlock.Rule
|
||||
if includeRuntime {
|
||||
if dirs := existingDirs(hostRuntimeRODirs); len(dirs) > 0 {
|
||||
opts = append(opts, landlock.RODirs(dirs...))
|
||||
}
|
||||
if dirs := existingDirs(hostRuntimeRWDirs); len(dirs) > 0 {
|
||||
opts = append(opts, landlock.RWDirs(dirs...))
|
||||
}
|
||||
for _, sock := range containerSockets {
|
||||
if fileExists(sock) {
|
||||
opts = append(opts, landlock.RWFiles(sock))
|
||||
}
|
||||
}
|
||||
if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
|
||||
if sock := filepath.Join(runtimeDir, "podman", "podman.sock"); fileExists(sock) {
|
||||
opts = append(opts, landlock.RWFiles(sock))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dirs := existingDirs(roots); len(dirs) > 0 {
|
||||
opts = append(opts, writableDirs(dirs))
|
||||
}
|
||||
if dirs := ensureDirs(owned); len(dirs) > 0 {
|
||||
opts = append(opts, writableDirs(dirs))
|
||||
}
|
||||
if len(opts) == 0 {
|
||||
return ErrNoAllowedRoots
|
||||
}
|
||||
if err := landlock.V9.BestEffort().RestrictPaths(opts...); err != nil {
|
||||
return fmt.Errorf("landlock restrict: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writableDirs grants read-write access, adding the "refer" right when the
|
||||
// kernel supports it. RWDirs alone forbids moving a file between two
|
||||
// directories, which the kernel reports as EXDEV and which breaks ordinary
|
||||
// work like unpacking a browser or swapping a template tree into place.
|
||||
//
|
||||
// Refer needs Landlock v2. Requesting it on a v1 kernel makes go-landlock
|
||||
// downgrade the whole ruleset to a no-op, so on v1 we keep the plain rule and
|
||||
// accept that cross-directory renames stay blocked rather than silently
|
||||
// dropping the sandbox.
|
||||
func writableDirs(dirs []string) landlock.Rule {
|
||||
rule := landlock.RWDirs(dirs...)
|
||||
if v, err := llsyscall.LandlockGetABIVersion(); err == nil && v >= 2 {
|
||||
return rule.WithRefer()
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
// ensureDirs is existingDirs for roots nuclei owns (templates, config, output).
|
||||
// Landlock can only reference paths that already exist, and these are routinely
|
||||
// absent on a first run, so a missing one is created rather than dropped:
|
||||
// dropping it would leave the process unable to create it afterwards.
|
||||
func ensureDirs(paths []string) []string {
|
||||
usable := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if mkErr := os.MkdirAll(path, 0o755); mkErr != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
usable = append(usable, path)
|
||||
}
|
||||
return existingDirs(usable)
|
||||
}
|
||||
|
||||
// existingDirs keeps only paths that are directories today, without creating
|
||||
// anything. Used for host runtime paths, which must never be fabricated.
|
||||
func existingDirs(paths []string) []string {
|
||||
out := make([]string, 0, len(paths))
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
out = append(out, path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gologger/levels"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/sandbox"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
landlockWorkerEnv = "NUCLEI_LANDLOCK_WORKER"
|
||||
landlockRuntimeWorkerEnv = "NUCLEI_LANDLOCK_RUNTIME_WORKER"
|
||||
)
|
||||
|
||||
// TestLandlockEnforcement runs real Landlock checks in a child process because
|
||||
// restrictions cannot be lifted once applied to the current process.
|
||||
func TestLandlockEnforcement(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
if !sandbox.Supported() {
|
||||
t.Skip("landlock is not supported on this kernel")
|
||||
}
|
||||
|
||||
if os.Getenv(landlockWorkerEnv) == "1" {
|
||||
runLandlockWorker(t)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestLandlockEnforcement$", "-test.count=1")
|
||||
cmd.Env = append(os.Environ(), landlockWorkerEnv+"=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("landlock worker failed: %v\n%s", err, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func runLandlockWorker(t *testing.T) {
|
||||
t.Helper()
|
||||
sandbox.ResetForTesting()
|
||||
|
||||
root, err := os.MkdirTemp("", "nuclei-landlock-*")
|
||||
require.NoError(t, err)
|
||||
|
||||
allowedFile := filepath.Join(root, "allowed.txt")
|
||||
require.NoError(t, os.WriteFile(allowedFile, []byte("inside"), 0o600))
|
||||
|
||||
missing := filepath.Join(root, "does-not-exist")
|
||||
require.NoError(t, sandbox.Apply(sandbox.Config{AllowedRoots: []string{root, missing}}))
|
||||
|
||||
inside, err := os.ReadFile(allowedFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("inside"), inside)
|
||||
|
||||
_, err = os.ReadFile("/etc/passwd")
|
||||
require.Error(t, err, "landlock should deny reads outside allowed roots")
|
||||
require.True(t, isPermissionError(err), "expected permission error, got: %v", err)
|
||||
|
||||
// Landlock blocks cleanup of dirs under /tmp; exit before test teardown.
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// TestLandlockRuntimePaths covers the production configuration, where the
|
||||
// sandbox must stay usable for nuclei itself: reading a CLI-supplied file from
|
||||
// the working directory and writing /dev/null both have to keep working, while
|
||||
// unrelated user data stays denied.
|
||||
func TestLandlockRuntimePaths(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
if !sandbox.Supported() {
|
||||
t.Skip("landlock is not supported on this kernel")
|
||||
}
|
||||
|
||||
if os.Getenv(landlockRuntimeWorkerEnv) == "1" {
|
||||
runLandlockRuntimeWorker(t)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestLandlockRuntimePaths$", "-test.count=1")
|
||||
cmd.Env = append(os.Environ(), landlockRuntimeWorkerEnv+"=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("landlock runtime worker failed: %v\n%s", err, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func runLandlockRuntimeWorker(t *testing.T) {
|
||||
t.Helper()
|
||||
sandbox.ResetForTesting()
|
||||
|
||||
workDir, err := os.MkdirTemp("", "nuclei-landlock-cwd-*")
|
||||
require.NoError(t, err)
|
||||
|
||||
targets := filepath.Join(workDir, "targets.txt")
|
||||
require.NoError(t, os.WriteFile(targets, []byte("scanme.sh\n"), 0o600))
|
||||
|
||||
denied := filepath.Join(workDir, "..", "nuclei-landlock-denied")
|
||||
require.NoError(t, os.MkdirAll(denied, 0o700))
|
||||
secret := filepath.Join(denied, "secret.txt")
|
||||
require.NoError(t, os.WriteFile(secret, []byte("secret"), 0o600))
|
||||
|
||||
require.NoError(t, sandbox.Apply(sandbox.Config{
|
||||
AllowedRoots: []string{workDir},
|
||||
IncludeRuntime: true,
|
||||
}))
|
||||
|
||||
got, err := os.ReadFile(targets)
|
||||
require.NoError(t, err, "CLI target list must stay readable under the sandbox")
|
||||
require.Equal(t, []byte("scanme.sh\n"), got)
|
||||
|
||||
require.NoError(t, os.WriteFile("/dev/null", []byte("x"), 0o600), "/dev/null must stay writable")
|
||||
|
||||
// Unpacking a browser or swapping a template tree moves files between
|
||||
// directories. Without the refer right the kernel rejects this as EXDEV.
|
||||
nested := filepath.Join(workDir, "nested")
|
||||
require.NoError(t, os.MkdirAll(nested, 0o755))
|
||||
staged := filepath.Join(nested, "payload")
|
||||
require.NoError(t, os.WriteFile(staged, []byte("staged"), 0o600))
|
||||
require.NoError(t, os.Rename(staged, filepath.Join(workDir, "payload")),
|
||||
"cross-directory rename inside a granted root must be allowed")
|
||||
|
||||
_, err = os.ReadFile(secret)
|
||||
require.Error(t, err, "paths outside the granted roots must stay denied")
|
||||
require.True(t, isPermissionError(err), "expected permission error, got: %v", err)
|
||||
|
||||
// Landlock blocks cleanup of dirs under /tmp; exit before test teardown.
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func isPermissionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if os.IsPermission(err) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "permission denied") ||
|
||||
strings.Contains(msg, "operation not permitted")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !linux
|
||||
|
||||
package sandbox
|
||||
|
||||
func platformSupported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func applyPlatform(_, _ []string, _ bool) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package sandbox_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gologger/levels"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/sandbox"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyDisabledIsNoOp(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
sandbox.ResetForTesting()
|
||||
t.Cleanup(sandbox.ResetForTesting)
|
||||
|
||||
err := sandbox.Apply(sandbox.Config{
|
||||
AllowedRoots: []string{os.TempDir()},
|
||||
Disabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplyWithEmptyRootsReturnsError(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
sandbox.ResetForTesting()
|
||||
t.Cleanup(sandbox.ResetForTesting)
|
||||
|
||||
err := sandbox.Apply(sandbox.Config{})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, sandbox.ErrNoAllowedRoots)
|
||||
}
|
||||
|
||||
func TestApplyWithValidRoots(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
if sandbox.Supported() {
|
||||
t.Skip("landlock apply is verified in TestLandlockEnforcement")
|
||||
}
|
||||
sandbox.ResetForTesting()
|
||||
t.Cleanup(sandbox.ResetForTesting)
|
||||
|
||||
root := t.TempDir()
|
||||
err := sandbox.Apply(sandbox.Config{AllowedRoots: []string{root}})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplyAllowsWritesInsideAllowedRoot(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
if sandbox.Supported() {
|
||||
t.Skip("landlock write access is verified in TestLandlockEnforcement")
|
||||
}
|
||||
sandbox.ResetForTesting()
|
||||
t.Cleanup(sandbox.ResetForTesting)
|
||||
|
||||
root := filepath.Join(os.TempDir(), "nuclei-sandbox-smoke-"+t.Name())
|
||||
require.NoError(t, os.MkdirAll(root, 0o700))
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
|
||||
require.NoError(t, sandbox.Apply(sandbox.Config{AllowedRoots: []string{root}}))
|
||||
|
||||
testFile := filepath.Join(root, "sandbox-write-test.txt")
|
||||
require.NoError(t, os.WriteFile(testFile, []byte("ok"), 0o600))
|
||||
}
|
||||
|
||||
func TestSupportedMatchesPlatform(t *testing.T) {
|
||||
// Supported() must be stable for the lifetime of the process.
|
||||
_ = sandbox.Supported()
|
||||
}
|
||||
@@ -330,6 +330,16 @@ type Options struct {
|
||||
ZTLS bool
|
||||
// AllowLocalFileAccess allows local file access from templates payloads
|
||||
AllowLocalFileAccess bool
|
||||
// AllowedPaths grants additional filesystem roots when -lfa is enabled
|
||||
AllowedPaths goflags.StringSlice
|
||||
// DisableSandbox disables the OS-level filesystem sandbox
|
||||
DisableSandbox bool
|
||||
// EnableProcessSandbox applies the OS-level filesystem sandbox (landlock on
|
||||
// Linux) to the whole nuclei process at protocol init. The nuclei CLI sets it;
|
||||
// SDK callers opt in with nuclei.EnableProcessSandbox(), because the sandbox
|
||||
// is process-wide and irreversible and would also confine the host program.
|
||||
// DisableSandbox always wins over it.
|
||||
EnableProcessSandbox bool
|
||||
// RestrictLocalNetworkAccess restricts local network access from templates requests
|
||||
RestrictLocalNetworkAccess bool
|
||||
// ShowMatchLine enables display of match line number
|
||||
@@ -741,6 +751,9 @@ func (options *Options) Copy() *Options {
|
||||
ClientCAFile: options.ClientCAFile,
|
||||
ZTLS: options.ZTLS,
|
||||
AllowLocalFileAccess: options.AllowLocalFileAccess,
|
||||
AllowedPaths: options.AllowedPaths,
|
||||
DisableSandbox: options.DisableSandbox,
|
||||
EnableProcessSandbox: options.EnableProcessSandbox,
|
||||
RestrictLocalNetworkAccess: options.RestrictLocalNetworkAccess,
|
||||
ShowMatchLine: options.ShowMatchLine,
|
||||
EnablePprof: options.EnablePprof,
|
||||
|
||||
Reference in new issue
Block a user