view_component 4.11.0 → 4.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13c7279c5aed2ac93503d70cfddae8b183b91c8a4ca0770a237d0b5aa081036c
4
- data.tar.gz: 5fd930eb6b28a01da1152fd7d4a09e8019de0902f0508e4ea6aa0f23d6c584bd
3
+ metadata.gz: b2f1a7d34956fc9ae629c0f0e710f4a7335b33ebc99f1c3eccafc41d19713524
4
+ data.tar.gz: efd4bec7d2425da171f89f28fbc1bfaf9f2cba94fd0f2a9cbce674830281938e
5
5
  SHA512:
6
- metadata.gz: d691402cf227340f287c804e6a0ce639928ca2d409dd745701eae63dba20abdae2d3b876fb6d2c81d8b63f3a4ba9b0af7aa4268da5faf37c2fff5f3f7aa9b7ee
7
- data.tar.gz: f9a554a6d53d75875c9863a1dedba3e6a51efd134e35dd43f5eb2883e446d5cab2ecba06f9a1fddd7e81d8e046aa5d0383585d9563f12897b4530606e225906c
6
+ metadata.gz: 5d0f5024d419a56b5e178f0cce3d77ac4a1ae2d4d3efc0ac5334e7b55a4dcb6cc7460aa4d60a47bce63918b0a452b53c5425297ef03da2ce074793407acef570
7
+ data.tar.gz: be1d649efec64995de234eb70998001d42cd9b0197bdce00ec5011d68cb2a2784925abf283b3f51c3986a55a446db3ba0c17cedf406c919e31b178ece2be6b51
data/docs/CHANGELOG.md CHANGED
@@ -10,6 +10,16 @@ nav_order: 6
10
10
 
11
11
  ## main
12
12
 
13
+ ## 4.12.0
14
+
15
+ * Fix stale render context on reused component instances. A `ViewComponent::Base` instance memoized its controller, helpers, request, view context, lookup context, view flow, and requested format details on first render via `||=`. Rendering the same instance a second time (intentionally or via aliasing) reused that stale context, which could leak data across requests, sessions, or users. `#render_in` now resets these ivars on every call so each render derives its context from the current view.
16
+
17
+ *Joel Hawksley*
18
+
19
+ * Fix HTML-safety bypass in `around_render`. `ViewComponent::Base#around_render` could return HTML-unsafe strings that bypassed the escaping applied to normal `#call` return values, creating an XSS risk. The vulnerability was amplified in `ViewComponent::Collection#render_in`, which joined per-item results and unconditionally marked the output `html_safe`. HTML-unsafe strings returned from `around_render` are now escaped (with a warning) and `Collection#render_in` now uses `safe_join` so unsafe per-item output is escaped instead of laundered into a `SafeBuffer`.
20
+
21
+ *Joel Hawksley*
22
+
13
23
  ## 4.11.0
14
24
 
15
25
  * Update `render_in` signature to accept `**_` for compatibility with Rails [#50623](https://github.com/rails/rails/pull/50623).
@@ -106,22 +106,24 @@ module ViewComponent
106
106
  def render_in(view_context, **_, &block)
107
107
  self.class.__vc_compile(raise_errors: true)
108
108
 
109
+ __vc_reset_render_state!
110
+
109
111
  @view_context = view_context
110
112
  @old_virtual_path = view_context.instance_variable_get(:@virtual_path)
111
- self.__vc_original_view_context ||= view_context
113
+ self.__vc_original_view_context = view_context
112
114
 
113
115
  @output_buffer = view_context.output_buffer
114
116
 
115
- @lookup_context ||= view_context.lookup_context
117
+ @lookup_context = view_context.lookup_context
116
118
 
117
119
  # For content_for
118
- @view_flow ||= view_context.view_flow
120
+ @view_flow = view_context.view_flow
119
121
 
120
122
  # For i18n
121
123
  @virtual_path ||= virtual_path
122
124
 
123
125
  # Describes the inferred request constraints (locales, formats, variants)
124
- @__vc_requested_details ||= @lookup_context.vc_requested_details
126
+ @__vc_requested_details = @lookup_context.vc_requested_details
125
127
 
126
128
  # For caching, such as #cache_if
127
129
  @current_template = nil unless defined?(@current_template)
@@ -142,10 +144,21 @@ module ViewComponent
142
144
  value = nil
143
145
 
144
146
  @output_buffer.with_buffer do
145
- rendered_template =
146
- around_render do
147
- render_template_for(@__vc_requested_details).to_s
148
- end
147
+ inner_rendered_template = nil
148
+ around_rendered_template = around_render do
149
+ inner_rendered_template = render_template_for(@__vc_requested_details).to_s
150
+ end
151
+
152
+ # If `around_render` returned the same object the block yielded, the inner
153
+ # template's escaping is authoritative and we can trust the result. If the
154
+ # user replaced/wrapped the value, re-check HTML safety to prevent
155
+ # bypassing the escaping applied to normal `#call` return values
156
+ # (GHSA-97jw-64cj-jc58).
157
+ rendered_template = if around_rendered_template.equal?(inner_rendered_template)
158
+ around_rendered_template
159
+ else
160
+ __vc_safe_around_render_output(around_rendered_template)
161
+ end
149
162
 
150
163
  # Avoid allocating new string when output_preamble and output_postamble are blank
151
164
  value = if output_preamble.blank? && output_postamble.blank?
@@ -255,7 +268,7 @@ module ViewComponent
255
268
  # @private
256
269
  def render(options = {}, args = {}, &block)
257
270
  if options.respond_to?(:set_original_view_context)
258
- options.set_original_view_context(self.__vc_original_view_context)
271
+ options.set_original_view_context(__vc_original_view_context)
259
272
 
260
273
  # We assume options is a component, so there's no need to evaluate the
261
274
  # block in the view context as we do below.
@@ -444,6 +457,28 @@ module ViewComponent
444
457
  end
445
458
  end
446
459
 
460
+ def __vc_safe_around_render_output(output)
461
+ __vc_maybe_escape_html(output) do
462
+ Kernel.warn("WARNING: The #{self.class} component's around_render returned an HTML-unsafe string. The output will be automatically escaped, but you may want to investigate.")
463
+ end
464
+ end
465
+
466
+ # Resets every render-scoped instance variable derived from the calling view
467
+ # context so a reused instance cannot leak controller/helper/request/format
468
+ # state from a previous render. Slot state (`@__vc_set_slots`,
469
+ # `@__vc_content_set_by_with_content`) is intentionally preserved because it
470
+ # is populated by callers _before_ `render_in` runs (e.g. via `with_*`
471
+ # slot setters or `with_content`).
472
+ def __vc_reset_render_state!
473
+ %i[
474
+ @__vc_controller
475
+ @__vc_helpers
476
+ @__vc_request
477
+ ].each do |ivar|
478
+ remove_instance_variable(ivar) if instance_variable_defined?(ivar)
479
+ end
480
+ end
481
+
447
482
  # Configuration for generators.
448
483
  #
449
484
  # All options under this namespace default to `false` unless otherwise
@@ -1,19 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "action_view/renderer/collection_renderer"
4
+ require "action_view/helpers/output_safety_helper"
4
5
 
5
6
  module ViewComponent
6
7
  class Collection
7
8
  include Enumerable
9
+ include ActionView::Helpers::OutputSafetyHelper
8
10
 
9
11
  attr_reader :component
10
12
 
11
13
  delegate :size, to: :@collection
12
14
 
13
15
  def render_in(view_context, **_, &block)
14
- components.map do |component|
16
+ rendered = components.map do |component|
15
17
  component.render_in(view_context, &block)
16
- end.join(rendered_spacer(view_context)).html_safe
18
+ end
19
+ safe_join(rendered, rendered_spacer(view_context))
17
20
  end
18
21
 
19
22
  def each(&block)
@@ -30,15 +33,15 @@ module ViewComponent
30
33
 
31
34
  private
32
35
 
36
+ # Always rebuild child component instances per render to avoid leaking
37
+ # request-scoped state from a previous render into a later one (GHSA).
33
38
  def components
34
- return @components if defined? @components
35
-
36
39
  iterator = ActionView::PartialIteration.new(@collection.size)
37
40
 
38
41
  component.__vc_validate_collection_parameter!(validate_default: true)
39
42
 
40
- @components = @collection.map do |item|
41
- component.new(**component_options(item, iterator)).tap do |component|
43
+ @collection.map do |item|
44
+ component.new(**component_options(item, iterator)).tap do |_|
42
45
  iterator.iterate!
43
46
  end
44
47
  end
@@ -67,12 +70,12 @@ module ViewComponent
67
70
  @options.merge(item_options)
68
71
  end
69
72
 
73
+ # Render the spacer through a fresh `dup` so a collection rendered multiple
74
+ # times always gets a clean spacer instance.
70
75
  def rendered_spacer(view_context)
71
- if @spacer_component
72
- @spacer_component.render_in(view_context)
73
- else
74
- ""
75
- end
76
+ return "" unless @spacer_component
77
+
78
+ @spacer_component.dup.render_in(view_context)
76
79
  end
77
80
  end
78
81
  end
@@ -3,7 +3,7 @@
3
3
  module ViewComponent
4
4
  module VERSION
5
5
  MAJOR = 4
6
- MINOR = 11
6
+ MINOR = 12
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.11.0
4
+ version: 4.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ViewComponent Team