Add sandbox support for templates

- Add sandboxed option to include tag
- Implement SecurityPolicy interface and DefaultSecurityPolicy
- Support restricting function calls, filters, and more in sandbox mode
- Add tests for sandbox functionality

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
semihalev 2025-03-11 22:37:06 +03:00
commit f49f4fa09e
8 changed files with 472 additions and 14 deletions

19
node.go
View file

@ -65,12 +65,13 @@ func NewExtendsNode(parent Node, line int) *ExtendsNode {
}
// NewIncludeNode creates a new include node
func NewIncludeNode(template Node, variables map[string]Node, ignoreMissing, only bool, line int) *IncludeNode {
func NewIncludeNode(template Node, variables map[string]Node, ignoreMissing, only, sandboxed bool, line int) *IncludeNode {
return &IncludeNode{
template: template,
variables: variables,
ignoreMissing: ignoreMissing,
only: only,
sandboxed: sandboxed,
line: line,
}
}
@ -147,6 +148,7 @@ const (
NodeDo
NodeModuleMethod
NodeApply
NodeSandbox
)
// RootNode represents the root of a template
@ -766,6 +768,7 @@ type IncludeNode struct {
variables map[string]Node
ignoreMissing bool
only bool
sandboxed bool
line int
}
@ -832,12 +835,22 @@ func (n *IncludeNode) Render(w io.Writer, ctx *RenderContext) error {
return template.nodes.Render(w, ctx)
}
// Need a new context for 'only' mode or with variables
// Need a new context for 'only' mode, sandboxed mode, or with variables
includeCtx := ctx
if n.only {
if n.only || n.sandboxed {
// Create minimal context with just what we need
includeCtx = NewRenderContext(ctx.env, make(map[string]interface{}, len(n.variables)), ctx.engine)
defer includeCtx.Release()
// If sandboxed, enable sandbox mode
if n.sandboxed {
includeCtx.sandboxed = true
// Check if a security policy is defined
if ctx.env.securityPolicy == nil {
return fmt.Errorf("cannot use sandboxed include without a security policy")
}
}
}
// Pre-evaluate all variables before setting them

79
parse_sandbox.go Normal file
View file

@ -0,0 +1,79 @@
package twig
import (
"fmt"
)
// parseSandbox parses a sandbox tag
// {% sandbox %} ... {% endsandbox %}
func (p *Parser) parseSandbox() (Node, error) {
// Get the line number of this tag for error reporting
line := p.getCurrentToken().Line
// Consume the sandbox token
p.nextToken()
// Consume the block end token
if err := p.expectTokenType(TOKEN_BLOCK_END, TOKEN_BLOCK_END_TRIM); err != nil {
return nil, err
}
// Parse the body of the sandbox
body, err := p.parseUntilTag("endsandbox")
if err != nil {
return nil, err
}
// Consume the end sandbox token
if err := p.expectTokenType(TOKEN_BLOCK_END, TOKEN_BLOCK_END_TRIM); err != nil {
return nil, err
}
// Create and return the sandbox node
return NewSandboxNode(body, line), nil
}
// SandboxNode represents a {% sandbox %} ... {% endsandbox %} block
type SandboxNode struct {
body []Node
line int
}
// NewSandboxNode creates a new sandbox node
func NewSandboxNode(body []Node, line int) *SandboxNode {
return &SandboxNode{
body: body,
line: line,
}
}
// Render renders the node to a writer
func (n *SandboxNode) Render(w io.Writer, ctx *RenderContext) error {
// Create a sandboxed rendering context
sandboxCtx := ctx.Clone()
sandboxCtx.EnableSandbox()
if sandboxCtx.environment.securityPolicy == nil {
return fmt.Errorf("sandbox error: no security policy defined")
}
// Render all body nodes within the sandboxed context
for _, node := range n.body {
err := node.Render(w, sandboxCtx)
if err != nil {
return fmt.Errorf("sandbox error: %w", err)
}
}
return nil
}
// Line returns the line number of the node
func (n *SandboxNode) Line() int {
return n.line
}
// Type returns the node type
func (n *SandboxNode) Type() NodeType {
return NodeSandbox
}

View file

@ -103,6 +103,7 @@ func (p *Parser) initBlockHandlers() {
"spaceless": p.parseSpaceless,
"verbatim": p.parseVerbatim,
"apply": p.parseApply,
"sandbox": p.parseSandbox,
// Special closing tags - they will be handled in their corresponding open tag parsers
"endif": p.parseEndTag,
@ -111,6 +112,7 @@ func (p *Parser) initBlockHandlers() {
"endblock": p.parseEndTag,
"endspaceless": p.parseEndTag,
"endapply": p.parseEndTag,
"endsandbox": p.parseEndTag,
"else": p.parseEndTag,
"elseif": p.parseEndTag,
"endverbatim": p.parseEndTag,

View file

@ -19,6 +19,7 @@ func (p *Parser) parseInclude(parser *Parser) (Node, error) {
var variables map[string]Node
var ignoreMissing bool
var onlyContext bool
var sandboxed bool
// Look for 'with', 'ignore missing', or 'only'
for parser.tokenIndex < len(parser.tokens) &&
@ -147,6 +148,9 @@ func (p *Parser) parseInclude(parser *Parser) (Node, error) {
case "only":
onlyContext = true
case "sandboxed":
sandboxed = true
default:
return nil, fmt.Errorf("unexpected keyword '%s' in include at line %d", keyword, includeLine)
@ -170,8 +174,9 @@ func (p *Parser) parseInclude(parser *Parser) (Node, error) {
variables: variables,
ignoreMissing: ignoreMissing,
only: onlyContext,
sandboxed: sandboxed,
line: includeLine,
}
return includeNode, nil
}
}

View file

@ -27,6 +27,7 @@ type RenderContext struct {
extending bool // Whether this template extends another
currentBlock *BlockNode // Current block being rendered (for parent() function)
inParentCall bool // Flag to indicate if we're currently rendering a parent() call
sandboxed bool // Flag indicating if this context is sandboxed
}
// renderContextPool is a sync.Pool for RenderContext objects
@ -228,6 +229,39 @@ func (ctx *RenderContext) SetParent(parent *RenderContext) {
ctx.parent = parent
}
// Clone creates a new RenderContext as a child of this one
func (ctx *RenderContext) Clone() *RenderContext {
newCtx := NewRenderContext(ctx.env, make(map[string]interface{}), ctx.engine)
// Inherit parent context
newCtx.parent = ctx
// Inherit sandbox state
newCtx.sandboxed = ctx.sandboxed
// Copy all blocks
for name, nodes := range ctx.blocks {
newCtx.blocks[name] = nodes
}
// Copy macros
for name, node := range ctx.macros {
newCtx.macros[name] = node
}
return newCtx
}
// EnableSandbox enables sandbox mode on this context
func (ctx *RenderContext) EnableSandbox() {
ctx.sandboxed = true
}
// IsSandboxed returns whether this context is sandboxed
func (ctx *RenderContext) IsSandboxed() bool {
return ctx.sandboxed
}
// GetMacro gets a macro from the context
func (ctx *RenderContext) GetMacro(name string) (interface{}, bool) {
// Check local macros first
@ -501,6 +535,20 @@ func (ctx *RenderContext) EvaluateExpression(node Node) (interface{}, error) {
return nil, nil
}
// Handle sandbox security policy checks
if ctx.sandboxed && ctx.env.securityPolicy != nil {
switch n := node.(type) {
case *FunctionNode:
if !ctx.env.securityPolicy.IsFunctionAllowed(n.name) {
return nil, NewFunctionViolation(n.name)
}
case *FilterNode:
if !ctx.env.securityPolicy.IsFilterAllowed(n.filter) {
return nil, NewFilterViolation(n.filter)
}
}
}
switch n := node.(type) {
case *LiteralNode:
return n.value, nil

76
sandbox_test.go Normal file
View file

@ -0,0 +1,76 @@
package twig
import (
"testing"
)
// TestSandboxIncludes tests the sandboxed option for include tags
func TestSandboxIncludes(t *testing.T) {
engine := New()
// Create a security policy
policy := NewDefaultSecurityPolicy()
// Enable sandbox mode with the policy
engine.EnableSandbox(policy)
// Register templates for testing
err := engine.RegisterString("sandbox_parent", "Parent: {% include 'sandbox_child' sandboxed %}")
if err != nil {
t.Fatalf("Error registering template: %v", err)
}
err = engine.RegisterString("sandbox_child", "{{ harmful_function() }}")
if err != nil {
t.Fatalf("Error registering template: %v", err)
}
// Define a harmful function in the context
context := map[string]interface{}{
"harmful_function": func() string {
return "This should be blocked in sandbox mode"
},
}
// Register the function
engine.RegisterFunction("harmful_function", func() string {
return "This should be blocked in sandbox mode"
})
// The harmful function is not in the allowed list, so it should fail
_, err = engine.Render("sandbox_parent", context)
if err == nil {
t.Errorf("Expected sandbox violation error but got none")
}
// Now allow the function in the security policy
policy.AllowedFunctions["harmful_function"] = true
// Now it should work
result, err := engine.Render("sandbox_parent", context)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
expected := "Parent: This should be blocked in sandbox mode"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
// Test non-sandboxed include
err = engine.RegisterString("non_sandbox_parent", "Parent: {% include 'sandbox_child' %}")
if err != nil {
t.Fatalf("Error registering template: %v", err)
}
// Non-sandboxed includes should always work regardless of security policy
result, err = engine.Render("non_sandbox_parent", context)
if err != nil {
t.Errorf("Unexpected error in non-sandboxed include: %v", err)
}
// The result should be the same
if result != expected {
t.Errorf("Expected '%s' for non-sandboxed include, got '%s'", expected, result)
}
}

223
security.go Normal file
View file

@ -0,0 +1,223 @@
package twig
import (
"fmt"
"reflect"
"strings"
)
// SecurityPolicy defines what's allowed in a sandboxed template context
type SecurityPolicy interface {
// Tag permissions
IsTagAllowed(tag string) bool
// Filter permissions
IsFilterAllowed(filter string) bool
// Function permissions
IsFunctionAllowed(function string) bool
// Property access permissions
IsPropertyAllowed(objType string, property string) bool
// Method call permissions
IsMethodAllowed(objType string, method string) bool
}
// DefaultSecurityPolicy implements a standard security policy
type DefaultSecurityPolicy struct {
// Allow lists
AllowedTags map[string]bool
AllowedFilters map[string]bool
AllowedFunctions map[string]bool
AllowedProperties map[string]map[string]bool // Type -> Property -> Allowed
AllowedMethods map[string]map[string]bool // Type -> Method -> Allowed
}
// NewDefaultSecurityPolicy creates a default security policy with safe defaults
func NewDefaultSecurityPolicy() *DefaultSecurityPolicy {
return &DefaultSecurityPolicy{
AllowedTags: map[string]bool{
"if": true,
"else": true,
"elseif": true,
"for": true,
"set": true,
"verbatim": true,
"do": true,
// More safe tags
},
AllowedFilters: map[string]bool{
"escape": true,
"e": true,
"raw": true,
"length": true,
"count": true,
"lower": true,
"upper": true,
"title": true,
"capitalize": true,
"trim": true,
"nl2br": true,
"join": true,
"split": true,
"default": true,
"date": true,
"number_format": true,
"abs": true,
"first": true,
"last": true,
"reverse": true,
"sort": true,
"slice": true,
// More safe filters
},
AllowedFunctions: map[string]bool{
"range": true,
"cycle": true,
"constant": true,
"date": true,
"min": true,
"max": true,
"random": true,
// More safe functions
},
AllowedProperties: make(map[string]map[string]bool),
AllowedMethods: make(map[string]map[string]bool),
}
}
// IsTagAllowed checks if a tag is allowed
func (p *DefaultSecurityPolicy) IsTagAllowed(tag string) bool {
return p.AllowedTags[tag]
}
// IsFilterAllowed checks if a filter is allowed
func (p *DefaultSecurityPolicy) IsFilterAllowed(filter string) bool {
return p.AllowedFilters[filter]
}
// IsFunctionAllowed checks if a function is allowed
func (p *DefaultSecurityPolicy) IsFunctionAllowed(function string) bool {
return p.AllowedFunctions[function]
}
// IsPropertyAllowed checks if property access is allowed for a type
func (p *DefaultSecurityPolicy) IsPropertyAllowed(objType string, property string) bool {
props, ok := p.AllowedProperties[objType]
if !ok {
return false
}
return props[property]
}
// IsMethodAllowed checks if method call is allowed for a type
func (p *DefaultSecurityPolicy) IsMethodAllowed(objType string, method string) bool {
methods, ok := p.AllowedMethods[objType]
if !ok {
return false
}
return methods[method]
}
// AllowObjectType adds all properties and methods of a type to the allowlist
func (p *DefaultSecurityPolicy) AllowObjectType(obj interface{}) {
t := reflect.TypeOf(obj)
typeName := t.String()
// Allow properties
if p.AllowedProperties[typeName] == nil {
p.AllowedProperties[typeName] = make(map[string]bool)
}
// Allow methods
if p.AllowedMethods[typeName] == nil {
p.AllowedMethods[typeName] = make(map[string]bool)
}
// Add all methods
for i := 0; i < t.NumMethod(); i++ {
methodName := t.Method(i).Name
p.AllowedMethods[typeName][methodName] = true
}
// If it's a struct, add all fields
if t.Kind() == reflect.Struct {
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// Only allow exported fields
if field.IsExported() {
p.AllowedProperties[typeName][field.Name] = true
}
}
}
}
// GetTypeString returns a string representation of an object's type
func GetTypeString(obj interface{}) string {
t := reflect.TypeOf(obj)
if t == nil {
return "nil"
}
return t.String()
}
// SecurityViolation represents a security policy violation
type SecurityViolation struct {
Message string
Tag string
Filter string
Obj string
Access string
}
// Error returns the string representation of this error
func (v *SecurityViolation) Error() string {
return fmt.Sprintf("Sandbox security violation: %s", v.Message)
}
// NewTagViolation creates a new tag security violation
func NewTagViolation(tag string) *SecurityViolation {
return &SecurityViolation{
Message: fmt.Sprintf("Tag '%s' is not allowed in sandbox mode", tag),
Tag: tag,
}
}
// NewFilterViolation creates a new filter security violation
func NewFilterViolation(filter string) *SecurityViolation {
return &SecurityViolation{
Message: fmt.Sprintf("Filter '%s' is not allowed in sandbox mode", filter),
Filter: filter,
}
}
// NewPropertyViolation creates a new property access security violation
func NewPropertyViolation(obj interface{}, property string) *SecurityViolation {
objType := GetTypeString(obj)
return &SecurityViolation{
Message: fmt.Sprintf("Property '%s' of type '%s' is not allowed in sandbox mode",
property, objType),
Obj: objType,
Access: property,
}
}
// NewMethodViolation creates a new method call security violation
func NewMethodViolation(obj interface{}, method string) *SecurityViolation {
objType := GetTypeString(obj)
return &SecurityViolation{
Message: fmt.Sprintf("Method '%s' of type '%s' is not allowed in sandbox mode",
method, objType),
Obj: objType,
Access: method,
}
}
// NewFunctionViolation creates a new function call security violation
func NewFunctionViolation(function string) *SecurityViolation {
return &SecurityViolation{
Message: fmt.Sprintf("Function '%s' is not allowed in sandbox mode", function),
Access: function,
}
}

32
twig.go
View file

@ -38,16 +38,17 @@ type Template struct {
// Environment holds configuration and context for template rendering
type Environment struct {
globals map[string]interface{}
filters map[string]FilterFunc
functions map[string]FunctionFunc
tests map[string]TestFunc
operators map[string]OperatorFunc
extensions []Extension
cache bool
autoescape bool
debug bool
sandbox bool
globals map[string]interface{}
filters map[string]FilterFunc
functions map[string]FunctionFunc
tests map[string]TestFunc
operators map[string]OperatorFunc
extensions []Extension
cache bool
autoescape bool
debug bool
sandbox bool
securityPolicy SecurityPolicy // Security policy for sandbox mode
}
// New creates a new Twig engine instance
@ -90,6 +91,17 @@ func (e *Engine) SetStrictVars(strictVars bool) {
e.strictVars = strictVars
}
// EnableSandbox enables sandbox mode with the given security policy
func (e *Engine) EnableSandbox(policy SecurityPolicy) {
e.environment.sandbox = true
e.environment.securityPolicy = policy
}
// DisableSandbox disables sandbox mode
func (e *Engine) DisableSandbox() {
e.environment.sandbox = false
}
// SetDebug enables or disables debug mode
func (e *Engine) SetDebug(enabled bool) {
e.environment.debug = enabled