Compare commits

...
11 Commits
Author SHA1 Message Date
Mzack9999 2bb0e39108 Merge dev 2026-10-26 13:48:10 +01:00
Mzack9999 080c2fe402 Harden goid 2026-10-26 07:57:22 +01:00
Mzack9999 33a9efa4b3 Merge dev 2026-10-26 07:48:35 +01:00
Mzack9999 0a650af86e merge origin/dev 2026-07-27 12:53:03 +04:00
Mzack9999 5908879cf1 fifo review 2026-07-26 14:59:37 +04:00
Mzack9999 bd13f61f57 windows home 2026-07-26 14:57:03 +04:00
Mzack9999 c2681f596d windows build 2026-07-26 14:39:04 +04:00
Mzack9999 47d22c6ae4 security tests 2026-07-26 14:16:32 +04:00
Mzack9999 a2c73cbeb5 review fixes 2026-07-26 14:10:34 +04:00
Mzack9999 4195b6992c merge dev 2026-07-26 14:09:53 +04:00
Mzack9999 8e389b32d9 file helper 2026-07-24 16:19:46 +04:00
10 changed files with 1083 additions and 0 deletions

No files matched your search

+5
View File
@@ -51,6 +51,11 @@ func init() {
return useragent.PickRandom().Raw, nil
}))
// file() reads helper-file contents through Options.LoadHelperFile so the
// same sandbox as payloads:/includes applies (templates dir, same-dir under
// home, -lfa, hard-link rejection). Requires WithFileLoadContext.
registerFileHelper()
dsl.PrintDebugCallback = func(args ...interface{}) error {
gologger.Debug().Msgf("print_debug value: %s", fmt.Sprint(args...))
return nil
+143
View File
@@ -0,0 +1,143 @@
package dsl
import (
"io"
"os"
"sync"
"github.com/projectdiscovery/dsl"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/utils/errkit"
unitutils "github.com/projectdiscovery/utils/unit"
)
const (
// maxFileHelperSize is the maximum number of bytes file() will read.
// Matches the default HTTP response read limit.
maxFileHelperSize = int64(10 * unitutils.Mega)
)
// FileLoadContext carries the sandbox inputs required by the file() DSL helper.
// It must be installed with WithFileLoadContext for the duration of template
// evaluation on a worker goroutine.
type FileLoadContext struct {
Options *types.Options
TemplatePath string
Catalog catalog.Catalog
cache sync.Map // abs/logical path -> string contents
}
type fileLoadFrame struct {
ctx *FileLoadContext
prev *fileLoadFrame
}
var fileLoadByGoid sync.Map // uint64 -> *fileLoadFrame
// WithFileLoadContext installs ctx for the calling goroutine, runs fn, then
// restores the previous context (if any). Nested calls are supported.
func WithFileLoadContext(ctx *FileLoadContext, fn func()) {
if fn == nil {
return
}
id := goid()
if id == 0 {
// goid could not be parsed; never share one slot between goroutines,
// so file() fails closed with "no template sandbox context".
fn()
return
}
var prev *fileLoadFrame
if existing, ok := fileLoadByGoid.Load(id); ok {
prev = existing.(*fileLoadFrame)
}
fileLoadByGoid.Store(id, &fileLoadFrame{ctx: ctx, prev: prev})
defer func() {
if prev == nil {
fileLoadByGoid.Delete(id)
} else {
fileLoadByGoid.Store(id, prev)
}
}()
fn()
}
func currentFileLoadContext() *FileLoadContext {
id := goid()
if id == 0 {
return nil
}
if v, ok := fileLoadByGoid.Load(id); ok {
if frame := v.(*fileLoadFrame); frame != nil {
return frame.ctx
}
}
return nil
}
func registerFileHelper() {
_ = dsl.AddFunction(dsl.NewWithMultipleSignatures("file", []string{
"(path string) string",
}, false, fileHelper))
}
func fileHelper(args ...interface{}) (interface{}, error) {
if len(args) != 1 {
return nil, dsl.ErrInvalidDslFunction
}
path := types.ToString(args[0])
if path == "" {
return nil, errkit.New("file(): path must not be empty")
}
ctx := currentFileLoadContext()
if ctx == nil || ctx.Options == nil {
return nil, errkit.New("file(): no template sandbox context (only available during template execution)")
}
if cached, ok := ctx.cache.Load(path); ok {
return cached, nil
}
contents, err := ctx.readFile(path)
if err != nil {
return nil, err
}
ctx.cache.Store(path, contents)
return contents, nil
}
func (ctx *FileLoadContext) readFile(path string) (string, error) {
reader, err := ctx.Options.LoadHelperFile(path, ctx.TemplatePath, ctx.Catalog)
if err != nil {
return "", errkit.Wrapf(err, "file(): could not load %q", path)
}
defer func() { _ = reader.Close() }()
if file, ok := reader.(*os.File); ok {
info, statErr := file.Stat()
if statErr != nil {
return "", errkit.Wrapf(statErr, "file(): could not stat %q", path)
}
if !info.Mode().IsRegular() {
return "", errkit.Newf("file(): %q is not a regular file", path)
}
if info.Size() > maxFileHelperSize {
return "", errkit.Newf("file(): %q exceeds maximum size of %d bytes", path, maxFileHelperSize)
}
}
limited := io.LimitReader(reader, maxFileHelperSize+1)
data, err := io.ReadAll(limited)
if err != nil {
return "", errkit.Wrapf(err, "file(): could not read %q", path)
}
if int64(len(data)) > maxFileHelperSize {
return "", errkit.Newf("file(): %q exceeds maximum size of %d bytes", path, maxFileHelperSize)
}
// Go strings are binary-safe; keep opaque bytes for payload use.
return string(data), nil
}
@@ -0,0 +1,333 @@
package dsl
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
folderutil "github.com/projectdiscovery/utils/folder"
"github.com/stretchr/testify/require"
)
// TestFileHelperSandboxDeniesUntrustedPaths exercises path variants that must
// remain unreadable under the default helper-file policy.
func TestFileHelperSandboxDeniesUntrustedPaths(t *testing.T) {
home, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "secret.txt")
require.NoError(t, os.WriteFile(outsideFile, []byte("outside-secret"), 0o600))
homeSibling := filepath.Join(home, "sibling-secret.txt")
require.NoError(t, os.WriteFile(homeSibling, []byte("home-sibling"), 0o600))
prefixSiblingDir := filepath.Join(home, "nuclei-templatesX")
require.NoError(t, os.MkdirAll(prefixSiblingDir, 0o755))
prefixSiblingFile := filepath.Join(prefixSiblingDir, "payload.txt")
require.NoError(t, os.WriteFile(prefixSiblingFile, []byte("prefix-sibling"), 0o600))
nestedOutside := filepath.Join(home, "nested", "deep", "secret.txt")
require.NoError(t, os.MkdirAll(filepath.Dir(nestedOutside), 0o755))
require.NoError(t, os.WriteFile(nestedOutside, []byte("nested-secret"), 0o600))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
cases := []struct {
name string
expr string
}{
{name: "absolute_outside_home", expr: fileExpr(outsideFile)},
{name: "absolute_under_home_but_outside_template_and_templates_dir", expr: fileExpr(homeSibling)},
{name: "absolute_prefix_sibling_of_templates_dir", expr: fileExpr(prefixSiblingFile)},
{name: "absolute_nested_under_home_outside_template_tree", expr: fileExpr(nestedOutside)},
{name: "relative_parent", expr: `file("../sibling-secret.txt")`},
{name: "relative_parent_chain", expr: `file("../../sibling-secret.txt")`},
{name: "relative_dotdot_via_subdir", expr: `file("payloads/../../sibling-secret.txt")`},
{name: "relative_dot_slash_parent", expr: `file("./../sibling-secret.txt")`},
{name: "relative_nested_escape", expr: `file("a/b/../../../sibling-secret.txt")`},
{name: "relative_to_nested_outside", expr: `file("../nested/deep/secret.txt")`},
{name: "whitespace_only_path", expr: `file(" ")`},
{name: "dot_path", expr: `file(".")`},
{name: "dotdot_path", expr: `file("..")`},
{name: "empty_components", expr: `file("////")`},
{name: "template_directory", expr: fileExpr(templateDir)},
{name: "templates_directory", expr: fileExpr(templatesDir)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, tc.expr, nil)
})
require.Error(t, got, "expression %s must fail", tc.expr)
require.NotContains(t, got.Error(), "outside-secret")
require.NotContains(t, got.Error(), "home-sibling")
require.NotContains(t, got.Error(), "prefix-sibling")
require.NotContains(t, got.Error(), "nested-secret")
})
}
}
func TestFileHelperRejectsMissingSandboxContext(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "ok.txt"), []byte("ok"), 0o600))
_, err := evalFile(t, `file("ok.txt")`, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "no template sandbox context")
ctxNilOptions := &FileLoadContext{
Options: nil,
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctxNilOptions, func() {
_, got = evalFile(t, `file("ok.txt")`, nil)
})
require.Error(t, got)
require.Contains(t, got.Error(), "no template sandbox context")
}
func TestFileHelperRejectsWrongArity(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, `file("a","b")`, nil)
})
require.Error(t, got)
}
func TestFileHelperRejectsTemplateAtHomeRootExpandingHome(t *testing.T) {
home, templatesDir, _ := setupFileHelperSandbox(t)
// Place the template directly in $HOME so templateDir == home. Rule 2 must
// refuse expanding the allowed tree to the entire home directory.
homeTemplate := filepath.Join(home, "template.yaml")
require.NoError(t, os.WriteFile(homeTemplate, []byte("id: home-root\n"), 0o600))
homeSecret := filepath.Join(home, "ssh-keys.txt")
require.NoError(t, os.WriteFile(homeSecret, []byte("id-rsa-material"), 0o600))
// Sanity-check the sandbox home override is what GetValidAbsPath will see.
require.Equal(t, filepath.Clean(home), filepath.Clean(folderutil.HomeDirOrDefault("")),
"sandbox home override must be active for this assertion")
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: homeTemplate,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, fileExpr(homeSecret), nil)
})
require.Error(t, got)
require.NotContains(t, got.Error(), "id-rsa-material")
}
func TestFileHelperAllowsNormalizedRelativeWithinTemplateDir(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
require.NoError(t, os.MkdirAll(filepath.Join(templateDir, "payloads"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "payloads", "data.txt"), []byte("in-sandbox"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "root.txt"), []byte("root-payload"), 0o600))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
cases := []struct {
name string
expr string
want string
}{
{name: "plain_relative", expr: `file("root.txt")`, want: "root-payload"},
{name: "dot_slash", expr: `file("./root.txt")`, want: "root-payload"},
{name: "child_relative", expr: `file("payloads/data.txt")`, want: "in-sandbox"},
{name: "dotdot_stays_inside", expr: `file("payloads/../root.txt")`, want: "root-payload"},
{name: "redundant_dots", expr: `file("./payloads/./data.txt")`, want: "in-sandbox"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var (
got string
err error
)
WithFileLoadContext(ctx, func() {
got, err = evalFile(t, tc.expr, nil)
})
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
}
func TestFileHelperNestedContextRestoresOuterSandbox(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
secretDir := t.TempDir()
secretPath := filepath.Join(secretDir, "secret.txt")
require.NoError(t, os.WriteFile(secretPath, []byte("elevated-only"), 0o600))
restricted := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
elevated := &FileLoadContext{
Options: &types.Options{
AllowLocalFileAccess: true,
},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
WithFileLoadContext(restricted, func() {
var elevatedErr error
WithFileLoadContext(elevated, func() {
got, err := evalFile(t, fileExpr(secretPath), nil)
require.NoError(t, err)
require.Equal(t, "elevated-only", got)
})
_, elevatedErr = evalFile(t, fileExpr(secretPath), nil)
require.Error(t, elevatedErr, "outer sandbox must be restored after nested context returns")
require.NotContains(t, elevatedErr.Error(), "elevated-only")
})
}
func TestFileHelperCacheDoesNotCrossContexts(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
secretDir := t.TempDir()
secretPath := filepath.Join(secretDir, "secret.txt")
require.NoError(t, os.WriteFile(secretPath, []byte("must-not-leak"), 0o600))
elevated := &FileLoadContext{
Options: &types.Options{
AllowLocalFileAccess: true,
},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
restricted := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
WithFileLoadContext(elevated, func() {
got, err := evalFile(t, fileExpr(secretPath), nil)
require.NoError(t, err)
require.Equal(t, "must-not-leak", got)
})
var got error
WithFileLoadContext(restricted, func() {
_, got = evalFile(t, fileExpr(secretPath), nil)
})
require.Error(t, got)
require.NotContains(t, got.Error(), "must-not-leak")
}
func TestFileHelperDeniesSymlinkChainOutsideSandbox(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is not reliable on all Windows runners")
}
_, templatesDir, templatePath := setupFileHelperSandbox(t)
outsideDir := filepath.Join(filepath.Dir(templatesDir), "outside-chain")
require.NoError(t, os.MkdirAll(outsideDir, 0o755))
outsideFile := filepath.Join(outsideDir, "secret.txt")
require.NoError(t, os.WriteFile(outsideFile, []byte("chain-secret"), 0o600))
midLink := filepath.Join(templatesDir, "mid.link")
finalLink := filepath.Join(templatesDir, "final.link")
require.NoError(t, os.Symlink(outsideFile, midLink))
require.NoError(t, os.Symlink(midLink, finalLink))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, fileExpr(finalLink), nil)
})
require.Error(t, got)
require.NotContains(t, got.Error(), "chain-secret")
}
func TestFileHelperEnforcesExactSizeBoundary(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
atLimit := filepath.Join(templateDir, "at-limit.bin")
overLimit := filepath.Join(templateDir, "over-limit.bin")
require.NoError(t, os.WriteFile(atLimit, make([]byte, maxFileHelperSize), 0o600))
require.NoError(t, os.WriteFile(overLimit, make([]byte, maxFileHelperSize+1), 0o600))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
WithFileLoadContext(ctx, func() {
got, err := evalFile(t, `file("at-limit.bin")`, nil)
require.NoError(t, err)
require.Len(t, got, int(maxFileHelperSize))
_, err = evalFile(t, `file("over-limit.bin")`, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "exceeds maximum size")
})
}
func TestFileHelperPathArgumentDoesNotExecuteAsExpression(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "ok.txt"), []byte("trusted-ok"), 0o600))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
// Path is opaque data; embedded file(...)-looking text must not open ok.txt.
var (
got string
err error
)
WithFileLoadContext(ctx, func() {
got, err = evalFile(t, `file("prefix file(ok.txt) suffix")`, nil)
})
require.Error(t, err)
require.NotEqual(t, "trusted-ok", got)
require.NotContains(t, err.Error(), "trusted-ok")
}
@@ -0,0 +1,40 @@
//go:build unix
package dsl
import (
"os"
"path/filepath"
"syscall"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func TestFileHelperDeniesFifo(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
fifoPath := filepath.Join(templateDir, "pipe.fifo")
require.NoError(t, syscall.Mkfifo(fifoPath, 0o600))
// Hold an RDWR handle so the helper's read open does not block waiting for a
// writer, and so rejection does not depend on a separate O_WRONLY opener.
keepalive, err := os.OpenFile(fifoPath, os.O_RDWR, 0)
require.NoError(t, err)
t.Cleanup(func() { _ = keepalive.Close() })
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, `file("pipe.fifo")`, nil)
})
require.Error(t, got)
require.Contains(t, got.Error(), "not a regular file")
}
+438
View File
@@ -0,0 +1,438 @@
package dsl
import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/projectdiscovery/govaluate"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func TestFileHelperRegistered(t *testing.T) {
_, ok := HelperFunctions["file"]
require.True(t, ok, "file helper must be registered in HelperFunctions")
}
func TestFileHelperResolvesRelativeAgainstTemplateNotCwd(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "payload.txt"), []byte("template-local"), 0o600))
originalWd, err := os.Getwd()
require.NoError(t, err)
unrelated := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(unrelated, "payload.txt"), []byte("cwd-local"), 0o600))
require.NoError(t, os.Chdir(unrelated))
t.Cleanup(func() { _ = os.Chdir(originalWd) })
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var (
got string
evalErr error
)
WithFileLoadContext(ctx, func() {
got, evalErr = evalFile(t, `file("payload.txt")`, nil)
})
require.NoError(t, evalErr)
require.Equal(t, "template-local", got, "must resolve relative to template dir, not CWD")
}
func TestFileHelperRejectsEmptyPath(t *testing.T) {
home, templatesDir, templatePath := setupFileHelperSandbox(t)
_ = home
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, `file("")`, nil)
})
require.Error(t, got)
require.Contains(t, got.Error(), "must not be empty")
}
func TestFileHelperReadsRelativeToTemplateDir(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
payloadPath := filepath.Join(templateDir, "payload.bin")
require.NoError(t, os.WriteFile(payloadPath, []byte{0x00, 0x01, 0xff, 'A'}, 0o600))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var (
got string
err error
)
WithFileLoadContext(ctx, func() {
got, err = evalFile(t, `file("payload.bin")`, nil)
})
require.NoError(t, err)
require.Equal(t, string([]byte{0x00, 0x01, 0xff, 'A'}), got)
}
func TestFileHelperReadsFromNucleiTemplatesDirectory(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
helperPath := filepath.Join(templatesDir, "shared-payload.txt")
require.NoError(t, os.WriteFile(helperPath, []byte("from-templates-dir"), 0o600))
// Template lives outside templatesDir; rule 1 still allows templates-dir helpers.
outsideHome := t.TempDir()
outsideTemplate := filepath.Join(outsideHome, "template.yaml")
require.NoError(t, os.WriteFile(outsideTemplate, []byte("id: x\n"), 0o600))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: outsideTemplate,
Catalog: disk.NewCatalog(templatesDir),
}
_ = templatePath
var (
got string
err error
)
WithFileLoadContext(ctx, func() {
got, err = evalFile(t, fileExpr(helperPath), nil)
})
require.NoError(t, err)
require.Equal(t, "from-templates-dir", got)
}
func TestFileHelperDeniesPathOutsideSandbox(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
secretDir := t.TempDir()
secretPath := filepath.Join(secretDir, "secret.txt")
require.NoError(t, os.WriteFile(secretPath, []byte("top-secret"), 0o600))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, fileExpr(secretPath), nil)
})
require.Error(t, got)
require.True(t, strings.Contains(got.Error(), "denied") || strings.Contains(got.Error(), "could not load"), got.Error())
}
func TestFileHelperDeniesTraversalOutsideTemplateDir(t *testing.T) {
home, templatesDir, templatePath := setupFileHelperSandbox(t)
outsideHelper := filepath.Join(home, "outside.txt")
require.NoError(t, os.WriteFile(outsideHelper, []byte("escaped"), 0o600))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, `file("../outside.txt")`, nil)
})
require.Error(t, got)
}
func TestFileHelperDeniesSiblingPrefixTemplatesDir(t *testing.T) {
home, templatesDir, templatePath := setupFileHelperSandbox(t)
evilDir := filepath.Join(home, "nuclei-templates-evil")
require.NoError(t, os.MkdirAll(evilDir, 0o755))
evilFile := filepath.Join(evilDir, "payloads.txt")
require.NoError(t, os.WriteFile(evilFile, []byte("evil"), 0o600))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, fileExpr(evilFile), nil)
})
require.Error(t, got)
}
func TestFileHelperDeniesSymlinkToOutside(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is not reliable on all Windows runners")
}
_, templatesDir, templatePath := setupFileHelperSandbox(t)
outsideDir := filepath.Join(filepath.Dir(templatesDir), "outside")
require.NoError(t, os.MkdirAll(outsideDir, 0o755))
outsideFile := filepath.Join(outsideDir, "secret.txt")
require.NoError(t, os.WriteFile(outsideFile, []byte("secret"), 0o600))
linkPath := filepath.Join(templatesDir, "linked-secret.txt")
require.NoError(t, os.Symlink(outsideFile, linkPath))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, fileExpr(linkPath), nil)
})
require.Error(t, got)
}
func TestFileHelperDeniesHardLink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("hard links are not exercised on Windows")
}
_, templatesDir, templatePath := setupFileHelperSandbox(t)
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "secret.txt")
require.NoError(t, os.WriteFile(outsideFile, []byte("secret"), 0o600))
linkPath := filepath.Join(templatesDir, "hardlinked-secret.txt")
if err := os.Link(outsideFile, linkPath); err != nil {
t.Skipf("hard link not supported: %v", err)
}
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, fileExpr(linkPath), nil)
})
require.Error(t, got)
require.Contains(t, got.Error(), "hard link")
}
func TestFileHelperDeniesDirectory(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
dirPath := filepath.Join(filepath.Dir(templatePath), "subdir")
require.NoError(t, os.MkdirAll(dirPath, 0o755))
ctx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, `file("subdir")`, nil)
})
require.Error(t, got)
require.Contains(t, got.Error(), "not a regular file")
}
func TestFileHelperAllowLocalFileAccessReadsAbsolutePath(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
secretDir := t.TempDir()
secretPath := filepath.Join(secretDir, "secret.txt")
require.NoError(t, os.WriteFile(secretPath, []byte("allowed-with-lfa"), 0o600))
ctx := &FileLoadContext{
Options: &types.Options{
AllowLocalFileAccess: true,
},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var (
got string
err error
)
WithFileLoadContext(ctx, func() {
got, err = evalFile(t, fileExpr(secretPath), nil)
})
require.NoError(t, err)
require.Equal(t, "allowed-with-lfa", got)
}
func TestFileHelperRejectsOversizedContent(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
ctx := &FileLoadContext{
Options: &types.Options{
LoadHelperFileFunction: func(helperFile, _ string, _ catalog.Catalog) (io.ReadCloser, error) {
return io.NopCloser(&hugeReader{n: maxFileHelperSize + 1}), nil
},
},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
var got error
WithFileLoadContext(ctx, func() {
_, got = evalFile(t, `file("huge.bin")`, nil)
})
require.Error(t, got)
require.Contains(t, got.Error(), "exceeds maximum size")
}
func TestFileHelperCachesPerContext(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
payloadPath := filepath.Join(templateDir, "cached.txt")
require.NoError(t, os.WriteFile(payloadPath, []byte("v1"), 0o600))
var opens int
ctx := &FileLoadContext{
Options: &types.Options{
LoadHelperFileFunction: func(helperFile, tpl string, cat catalog.Catalog) (io.ReadCloser, error) {
opens++
return (&types.Options{}).LoadHelperFile(helperFile, tpl, cat)
},
},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
WithFileLoadContext(ctx, func() {
got1, err := evalFile(t, `file("cached.txt")`, nil)
require.NoError(t, err)
require.Equal(t, "v1", got1)
got2, err := evalFile(t, `file("cached.txt")`, nil)
require.NoError(t, err)
require.Equal(t, "v1", got2)
})
require.Equal(t, 1, opens, "second file() call should use cache")
}
func TestFileHelperContextDoesNotLeakAcrossGoroutines(t *testing.T) {
_, templatesDir, templatePath := setupFileHelperSandbox(t)
templateDir := filepath.Dir(templatePath)
require.NoError(t, os.WriteFile(filepath.Join(templateDir, "ok.txt"), []byte("ok"), 0o600))
allowedCtx := &FileLoadContext{
Options: &types.Options{},
TemplatePath: templatePath,
Catalog: disk.NewCatalog(templatesDir),
}
started := make(chan struct{})
release := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
WithFileLoadContext(allowedCtx, func() {
_, err := evalFile(t, `file("ok.txt")`, nil)
require.NoError(t, err)
close(started)
<-release
})
}()
<-started
_, errNoCtx := evalFile(t, `file("ok.txt")`, nil)
close(release)
<-done
require.Error(t, errNoCtx, "goroutine without context must not observe another goroutine's sandbox")
require.Contains(t, errNoCtx.Error(), "no template sandbox context")
}
type hugeReader struct {
n int64
i int64
}
func (h *hugeReader) Read(p []byte) (int, error) {
if h.i >= h.n {
return 0, io.EOF
}
remaining := h.n - h.i
if int64(len(p)) > remaining {
p = p[:remaining]
}
for i := range p {
p[i] = 'A'
}
h.i += int64(len(p))
return len(p), nil
}
func setupFileHelperSandbox(t *testing.T) (home, templatesDir, templatePath string) {
t.Helper()
home = t.TempDir()
// os.UserHomeDir (used by folderutil.HomeDirOrDefault) reads HOME on Unix and
// USERPROFILE on Windows; set both so sandbox rule-2 home checks are portable.
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
templatesDir = filepath.Join(home, "nuclei-templates")
templateDir := filepath.Join(home, "custom-templates")
for _, dir := range []string{templatesDir, templateDir} {
require.NoError(t, os.MkdirAll(dir, 0o755))
}
oldTemplatesDir := config.DefaultConfig.TemplatesDirectory
config.DefaultConfig.SetTemplatesDir(templatesDir)
t.Cleanup(func() {
config.DefaultConfig.SetTemplatesDir(oldTemplatesDir)
})
templatePath = filepath.Join(templateDir, "template.yaml")
require.NoError(t, os.WriteFile(templatePath, []byte("id: file-helper\n"), 0o600))
return home, templatesDir, templatePath
}
func evalFile(t *testing.T, expression string, values map[string]interface{}) (string, error) {
t.Helper()
compiled, err := govaluate.NewEvaluableExpressionWithFunctions(expression, HelperFunctions)
require.NoError(t, err, "expression must compile")
result, err := compiled.Evaluate(values)
if err != nil {
return "", err
}
return types.ToString(result), nil
}
// fileExpr builds a file() call with a slash-normalized path so Windows
// backslashes are not treated as string escapes by the expression parser.
func fileExpr(path string) string {
return `file("` + filepath.ToSlash(path) + `")`
}
+25
View File
@@ -0,0 +1,25 @@
package dsl
import (
"runtime"
"strconv"
"strings"
)
// goid returns the current goroutine id.
//
// DSL helpers have no call-site context argument, so file() scopes its
// LoadHelperFile sandbox to the executing goroutine while a template runs.
func goid() uint64 {
var buf [64]byte
n := runtime.Stack(buf[:], false)
s := string(buf[:n])
s = strings.TrimPrefix(s, "goroutine ")
if i := strings.IndexByte(s, ' '); i > 0 {
id, err := strconv.ParseUint(s[:i], 10, 64)
if err == nil {
return id
}
}
return 0
}
+21
View File
@@ -0,0 +1,21 @@
package dsl
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestGoidParsesStackHeader fails if the runtime.Stack header format changes,
// which would otherwise make file() silently lose its sandbox context.
func TestGoidParsesStackHeader(t *testing.T) {
id := goid()
require.NotZero(t, id)
require.Equal(t, id, goid())
other := make(chan uint64)
go func() { other <- goid() }()
otherID := <-other
require.NotZero(t, otherID)
require.NotEqual(t, id, otherID)
}
@@ -1,6 +1,7 @@
package variables
import (
"regexp"
"strings"
"github.com/invopop/jsonschema"
@@ -18,6 +19,9 @@ import (
stringsutil "github.com/projectdiscovery/utils/strings"
)
// fileHelperCall matches a file() DSL helper call, not substrings like "profile(".
var fileHelperCall = regexp.MustCompile(`\bfile\s*\(`)
// Variable is a key-value pair of strings that can be used
// throughout template.
type Variable struct {
@@ -239,6 +243,13 @@ func (variables *Variable) checkForLazyEval() bool {
return
}
// file() needs the per-template LoadHelperFile sandbox installed during
// execution (TemplatePath / Options), so defer evaluation until then.
if fileHelperCall.MatchString(types.ToString(value)) {
needsLazy = true
return
}
if hasUndefinedParams(types.ToString(value), variables) {
needsLazy = true
return
@@ -109,6 +109,29 @@ func TestCheckForLazyEval(t *testing.T) {
require.False(t, variables.LazyEval, "LazyEval flag should be false")
})
t.Run("file-helper-requires-runtime-sandbox", func(t *testing.T) {
variables := &Variable{
InsertionOrderedStringMap: *utils.NewEmptyInsertionOrderedStringMap(1),
}
variables.Set("payload", `{{file("payload.bin")}}`)
result := variables.checkForLazyEval()
require.True(t, result, "file() needs template sandbox context at execution time")
require.True(t, variables.LazyEval, "LazyEval flag should be true for file()")
})
t.Run("literal-text-containing-file-substring-is-not-lazy", func(t *testing.T) {
variables := &Variable{
InsertionOrderedStringMap: *utils.NewEmptyInsertionOrderedStringMap(1),
}
// "profile(" contains the substring "file(" but is not a file() call.
variables.Set("label", "user profile(admin)")
result := variables.checkForLazyEval()
require.False(t, result, "literal text containing file( substring must not force lazy eval")
require.False(t, variables.LazyEval)
})
t.Run("known-runtime-variables", func(t *testing.T) {
// Variables with known runtime variables (Host, BaseURL, etc.) should be lazy
variables := &Variable{
+44
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
@@ -31,6 +32,9 @@ type TemplateExecuter struct {
engine TemplateEngine
results *atomic.Bool
program *goja.Program
fileLoadCtx *dsl.FileLoadContext
fileLoadCtxOnce sync.Once
}
// Both executer & Executor are correct spellings (its open to interpretation)
@@ -62,6 +66,14 @@ func NewTemplateExecuter(requests []protocols.Request, options *protocols.Execut
// Compile compiles the execution generators preparing any requests possible.
func (e *TemplateExecuter) Compile() error {
var compileErr error
dsl.WithFileLoadContext(e.fileLoadContext(), func() {
compileErr = e.compile()
})
return compileErr
}
func (e *TemplateExecuter) compile() error {
cliOptions := e.options.Options
for _, request := range e.requests {
@@ -99,6 +111,15 @@ func (e *TemplateExecuter) Requests() int {
// Execute executes the protocol group and returns true or false if results were found.
func (e *TemplateExecuter) Execute(ctx *scan.ScanContext) (bool, error) {
var matched bool
var execErr error
dsl.WithFileLoadContext(e.fileLoadContext(), func() {
matched, execErr = e.execute(ctx)
})
return matched, execErr
}
func (e *TemplateExecuter) execute(ctx *scan.ScanContext) (bool, error) {
// === when nuclei is built with -tags=stats ===
// Note: this is no-op (empty functions) when nuclei is built in normal or without -tags=stats
@@ -283,6 +304,15 @@ func getErrorCause(err error) string {
// ExecuteWithResults executes the protocol requests and returns results instead of writing them.
func (e *TemplateExecuter) ExecuteWithResults(ctx *scan.ScanContext) ([]*output.ResultEvent, error) {
var results []*output.ResultEvent
var execErr error
dsl.WithFileLoadContext(e.fileLoadContext(), func() {
results, execErr = e.executeWithResults(ctx)
})
return results, execErr
}
func (e *TemplateExecuter) executeWithResults(ctx *scan.ScanContext) ([]*output.ResultEvent, error) {
defer e.options.RemoveTemplateCtx(ctx.Input.MetaInput)
var errx error
@@ -306,6 +336,20 @@ func (e *TemplateExecuter) ExecuteWithResults(ctx *scan.ScanContext) ([]*output.
return ctx.GenerateResult(), errx
}
func (e *TemplateExecuter) fileLoadContext() *dsl.FileLoadContext {
if e == nil || e.options == nil {
return nil
}
e.fileLoadCtxOnce.Do(func() {
e.fileLoadCtx = &dsl.FileLoadContext{
Options: e.options.Options,
TemplatePath: e.options.TemplatePath,
Catalog: e.options.Catalog,
}
})
return e.fileLoadCtx
}
// getTemplateType returns the template type of the template
func (e *TemplateExecuter) getTemplateType() string {
if len(e.requests) == 0 {