Files
chatwoot/spec/lib/custom_markdown_renderer_spec.rb
T
2192af80f4 fix: html-escape captured values in helpcenter article markdown embeds (#14140)
Embed templates interpolate regex captures from user-authored article
URLs into HTML attribute values. CommonMark's angle-bracket link
destination syntax allows characters that the capture regexes don't
filter, so the unescaped substitution could produce malformed attribute
output. Escaping at substitution time keeps the render deterministic
regardless of the URL.

### How was this tested?
Added specs.

Fixes [CW-6934](https://linear.app/chatwoot/issue/CW-6934/)

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-05-05 17:46:21 +05:30

261 lines
12 KiB
Ruby

require 'rails_helper'
describe CustomMarkdownRenderer do
let(:renderer) { described_class.new }
def render_markdown(markdown)
doc = CommonMarker.render_doc(markdown, :DEFAULT)
renderer.render(doc)
end
describe '#text' do
it 'converts text wrapped in ^ to superscript' do
markdown = 'This is an example of a superscript: ^superscript^.'
expect(render_markdown(markdown)).to include('<sup>superscript</sup>')
end
it 'does not convert text not wrapped in ^' do
markdown = 'This is an example without superscript.'
expect(render_markdown(markdown)).not_to include('<sup>')
end
it 'converts multiple superscripts in the same text' do
markdown = 'This is an example with ^multiple^ ^superscripts^.'
rendered_html = render_markdown(markdown)
expect(rendered_html.scan('<sup>').length).to eq(2)
expect(rendered_html).to include('<sup>multiple</sup>')
expect(rendered_html).to include('<sup>superscripts</sup>')
end
end
describe 'broken ^ usage' do
it 'does not convert text that only starts with ^' do
markdown = 'This is an example with ^broken superscript.'
expected_output = '<p>This is an example with ^broken superscript.</p>'
expect(render_markdown(markdown)).to include(expected_output)
end
it 'does not convert text that only ends with ^' do
markdown = 'This is an example with broken^ superscript.'
expected_output = '<p>This is an example with broken^ superscript.</p>'
expect(render_markdown(markdown)).to include(expected_output)
end
it 'does not convert text with uneven numbers of ^' do
markdown = 'This is an example with ^broken^ superscript^.'
expected_output = '<p>This is an example with <sup>broken</sup> superscript^.</p>'
expect(render_markdown(markdown)).to include(expected_output)
end
end
describe '#link' do
def render_markdown_link(link)
doc = CommonMarker.render_doc("[link](#{link})", :DEFAULT)
renderer.render(doc)
end
context 'when link is a YouTube URL' do
let(:youtube_url) { 'https://www.youtube.com/watch?v=VIDEO_ID' }
it 'renders an iframe with YouTube embed code' do
output = render_markdown_link(youtube_url)
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
expect(output).to include('allowfullscreen')
end
end
context 'when link is a Loom URL' do
let(:loom_url) { 'https://www.loom.com/share/VIDEO_ID' }
it 'renders an iframe with Loom embed code' do
output = render_markdown_link(loom_url)
expect(output).to include('src="https://www.loom.com/embed/VIDEO_ID"')
expect(output).to include('webkitallowfullscreen mozallowfullscreen allowfullscreen')
end
end
context 'when link is a Vimeo URL' do
let(:vimeo_url) { 'https://vimeo.com/1234567' }
it 'renders an iframe with Vimeo embed code' do
output = render_markdown_link(vimeo_url)
expect(output).to include('src="https://player.vimeo.com/video/1234567?dnt=true"')
expect(output).to include('allowfullscreen')
end
end
context 'when link is an MP4 URL' do
let(:mp4_url) { 'https://example.com/video.mp4' }
it 'renders a video element with the MP4 source' do
output = render_markdown_link(mp4_url)
expect(output).to include('<video width="640" height="360" controls')
expect(output).to include('<source src="https://example.com/video.mp4" type="video/mp4">')
end
end
context 'when link is a normal URL' do
let(:normal_url) { 'https://example.com' }
it 'renders a normal link' do
output = render_markdown_link(normal_url)
expect(output).to include('<a href="https://example.com">')
end
end
context 'when multiple links are present' do
it 'renders all links when present between empty lines' do
markdown = "\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n\n[vimeo](https://vimeo.com/1234567)\n^ hello ^ [normal](https://example.com)"
output = render_markdown(markdown)
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
expect(output).to include('src="https://player.vimeo.com/video/1234567?dnt=true"')
expect(output).to include('<a href="https://example.com">')
expect(output).to include('<sup> hello </sup>')
end
end
context 'when links within text are present' do
it 'renders only text within blank lines as embeds' do
markdown = "\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\nthis is such an amazing [vimeo](https://vimeo.com/1234567)\n[vimeo](https://vimeo.com/1234567)\n"
output = render_markdown(markdown)
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
expect(output).to include('src="https://player.vimeo.com/video/1234567?dnt=true"')
expect(output).to include('href="https://vimeo.com/1234567"')
end
end
context 'when link is an Arcade URL' do
let(:arcade_url) { 'https://app.arcade.software/share/ARCADE_ID' }
it 'renders an iframe with Arcade embed code' do
output = render_markdown_link(arcade_url)
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
expect(output).to include('<iframe')
expect(output).to include('webkitallowfullscreen')
expect(output).to include('mozallowfullscreen')
expect(output).to include('allowfullscreen')
end
it 'wraps iframe in responsive container' do
output = render_markdown_link(arcade_url)
expect(output).to include('position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;')
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
end
end
context 'when link is an Arcade tab URL' do
let(:arcade_tab_url) { 'https://app.arcade.software/share/ARCADE_TAB_ID?embed_mobile=tab' }
it 'renders an iframe with Arcade tab embed code' do
output = render_markdown_link(arcade_tab_url)
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_TAB_ID?embed&embed_mobile=tab"')
end
it 'supports additional query params after embed_mobile' do
url = 'https://app.arcade.software/share/ARCADE_TAB_ID?foo=bar&embed_mobile=tab?user_id=1'
output = render_markdown_link(url)
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_TAB_ID?embed&embed_mobile=tab"')
end
it 'wraps iframe in responsive container' do
output = render_markdown_link(arcade_tab_url)
expect(output).to include('position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;')
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
end
end
context 'when link is a wistia URL' do
let(:wistia_url) { 'https://chatwoot.wistia.com/medias/kjwjeq6f9i' }
it 'renders a custom element with Wistia embed code' do
output = render_markdown_link(wistia_url)
expect(output).to include('<script src="https://fast.wistia.com/player.js" async></script>')
expect(output).to include('<wistia-player')
expect(output).to include('media-id="kjwjeq6f9i"')
end
end
context 'when multiple links including Arcade are present' do
it 'renders Arcade embed along with other content types' do
markdown = "\n[arcade](https://app.arcade.software/share/ARCADE_ID)\n\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n"
output = render_markdown(markdown)
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
end
end
context 'when link is a GuideJar embed URL' do
let(:guidejar_url) { 'https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA' }
it 'renders an iframe with GuideJar embed code' do
output = render_markdown_link(guidejar_url)
expect(output).to include('src="https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA?type=1&controls=on"')
expect(output).to include('allowfullscreen')
end
end
context 'when link is a GuideJar guides URL' do
let(:guidejar_url) { 'https://guidejar.com/guides/d6a6fdc2-4812-4777-897e-ec1b0c64238f' }
it 'renders an iframe with GuideJar embed code' do
output = render_markdown_link(guidejar_url)
expect(output).to include('src="https://www.guidejar.com/embed/d6a6fdc2-4812-4777-897e-ec1b0c64238f?type=1&controls=on"')
expect(output).to include('allowfullscreen')
end
it 'wraps iframe in responsive container' do
output = render_markdown_link(guidejar_url)
expect(output).to include('position: relative; padding-bottom: 62.5%; height: 0;')
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
end
end
context 'when link is a Bunny.net iframe URL' do
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
it 'renders an iframe with Bunny embed code' do
output = render_markdown_link(bunny_url)
expect(output).to include('src="https://player.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
expect(output).to include('allowfullscreen')
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
end
it 'wraps iframe in responsive container' do
output = render_markdown_link(bunny_url)
expect(output).to include('position: relative; padding-top: 56.25%;')
expect(output).to include('position: absolute; top: 0; height: 100%; width: 100%;')
end
end
context 'when link is a Bunny.net player URL' do
let(:bunny_url) { 'https://player.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
it 'renders an iframe with Bunny embed code' do
output = render_markdown_link(bunny_url)
expect(output).to include('embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c')
expect(output).to include('autoplay=false&loop=false&muted=false&preload=true&responsive=true')
expect(output).to include('allowfullscreen')
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
end
end
context 'when captured values contain HTML-special characters' do
# CommonMark angle-bracket link destinations `[text](<URL>)` permit characters
# like `"` that the embed regex captures would otherwise pass through raw into
# attribute values. Captures are HTML-escaped before interpolation so the
# substituted value cannot break out of the surrounding attribute context.
it 'escapes double quotes in captured YouTube video_id' do
markdown = "\n[demo](<https://www.youtube.com/watch?v=x\" onload=\"alert(1)>)\n"
output = render_markdown(markdown)
expect(output).not_to include('onload="alert(1)"')
expect(output).to include('&quot;')
end
it 'leaves legitimate alphanumeric IDs untouched' do
output = render_markdown_link('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
expect(output).to include('src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"')
end
end
end
end