diff --git a/node.go b/node.go index 0ba7f95..b6882ae 100644 --- a/node.go +++ b/node.go @@ -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 diff --git a/parse_sandbox.go b/parse_sandbox.go new file mode 100644 index 0000000..a35515b --- /dev/null +++ b/parse_sandbox.go @@ -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 +} \ No newline at end of file diff --git a/parser.go b/parser.go index 9e896b7..645b675 100644 --- a/parser.go +++ b/parser.go @@ -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, diff --git a/parser_include.go b/parser_include.go index 8071f5d..249237f 100644 --- a/parser_include.go +++ b/parser_include.go @@ -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 -} +} \ No newline at end of file diff --git a/render.go b/render.go index 7356080..dcff9c9 100644 --- a/render.go +++ b/render.go @@ -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 diff --git a/sandbox_test.go b/sandbox_test.go new file mode 100644 index 0000000..c10740c --- /dev/null +++ b/sandbox_test.go @@ -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) + } +} \ No newline at end of file diff --git a/security.go b/security.go new file mode 100644 index 0000000..0b0a078 --- /dev/null +++ b/security.go @@ -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, + } +} \ No newline at end of file diff --git a/twig.go b/twig.go index 5bbcc0e..0895f1a 100644 --- a/twig.go +++ b/twig.go @@ -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