Back to Repositories

Validating Template Security Integration in Shopify/liquid

This integration test suite validates security measures in Liquid templating, focusing on preventing code injection and maintaining system integrity. The tests verify protection against instance_eval exploitation and manage resource constraints like symbol table pollution and nested block depth limits.

Test Coverage Overview

The test suite provides comprehensive coverage of security-critical functionality in Liquid templating:

  • Instance evaluation protection tests
  • Symbol table pollution prevention
  • Drop method security validation
  • Nested block depth limit verification
  • Filter chain security tests

Implementation Analysis

The testing approach employs Minitest framework with systematic security validation patterns. Tests utilize mock assigns, template parsing, and rendering to verify security boundaries. Implementation includes thread isolation for memory management tests and explicit verification of system state after potentially dangerous operations.

Technical Details

Testing infrastructure includes:

  • Minitest as the testing framework
  • Custom SecurityFilter module for filter chain testing
  • Thread-based isolation for memory tests
  • GC.start() for garbage collection verification
  • Symbol.all_symbols tracking for memory safety

Best Practices Demonstrated

The test suite exemplifies security testing best practices through systematic validation of potential attack vectors. It demonstrates proper isolation of tests, thorough edge case coverage, and careful state management. The code organization follows a clear pattern of setup, execution, and verification with explicit expected values.

shopify/liquid

test/integration/security_test.rb

            
# frozen_string_literal: true

require 'test_helper'

module SecurityFilter
  def add_one(input)
    "#{input} + 1"
  end
end

class SecurityTest < Minitest::Test
  include Liquid

  def setup
    @assigns = {}
  end

  def test_no_instance_eval
    text     = %( {{ '1+1' | instance_eval }} )
    expected = %( 1+1 )

    assert_equal(expected, Template.parse(text).render!(@assigns))
  end

  def test_no_existing_instance_eval
    text     = %( {{ '1+1' | __instance_eval__ }} )
    expected = %( 1+1 )

    assert_equal(expected, Template.parse(text).render!(@assigns))
  end

  def test_no_instance_eval_after_mixing_in_new_filter
    text     = %( {{ '1+1' | instance_eval }} )
    expected = %( 1+1 )

    assert_equal(expected, Template.parse(text).render!(@assigns))
  end

  def test_no_instance_eval_later_in_chain
    text     = %( {{ '1+1' | add_one | instance_eval }} )
    expected = %( 1+1 + 1 )

    assert_equal(expected, Template.parse(text).render!(@assigns, filters: SecurityFilter))
  end

  def test_does_not_permanently_add_filters_to_symbol_table
    current_symbols = Symbol.all_symbols

    # MRI imprecisely marks objects found on the C stack, which can result
    # in uninitialized memory being marked. This can even result in the test failing
    # deterministically for a given compilation of ruby. Using a separate thread will
    # keep these writes of the symbol pointer on a separate stack that will be garbage
    # collected after Thread#join.
    Thread.new do
      test = %( {{ "some_string" | a_bad_filter }} )
      Template.parse(test).render!
      nil
    end.join

    GC.start

    assert_equal([], (Symbol.all_symbols - current_symbols))
  end

  def test_does_not_add_drop_methods_to_symbol_table
    current_symbols = Symbol.all_symbols

    assigns = { 'drop' => Drop.new }
    assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
    assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
    assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)

    assert_equal([], (Symbol.all_symbols - current_symbols))
  end

  def test_max_depth_nested_blocks_does_not_raise_exception
    depth = Liquid::Block::MAX_DEPTH
    code  = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth
    assert_equal("rendered", Template.parse(code).render!)
  end

  def test_more_than_max_depth_nested_blocks_raises_exception
    depth = Liquid::Block::MAX_DEPTH + 1
    code  = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth
    assert_raises(Liquid::StackLevelError) do
      Template.parse(code).render!
    end
  end
end # SecurityTest