Skip to content
Open
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
10 changes: 5 additions & 5 deletions mustache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ go get github.com/gofiber/template/mustache/v4

_**./views/index.mustache**_
```html
{{> views/partials/header }}
{{> partials/header }}

<h1>{{Title}}</h1>

{{> views/partials/footer }}
{{> partials/footer }}
```
_**./views/partials/header.mustache**_
```html
Expand Down Expand Up @@ -66,9 +66,9 @@ func main() {
engine := mustache.New("./views", ".mustache")

// Or from an embedded system
// Note that with an embedded system the partials included from template files must be
// specified relative to the filesystem's root, not the current working directory
// engine := mustache.NewFileSystem(http.Dir("./views", ".mustache"), ".mustache")
// Partials are resolved relative to the engine directory / filesystem root.
// For compatibility, full paths also work when present in your templates.
// engine := mustache.NewFileSystem(http.Dir("./views"), ".mustache")
Comment on lines 68 to +71
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README example shows http.Dir(...) in the (commented) embedded/filesystem snippet but the import block in the example doesn’t include net/http. Add the import so users can copy/paste and uncomment the snippet without compile errors.

Copilot uses AI. Check for mistakes.

// Pass the engine to the Views
app := fiber.New(fiber.Config{
Expand Down
76 changes: 73 additions & 3 deletions mustache/mustache.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"

Expand All @@ -26,16 +27,81 @@ type Engine struct {
type fileSystemPartialProvider struct {
fileSystem http.FileSystem
extension string
baseDir string
verbose bool
}

func (p fileSystemPartialProvider) Get(path string) (string, error) {
buf, err := core.ReadFile(path+p.extension, p.fileSystem)
return string(buf), err
func (p fileSystemPartialProvider) Get(partial string) (string, error) {
candidates := p.lookupCandidates(partial)
var firstErr error
for _, candidate := range candidates {
buf, err := core.ReadFile(candidate, p.fileSystem)
if err == nil {
return string(buf), nil
}

if firstErr == nil {
firstErr = err
}
if p.verbose {
log.Printf("views: partial lookup failed: partial=%q candidate=%q err=%v", partial, candidate, err)
}
}

if p.verbose {
log.Printf("views: partial not found: partial=%q candidates=%v", partial, candidates)
}

if firstErr == nil {
firstErr = fmt.Errorf("no partial candidates generated")
}
return "", fmt.Errorf("render: partial %q does not exist (tried: %s): %w", partial, strings.Join(candidates, ", "), firstErr)
}
Comment on lines +55 to +59
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new partial lookup/error-wrapping behavior isn’t covered by a failing-path test. Add a test that renders a template referencing a missing partial and assert that Render (or Load, depending on mustache behavior) returns an error containing the partial name and attempted candidates (and that it’s not silently ignored).

Copilot uses AI. Check for mistakes.

func (p fileSystemPartialProvider) lookupCandidates(partial string) []string {
addCandidate := func(candidates []string, candidate string) []string {
for _, existing := range candidates {
if existing == candidate {
return candidates
}
}
return append(candidates, candidate)
}

addExtension := func(raw string) string {
if strings.HasSuffix(raw, p.extension) {
return raw
}
return raw + p.extension
}

base := filepath.ToSlash(strings.TrimSpace(p.baseDir))
base = strings.TrimSuffix(base, "/")
if base == "." || base == "/" {
base = ""
}

clean := filepath.ToSlash(strings.TrimSpace(partial))
clean = strings.TrimPrefix(clean, "./")

candidates := make([]string, 0, 2)
if clean != "" {
candidates = addCandidate(candidates, addExtension(clean))
if base != "" {
candidates = addCandidate(candidates, addExtension(path.Join(base, clean)))
}
Comment on lines +89 to +92
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lookupCandidates currently tries the raw partial path before the engine baseDir candidate. That means a partials/header reference can resolve to a file relative to the process working directory even when an engine directory is configured, which is contrary to the README statement that partials resolve relative to the engine directory / filesystem root and can pick the wrong file if duplicates exist. Prefer trying baseDir + partial first (when baseDir is set), and fall back to the raw path for compatibility.

Suggested change
candidates = addCandidate(candidates, addExtension(clean))
if base != "" {
candidates = addCandidate(candidates, addExtension(path.Join(base, clean)))
}
if base != "" {
candidates = addCandidate(candidates, addExtension(path.Join(base, clean)))
}
candidates = addCandidate(candidates, addExtension(clean))

Copilot uses AI. Check for mistakes.
}

return candidates
}
Comment on lines +61 to 96

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The lookupCandidates method does not sanitize the partial input, leading to a Path Traversal vulnerability. When the engine is initialized using mustache.New(), it uses the local filesystem directly (p.fileSystem is nil). A malicious template could use ../ sequences in a partial tag (e.g., {{> ../../../etc/passwd }}) to read arbitrary files from the server, escaping the intended template directory. More robust sanitization is required to prevent access outside the intended template directory. This involves cleaning the path and then checking for directory traversal attempts or absolute paths to ensure the resolved path remains within the baseDir.


// New returns a Mustache render engine for Fiber
func New(directory, extension string) *Engine {
engine := &Engine{
partialsProvider: &fileSystemPartialProvider{
extension: extension,
baseDir: directory,
},
Engine: core.Engine{
Directory: directory,
Extension: extension,
Expand All @@ -56,6 +122,7 @@ func NewFileSystemPartials(fs http.FileSystem, extension string, partialsFS http
partialsProvider: &fileSystemPartialProvider{
fileSystem: partialsFS,
extension: extension,
baseDir: "/",
},
Engine: core.Engine{
Directory: "/",
Expand All @@ -75,6 +142,9 @@ func (e *Engine) Load() error {
defer e.Mutex.Unlock()

e.Templates = make(map[string]*mustache.Template)
if e.partialsProvider != nil {
e.partialsProvider.verbose = e.Verbose
}

// Loop trough each directory and register template files
walkFn := func(path string, info os.FileInfo, err error) error {
Expand Down
18 changes: 17 additions & 1 deletion mustache/mustache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ func Test_Render(t *testing.T) {
require.Equal(t, expect, result)
}

func Test_Render_RelativePartials(t *testing.T) {
t.Parallel()
engine := New("./views", ".mustache")
require.NoError(t, engine.Load())

var buf bytes.Buffer
err := engine.Render(&buf, "relative", customMap{
"Title": "Hello, Relative!",
})
require.NoError(t, err)

expect := `<h2>Header</h2><h1>Hello, Relative!</h1><h2>Footer</h2>`
result := trim(buf.String())
require.Equal(t, expect, result)
}

func Test_Layout(t *testing.T) {
t.Parallel()
engine := New("./views", ".mustache")
Expand Down Expand Up @@ -82,7 +98,7 @@ func Test_Empty_Layout(t *testing.T) {

func Test_FileSystem(t *testing.T) {
t.Parallel()
engine := NewFileSystemPartials(http.Dir("./views"), ".mustache", http.Dir("."))
engine := NewFileSystemPartials(http.Dir("./views"), ".mustache", http.Dir("./views"))
require.NoError(t, engine.Load())

var buf bytes.Buffer
Expand Down
4 changes: 2 additions & 2 deletions mustache/views/index.mustache
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{{> views/partials/header }}
{{> partials/header }}
<h1>{{Title}}</h1>
{{> views/partials/footer }}
{{> partials/footer }}
3 changes: 3 additions & 0 deletions mustache/views/relative.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{{> partials/header }}
<h1>{{Title}}</h1>
{{> partials/footer }}
Loading