Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions pkg/catalog/config/nucleiconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"sync"

"github.com/projectdiscovery/gologger"
filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
"github.com/projectdiscovery/utils/env"
"github.com/projectdiscovery/utils/errkit"
Expand Down Expand Up @@ -70,11 +71,14 @@ func (c *Config) IsCustomTemplate(templatePath string) bool {
}

for _, dir := range customDirs {
if strings.HasPrefix(templatePath, dir) {
if dir != "" && filepathutil.IsPathWithinDirectory(templatePath, dir) {
return true
}
}
return !strings.HasPrefix(templatePath, c.TemplatesDirectory)
if c.TemplatesDirectory == "" {
return false
}
return !filepathutil.IsPathWithinDirectory(templatePath, c.TemplatesDirectory)
}

// WriteVersionCheckData writes version check data to config file
Expand Down
48 changes: 48 additions & 0 deletions pkg/catalog/config/nucleiconfig_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package config

import (
"path/filepath"
"testing"
)

func TestIsCustomTemplateUsesPathBoundaries(t *testing.T) {
templatesDir := filepath.Join(t.TempDir(), "nuclei-templates")
cfg := &Config{}
cfg.SetTemplatesDir(templatesDir)

tests := []struct {
name string
templatePath string
want bool
}{
{
name: "official template",
templatePath: filepath.Join(templatesDir, "http", "test.yaml"),
want: false,
},
{
name: "official template sibling prefix",
templatePath: filepath.Join(templatesDir+"-evil", "test.yaml"),
want: true,
},
{
name: "custom template",
templatePath: filepath.Join(cfg.CustomGitHubTemplatesDirectory, "owner", "repo", "test.yaml"),
want: true,
},
{
name: "custom template sibling prefix",
templatePath: filepath.Join(cfg.CustomGitHubTemplatesDirectory+"-evil", "test.yaml"),
want: false,
},
}

for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
got := cfg.IsCustomTemplate(testCase.templatePath)
if got != testCase.want {
t.Fatalf("expected %v, got %v", testCase.want, got)
}
})
}
}
5 changes: 3 additions & 2 deletions pkg/catalog/config/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/projectdiscovery/nuclei/v3/pkg/templates/extensions"
filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
fileutil "github.com/projectdiscovery/utils/file"
stringsutil "github.com/projectdiscovery/utils/strings"
)
Expand Down Expand Up @@ -85,7 +86,7 @@ func IsTemplateWithRoot(fpath, rootDir string) bool {
if rootDir != "" {
if filepath.IsAbs(fpath) {
rel, err := filepath.Rel(rootDir, fpath)
if err == nil && !strings.HasPrefix(rel, "..") {
if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
pathToCheck = rel
} else {
pathToCheck = fpath
Expand Down Expand Up @@ -184,7 +185,7 @@ func GetNucleiTemplatesIndex() (map[string]string, error) {
DefaultConfig.Logger.Verbose().Msgf("failed to walk path=%v err=%v", path, err)
return nil
}
if d.IsDir() || !IsTemplateWithRoot(path, DefaultConfig.TemplatesDirectory) || stringsutil.ContainsAny(path, ignoreDirs...) {
if d.IsDir() || !IsTemplateWithRoot(path, DefaultConfig.TemplatesDirectory) || filepathutil.IsPathWithinAnyDirectory(path, ignoreDirs...) {
return nil
}
// Normalize path for consistent comparison (handles Windows path issues)
Expand Down
35 changes: 35 additions & 0 deletions pkg/catalog/config/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,38 @@ func TestIsTemplate(t *testing.T) {
})
}
}

func TestGetNucleiTemplatesIndexIncludesCustomDirSiblingPrefix(t *testing.T) {
tmpDir := t.TempDir()
cfgDir := t.TempDir()

oldConfigDir := DefaultConfig.GetConfigDir()
oldTemplatesDir := DefaultConfig.TemplatesDirectory
DefaultConfig.SetConfigDir(cfgDir)
DefaultConfig.SetTemplatesDir(tmpDir)
t.Cleanup(func() {
DefaultConfig.SetConfigDir(oldConfigDir)
DefaultConfig.SetTemplatesDir(oldTemplatesDir)
})

customGitHubDir := filepath.Join(tmpDir, "github")
require.NoError(t, os.MkdirAll(customGitHubDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(customGitHubDir, "custom-template.yaml"), []byte(`id: custom-template
info:
name: Custom Template
author: test
severity: info`), 0644))

siblingDir := filepath.Join(tmpDir, "github-evil")
require.NoError(t, os.MkdirAll(siblingDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(siblingDir, "sibling-template.yaml"), []byte(`id: sibling-template
info:
name: Sibling Template
author: test
severity: info`), 0644))

index, err := GetNucleiTemplatesIndex()
require.NoError(t, err)
require.NotContains(t, index, "custom-template", "custom template directory should be excluded")
require.Contains(t, index, "sibling-template", "custom directory sibling prefix should be indexed")
}
28 changes: 18 additions & 10 deletions pkg/catalog/disk/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/logrusorgru/aurora/v4"
"github.com/pkg/errors"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
stringsutil "github.com/projectdiscovery/utils/strings"
updateutils "github.com/projectdiscovery/utils/update"
urlutil "github.com/projectdiscovery/utils/url"
Expand Down Expand Up @@ -131,20 +132,27 @@ func (c *DiskCatalog) convertPathToAbsolute(t string) (string, error) {

// findGlobPathMatches returns the matched files from a glob path
func (c *DiskCatalog) findGlobPathMatches(absPath string, processed map[string]struct{}) ([]string, error) {
// trim templateDir if any
relPath := strings.TrimPrefix(absPath, c.templatesDirectory)
// trim leading slash if any
if c.templatesFS != nil {
// fs.FS always uses forward slashes
relPath = strings.TrimPrefix(relPath, "/")
} else {
relPath = strings.TrimPrefix(relPath, string(os.PathSeparator))
}

var err error
var matches []string

if c.templatesFS != nil {
// Compute the path relative to the templates directory using
// canonical containment instead of a lexical TrimPrefix. The previous
// strings.TrimPrefix(absPath, c.templatesDirectory) would, for a
// templatesDirectory like "/foo" and an absPath like
// "/foo-evil/x*.yaml", silently truncate to "-evil/x*.yaml" — a
// sibling-prefix bug equivalent to the one fixed across the rest of
// the codebase. With canonical containment the sibling-prefix case
// no longer aliases into the embedded FS root.
relPath := absPath
if c.templatesDirectory != "" && filepathutil.IsPathWithinDirectory(absPath, c.templatesDirectory) {
if rel, relErr := filepath.Rel(c.templatesDirectory, absPath); relErr == nil && rel != "." {
relPath = rel
}
}
// fs.FS always uses forward slashes.
relPath = filepath.ToSlash(relPath)
relPath = strings.TrimPrefix(relPath, "/")
matches, err = fs.Glob(c.templatesFS, relPath)
} else {
matches, err = filepath.Glob(absPath)
Expand Down
58 changes: 58 additions & 0 deletions pkg/catalog/disk/find_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package disk

import (
"testing/fstest"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

// TestFindGlobPathMatchesUsesCanonicalContainment ensures that when an
// absPath shares a lexical prefix with templatesDirectory but is not actually
// inside it (a sibling-prefix path), the embedded-FS lookup does NOT silently
// strip part of the prefix and resolve into the templates root.
func TestFindGlobPathMatchesUsesCanonicalContainment(t *testing.T) {
// Build a small in-memory FS that simulates an embedded templates tree
// and a sibling tree. fs.Glob is rooted at the FS root, so the test
// asserts that with the buggy prefix-trim a sibling-prefix glob would
// match the in-bounds entry; with the fix it does not.
memFS := fstest.MapFS{
"http/test.yaml": {Data: []byte("legit")},
"http-evil/test.yaml": {Data: []byte("sibling")},
}

templatesDir := filepath.Join(t.TempDir(), "templates")

cat := NewFSCatalog(memFS, templatesDir)

// Glob for siblings of templatesDir (the previous TrimPrefix would have
// silently aliased "/.../templates-evil/*.yaml" into "-evil/*.yaml" and
// then collapsed to a glob with no leading separator that ran against
// memFS root — observable as confused matches in production code paths
// that rely on this method).
sibling := templatesDir + "-evil"
matches, err := cat.findGlobPathMatches(filepath.Join(sibling, "*.yaml"), map[string]struct{}{})
require.NoError(t, err)
// Whatever the glob produces, it must NOT include the in-bounds
// http/test.yaml from inside the templates directory.
for _, m := range matches {
require.NotContains(t, m, "http/test.yaml",
"sibling-prefix glob must not alias into the templates root")
}
}

func TestFindGlobPathMatchesResolvesContainedPath(t *testing.T) {
memFS := fstest.MapFS{
"http/test.yaml": {Data: []byte("legit")},
}

templatesDir := filepath.Join(t.TempDir(), "templates")
cat := NewFSCatalog(memFS, templatesDir)

// A glob that lives inside templatesDir resolves correctly via the
// canonical relative path (no surprises from the rewrite).
matches, err := cat.findGlobPathMatches(filepath.Join(templatesDir, "http", "*.yaml"), map[string]struct{}{})
require.NoError(t, err)
require.Equal(t, []string{"http/test.yaml"}, matches)
}
50 changes: 0 additions & 50 deletions pkg/catalog/disk/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"

"github.com/pkg/errors"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
fileutil "github.com/projectdiscovery/utils/file"
urlutil "github.com/projectdiscovery/utils/url"
)

// ResolvePath resolves the path to an absolute one in various ways.
Expand Down Expand Up @@ -69,51 +67,3 @@ func (c *DiskCatalog) tryResolve(fullPath string) (string, error) {
}
return "", errNoValidCombination
}

// BackwardsCompatiblePaths returns new paths for all old/legacy template paths
// Note: this is a temporary function and will be removed in the future release
//
// Deprecated: No longer used since the official Nuclei Templates repository
// have restructured this a long time ago.
func BackwardsCompatiblePaths(templateDir string, oldPath string) string {
// TODO: remove this function in the future release
// 1. all http related paths are now moved at path /http
// 2. network related CVES are now moved at path /network/cves
newPathCallback := func(path string) string {
// trim prefix slash if any
path = strings.TrimPrefix(path, "/")
// try to resolve path at /http subdirectory
if fileutil.FileOrFolderExists(filepath.Join(templateDir, "http", path)) {
return filepath.Join(templateDir, "http", path)
// try to resolve path at /network/cves subdirectory
} else if strings.HasPrefix(path, "cves") && fileutil.FileOrFolderExists(filepath.Join(templateDir, "network", "cves", path)) {
return filepath.Join(templateDir, "network", "cves", path)
}
// most likely the path is not found
return filepath.Join(templateDir, path)
}
switch {
case fileutil.FileOrFolderExists(oldPath):
// new path specified skip processing
return oldPath
case filepath.IsAbs(oldPath):
tmp := strings.TrimPrefix(oldPath, templateDir)
if tmp == oldPath {
// user provided absolute path which is not in template directory
// skip processing
return oldPath
}
// trim the template directory from the path
return newPathCallback(tmp)
case strings.Contains(oldPath, urlutil.SchemeSeparator):
// scheme separator is used to identify the path as url
// TBD: add support for url directories ??
return oldPath
case strings.Contains(oldPath, "*"):
// this is most likely a glob path skip processing
return oldPath
default:
// this is most likely a relative path
return newPathCallback(oldPath)
}
}
10 changes: 9 additions & 1 deletion pkg/external/customtemplates/azure_blob.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,16 @@ func (bk *customTemplateAzureBlob) Download(ctx context.Context) {
for _, blob := range resp.Segment.BlobItems {
// If the blob is a .yaml download the file to the local filesystem
if strings.HasSuffix(*blob.Name, ".yaml") {
// Resolve the destination path safely so a blob name carrying
// path-traversal segments cannot escape the configured download
// directory.
outputPath, err := safeJoinWithinDirectory(downloadPath, *blob.Name)
if err != nil {
gologger.Error().Msgf("Skipping unsafe Azure blob name %q: %v", *blob.Name, err)
continue
}
// Download the template to the local filesystem at the downloadPath
err := downloadTemplate(bk.azureBlobClient, bk.containerName, *blob.Name, filepath.Join(downloadPath, *blob.Name), ctx)
err = downloadTemplate(bk.azureBlobClient, bk.containerName, *blob.Name, outputPath, ctx)
if err != nil {
gologger.Error().Msgf("Error downloading template: %v", err)
} else {
Expand Down
57 changes: 57 additions & 0 deletions pkg/external/customtemplates/azure_blob_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package customtemplates

import (
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

// The Azure Download path validates blob names through safeJoinWithinDirectory.
// These tests cover that validation directly, mirroring the per-blob iteration
// in customTemplateAzureBlob.Download so we lock in the same allow/deny shape
// the runtime applies to server-controlled blob names.

func TestAzureSafeJoinRejectsTraversalBlobNames(t *testing.T) {
downloadPath := t.TempDir()

cases := []struct {
name string
blobName string
}{
{"parent traversal", "../evil.yaml"},
{"deep traversal", "a/b/../../../etc/evil.yaml"},
{"bare dotdot", ".."},
{"sibling prefix via dotdot", "../" + filepath.Base(downloadPath) + "-evil/template.yaml"},
{"absolute escape via dotdot prefix", "../../etc/evil.yaml"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := safeJoinWithinDirectory(downloadPath, tc.blobName)
require.Errorf(t, err, "blob name %q must be rejected", tc.blobName)
})
}
}

func TestAzureSafeJoinPreservesNestedBlobNames(t *testing.T) {
downloadPath := t.TempDir()

cases := []struct {
name string
blobName string
want string
}{
{"flat", "template.yaml", filepath.Join(downloadPath, "template.yaml")},
{"nested", "dir/sub/template.yaml", filepath.Join(downloadPath, "dir", "sub", "template.yaml")},
{"sibling-basename in different dirs A", "alpha/template.yaml", filepath.Join(downloadPath, "alpha", "template.yaml")},
{"sibling-basename in different dirs B", "beta/template.yaml", filepath.Join(downloadPath, "beta", "template.yaml")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := safeJoinWithinDirectory(downloadPath, tc.blobName)
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
}
Loading
Loading