discourse/spec/lib/email/styles_spec.rb
Martin Brennan 61c87fb59f
FIX: Properly attach secure images to email for non-secure uploads (#23865)
There are cases where a user can copy image markdown from a public
post (such as via the discourse-templates plugin) into a PM which
is then sent via an email. Since a PM is a secure context (via the
.with_secure_uploads? check on Post), the image will get a secure
URL in the PM post even though the backing upload is not secure.

This fixes the bug in that case where the image would be stripped
from the email (since it had a /secure-uploads/ URL) but not re-attached
further down the line using the secure_uploads_allow_embed_images_in_emails
setting because the upload itself was not secure.

The flow in Email::Sender for doing this is still not ideal, but
there are chicken and egg problems around when to strip the images,
how to fit in with other attachments and email size limits, and
when to apply the images inline via Email::Styles. It's convoluted,
but at least this fixes the Template use case for now.
2023-10-17 14:08:21 +10:00

480 lines
18 KiB
Ruby

# frozen_string_literal: true
require "email"
RSpec.describe Email::Styles do
let(:attachments) { {} }
def basic_fragment(html)
styler = Email::Styles.new(html)
styler.format_basic
Nokogiri::HTML5.fragment(styler.to_html)
end
def html_fragment(html)
styler = Email::Styles.new(html)
styler.format_basic
styler.format_html
Nokogiri::HTML5.fragment(styler.to_html)
end
describe "basic formatter" do
it "adds a max-width to large images" do
frag = basic_fragment("<img height='auto' width='auto' src='gigantic.jpg'>")
expect(frag.at("img")["style"]).to match("max-width")
end
it "adds a width and height to emojis" do
frag = basic_fragment("<img src='/images/emoji/fish.png' class='emoji'>")
expect(frag.at("img")["width"]).to eq("20")
expect(frag.at("img")["height"]).to eq("20")
end
it "adds a width and height to custom emojis" do
frag =
basic_fragment("<img src='/uploads/default/_emoji/fish.png' class='emoji emoji-custom'>")
expect(frag.at("img")["width"]).to eq("20")
expect(frag.at("img")["height"]).to eq("20")
end
it "converts relative paths to absolute paths" do
frag = basic_fragment("<img src='/some-image.png'>")
expect(frag.at("img")["src"]).to eq("#{Discourse.base_url}/some-image.png")
end
it "strips classes and ids" do
frag = basic_fragment("<div class='foo' id='bar'><div class='foo' id='bar'></div></div>")
expect(frag.to_html).to eq("<div><div></div></div>")
end
end
describe "html template formatter" do
it "attaches a style to h3 tags" do
frag = html_fragment("<h3>hello</h3>")
expect(frag.at("h3")["style"]).to be_present
end
it "attaches a style to hr tags" do
frag = html_fragment("hello<hr>")
expect(frag.at("hr")["style"]).to be_present
end
it "attaches a style to a tags" do
frag = html_fragment("<a href>wat</a>")
expect(frag.at("a")["style"]).to be_present
end
it "attaches a style to a tags" do
frag = html_fragment("<a href>wat</a>")
expect(frag.at("a")["style"]).to be_present
end
it "attaches a style to ul and li tags" do
frag = html_fragment("<ul><li>hello</li></ul>")
expect(frag.at("ul")["style"]).to be_present
expect(frag.at("li")["style"]).to be_present
end
it "converts iframes to links" do
iframe_url = "http://www.youtube.com/embed/7twifrxOTQY?feature=oembed&wmode=opaque"
frag = html_fragment("<iframe src=\"#{iframe_url}\"></iframe>")
expect(frag.at("iframe")).to be_blank
expect(frag.at("a")).to be_present
expect(frag.at("a")["href"]).to eq(iframe_url)
end
it "won't allow non URLs in iframe src, strips them with no link" do
iframe_url = "alert('xss hole')"
frag = html_fragment("<iframe src=\"#{iframe_url}\"></iframe>")
expect(frag.at("iframe")).to be_blank
expect(frag.at("a")).to be_blank
end
it "won't allow empty iframe src, strips them with no link" do
frag = html_fragment("<iframe src=''></iframe>")
expect(frag.at("iframe")).to be_blank
expect(frag.at("a")).to be_blank
end
it "prefers data-original-href attribute to get iframe link" do
original_url = "https://vimeo.com/329875646/85f1546a42"
iframe_url = "https://player.vimeo.com/video/329875646"
frag =
html_fragment(
"<iframe src=\"#{iframe_url}\" data-original-href=\"#{original_url}\"></iframe>",
)
expect(frag.at("iframe")).to be_blank
expect(frag.at("a")).to be_present
expect(frag.at("a")["href"]).to eq(original_url)
end
it "replaces hashtag-cooked text with raw #hashtag" do
category = Fabricate(:category, name: "dev", slug: "dev")
post = Fabricate(:post, raw: "this is #dev")
post.rebake!
hashtag_html = post.cooked
frag = html_fragment(hashtag_html)
expect(frag.at("a").text.chomp).to eq("#dev")
end
end
describe "rewriting protocol relative URLs to the forum" do
it "doesn't rewrite a url to another site" do
frag = html_fragment('<a href="//youtube.com/discourse">hello</a>')
expect(frag.at("a")["href"]).to eq("//youtube.com/discourse")
end
context "without https" do
before { SiteSetting.force_https = false }
it "rewrites the href to have http" do
frag = html_fragment('<a href="//test.localhost/discourse">hello</a>')
expect(frag.at("a")["href"]).to eq("http://test.localhost/discourse")
end
it "rewrites the href for attachment files to have http" do
frag =
html_fragment(
'<a class="attachment" href="//try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt">attachment_file.txt</a>',
)
expect(frag.at("a")["href"]).to eq(
"http://try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt",
)
end
it "rewrites the src to have http" do
frag = html_fragment('<img src="//test.localhost/blah.jpg">')
expect(frag.at("img")["src"]).to eq("http://test.localhost/blah.jpg")
end
end
context "with https" do
before { SiteSetting.force_https = true }
it "rewrites the forum URL to have https" do
frag = html_fragment('<a href="//test.localhost/discourse">hello</a>')
expect(frag.at("a")["href"]).to eq("https://test.localhost/discourse")
end
it "rewrites the href for attachment files to have https" do
frag =
html_fragment(
'<a class="attachment" href="//try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt">attachment_file.txt</a>',
)
expect(frag.at("a")["href"]).to eq(
"https://try-discourse.global.ssl.fastly.net/uploads/default/368/40b610b0aa90cfcf.txt",
)
end
it "rewrites the src to have https" do
frag = html_fragment('<img src="//test.localhost/blah.jpg">')
expect(frag.at("img")["src"]).to eq("https://test.localhost/blah.jpg")
end
end
end
describe "dark mode emails" do
it "adds dark_mode_styles when site setting active" do
frag = html_fragment('<div class="body">test</div>')
styler = Email::Styles.new(frag)
styler.format_basic
styler.format_html
@frag = Nokogiri::HTML5.fragment(styler.to_s)
# dark mode attribute
expect(@frag.css('[dm="body"]')).to be_present
end
end
describe "strip_avatars_and_emojis" do
it "works for lonesome emoji with no title" do
emoji = "<img src='/images/emoji/twitter/crying_cat_face.png'>"
style = Email::Styles.new(emoji)
style.strip_avatars_and_emojis
expect(style.to_html).to match_html(emoji)
end
it "works for lonesome emoji with title" do
emoji = "<img title='cry_cry' src='/images/emoji/twitter/crying_cat_face.png'>"
style = Email::Styles.new(emoji)
style.strip_avatars_and_emojis
expect(style.to_html).to match_html("cry_cry")
end
it "works if img tag has no attrs" do
cooked =
"Create a method for click on image and use ng-click in <img> in your slide box...it is simple"
style = Email::Styles.new(cooked)
style.strip_avatars_and_emojis
expect(style.to_html).to include(cooked)
end
end
describe "onebox_styles" do
it "renders quote as <blockquote>" do
fragment =
html_fragment(
'<aside class="quote"> <div class="title"> <div class="quote-controls"> <i class="fa fa-chevron-down" title="expand/collapse"></i><a href="/t/xyz/123" title="go to the quoted post" class="back"></a> </div> <img alt="" width="20" height="20" src="https://cdn-enterprise.discourse.org/boingboing/user_avatar/bbs.boingboing.net/techapj/40/54379_1.png" class="avatar">techAPJ: </div> <blockquote> <p>lorem ipsum</p> </blockquote> </aside>',
)
expect(fragment.to_s.squish).to match(%r{^<blockquote.+</blockquote>$})
end
it "removes GitHub excerpts" do
stub_request(:head, "https://github.com/discourse/discourse/pull/1253").to_return(
status: 200,
body: "",
headers: {
},
)
stub_request(:get, "https://api.github.com/repos/discourse/discourse/pulls/1253").to_return(
status: 200,
body: onebox_response("githubpullrequest"),
)
onebox = Oneboxer.onebox("https://github.com/discourse/discourse/pull/1253")
fragment = html_fragment(onebox)
expect(fragment.css(".github-body-container .excerpt")).to be_empty
end
end
describe "replace_secure_uploads_urls" do
before do
setup_s3
SiteSetting.secure_uploads = true
end
let(:attachments) { { "testimage.png" => stub(url: "email/test.png") } }
it "replaces secure uploads within a link with a placeholder" do
frag =
html_fragment(
"<a href=\"#{Discourse.base_url}\/secure-uploads/original/1X/testimage.png\"><img src=\"/secure-uploads/original/1X/testimage.png\"></a>",
)
expect(frag.at("img")).not_to be_present
expect(frag.to_s).to include("Redacted")
end
it "replaces secure images with a placeholder" do
frag = html_fragment("<img src=\"/secure-uploads/original/1X/testimage.png\">")
expect(frag.at("img")).not_to be_present
expect(frag.to_s).to include("Redacted")
end
it "does not replace topic links with secure-uploads in the name" do
frag =
html_fragment("<a href=\"#{Discourse.base_url}\/t/secure-uploads/235723\">Visit Topic</a>")
expect(frag.to_s).not_to include("Redacted")
end
it "works in lightboxes with missing srcset attribute" do
frag =
html_fragment(
"<a href=\"#{Discourse.base_url}\/secure-uploads/original/1X/testimage.png\" class=\"lightbox\"><img src=\"/secure-uploads/original/1X/testimage.png\"></a>",
)
expect(frag.at("img")).not_to be_present
expect(frag.to_s).to include("Redacted")
end
it "works in lightboxes with srcset attribute set" do
frag = html_fragment(<<~HTML)
<a href="#{Discourse.base_url}/secure-uploads/original/1X/testimage.png" class="lightbox">
<img src="/secure-uploads/original/1X/testimage.png" srcset="/secure-uploads/optimized/1X/testimage.png, /secure-uploads/original/1X/testimage.png 1.5x" />
</a>
HTML
expect(frag.at("img")).not_to be_present
expect(frag.to_s).to include("Redacted")
end
it "skips links with no images as children" do
frag =
html_fragment(
"<a href=\"#{Discourse.base_url}\/secure-uploads/original/1X/testimage.png\"><span>Clearly not an image</span></a>",
)
expect(frag.to_s).to include("not an image")
end
end
describe "#inline_secure_images" do
before do
setup_s3
SiteSetting.secure_uploads = true
end
fab!(:upload) do
Fabricate(:upload, original_filename: "testimage.png", secure: true, sha1: "123456")
end
let(:attachments) { [stub(url: "cid:email/test.png")] }
let(:attachments_index) { { upload.sha1 => 0 } }
let(:html) do
"<a href=\"#{Discourse.base_url}\/secure-uploads/original/1X/123456.png\"><img src=\"/secure-uploads/original/1X/123456.png\" width=\"20\" height=\"30\"></a>"
end
def strip_and_inline
# strip out the secure uploads
styler = Email::Styles.new(html)
styler.format_basic
styler.format_html
html = styler.to_html
# pass in the attachments to match uploads based on sha + original filename
styler = Email::Styles.new(html)
styler.inline_secure_images(attachments, attachments_index)
@frag = Nokogiri::HTML5.fragment(styler.to_s)
end
it "inlines attachments where stripped-secure-media data attr is present" do
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
expect(@frag.children.attr("style").value).to eq("width: 20px; height: 30px;")
end
it "does not inline anything if the upload cannot be found" do
upload.update(sha1: "blah12")
strip_and_inline
expect(@frag.to_s).not_to include("cid:email/test.png")
expect(@frag.css("[data-stripped-secure-upload]")).to be_present
end
context "when an optimized image is used instead of the original" do
let(:html) do
"<a href=\"#{Discourse.base_url}\/secure-uploads/optimized/2X/1/123456_2_20x30.png\"><img src=\"/secure-uploads/optimized/2X/1/123456_2_20x30.png\" width=\"20\" height=\"30\"></a>"
end
it "inlines attachments where the stripped-secure-media data attr is present" do
optimized = Fabricate(:optimized_image, upload: upload, width: 20, height: 30)
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
expect(@frag.children.attr("style").value).to eq("width: 20px; height: 30px;")
end
end
context "when inlining an originally oneboxed image" do
before { SiteSetting.authorized_extensions = "*" }
let(:siteicon) { Fabricate(:upload, original_filename: "siteicon.ico") }
let(:attachments) { [stub(url: "cid:email/test.png"), stub(url: "cid:email/test2.ico")] }
let(:attachments_index) { { upload.sha1 => 0, siteicon.sha1 => 1 } }
let(:html) { <<~HTML }
<aside class="onebox allowlistedgeneric">
<header class="source">
<img src="#{Discourse.base_url}/secure-uploads/original/1X/#{siteicon.sha1}.ico" class="site-icon" width="64" height="64">
<a href="https://test.com/article" target="_blank" rel="noopener" title="02:33PM - 24 October 2020">Test</a>
</header>
<article class="onebox-body">
<div class="aspect-image" style="--aspect-ratio:20/30;"><img src="#{Discourse.base_url}/secure-uploads/optimized/2X/1/123456_2_20x30.png" class="thumbnail d-lazyload" width="20" height="30" srcset="#{Discourse.base_url}/secure-uploads/optimized/2X/1/123456_2_20x30.png"></div>
<h3><a href="https://test.com/article" target="_blank" rel="noopener">Test</a></h3>
<p>This is a test onebox.</p>
</article>
<div class="onebox-metadata">
</div>
<div style="clear: both"></div>
</aside>
HTML
it "keeps the special site icon width and height and onebox styles" do
optimized = Fabricate(:optimized_image, upload: upload, width: 20, height: 30)
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.to_s).to include("cid:email/test2.ico")
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
expect(@frag.css("[data-embedded-secure-image]")[0].attr("style")).to eq(
"width: 16px; height: 16px;",
)
expect(@frag.css("[data-embedded-secure-image]")[1].attr("style")).to eq(
"width: 60px; max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;",
)
end
context "when inlining a oneboxed image with a direct parent of onebox-body" do
let(:html) { <<~HTML }
<aside class="onebox allowlistedgeneric">
<header class="source">
<img src="#{Discourse.base_url}/secure-uploads/original/1X/#{siteicon.sha1}.ico" class="site-icon" width="64" height="64">
<a href="https://test.com/article" target="_blank" rel="noopener" title="02:33PM - 24 October 2020">Test</a>
</header>
<article class="onebox-body">
<img src="#{Discourse.base_url}/secure-uploads/original/1X/123456.png" class="thumbnail onebox-avatar" width="20" height="30">
<h3><a href="https://test.com/article" target="_blank" rel="noopener">Test</a></h3>
<p>This is a test onebox.</p>
</article>
<div class="onebox-metadata">
</div>
<div style="clear: both"></div>
</aside>
HTML
it "keeps the special onebox styles" do
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.to_s).to include("cid:email/test2.ico")
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
expect(@frag.css("[data-embedded-secure-image]")[1].attr("style")).to eq(
"width: 60px; max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;",
)
end
end
context "when there is an inline-avatar in the onebox" do
let(:html) { <<~HTML }
<p><a class="mention" href="/u/martin">@martin</a> check this out:</p>
<aside class="onebox githubpullrequest">
<header class="source">
<a href="https://github.com/discourse/discourse/pull/11140" target="_blank" rel="noopener">github.com/discourse/discourse</a>
</header>
<article class="onebox-body">
<div class="github-row">
<div class="github-info-container">
<h4>
<a href="https://github.com/discourse/discourse/pull/11140" target="_blank" rel="noopener">FEATURE: Implement edit functionality for post notices</a>
</h4>
<div class="branches">
<code>discourse:master</code> ← <code>discourse:feature/post_notices_edit</code>
</div>
<div class="github-info">
<div class="date">
opened <span class="discourse-local-date" data-format="ll" data-date="2020-11-05" data-time="20:33:53" data-timezone="UTC">08:33PM - 05 Nov 20 UTC</span>
</div>
<div class="user">
<a href="https://github.com/udan11" target="_blank" rel="noopener">
<img alt="udan11" src="#{Discourse.base_url}/secure-uploads/original/1X/123456.png" class="onebox-avatar-inline" width="20" height="20">
udan11
</a>
</div>
<div class="lines" title="2 commits changed 27 files with 250 additions and 224 deletions">
<a href="https://github.com/discourse/discourse/pull/11140/files" target="_blank" rel="noopener">
<span class="added">+250</span>
<span class="removed">-224</span>
</a>
</div>
</div>
</div>
</div>
</article>
<div class="onebox-metadata">
</div>
<div style="clear: both"></div>
</aside>
HTML
it "keeps the special onebox styles" do
strip_and_inline
expect(@frag.to_s).to include("cid:email/test.png")
expect(@frag.css("[data-stripped-secure-upload]")).not_to be_present
expect(@frag.css("[data-embedded-secure-image]")[0].attr("style")).to eq(
"width: 20px; height: 20px; float: none; vertical-align: middle; max-height: 80%; max-width: 20%; height: auto; float: left; margin-right: 10px;",
)
end
end
end
end
end