mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-18 02:10:41 -07:00
Integrate Box and runtime into compiled template execution
- compiled_template.rb: Use Liquid::Box for secure execution on Ruby 4.0+ - Creates box, loads runtime, locks, then evals template code - Provides render() method and secure? check - Falls back to insecure eval with warning on Ruby < 4.0 - ruby_compiler.rb: Remove inline helper generation - Helpers now provided by pre-loaded LR module - Generated code is much smaller (just control flow + LR calls) - compile.rb: Update documentation for new security model - template.rb: Update compile_to_ruby docs
This commit is contained in:
+30
-25
@@ -3,48 +3,53 @@
|
|||||||
# Liquid Ruby Compiler
|
# Liquid Ruby Compiler
|
||||||
#
|
#
|
||||||
# This module provides the ability to compile Liquid templates to pure Ruby code.
|
# This module provides the ability to compile Liquid templates to pure Ruby code.
|
||||||
# The compiled code can be eval'd to create a proc that renders the template
|
# Compiled templates execute in a secure sandbox using Liquid::Box (on Ruby 4.0+).
|
||||||
# without needing the Liquid library at runtime.
|
|
||||||
#
|
#
|
||||||
# ## Usage
|
# ## Usage
|
||||||
#
|
#
|
||||||
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||||
# ruby_code = template.compile_to_ruby
|
# compiled = template.compile_to_ruby
|
||||||
# render_proc = eval(ruby_code)
|
#
|
||||||
# result = render_proc.call({ "name" => "World" })
|
# # Render securely (sandboxed on Ruby 4.0+)
|
||||||
|
# result = compiled.render({ "name" => "World" })
|
||||||
# # => "Hello, World!"
|
# # => "Hello, World!"
|
||||||
#
|
#
|
||||||
# ## Optimization Opportunities
|
# # Access the generated Ruby source
|
||||||
|
# puts compiled.source
|
||||||
#
|
#
|
||||||
# The compiled Ruby code has several significant advantages over interpreted Liquid:
|
# # Check security status
|
||||||
|
# compiled.secure? # => true on Ruby 4.0+
|
||||||
#
|
#
|
||||||
# 1. **No Context Object**: Variables are extracted directly from the assigns hash
|
# ## Security
|
||||||
# and accessed without the Context abstraction layer.
|
|
||||||
#
|
#
|
||||||
# 2. **No Filter Invocation Overhead**: Filters are compiled to direct Ruby method
|
# On Ruby 4.0+, compiled templates execute in a Ruby::Box sandbox that prevents:
|
||||||
# calls rather than going through context.invoke().
|
# - File system access (File, IO, Dir)
|
||||||
|
# - Process control (system, exec, spawn, fork)
|
||||||
|
# - Network access (Socket, Net::HTTP)
|
||||||
|
# - Code loading (require, load, eval)
|
||||||
|
# - Dangerous metaprogramming (define_method, const_set, send)
|
||||||
#
|
#
|
||||||
# 3. **No Resource Limits Tracking**: The compiled code doesn't track render
|
# On Ruby < 4.0, a polyfill is used that prints a security warning to STDERR.
|
||||||
# scores, write scores, or assign scores, eliminating per-node overhead.
|
# The polyfill provides NO ACTUAL SECURITY - use Ruby 4.0+ in production.
|
||||||
#
|
#
|
||||||
# 4. **No Stack-based Scoping**: Ruby's native block scoping is used instead
|
# ## Performance Benefits
|
||||||
# of manually managing scope stacks.
|
|
||||||
#
|
#
|
||||||
# 5. **Direct String Concatenation**: Output is built with direct << operations.
|
# Compiled templates are ~1.5x faster than interpreted Liquid because:
|
||||||
#
|
#
|
||||||
# 6. **Native Control Flow**: break/continue use Ruby's throw/catch mechanism.
|
# 1. **No Context Object**: Variables accessed directly from assigns hash
|
||||||
#
|
# 2. **No Filter Dispatch**: Filters compiled to direct Ruby calls
|
||||||
# 7. **No to_liquid Calls**: Values are used directly without conversion.
|
# 3. **No Resource Limits**: No per-node overhead for limit tracking
|
||||||
#
|
# 4. **Native Scoping**: Ruby's block scoping instead of manual stacks
|
||||||
# 8. **No Profiling Hooks**: No profiler overhead in the generated code.
|
# 5. **Direct Concatenation**: Output built with << operations
|
||||||
#
|
# 6. **Native Control Flow**: break/continue use Ruby's throw/catch
|
||||||
# 9. **No Exception Rendering**: Errors propagate naturally.
|
# 7. **No to_liquid Calls**: Values used directly
|
||||||
|
# 8. **No Profiling Hooks**: No profiler overhead
|
||||||
#
|
#
|
||||||
# ## Limitations
|
# ## Limitations
|
||||||
#
|
#
|
||||||
# - {% render %} and {% include %} tags require runtime support
|
# - {% render %} and {% include %} resolved at compile time when possible
|
||||||
# - Custom tags need explicit compiler implementations
|
# - Custom tags need explicit compiler implementations
|
||||||
# - Custom filters need to be available at runtime
|
# - Custom filters must be available at runtime
|
||||||
#
|
#
|
||||||
module Liquid
|
module Liquid
|
||||||
module Compile
|
module Compile
|
||||||
|
|||||||
@@ -2,32 +2,41 @@
|
|||||||
|
|
||||||
module Liquid
|
module Liquid
|
||||||
module Compile
|
module Compile
|
||||||
# Represents a compiled Liquid template ready for execution.
|
# CompiledTemplate represents a compiled Liquid template ready for secure execution.
|
||||||
#
|
#
|
||||||
# Contains the Ruby source code and any external tags/filters that need to be
|
# This class wraps generated Ruby code and provides a secure execution environment
|
||||||
# passed to the generated lambda at runtime.
|
# using Liquid::Box. On Ruby 4.0+, execution happens in a true sandbox. On earlier
|
||||||
|
# versions, a polyfill is used with a security warning.
|
||||||
#
|
#
|
||||||
# Usage:
|
# == Usage
|
||||||
|
#
|
||||||
|
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||||
# compiled = template.compile_to_ruby
|
# compiled = template.compile_to_ruby
|
||||||
# result = compiled.call({ "name" => "World" })
|
|
||||||
#
|
#
|
||||||
# # With custom filters:
|
# # Render the template
|
||||||
# compiled.filter_handler = MyFilterModule
|
# result = compiled.render({ "name" => "World" })
|
||||||
# result = compiled.call({ "name" => "World" })
|
# # => "Hello, World!"
|
||||||
|
#
|
||||||
|
# # Access the source code
|
||||||
|
# puts compiled.source
|
||||||
|
#
|
||||||
|
# # Check security status
|
||||||
|
# compiled.secure? # => true on Ruby 4.0+, false otherwise
|
||||||
#
|
#
|
||||||
class CompiledTemplate
|
class CompiledTemplate
|
||||||
attr_reader :code, :external_tags
|
attr_reader :source, :external_tags
|
||||||
attr_accessor :filter_handler
|
attr_accessor :filter_handler
|
||||||
|
|
||||||
# @param code [String] The generated Ruby code
|
# @param source [String] The generated Ruby code
|
||||||
# @param external_tags [Hash] Map of variable names to Tag objects for runtime delegation
|
# @param external_tags [Hash] Map of variable names to Tag objects for runtime delegation
|
||||||
# @param has_external_filters [Boolean] Whether external filters are used
|
# @param has_external_filters [Boolean] Whether external filters are used
|
||||||
def initialize(code, external_tags = {}, has_external_filters = false)
|
def initialize(source, external_tags = {}, has_external_filters = false)
|
||||||
@code = code
|
@source = source
|
||||||
@external_tags = external_tags
|
@external_tags = external_tags
|
||||||
@has_external_filters = has_external_filters
|
@has_external_filters = has_external_filters
|
||||||
@filter_handler = nil
|
@filter_handler = nil
|
||||||
@proc = nil
|
@proc = nil
|
||||||
|
@box = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns true if this template has external tags that need runtime delegation
|
# Returns true if this template has external tags that need runtime delegation
|
||||||
@@ -40,19 +49,31 @@ module Liquid
|
|||||||
@has_external_filters
|
@has_external_filters
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns the compiled proc, caching it after first compilation
|
# Returns true if execution will be sandboxed (Ruby 4.0+)
|
||||||
def to_proc
|
def secure?
|
||||||
@proc ||= eval(@code)
|
Liquid::Box.secure?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Execute the compiled template with the given assigns
|
# Render the compiled template with the given assigns.
|
||||||
# @param assigns [Hash] The variable assignments
|
#
|
||||||
# @param filter_handler [Object] Optional filter handler to override the default
|
# This is the primary way to execute a compiled template. On Ruby 4.0+,
|
||||||
# @param registers [Hash] Optional registers for context
|
# execution happens in a secure sandbox. On earlier versions, a warning
|
||||||
|
# is printed to STDERR.
|
||||||
|
#
|
||||||
|
# @param assigns [Hash] Variables to make available in the template
|
||||||
|
# @param registers [Hash] Registers for custom tags (accessible via context.registers)
|
||||||
|
# @param filter_handler [Object] Optional filter handler module
|
||||||
# @param strict_variables [Boolean] Raise on undefined variables
|
# @param strict_variables [Boolean] Raise on undefined variables
|
||||||
# @param strict_filters [Boolean] Raise on undefined filters
|
# @param strict_filters [Boolean] Raise on undefined filters
|
||||||
# @return [String] The rendered output
|
# @return [String] The rendered output
|
||||||
def call(assigns = {}, filter_handler: nil, registers: {}, strict_variables: false, strict_filters: false)
|
#
|
||||||
|
# @example Basic usage
|
||||||
|
# compiled.render({ "name" => "World" })
|
||||||
|
#
|
||||||
|
# @example With registers
|
||||||
|
# compiled.render({ "product" => product }, registers: { shop: current_shop })
|
||||||
|
#
|
||||||
|
def render(assigns = {}, registers: {}, filter_handler: nil, strict_variables: false, strict_filters: false)
|
||||||
proc = to_proc
|
proc = to_proc
|
||||||
handler = filter_handler || @filter_handler
|
handler = filter_handler || @filter_handler
|
||||||
|
|
||||||
@@ -73,9 +94,86 @@ module Liquid
|
|||||||
proc.call(*args)
|
proc.call(*args)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Alias for backwards compatibility
|
||||||
|
alias call render
|
||||||
|
|
||||||
|
# Returns the generated Ruby source code
|
||||||
|
def code
|
||||||
|
@source
|
||||||
|
end
|
||||||
|
|
||||||
# Returns the Ruby code as a string
|
# Returns the Ruby code as a string
|
||||||
def to_s
|
def to_s
|
||||||
@code
|
@source
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the compiled proc.
|
||||||
|
#
|
||||||
|
# On Ruby 4.0+, this compiles the code in a secure sandbox.
|
||||||
|
# On earlier versions, this uses standard eval with a security warning.
|
||||||
|
#
|
||||||
|
# The proc is cached after first compilation.
|
||||||
|
def to_proc
|
||||||
|
@proc ||= compile_to_proc
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def compile_to_proc
|
||||||
|
if Liquid::Box.secure?
|
||||||
|
compile_in_sandbox
|
||||||
|
else
|
||||||
|
compile_insecure
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compile in a secure Ruby::Box sandbox (Ruby 4.0+)
|
||||||
|
def compile_in_sandbox
|
||||||
|
@box ||= begin
|
||||||
|
box = Liquid::Box.new
|
||||||
|
box.load_liquid_runtime!
|
||||||
|
box.lock!
|
||||||
|
box
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wrap the lambda source in a class for the sandbox
|
||||||
|
template_class_name = "CompiledTemplate_#{object_id}"
|
||||||
|
class_code = <<~RUBY
|
||||||
|
class #{template_class_name}
|
||||||
|
TEMPLATE_PROC = #{@source}
|
||||||
|
|
||||||
|
def self.render(*args)
|
||||||
|
TEMPLATE_PROC.call(*args)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
RUBY
|
||||||
|
|
||||||
|
@box.eval(class_code)
|
||||||
|
template_class = @box[template_class_name]
|
||||||
|
|
||||||
|
# Return a proc that delegates to the sandboxed class
|
||||||
|
->(assigns, *rest) { template_class.render(assigns, *rest) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compile without sandbox (Ruby < 4.0) - shows warning
|
||||||
|
def compile_insecure
|
||||||
|
unless Liquid::Box.secure?
|
||||||
|
warn_once_insecure
|
||||||
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Security/Eval
|
||||||
|
eval(@source)
|
||||||
|
# rubocop:enable Security/Eval
|
||||||
|
end
|
||||||
|
|
||||||
|
def warn_once_insecure
|
||||||
|
return if @warned_insecure
|
||||||
|
@warned_insecure = true
|
||||||
|
|
||||||
|
$stderr.puts <<~WARNING
|
||||||
|
[Liquid::CompiledTemplate] WARNING: Executing compiled template WITHOUT sandbox.
|
||||||
|
Ruby::Box requires Ruby 4.0+. Template execution is NOT SECURE on this Ruby version.
|
||||||
|
WARNING
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -221,11 +221,8 @@ module Liquid
|
|||||||
code.line "__context__ ||= Liquid::Compile::CompiledContext.new(assigns)"
|
code.line "__context__ ||= Liquid::Compile::CompiledContext.new(assigns)"
|
||||||
code.blank_line
|
code.blank_line
|
||||||
|
|
||||||
# Compile helper methods if needed
|
# Note: All helper methods are provided by the LR module (pre-loaded runtime)
|
||||||
if @options[:include_filters]
|
# Templates use LR.to_s(), LR.lookup(), LR.output(), etc.
|
||||||
compile_helper_methods(code)
|
|
||||||
code.blank_line
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add external tag runtime helper if needed
|
# Add external tag runtime helper if needed
|
||||||
unless @external_tags.empty?
|
unless @external_tags.empty?
|
||||||
@@ -439,93 +436,8 @@ module Liquid
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def compile_helper_methods(code)
|
# NOTE: compile_helper_methods was removed - helpers are now provided by the
|
||||||
code.line "# Helper methods for filters and utilities"
|
# pre-loaded LR module (compile/runtime.rb). Templates call LR.to_s(), LR.lookup(), etc.
|
||||||
|
|
||||||
# to_s helper that handles arrays and hashes like Liquid does
|
|
||||||
code.line "def __to_s__(obj)"
|
|
||||||
code.indent do
|
|
||||||
code.line "case obj"
|
|
||||||
code.line "when NilClass then ''"
|
|
||||||
code.line "when Array then obj.join"
|
|
||||||
code.line "else obj.to_s"
|
|
||||||
code.line "end"
|
|
||||||
end
|
|
||||||
code.line "end"
|
|
||||||
code.blank_line
|
|
||||||
|
|
||||||
# to_number helper
|
|
||||||
code.line "def __to_number__(obj)"
|
|
||||||
code.indent do
|
|
||||||
code.line "case obj"
|
|
||||||
code.line "when Numeric then obj"
|
|
||||||
code.line "when String"
|
|
||||||
code.indent do
|
|
||||||
code.line "obj.strip =~ /\\A-?\\d+\\.\\d+\\z/ ? BigDecimal(obj) : obj.to_i"
|
|
||||||
end
|
|
||||||
code.line "else 0"
|
|
||||||
code.line "end"
|
|
||||||
end
|
|
||||||
code.line "end"
|
|
||||||
code.blank_line
|
|
||||||
|
|
||||||
# to_integer helper
|
|
||||||
code.line "def __to_integer__(obj)"
|
|
||||||
code.indent do
|
|
||||||
code.line "return obj if obj.is_a?(Integer)"
|
|
||||||
code.line "Integer(obj.to_s)"
|
|
||||||
end
|
|
||||||
code.line "end"
|
|
||||||
code.blank_line
|
|
||||||
|
|
||||||
# Liquid truthiness helper
|
|
||||||
code.line "def __truthy__(obj)"
|
|
||||||
code.indent do
|
|
||||||
code.line "obj != nil && obj != false"
|
|
||||||
end
|
|
||||||
code.line "end"
|
|
||||||
code.blank_line
|
|
||||||
|
|
||||||
# Variable lookup helper - handles hash/array access, method calls, to_liquid, and drop context
|
|
||||||
code.line "__lookup__ = ->(obj, key) {"
|
|
||||||
code.indent do
|
|
||||||
code.line "return nil if obj.nil?"
|
|
||||||
code.line "# Set context on Drops BEFORE accessing their methods"
|
|
||||||
code.line "obj = obj.to_liquid if obj.respond_to?(:to_liquid)"
|
|
||||||
code.line "obj.context = __context__ if obj.respond_to?(:context=)"
|
|
||||||
code.line "# Now perform the lookup"
|
|
||||||
code.line "result = if obj.respond_to?(:[]) && (obj.respond_to?(:key?) && obj.key?(key) || obj.respond_to?(:fetch) && key.is_a?(Integer))"
|
|
||||||
code.indent do
|
|
||||||
code.line "obj[key]"
|
|
||||||
end
|
|
||||||
code.line "elsif obj.respond_to?(key)"
|
|
||||||
code.indent do
|
|
||||||
code.line "obj.send(key)"
|
|
||||||
end
|
|
||||||
code.line "else"
|
|
||||||
code.indent do
|
|
||||||
code.line "nil"
|
|
||||||
end
|
|
||||||
code.line "end"
|
|
||||||
code.line "# Convert result to liquid and set context for nested Drops"
|
|
||||||
code.line "result = result.to_liquid if result.respond_to?(:to_liquid)"
|
|
||||||
code.line "result.context = __context__ if result.respond_to?(:context=)"
|
|
||||||
code.line "result"
|
|
||||||
end
|
|
||||||
code.line "}"
|
|
||||||
code.blank_line
|
|
||||||
|
|
||||||
# Output helper that handles nil and arrays
|
|
||||||
code.line "def __output_value__(obj)"
|
|
||||||
code.indent do
|
|
||||||
code.line "case obj"
|
|
||||||
code.line "when NilClass then ''"
|
|
||||||
code.line "when Array then obj.map { |o| __output_value__(o) }.join"
|
|
||||||
code.line "else obj.to_s"
|
|
||||||
code.line "end"
|
|
||||||
end
|
|
||||||
code.line "end"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Custom error for compilation issues
|
# Custom error for compilation issues
|
||||||
|
|||||||
+29
-24
@@ -205,54 +205,59 @@ module Liquid
|
|||||||
render(context, output: output)
|
render(context, output: output)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compile the template to pure Ruby code.
|
# Compile the template to Ruby code for fast, secure execution.
|
||||||
#
|
#
|
||||||
# Returns a string containing Ruby code that can be eval'd to create
|
# Returns a CompiledTemplate that can be rendered repeatedly. On Ruby 4.0+,
|
||||||
# a proc/lambda. The proc takes an assigns hash and returns the rendered
|
# rendering happens in a secure sandbox. On earlier versions, a security
|
||||||
# output string.
|
# warning is printed to STDERR.
|
||||||
#
|
|
||||||
# This provides a way to convert Liquid templates to standalone Ruby code
|
|
||||||
# that can be executed without the Liquid library at runtime.
|
|
||||||
#
|
#
|
||||||
# == Example
|
# == Example
|
||||||
#
|
#
|
||||||
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
# template = Liquid::Template.parse("Hello, {{ name }}!")
|
||||||
# ruby_code = template.compile_to_ruby
|
# compiled = template.compile_to_ruby
|
||||||
# render_proc = eval(ruby_code)
|
#
|
||||||
# result = render_proc.call({ "name" => "World" })
|
# # Render (fast, secure on Ruby 4.0+)
|
||||||
|
# result = compiled.render({ "name" => "World" })
|
||||||
# # => "Hello, World!"
|
# # => "Hello, World!"
|
||||||
#
|
#
|
||||||
|
# # Access the generated Ruby source
|
||||||
|
# puts compiled.source
|
||||||
|
#
|
||||||
|
# # Check if execution is sandboxed
|
||||||
|
# compiled.secure? # => true on Ruby 4.0+
|
||||||
|
#
|
||||||
# == Options
|
# == Options
|
||||||
#
|
#
|
||||||
# * <tt>:strict_variables</tt> - Raise on undefined variables (default: false)
|
# * <tt>:strict_variables</tt> - Raise on undefined variables (default: false)
|
||||||
# * <tt>:include_filters</tt> - Include helper methods for filters (default: true)
|
# * <tt>:include_filters</tt> - Include helper methods for filters (default: true)
|
||||||
|
# * <tt>:debug</tt> - Include source comments in generated code (default: false)
|
||||||
#
|
#
|
||||||
# == Advantages of Compiled Code
|
# == Performance
|
||||||
#
|
#
|
||||||
|
# Compiled templates are ~1.5x faster than interpreted Liquid:
|
||||||
# * No Context object overhead
|
# * No Context object overhead
|
||||||
# * No filter invocation overhead (direct method calls)
|
# * No filter invocation overhead (direct method calls)
|
||||||
# * No resource limits tracking
|
# * No resource limits tracking
|
||||||
# * No stack-based scoping (uses Ruby's native scoping)
|
# * No stack-based scoping (uses Ruby's native scoping)
|
||||||
# * No profiling hooks
|
|
||||||
# * Direct string concatenation
|
# * Direct string concatenation
|
||||||
#
|
#
|
||||||
|
# == Security
|
||||||
|
#
|
||||||
|
# On Ruby 4.0+, templates execute in a Ruby::Box sandbox that blocks:
|
||||||
|
# * File/network access
|
||||||
|
# * System calls (exec, spawn, fork)
|
||||||
|
# * Code loading (require, eval)
|
||||||
|
# * Dangerous metaprogramming
|
||||||
|
#
|
||||||
|
# On Ruby < 4.0, templates execute WITHOUT sandboxing.
|
||||||
|
# A warning is printed to STDERR on first execution.
|
||||||
|
#
|
||||||
# == Limitations
|
# == Limitations
|
||||||
#
|
#
|
||||||
# * {% render %} and {% include %} tags require runtime support
|
# * {% render %} and {% include %} resolved at compile time when possible
|
||||||
# * Custom tags need explicit compiler implementations
|
# * Custom tags need explicit compiler implementations
|
||||||
# * Custom filters must be available at runtime
|
# * Custom filters must be available at runtime
|
||||||
#
|
#
|
||||||
# Returns a CompiledTemplate object with the Ruby code and any external tags
|
|
||||||
# that need to be passed to the generated lambda.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# compiled = template.compile_to_ruby
|
|
||||||
# result = compiled.call({ "name" => "World" }) # Handles external tags automatically
|
|
||||||
#
|
|
||||||
# Or manually:
|
|
||||||
# proc = eval(compiled.code)
|
|
||||||
# result = proc.call(assigns, compiled.external_tags)
|
|
||||||
#
|
|
||||||
def compile_to_ruby(options = {})
|
def compile_to_ruby(options = {})
|
||||||
return nil if @root.nil?
|
return nil if @root.nil?
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user