pongo2 provides several security features to protect against template injection attacks, restrict template capabilities, and isolate template sets from each other.
Important: The package-level convenience functions (
pongo2.FromFile(),pongo2.FromString(), etc.) use aDefaultSetthat is configured with aLocalFilesystemLoader. This loader has unrestricted access to the local filesystem.For production applications, especially those that:
- Handle user-generated template content
- Need to restrict template capabilities
- Require sandboxing
Create a custom template set with appropriate loaders and restrictions instead of using the DefaultSet.
// Instead of using pongo2.FromFile() (which uses DefaultSet), do this:
// Option 1: Use embedded templates (most secure)
//go:embed templates/*
var templateFS embed.FS
loader := pongo2.NewFSLoader(templateFS)
set := pongo2.NewSet("secure", loader)
// Option 2: Use LocalFilesystemLoader with sandbox restrictions
loader := pongo2.MustNewLocalFileSystemLoader("/var/templates")
set := pongo2.NewSet("restricted", loader)
set.BanTag("include") // Prevent file inclusion
set.BanTag("import") // Prevent macro imports
set.BanTag("ssi") // Prevent server-side includes
set.BanTag("extends") // Prevent template inheritance
set.BanFilter("safe") // Prevent autoescape bypass
// Now use the custom set
tpl, err := set.FromFile("page.html")See Template Sets for more information on creating and configuring template sets.
By default, pongo2 automatically escapes HTML special characters in variable output to prevent XSS (Cross-Site Scripting) attacks.
{{ user_input }}If user_input contains <script>alert('xss')</script>, the output will be:
<script>alert('xss')</script>Global setting (DefaultSet):
// Disable autoescape globally
pongo2.SetAutoescape(false)
// Enable autoescape globally (default)
pongo2.SetAutoescape(true)Per-set setting:
set := pongo2.NewSet("my-set", loader)
set.SetAutoescape(false) // Disable for this set onlyPer-template block:
{% autoescape off %}
{{ trusted_html }} {# Will NOT be escaped #}
{% endautoescape %}
{% autoescape on %}
{{ user_input }} {# Will be escaped #}
{% endautoescape %}Per-variable:
{{ trusted_html|safe }} {# Mark as safe, skip escaping #}- Keep autoescape enabled (default) for user-facing templates
- Use
|safesparingly and only for content you fully control - Use
|escapejsfor JavaScript contexts:<script> var data = "{{ user_input|escapejs }}"; </script>
- Validate and sanitize user input before it reaches templates
Template sets support sandboxing to restrict what templates can do.
Prevent templates from using specific tags:
set := pongo2.NewSet("sandboxed", loader)
// Ban potentially dangerous tags
set.BanTag("include") // Prevent file inclusion
set.BanTag("import") // Prevent macro imports from other files
set.BanTag("ssi") // Prevent server-side includes
set.BanTag("extends") // Prevent template inheritance
// Now load templates - they can't use banned tags
tpl, err := set.FromFile("user-template.html")Prevent templates from using specific filters:
set := pongo2.NewSet("restricted", loader)
// Ban filters that bypass security
set.BanFilter("safe") // Prevent bypassing autoescape
// Ban filters based on your security requirements
set.BanFilter("escapejs") // If you don't want JS outputTags and filters must be banned BEFORE the first template is loaded:
set := pongo2.NewSet("test", loader)
// This works - no templates loaded yet
set.BanTag("ssi")
// Load a template
tpl, _ := set.FromFile("page.html")
// This FAILS - too late!
err := set.BanTag("include")
// err: "you cannot ban any tags after you've added your first template..."This restriction exists because:
- Bans are checked at parse time for efficiency
- Once a template is parsed, it's cached
- Allowing late bans would be confusing (some templates might have used the tag already)
For user-submitted templates (e.g., email templates, CMS content):
func createSandboxedSet(loader pongo2.TemplateLoader) *pongo2.TemplateSet {
set := pongo2.NewSet("user-content", loader)
// Prevent file system access
set.BanTag("include")
set.BanTag("import")
set.BanTag("ssi")
set.BanTag("extends")
// Prevent autoescape bypass
set.BanFilter("safe")
// Limit to basic control flow only
// (all other tags remain available: if, for, with, set, etc.)
return set
}Each TemplateSet is fully isolated with its own:
- Tag registry - Custom tags are per-set
- Filter registry - Custom filters are per-set
- Globals - Global variables are per-set
- Cache - Template cache is per-set
- Banned tags/filters - Sandbox restrictions are per-set
- Options - TrimBlocks, LStripBlocks are per-set
- Autoescape setting - Can be configured per-set
Register tags and filters specific to a template set:
// Create isolated sets
webSet := pongo2.NewSet("web", webLoader)
emailSet := pongo2.NewSet("email", emailLoader)
// Register filters only for web templates
webSet.RegisterFilter("asset_url", assetUrlFilter)
// Register tags only for email templates
emailSet.RegisterTag("unsubscribe_link", unsubscribeLinkParser)
// These filters/tags are NOT available in the other setwebSet.Globals["site_url"] = "https://example.com"
webSet.Globals["current_year"] = time.Now().Year()
emailSet.Globals["company_name"] = "ACME Corp"
emailSet.Globals["support_email"] = "support@example.com"Important: The LocalFileSystemLoader's base directory is NOT a security feature. It only serves as the root path for resolving relative template paths to absolute paths. Templates can still access files outside the base directory using absolute paths.
// The base directory is for path resolution, NOT security
loader := pongo2.MustNewLocalFileSystemLoader("/var/templates")
set := pongo2.NewSet("app", loader)
// Relative paths are resolved from the base directory
tpl, _ := set.FromFile("pages/home.html") // Loads /var/templates/pages/home.html
// WARNING: Absolute paths bypass the base directory entirely
tpl, _ := set.FromFile("/etc/passwd") // This would work if not restricted!For actual security, use sandbox features to restrict file inclusion:
set := pongo2.NewSet("sandboxed", loader)
set.BanTag("include") // Prevent {% include %}
set.BanTag("import") // Prevent {% import %}
set.BanTag("ssi") // Prevent {% ssi %}
set.BanTag("extends") // Prevent {% extends %}Implement a custom loader with additional restrictions:
type SecureLoader struct {
baseDir string
allowedExt []string
}
func (l *SecureLoader) Abs(base, name string) string {
// Resolve path relative to base directory
resolved := filepath.Join(filepath.Dir(base), name)
// Ensure result is within allowed directory
if !strings.HasPrefix(resolved, l.baseDir) {
return "" // Return empty to indicate not found
}
return resolved
}
func (l *SecureLoader) Get(path string) (io.Reader, error) {
// Check file extension
ext := filepath.Ext(path)
allowed := false
for _, e := range l.allowedExt {
if ext == e {
allowed = true
break
}
}
if !allowed {
return nil, fmt.Errorf("file extension not allowed: %s", ext)
}
return os.Open(path)
}pongo2 limits macro recursion to prevent stack overflow:
{% macro infinite() %}
{{ infinite() }} {# This will eventually fail #}
{% endmacro %}The maximum recursion depth is 1000 calls. When exceeded:
maximum recursive macro call depth reached (max is 1000)
This protects against:
- Accidental infinite recursion in user templates
- Denial of service via deeply nested macro calls
type ExecutionContext struct {
Public Context // User-provided data (read-only by convention)
Private Context // Internal engine data (forloop, macro args, etc.)
Shared Context // Shared across included templates
}- Public: Data you provide via
tpl.Execute(ctx). Templates can read but shouldn't modify. - Private: Internal variables like
forloop. Templates can access but not override user data. - Shared: Persists across
{% include %}calls.
Context keys must be valid identifiers:
// Valid
ctx := pongo2.Context{
"user": user,
"item_1": item,
}
// Invalid - will panic
ctx := pongo2.Context{
"'invalid": value, // Can't start with quote
"foo-bar": value, // Can't contain hyphen
"foo.bar": value, // Can't contain dot
}Avoid exposing template internals to users:
func handler(w http.ResponseWriter, r *http.Request) {
tpl, err := templates.FromCache("page.html")
if err != nil {
// Log detailed error internally
log.Printf("Template error: %v", err)
// Return generic error to user
http.Error(w, "Internal Server Error", 500)
return
}
err = tpl.ExecuteWriter(ctx, w)
if err != nil {
// Log detailed error internally
log.Printf("Execution error: %v", err)
// Don't expose template paths or variable names
http.Error(w, "Error rendering page", 500)
}
}Set Debug = false in production:
set := pongo2.NewSet("production", loader)
set.Debug = false // Disable debug logging, enable caching
// In development
devSet := pongo2.NewSet("development", loader)
devSet.Debug = true // Enable debug logging, disable caching- Keep autoescape enabled (default)
- Use
|escapejsfor JavaScript contexts - Validate user input before passing to templates
- Use parameterized queries, not template string concatenation for SQL
- Review all uses of
|safefilter
- Create a dedicated sandboxed
TemplateSet - Ban
include,import,ssi,extendstags - Ban
safefilter - Use a restricted template loader
- Set resource limits (template size, execution time) at the application level
- Consider banning complex expressions if not needed
- Set
Debug = false - Don't expose detailed template errors to users
- Use
FromCachefor performance and to prevent repeated parsing - Monitor template execution times
- Use separate template sets for different trust levels
Risk: If user input is directly concatenated into template strings:
// DANGEROUS - NEVER DO THIS
template := "Hello " + userInput + "!"
tpl, _ := pongo2.FromString(template)Mitigation: Always pass user input via context:
// SAFE
tpl, _ := pongo2.FromString("Hello {{ name }}!")
tpl.Execute(pongo2.Context{"name": userInput})Risk: Rendering unsanitized user input.
Mitigation:
- Keep autoescape enabled
- Use
|escapejsfor JavaScript - Validate/sanitize input before it reaches templates
Risk: User-controlled template names could access unintended files.
Mitigation:
- Never pass user input directly to
FromFile- theLocalFileSystemLoaderbase directory does NOT prevent path traversal (absolute paths bypass it entirely) - Ban file inclusion tags if templates come from untrusted sources:
set.BanTag("include") set.BanTag("import") set.BanTag("extends") set.BanTag("ssi")
- Use an allowlist of permitted template names in your application code:
allowedTemplates := map[string]bool{ "home.html": true, "about.html": true, } if !allowedTemplates[templateName] { return errors.New("template not allowed") }
- Implement a secure custom loader that validates and restricts paths (see "Custom Loaders for Real Security" above)
Risk: Complex templates, deep recursion, or large contexts could exhaust resources.
Mitigation:
- Macro recursion is automatically limited to 1000 calls
- Implement application-level timeouts for template execution
- Limit context data size
- Use template caching (
FromCache)