Summary
Alchemy::Ingredients::SelectView#call delegates to super (i.e., Alchemy::Ingredients::BaseView#call) for the single-value path, which returns value.html_safe. The allowed option list (select_values) defined in elements.yml is enforced only in the admin UI dropdown; no server-side validation checks that a submitted value belongs to the allowed set. An authenticated CMS author can bypass the dropdown by sending a direct API request with an arbitrary HTML string as the ingredient value. That string is stored verbatim and rendered unescaped into the published public page, executing in every visitor's browser.
Details
Root cause
The call chain for a single-select ingredient at render time is:
SelectView#call
└─ (ingredient.multiple? is false) → super
└─ BaseView#call → value.html_safe
# app/components/alchemy/ingredients/select_view.rb, lines 4–11
def call
if ingredient.multiple? && value.is_a?(Array)
value.to_sentence
else
super # delegates to BaseView#call
end
end
# app/components/alchemy/ingredients/base_view.rb, lines 18–20
def call
value.html_safe # trusts the stored string unconditionally
end
value is the raw string stored in alchemy_ingredients.value via the admin elements API. The allowed values list is defined in elements.yml under select_values and rendered as a <select> dropdown in the admin UI. This constraint is applied only client-side; the API endpoint (PATCH /admin/elements/:id) accepts any string in element[ingredients_attributes][N][value] without checking it against the YAML definition.
No validation exists in the Alchemy::Ingredients::Select model:
# app/models/alchemy/ingredients/select.rb
allow_settings %i[allow_clear display_inline select_values multiple]
# No validates :value, inclusion: { in: ... }
Because value.html_safe is returned by BaseView#call, Rails' default HTML escaping is suppressed and the raw stored string appears in the public page inside whatever wrapper the template provides (here <div class="select">…</div>).
PoC
- Store CSRF token
d
- Create XSS Payload
- Publish the page.
- Browse to the page and observe the XSS Popped
Impact
Any authenticated CMS user with author role or above can plant persistent JavaScript in a select ingredient by bypassing the client-side option list. The payload executes in every anonymous visitor's browser when the published page loads. The absence of server-side validation is a systemic issue: the entire security model for the select ingredient's allowed values relies on the browser never sending an out-of-list submission. A trivial curl command defeats this. Combined with BaseView#call returning value.html_safe, the path from authorship to XSS requires no privilege escalation beyond the author role. Attack surface: session hijacking, phishing overlays, drive-by malware delivery, defacement.
Summary
Alchemy::Ingredients::SelectView#calldelegates tosuper(i.e.,Alchemy::Ingredients::BaseView#call) for the single-value path, which returnsvalue.html_safe. The allowed option list (select_values) defined inelements.ymlis enforced only in the admin UI dropdown; no server-side validation checks that a submitted value belongs to the allowed set. An authenticated CMS author can bypass the dropdown by sending a direct API request with an arbitrary HTML string as the ingredient value. That string is stored verbatim and rendered unescaped into the published public page, executing in every visitor's browser.Details
Root cause
The call chain for a single-select ingredient at render time is:
valueis the raw string stored inalchemy_ingredients.valuevia the admin elements API. The allowed values list is defined inelements.ymlunderselect_valuesand rendered as a<select>dropdown in the admin UI. This constraint is applied only client-side; the API endpoint (PATCH /admin/elements/:id) accepts any string inelement[ingredients_attributes][N][value]without checking it against the YAML definition.No validation exists in the
Alchemy::Ingredients::Selectmodel:Because
value.html_safeis returned byBaseView#call, Rails' default HTML escaping is suppressed and the raw stored string appears in the public page inside whatever wrapper the template provides (here<div class="select">…</div>).PoC
Impact
Any authenticated CMS user with author role or above can plant persistent JavaScript in a select ingredient by bypassing the client-side option list. The payload executes in every anonymous visitor's browser when the published page loads. The absence of server-side validation is a systemic issue: the entire security model for the select ingredient's allowed values relies on the browser never sending an out-of-list submission. A trivial
curlcommand defeats this. Combined withBaseView#callreturningvalue.html_safe, the path from authorship to XSS requires no privilege escalation beyond the author role. Attack surface: session hijacking, phishing overlays, drive-by malware delivery, defacement.