Summary
Alchemy::Ingredients::PictureView#caption calls ingredient.caption.html_safe and passes the result to content_tag(:figcaption, ...). The caption value is user-supplied (set via the ingredients_attributes[caption] parameter in the element save API) and is stored in a JSON data column with no sanitization. Because .html_safe is called before content_tag, Rails' automatic HTML escaping is suppressed and the raw user string is emitted into the published page, executing in every visitor's browser.
Details
Root cause
ingredient.caption is a store_accessor backed by the JSON data column of alchemy_ingredients. It returns a plain Ruby String. The view calls .html_safe on it unconditionally before passing it to content_tag:
# app/components/alchemy/ingredients/picture_view.rb, line 71
def caption
return unless show_caption?
@_caption ||= content_tag(:figcaption, ingredient.caption.html_safe)
# ^^^^^^^^^^^
# .html_safe marks user input as trusted HTML; content_tag skips escaping
end
Rails' content_tag escapes its body argument by default — but only when the argument is not already html_safe. The .html_safe call on line 71 sets the html_safe? flag to true, so content_tag passes the string through unchanged. Any HTML tags in the caption appear verbatim in the rendered <figcaption> element.
No sanitizer is applied to the caption field in the model, the controller, or anywhere else in the request lifecycle. The data column accepts arbitrary JSON with no allow-list.
PoC
- Upload an image in article
- Insert a caption with payload
- Save the caption and save the page.
- Proceed to the page and observe the XSS popped.
Impact
Any authenticated CMS user with author role or above can plant persistent JavaScript in the caption of any picture ingredient. The payload executes in every anonymous visitor's browser when they load the affected page. Attack surface includes session hijacking, credential phishing overlays, drive-by malware delivery, and defacement. Because picture ingredients can appear on any page version, a single poisoned caption persists across re-publishes until explicitly cleared.
Summary
Alchemy::Ingredients::PictureView#captioncallsingredient.caption.html_safeand passes the result tocontent_tag(:figcaption, ...). The caption value is user-supplied (set via theingredients_attributes[caption]parameter in the element save API) and is stored in a JSONdatacolumn with no sanitization. Because.html_safeis called beforecontent_tag, Rails' automatic HTML escaping is suppressed and the raw user string is emitted into the published page, executing in every visitor's browser.Details
Root cause
ingredient.captionis astore_accessorbacked by the JSONdatacolumn ofalchemy_ingredients. It returns a plain RubyString. The view calls.html_safeon it unconditionally before passing it tocontent_tag:Rails'
content_tagescapes its body argument by default — but only when the argument is not alreadyhtml_safe. The.html_safecall on line 71 sets thehtml_safe?flag totrue, socontent_tagpasses the string through unchanged. Any HTML tags in the caption appear verbatim in the rendered<figcaption>element.No sanitizer is applied to the caption field in the model, the controller, or anywhere else in the request lifecycle. The
datacolumn accepts arbitrary JSON with no allow-list.PoC
Impact
Any authenticated CMS user with author role or above can plant persistent JavaScript in the caption of any picture ingredient. The payload executes in every anonymous visitor's browser when they load the affected page. Attack surface includes session hijacking, credential phishing overlays, drive-by malware delivery, and defacement. Because picture ingredients can appear on any page version, a single poisoned caption persists across re-publishes until explicitly cleared.