mirror of
https://github.com/semihalev/twig.git
synced 2026-03-14 13:55:46 +01:00
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:
parent
679c36bbc3
commit
f49f4fa09e
8 changed files with 472 additions and 14 deletions
19
node.go
19
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
|
||||
|
|
|
|||
79
parse_sandbox.go
Normal file
79
parse_sandbox.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
48
render.go
48
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
|
||||
|
|
|
|||
76
sandbox_test.go
Normal file
76
sandbox_test.go
Normal 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
223
security.go
Normal 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
32
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue