Skip to content

ViewComponent 4.x Bug: ERB::Util.html_escape not working in test environment #2543

@andy-rootly

Description

@andy-rootly

Note: Investigation and report by Claude Code.


ViewComponent 4.x Bug: ERB::Util.html_escape not working in test environment

Summary

In ViewComponent 4.0.0+, when a template uses ERB::Util.html_escape(), the test environment renders the original unescaped value instead of the escaped value returned by the method. This is a regression from ViewComponent 3.x and only affects the test environment - production rendering works correctly.

Versions Affected

  • ViewComponent 3.24.0: Works correctly
  • ViewComponent 4.0.0 - 4.2.0: Broken
  • Rails: 8.1.2
  • Ruby: 3.2.0+

Reproduction

Component Code

# app/components/tooltip_component.rb
class TooltipComponent < ViewComponent::Base
  attr_reader :text

  def initialize(text:)
    @text = text
    super()
  end
end
<%# app/components/tooltip_component.html.erb %>
<div data-text="<%= ERB::Util.html_escape(text) %>">
  Content
</div>

Test Case

RSpec.describe TooltipComponent, type: :component do
  it "escapes HTML in template" do
    xss_payload = "'><script>alert('XSS')</script>"
    rendered = render_inline(TooltipComponent.new(text: xss_payload))
    div_html = rendered.css("div").first.to_html

    # EXPECTED: data-text="&#39;&gt;&lt;script&gt;...&lt;/script&gt;"
    # ACTUAL:   data-text="'><script>alert('XSS')</script>"

    expect(div_html).not_to include("<script>")  # FAILS!
    expect(div_html).to include("&lt;script&gt;")  # FAILS!
  end
end

Expected vs Actual Behavior

Expected (and what happens in production):

<div data-text="&#39;&gt;&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;">Content</div>

Actual (in test environment with render_inline):

<div data-text="'><script>alert('XSS')</script>">Content</div>

Root Cause Analysis

The Issue

ERB::Util.html_escape returns an ActiveSupport::SafeBuffer (which is html_safe?):

text = "<script>"
result = ERB::Util.html_escape(text)
# => "&lt;script&gt;"
result.class
# => ActiveSupport::SafeBuffer
result.html_safe?
# => true

In ViewComponent 4.x's test environment, when a method returns an html_safe string, the rendering pipeline uses the original instance variable value instead of the method's return value.

Proof

Added debugging to component:

def text
  result = CGI.escapeHTML(@text)
  $stderr.puts "Method returns: #{result.inspect}"
  result
end

Output shows:

Method returns: "&#39;&gt;&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;"

But rendered HTML shows:

data-text="'><script>alert('XSS')</script>"

The method returns the correct escaped value, but ViewComponent renders the unescaped original!

Production vs Test Environment

Production (works correctly):

$ bundle exec rails runner "
  component = TooltipComponent.new(text: '<script>test</script>')
  puts component.render_in(ApplicationController.new.view_context)
"
# Output: data-text="&lt;script&gt;test&lt;/script&gt;"  ✅

Test (broken):

render_inline(TooltipComponent.new(text: '<script>test</script>'))
# Output: data-text="<script>test</script>"  ❌

Breaking Commit

Commit: ad6a494
PR: #2158
Title: "Refactor compiler and template to use requested details"
Author: Stephen Nelson
Date: January 17, 2025
First Released In: v4.0.0.rc1

Key Changes in This Commit

  1. Changed template execution from method names to procs:
# BEFORE (v3.x):
def safe_method_name_call
  safe_method_name  # returns string
end

# AFTER (v4.0+):
def safe_method_name_call
  m = safe_method_name
  proc { send(m) }  # returns proc
end
  1. Compiler now uses instance_exec with procs:
# v4.0+
@component.define_method(:render_template_for) do |details|
  if (@current_template = compiler.find_templates_for(details).first)
    instance_exec(&@current_template.safe_method_name_call)  # proc execution
  end
end

This refactoring appears to have broken the interaction between ERB::Util.html_escape's ActiveSupport::SafeBuffer return value and ViewComponent's rendering pipeline in the test environment.

Additional Notes

  • This affects ANY use of ERB::Util.html_escape in ViewComponent templates
  • The issue is specific to the test environment's render_inline method
  • Other escaping methods that return ActiveSupport::SafeBuffer may be similarly affected
  • CGI.escapeHTML returns plain String (not SafeBuffer) but still exhibits the same bug

Environment

  • ViewComponent: 4.2.0
  • Rails: 8.1.2
  • Ruby: 3.2.0+
  • Test Framework: RSpec with ViewComponent::TestHelpers

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions