Yield to caller for external tags and filters

Instead of trying to handle external tags/filters inside the sandbox,
yield back to the caller with [:tag, ...] or [:filter, ...] args.

This cleanly separates concerns:
- Sandbox handles compiled template logic
- Caller handles external calls with full Ruby access

API:
  compiled.render(assigns) do |call_type, *args|
    case call_type
    when :tag
      tag_var, tag_assigns = args
      # Handle with full Liquid context
    when :filter
      filter_name, input, filter_args = args
      # Handle with custom filter handler
    end
  end

If no block is given, a default handler is used that:
- Renders external tags using Liquid::Context
- Calls filter methods via filter_handler

Also:
- Keep public_send in sandbox (safe, only calls public methods)
- Load date/time libs into sandbox for date filter support
- Preserve Date, DateTime, Time constants after lock
This commit is contained in:
Tobi Lutke
2025-12-31 12:32:41 -04:00
parent 560d2ce9d4
commit 6c0d599c89
4 changed files with 100 additions and 82 deletions
+14 -1
View File
@@ -125,6 +125,8 @@ module Liquid
@box.require('base64')
@box.require('bigdecimal')
@box.require('bigdecimal/util') # For String#to_d etc.
@box.require('date') # For date filter
@box.require('time') # For Time.parse
# Now load the runtime which captures method references from these
@box.require(RUNTIME_PATH)
@@ -134,6 +136,8 @@ module Liquid
require 'base64'
require 'bigdecimal'
require 'bigdecimal/util'
require 'date'
require 'time'
require RUNTIME_PATH
end
@@ -143,6 +147,10 @@ module Liquid
@user_constants << "CGI"
@user_constants << "Base64"
@user_constants << "BigDecimal"
@user_constants << "Date"
@user_constants << "DateTime"
@user_constants << "Time"
@user_constants << "Liquid" # For Liquid::Compile::CompiledContext
end
# Add gem paths to the box's load_path so require works for gems
@@ -374,22 +382,27 @@ module Liquid
end
def neuter_basic_object!
# Suppress the "__send__" warning - we know what we're doing
@box.eval(<<~'RUBY')
original_verbose = $VERBOSE
$VERBOSE = nil
class BasicObject
undef_method(:instance_eval) rescue nil
undef_method(:instance_exec) rescue nil
undef_method(:__send__) rescue nil
end
$VERBOSE = original_verbose
RUBY
end
def neuter_object!
@box.eval(<<~'RUBY')
class Object
# Keep public_send - it's safe (only calls public methods) and useful
[:gem, :gem_original_require, :require, :require_relative, :load,
:display, :define_singleton_method,
:instance_variable_set, :remove_instance_variable,
:extend, :send, :public_send,
:extend, :send,
].each { |m| undef_method(m) rescue nil }
end
RUBY
+75 -13
View File
@@ -23,6 +23,22 @@ module Liquid
# # Check security status
# compiled.secure? # => true on Ruby 4.0+, false otherwise
#
# == External Calls (Tags and Filters)
#
# When the sandbox encounters an external tag or filter it can't handle,
# it yields back to the caller. You can provide a block to handle these:
#
# compiled.render(assigns) do |call_type, *args|
# case call_type
# when :tag
# tag_name, tag_obj, tag_context = args
# tag_obj.render(tag_context)
# when :filter
# filter_name, input, filter_args = args
# my_filter_handler.send(filter_name, input, *filter_args)
# end
# end
#
class CompiledTemplate
attr_reader :source, :external_tags
attr_accessor :filter_handler
@@ -60,21 +76,31 @@ module Liquid
# execution happens in a secure sandbox. On earlier versions, a warning
# is printed to STDERR.
#
# When the template needs to call an external tag or filter, it yields
# back to the caller with [:tag, ...] or [:filter, ...] args. If no block
# is given, a default handler is used.
#
# @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_filters [Boolean] Raise on undefined filters
# @yield [call_type, *args] Called for external tags/filters
# @return [String] The rendered output
#
# @example Basic usage
# compiled.render({ "name" => "World" })
#
# @example With registers
# compiled.render({ "product" => product }, registers: { shop: current_shop })
# @example With block for external calls
# compiled.render(assigns) do |type, *args|
# case type
# when :tag then handle_tag(*args)
# when :filter then handle_filter(*args)
# end
# end
#
def render(assigns = {}, registers: {}, filter_handler: nil, strict_variables: false, strict_filters: false)
proc = to_proc
def render(assigns = {}, registers: {}, filter_handler: nil, strict_variables: false, strict_filters: false, &block)
compiled_proc = to_proc
handler = filter_handler || @filter_handler
# Create a context for Drop support
@@ -85,13 +111,11 @@ module Liquid
strict_filters: strict_filters
)
# Build arguments based on what the lambda expects
args = [assigns]
args << @external_tags if has_external_tags?
args << handler if has_external_filters?
args << context # Always pass context as last arg
# Create the external call handler
external_handler = block || default_external_handler(handler)
proc.call(*args)
# Build arguments: assigns, context, external_handler
compiled_proc.call(assigns, context, external_handler)
end
# Alias for backwards compatibility
@@ -119,6 +143,42 @@ module Liquid
private
# Default handler for external calls when no block is provided
def default_external_handler(filter_handler)
external_tags = @external_tags
->(call_type, *args) do
case call_type
when :tag
tag_var, tag_assigns = args
tag = external_tags[tag_var]
return '' unless tag
# Create a context and render the tag
ctx = Liquid::Context.new(
[tag_assigns], {}, {},
false, nil, {},
Liquid::Environment.default
)
output = +''
tag.render_to_output_buffer(ctx, output)
output
when :filter
filter_name, input, filter_args = args
if filter_handler&.respond_to?(filter_name)
m = filter_handler.method(filter_name)
m.call(input, *filter_args)
else
input # Return unchanged if filter not found
end
else
raise ArgumentError, "Unknown external call type: #{call_type}"
end
end
end
def compile_to_proc
if Liquid::Box.secure?
compile_in_sandbox
@@ -142,8 +202,8 @@ module Liquid
class #{template_class_name}
TEMPLATE_PROC = #{@source}
def self.render(*args)
TEMPLATE_PROC.call(*args)
def self.render(*args, &block)
TEMPLATE_PROC.call(*args, &block)
end
end
RUBY
@@ -152,7 +212,9 @@ module Liquid
template_class = @box[template_class_name]
# Return a proc that delegates to the sandboxed class
->(assigns, *rest) { template_class.render(assigns, *rest) }
->(assigns, context, external_handler) do
template_class.render(assigns, context, external_handler)
end
end
# Compile without sandbox (Ruby < 4.0) - shows warning
+3 -3
View File
@@ -203,7 +203,7 @@ module Liquid
end
# Compile a filter that's not built-in
# Uses __call_filter__ helper which must be provided by the runtime
# Yields [:filter, name, input, args] to the external handler
def self.compile_generic_filter(input, name, args, kwargs, compiler)
# Mark that we're using external filters
compiler.register_external_filter
@@ -217,8 +217,8 @@ module Liquid
args_str = compiled_args.empty? ? "[]" : "[#{compiled_args.join(', ')}]"
# Call through the filter helper which delegates to registered filters
"__call_filter__.call(#{name.inspect}, #{input}, #{args_str})"
# Yield to the external handler
"__external__.call(:filter, #{name.inspect}, #{input}, #{args_str})"
end
# Compile a filter argument
+8 -65
View File
@@ -203,39 +203,17 @@ module Liquid
main_code = CodeGenerator.new
compile_node(@template.root, main_code)
# Determine lambda parameters based on external dependencies
params = ["assigns = {}"]
params << "__external_tags__ = {}" unless @external_tags.empty?
params << "__filter_handler__ = nil" if @has_external_filters
params << "__context__ = nil"
code.line "->(#{params.join(', ')}) do"
# Lambda signature: (assigns, context, external_handler)
# - assigns: Hash of template variables
# - context: CompiledContext for Drop support
# - external_handler: Proc that handles [:tag, ...] and [:filter, ...] calls
code.line "->(assigns, __context__, __external__) do"
code.indent do
# Initialize the output buffer
code.line '__output__ = +""'
code.blank_line
# Create a compiled context if not provided (for Drop support)
code.line "# Create context for Drop support"
code.line "__context__ ||= Liquid::Compile::CompiledContext.new(assigns)"
code.blank_line
# Note: All helper methods are provided by the LR module (pre-loaded runtime)
# Templates use LR.to_s(), LR.lookup(), LR.output(), etc.
# Add external tag runtime helper if needed
unless @external_tags.empty?
compile_external_tag_helper(code)
code.blank_line
end
# Add external filter helper if needed
if @has_external_filters
compile_filter_helper(code)
code.blank_line
end
# Compile partial methods (before main body so they're available)
compile_partials(code)
@@ -250,41 +228,6 @@ module Liquid
code.to_s
end
# Compile helper for calling external tags at runtime
def compile_external_tag_helper(code)
code.line "# Helper for calling external (unknown) tags at runtime"
code.line "__call_external_tag__ = ->(tag_var, tag_assigns) {"
code.indent do
code.line "tag = __external_tags__[tag_var]"
code.line "next '' unless tag"
code.line "# Create a context using the default environment (which has filters registered)"
code.line "ctx = Liquid::Context.new([tag_assigns], {}, {}, false, nil, {}, Liquid::Environment.default)"
code.line "output = +''"
code.line "# Use render_to_output_buffer to ensure block tags work correctly"
code.line "tag.render_to_output_buffer(ctx, output)"
code.line "output"
end
code.line "}"
end
# Compile helper for calling external filters at runtime
def compile_filter_helper(code)
code.line "# Helper for calling external (unknown) filters at runtime"
code.line "__call_filter__ = ->(name, input, args) {"
code.indent do
code.line "if __filter_handler__&.respond_to?(name)"
code.indent do
code.line "__filter_handler__.send(name, input, *args)"
end
code.line "else"
code.indent do
code.line "input # Return input unchanged if filter not found"
end
code.line "end"
end
code.line "}"
end
# Compile all registered partials as inner methods
def compile_partials(code)
@partials.each do |name, method_name|
@@ -387,10 +330,10 @@ module Liquid
tag_var = register_external_tag(tag)
tag_name = tag.class.name.split('::').last
if debug?
code.line "# External tag: #{tag_name} (delegated to runtime)"
code.line "$stderr.puts '* WARN: Liquid external tag call - #{tag_name} (not compiled, delegated to runtime)' if $VERBOSE"
code.line "# External tag: #{tag_name} (yields to caller)"
end
code.line "__output__ << __call_external_tag__.call(#{tag_var.inspect}, assigns)"
# Yield [:tag, tag_var, assigns] to the external handler
code.line "__output__ << __external__.call(:tag, #{tag_var.inspect}, assigns)"
end
def find_tag_compiler(tag)