Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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