From 6c0d599c89c75a6b0e26ae6f541196e1b4b072dd Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Wed, 31 Dec 2025 12:32:41 -0400 Subject: [PATCH] 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 --- lib/liquid/box.rb | 15 ++++- lib/liquid/compile/compiled_template.rb | 88 +++++++++++++++++++++---- lib/liquid/compile/filter_compiler.rb | 6 +- lib/liquid/compile/ruby_compiler.rb | 73 +++----------------- 4 files changed, 100 insertions(+), 82 deletions(-) diff --git a/lib/liquid/box.rb b/lib/liquid/box.rb index 787f9d5b..1949d575 100644 --- a/lib/liquid/box.rb +++ b/lib/liquid/box.rb @@ -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 diff --git a/lib/liquid/compile/compiled_template.rb b/lib/liquid/compile/compiled_template.rb index 9a83da38..de2ed5cb 100644 --- a/lib/liquid/compile/compiled_template.rb +++ b/lib/liquid/compile/compiled_template.rb @@ -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 diff --git a/lib/liquid/compile/filter_compiler.rb b/lib/liquid/compile/filter_compiler.rb index 50b82633..09fdbea9 100644 --- a/lib/liquid/compile/filter_compiler.rb +++ b/lib/liquid/compile/filter_compiler.rb @@ -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 diff --git a/lib/liquid/compile/ruby_compiler.rb b/lib/liquid/compile/ruby_compiler.rb index 3edd366c..98fd3db6 100644 --- a/lib/liquid/compile/ruby_compiler.rb +++ b/lib/liquid/compile/ruby_compiler.rb @@ -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)