Medium severity5.5NVD Advisory· Published May 8, 2026· Updated May 8, 2026
CVE-2026-41646
CVE-2026-41646
Description
Nuclei is a vulnerability scanner built on a simple YAML-based DSL. From version 3.0.0 to before version 3.8.0, a vulnerability in Nuclei's JavaScript protocol runtime allows JavaScript templates to read local .js and .json files through the require() function, bypassing the default local file access restriction. This issue has been patched in version 3.8.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/projectdiscovery/nuclei/v3Go | >= 3.0.0, < 3.8.0 | 3.8.0 |
Affected products
2<3.8.0+ 1 more
- (no CPE)range: <3.8.0
- cpe:2.3:a:projectdiscovery:nuclei:*:*:*:*:*:go:*:*range: >=3.0.0,<3.8.0
Patches
16f2ade6a9b42fix(js): respect `allow-local-file-access` in `require` (#7332)
7 files changed · +238 −13
pkg/js/compiler/compiler_test.go+108 −0 modified@@ -2,13 +2,21 @@ package compiler import ( "context" + "fmt" + "os" + "path/filepath" + "runtime" "strings" "testing" "time" + "github.com/Mzack9999/goja" "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 TestNewCompilerConsoleDebug(t *testing.T) { @@ -37,6 +45,106 @@ func TestNewCompilerConsoleDebug(t *testing.T) { } } +func TestRequireLocalFileAccessDenied(t *testing.T) { + modulePath := writeModuleFile(t, t.TempDir(), "outside.js", `module.exports = { value: "outside-secret" };`) + 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.Equal(t, err.Error(), result["error"]) +} + +func TestRequireTemplateModuleAllowedWithoutLFA(t *testing.T) { + originalTemplatesDir := config.DefaultConfig.TemplatesDirectory + templatesDir := t.TempDir() + configuredTemplatesDir, moduleBaseDir := templateDirAlias(t, templatesDir) + config.DefaultConfig.SetTemplatesDir(configuredTemplatesDir) + t.Cleanup(func() { + config.DefaultConfig.SetTemplatesDir(originalTemplatesDir) + }) + + modulePath := writeModuleFile(t, moduleBaseDir, filepath.Join("helpers", "allowed.js"), `module.exports = { value: "sandbox-ok" };`) + script := fmt.Sprintf(`var helper = require(%q); ExportAs("value", helper.value); true;`, modulePath) + + result, err := executeScript(t, t.Name(), false, script) + require.NoError(t, err) + require.Equal(t, "sandbox-ok", result["value"]) +} + +func TestRequireLocalFileAccessAllowed(t *testing.T) { + modulePath := writeModuleFile(t, t.TempDir(), "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) + require.NoError(t, err) + require.Equal(t, "outside-ok", result["value"]) +} + +func TestRequireDoesNotReusePrivilegedModuleCacheAcrossExecutions(t *testing.T) { + modulePath := writeModuleFile(t, t.TempDir(), "outside.js", `module.exports = { value: "outside-ok" };`) + program, err := goja.Compile("", fmt.Sprintf(`require(%q).value`, modulePath), false) + require.NoError(t, err) + + allowExecutionID := "allow-" + t.Name() + denyExecutionID := "deny-" + t.Name() + protocolstate.SetLfaAllowed(&types.Options{ExecutionId: allowExecutionID, AllowLocalFileAccess: true}) + protocolstate.SetLfaAllowed(&types.Options{ExecutionId: denyExecutionID, AllowLocalFileAccess: false}) + + runtime := createNewRuntime() + firstValue, err := executeWithRuntime(runtime, program, NewExecuteArgs(), &ExecuteOptions{ + ExecutionId: allowExecutionID, + Context: context.Background(), + }) + require.NoError(t, err) + require.Equal(t, "outside-ok", firstValue.Export()) + + _, err = executeWithRuntime(runtime, program, NewExecuteArgs(), &ExecuteOptions{ + ExecutionId: denyExecutionID, + Context: context.Background(), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "-lfa is not enabled") +} + +func executeScript(t *testing.T, executionID string, allowLocalFileAccess bool, script string) (ExecuteResult, error) { + t.Helper() + protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: allowLocalFileAccess}) + + compiled, err := SourceAutoMode(script, false) + require.NoError(t, err) + + compiler := New() + return compiler.ExecuteWithOptions(compiled, NewExecuteArgs(), &ExecuteOptions{ + ExecutionId: executionID, + Context: context.Background(), + Source: &script, + TimeoutVariants: &types.Timeouts{ + JsCompilerExecutionTimeout: 5 * time.Second, + }, + }) +} + +func writeModuleFile(t *testing.T, baseDir string, relativePath string, contents string) string { + t.Helper() + modulePath := filepath.Join(baseDir, relativePath) + require.NoError(t, os.MkdirAll(filepath.Dir(modulePath), 0o755)) + require.NoError(t, os.WriteFile(modulePath, []byte(contents), 0o600)) + return modulePath +} + +func templateDirAlias(t *testing.T, templateDir string) (string, string) { + t.Helper() + if runtime.GOOS == "windows" { + return templateDir, templateDir + } + aliasPath := filepath.Join(t.TempDir(), "templates-link") + if err := os.Symlink(templateDir, aliasPath); err != nil { + return templateDir, templateDir + } + return aliasPath, templateDir +} + type noopWriter struct { Callback func(data []byte, level levels.Level) }
pkg/js/compiler/pool.go+26 −9 modified@@ -48,9 +48,7 @@ const ( ) var ( - r *require.Registry lazyRegistryInit = sync.OnceFunc(func() { - r = new(require.Registry) // this can be shared by multiple runtimes // autoregister console node module with default printer it uses gologger backend require.RegisterNativeModule(console.ModuleName, console.RequireWithPrinter(goconsole.NewGoConsolePrinter())) }) @@ -106,17 +104,18 @@ func executeWithRuntime(runtime *goja.Runtime, p *goja.Program, args *ExecuteArg for k, v := range args.Args { _ = runtime.Set(k, v) } + + runtime.SetContextValue("executionId", opts.ExecutionId) + runtime.SetContextValue("ctx", opts.Context) + enableRequire(runtime) + // register extra callbacks if any if opts != nil && opts.Callback != nil { if err := opts.Callback(runtime); err != nil { return nil, err } } - // inject execution id and context - runtime.SetContextValue("executionId", opts.ExecutionId) - runtime.SetContextValue("ctx", opts.Context) - // execute the script return runtime.RunProgram(p) } @@ -232,14 +231,32 @@ func InternalGetGeneratorRuntime() *goja.Runtime { return runtime } -func getRegistry() *require.Registry { +func enableRequire(runtime *goja.Runtime) { lazyRegistryInit() - return r + _ = require.NewRegistry(require.WithLoader(newSourceLoader(runtime))).Enable(runtime) +} + +func newSourceLoader(runtime *goja.Runtime) require.SourceLoader { + return func(path string) ([]byte, error) { + executionID := "" + if value, ok := runtime.GetContextValue("executionId"); ok { + if id, ok := value.(string); ok { + executionID = id + } + } + + normalizedPath, err := protocolstate.NormalizePathWithExecutionId(executionID, path) + if err != nil { + return nil, err + } + + return require.DefaultSourceLoader(normalizedPath) + } } func createNewRuntime() *goja.Runtime { runtime := protocolstate.NewJSRuntime() - _ = getRegistry().Enable(runtime) + enableRequire(runtime) // by default import below modules every time _ = runtime.Set("console", require.Require(runtime, console.ModuleName))
pkg/protocols/common/protocolstate/file.go+2 −3 modified@@ -1,10 +1,9 @@ package protocolstate import ( - "strings" - "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" @@ -71,7 +70,7 @@ func NormalizePath(options *types.Options, filePath string) (string, error) { } // only allow files inside nuclei-templates directory // even current working directory is not allowed - if strings.HasPrefix(cleaned, config.DefaultConfig.GetTemplateDir()) { + if filepathutil.IsPathWithinDirectory(cleaned, config.DefaultConfig.GetTemplateDir()) { return cleaned, nil } return "", errkit.Newf("path %v is outside nuclei-template directory and -lfa is not enabled", filePath)
pkg/types/types.go+2 −1 modified@@ -14,6 +14,7 @@ import ( "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config" "github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity" "github.com/projectdiscovery/nuclei/v3/pkg/templates/types" + filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath" "github.com/projectdiscovery/utils/errkit" fileutil "github.com/projectdiscovery/utils/file" folderutil "github.com/projectdiscovery/utils/folder" @@ -877,7 +878,7 @@ func (o *Options) GetValidAbsPath(helperFilePath, templatePath string) (string, resolvedPath, err := fileutil.ResolveNClean(helperFilePath, config.DefaultConfig.GetTemplateDir()) if err == nil { // As per rule 1, if helper file is present in nuclei-templates directory, allow it - if strings.HasPrefix(resolvedPath, config.DefaultConfig.GetTemplateDir()) { + if filepathutil.IsPathWithinDirectory(resolvedPath, config.DefaultConfig.GetTemplateDir()) { return resolvedPath, nil } }
pkg/utils/filepath/doc.go+10 −0 added@@ -0,0 +1,10 @@ +// Package filepathutil provides utilities for safe filepath operations, +// particularly for sandboxing file access in template environments. +// +// It includes functions to check if a path is contained within a directory, +// with proper canonicalization to handle symlinks and platform-specific +// path differences (such as case sensitivity on Windows). +// +// TODO(dwisiswant0): This package should be moved to the +// [github.com/projectdiscovery/utils/filepath], but let see how it goes first. +package filepathutil
pkg/utils/filepath/filepath.go+35 −0 added@@ -0,0 +1,35 @@ +package filepathutil + +import ( + "path/filepath" + "runtime" + "strings" +) + +// IsPathWithinDirectory returns true when path resolves inside directory. +// Both values are canonicalized to handle symlinks and platform-specific case rules. +func IsPathWithinDirectory(path string, directory string) bool { + canonicalPath := canonicalizePath(path) + canonicalDirectory := canonicalizePath(directory) + + relativePath, err := filepath.Rel(canonicalDirectory, canonicalPath) + if err != nil { + return false + } + return relativePath == "." || (relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(filepath.Separator))) +} + +func canonicalizePath(path string) string { + canonicalPath, err := filepath.Abs(path) + if err != nil { + canonicalPath = filepath.Clean(path) + } + if resolvedPath, err := filepath.EvalSymlinks(canonicalPath); err == nil { + canonicalPath = resolvedPath + } + canonicalPath = filepath.Clean(canonicalPath) + if runtime.GOOS == "windows" { + canonicalPath = strings.ToLower(canonicalPath) + } + return canonicalPath +}
pkg/utils/filepath/filepath_test.go+55 −0 added@@ -0,0 +1,55 @@ +package filepathutil + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestIsPathWithinDirectory(t *testing.T) { + baseDir := t.TempDir() + childFile := filepath.Join(baseDir, "nested", "child.txt") + if err := os.MkdirAll(filepath.Dir(childFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(childFile, []byte("ok"), 0o600); err != nil { + t.Fatal(err) + } + + if !IsPathWithinDirectory(childFile, baseDir) { + t.Fatalf("expected %q to be inside %q", childFile, baseDir) + } + + outsideFile := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(outsideFile, []byte("nope"), 0o600); err != nil { + t.Fatal(err) + } + if IsPathWithinDirectory(outsideFile, baseDir) { + t.Fatalf("expected %q to be outside %q", outsideFile, baseDir) + } +} + +func TestIsPathWithinDirectoryWithSymlinkedDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliable on all Windows runners") + } + + realDir := t.TempDir() + aliasDir := filepath.Join(t.TempDir(), "templates-link") + if err := os.Symlink(realDir, aliasDir); err != nil { + t.Fatalf("create symlink: %v", err) + } + + childFile := filepath.Join(realDir, "helpers", "allowed.js") + if err := os.MkdirAll(filepath.Dir(childFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(childFile, []byte("module.exports = {};"), 0o600); err != nil { + t.Fatal(err) + } + + if !IsPathWithinDirectory(childFile, aliasDir) { + t.Fatalf("expected %q to be inside symlinked dir %q", childFile, aliasDir) + } +}
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
5- github.com/projectdiscovery/nuclei/commit/6f2ade6a9b427c284c15a43445f9c7f055e60e5dnvdPatchWEB
- github.com/projectdiscovery/nuclei/pull/7332nvdIssue TrackingPatchWEB
- github.com/projectdiscovery/nuclei/security/advisories/GHSA-29rg-wmcw-hpf4nvdMitigationPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-29rg-wmcw-hpf4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41646ghsaADVISORY
News mentions
0No linked articles in our index yet.