0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 14:34:02 +08:00
discourse/spec/requests/embed_controller_spec.rb
Juan David Martínez Cubillos 1dd0603581
FIX: Validate and safely handle malformed embed allowlist regexes (#42327)
## Summary

EmbeddableHost allowed_paths values are stored as regular expression
source and compiled during request handling without validation or error
handling. Now new or updated allowlists are validated on save, and
legacy invalid or timed-out patterns are safely skipped as non-matches
rather than causing HTTP 500 errors on public embed endpoints. This
prevents an administrator from accidentally breaking embed functionality
across their site.

## Source

- Patch Triage: `patch-triage/1556`

Co-authored-by: discourse-patch-triage
<272280883+discourse-patch-triage[bot]@users.noreply.github.com>
2026-08-05 12:54:43 -05:00

690 lines
22 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe EmbedController do
let(:embed_url) { "http://eviltrout.com/2013/02/10/why-discourse-uses-emberjs.html" }
let(:embed_url_secure) { "https://eviltrout.com/2013/02/10/why-discourse-uses-emberjs.html" }
fab!(:topic)
describe "#info" do
context "without api key" do
it "fails" do
get "/embed/info.json"
expect(response.body).to match(I18n.t("embed.error"))
end
end
context "with api key" do
let(:api_key) { Fabricate(:api_key) }
context "with valid embed url" do
let(:topic_embed) { Fabricate(:topic_embed, embed_url: embed_url) }
it "returns information about the topic" do
get "/embed/info.json",
params: {
embed_url: topic_embed.embed_url,
},
headers: {
HTTP_API_KEY: api_key.key,
HTTP_API_USERNAME: "system",
}
expect(response.parsed_body["topic_id"]).to eq(topic_embed.topic.id)
expect(response.parsed_body["post_id"]).to eq(topic_embed.post.id)
expect(response.parsed_body["topic_slug"]).to eq(topic_embed.topic.slug)
end
it "returns not found for topics the API user cannot see" do
user = Fabricate(:user)
api_key = Fabricate(:api_key, user: user)
restricted_category = Fabricate(:category)
restricted_category.set_permissions(staff: :full)
restricted_category.save!
restricted_topic = Fabricate(:topic, category: restricted_category, posts_count: 5)
restricted_topic_embed =
Fabricate(
:topic_embed,
post: Fabricate(:post, topic: restricted_topic),
topic: restricted_topic,
embed_url: "http://eviltrout.com/private-article",
)
get "/embed/info.json",
params: {
embed_url: restricted_topic_embed.embed_url,
},
headers: {
HTTP_API_KEY: api_key.key,
HTTP_API_USERNAME: user.username,
}
expect(response.status).to eq(404)
expect(response.parsed_body["error_type"]).to eq("not_found")
expect(response.body).not_to include(restricted_topic.slug)
end
end
context "without invalid embed url" do
it "returns error response" do
get "/embed/info.json",
params: {
embed_url: "http://nope.com",
},
headers: {
HTTP_API_KEY: api_key.key,
HTTP_API_USERNAME: "system",
}
json = response.parsed_body
expect(json["error_type"]).to eq("not_found")
end
end
end
end
describe "#topics" do
it "raises an error when not enabled" do
get "/embed/topics?embed_id=de-1234"
expect(response.status).to eq(400)
end
context "when enabled" do
before { SiteSetting.embed_topics_list = true }
it "raises an error with a weird id" do
get "/embed/topics?discourse_embed_id=../asdf/-1234", headers: headers
expect(response.status).to eq(400)
end
it "returns a list of topics" do
get "/embed/topics?discourse_embed_id=de-1234",
headers: {
"REFERER" => "https://example.com/evil-trout",
}
expect(response.status).to eq(200)
expect(response.headers["X-Frame-Options"]).to be_nil
expect(response.body).to match("data-embed-id=\"de-1234\"")
expect(response.body).to match("data-topic-id=\"#{topic.id}\"")
expect(response.body).to match("data-referer=\"https://example.com/evil-trout\"")
end
it "ignores invalid path allowlists from legacy hosts" do
embeddable_host = Fabricate(:embeddable_host, allowed_paths: "/articles/.*")
embeddable_host.update_column(:allowed_paths, "[invalid")
get "/embed/topics?discourse_embed_id=de-1234",
headers: {
"REFERER" => "https://#{embeddable_host.host}/articles/test",
}
expect(response.status).to eq(200)
expect(response.body).to match("data-embed-id=\"de-1234\"")
end
it "returns a list of top topics" do
good_topic = Fabricate(:topic, like_count: 1000, posts_count: 100)
TopTopic.refresh!
get "/embed/topics?discourse_embed_id=de-1234&top_period=yearly",
headers: {
"REFERER" => "https://example.com/evil-trout",
}
expect(response.status).to eq(200)
expect(response.headers["X-Frame-Options"]).to be_nil
expect(response.body).to match("data-embed-id=\"de-1234\"")
expect(response.body).to match("data-topic-id=\"#{good_topic.id}\"")
expect(response.body).not_to match("data-topic-id=\"#{topic.id}\"")
expect(response.body).to match("data-referer=\"https://example.com/evil-trout\"")
end
it "returns a list of topics if the top_period is not valid" do
good_topic = Fabricate(:topic, like_count: 1000, posts_count: 100)
TopTopic.refresh!
TopicQuery.any_instance.expects(:list_top_for).never
get "/embed/topics?discourse_embed_id=de-1234&top_period=decadely",
headers: {
"REFERER" => "https://example.com/evil-trout",
}
expect(response.status).to eq(200)
expect(response.headers["X-Frame-Options"]).to be_nil
expect(response.body).to match("data-embed-id=\"de-1234\"")
expect(response.body).to match("data-topic-id=\"#{good_topic.id}\"")
expect(response.body).to match("data-topic-id=\"#{topic.id}\"")
expect(response.body).to match("data-referer=\"https://example.com/evil-trout\"")
end
it "wraps the list in a custom class" do
get "/embed/topics?discourse_embed_id=de-1234&embed_class=my-special-class",
headers: {
"REFERER" => "https://example.com/evil-trout",
}
expect(response.status).to eq(200)
expect(response.headers["X-Frame-Options"]).to be_nil
expect(response.body).to match("class='topics-list my-special-class'")
end
it "returns no referer if not supplied" do
get "/embed/topics?discourse_embed_id=de-1234"
expect(response.status).to eq(200)
expect(response.body).to match("data-referer=\"\"")
end
it "returns * for the referer if `embed_any_origin` is set" do
SiteSetting.embed_any_origin = true
get "/embed/topics?discourse_embed_id=de-1234"
expect(response.status).to eq(200)
expect(response.body).to match("data-referer=\"\\*\"")
end
it "disallows indexing the embed topic list" do
get "/embed/topics?discourse_embed_id=de-1234",
headers: {
"REFERER" => "https://example.com/evil-trout",
}
expect(response.status).to eq(200)
expect(response.headers["X-Robots-Tag"]).to match(/noindex/)
end
end
end
describe "#comments" do
it "is 404 without an embed_url" do
get "/embed/comments"
expect(response.body).to match(I18n.t("embed.error"))
end
it "raises an error with a missing host" do
get "/embed/comments", params: { embed_url: embed_url }
expect(response.body).to match(I18n.t("embed.error"))
end
describe "by topic id" do
fab!(:embeddable_host)
it "allows a topic to be embedded by id" do
get "/embed/comments",
params: {
topic_id: topic.id,
},
headers: {
"REFERER" => "http://eviltrout.com/some-page",
}
expect(response.status).to eq(200)
end
it "does not share an anonymous cached response between referers" do
global_setting :anon_cache_store_threshold, 1
Middleware::AnonymousCache.enable_anon_cache
Middleware::AnonymousCache.clear_all_cache!
attacker_referer = "https://origin-a.example/page"
victim_referer = "https://origin-b.example/page"
get "/embed/comments",
params: {
topic_id: topic.id,
},
headers: {
"REFERER" => attacker_referer,
}
expect(response.status).to eq(200)
expect(response.headers["X-Discourse-Cached"]).to eq("store")
expect(response.body).to include("data-referer=\"#{attacker_referer}\"")
get "/embed/comments",
params: {
topic_id: topic.id,
},
headers: {
"REFERER" => victim_referer,
}
expect(response.status).to eq(200)
expect(response.headers["X-Discourse-Cached"]).to eq("store")
expect(response.body).to include("data-referer=\"#{victim_referer}\"")
end
fab!(:attacker, :trust_level_1)
fab!(:existing_post) { Fabricate(:post, topic: topic) }
it "does not reinterpret an allowlisted non-Vimeo iframe as Vimeo content" do
iframe_source = "https://www.instagram.com/?x=player.vimeo.com/<img src=x onerror=alert(1)>"
sign_in(attacker)
post "/posts.json",
params: {
raw: %(<iframe src="#{iframe_source}"></iframe>),
topic_id: topic.id,
}
expect(response.status).to eq(200)
created_post = Post.find(response.parsed_body["id"])
cooked_iframe = Nokogiri::HTML5.fragment(created_post.cooked).at_css("iframe")
aggregate_failures do
expect(created_post).to have_attributes(hidden: false, deleted_at: nil)
expect(cooked_iframe["src"]).to eq(iframe_source)
end
sign_out
get "/embed/comments", params: { topic_id: topic.id }
document = Nokogiri.HTML5(response.body)
rendered_iframe = document.at_css("article#post-#{created_post.id} .cooked > iframe")
aggregate_failures do
expect(response.status).to eq(200)
expect(rendered_iframe).to be_present
expect(rendered_iframe&.[]("src")).to eq(iframe_source)
expect(document.css("img[onerror]")).to be_empty
end
end
end
describe "full_app redirect" do
fab!(:embeddable_host)
before { SiteSetting.embed_full_app = true }
it "redirects to topic URL with embed_mode when full_app is present" do
topic_embed = Fabricate(:topic_embed, embed_url: embed_url)
get "/embed/comments",
params: {
embed_url: embed_url,
full_app: "true",
},
headers: {
"REFERER" => embed_url,
}
expect(response).to redirect_to("#{topic_embed.topic.url}?embed_mode=true")
end
it "redirects to topic URL with embed_mode when using topic_id" do
get "/embed/comments",
params: {
topic_id: topic.id,
full_app: "true",
},
headers: {
"REFERER" => "http://eviltrout.com/some-page",
}
expect(response).to redirect_to("#{topic.url}?embed_mode=true")
end
it "forwards class_name to the topic URL" do
get "/embed/comments",
params: {
topic_id: topic.id,
full_app: "true",
class_name: "lee-af",
},
headers: {
"REFERER" => "http://eviltrout.com/some-page",
}
expect(response).to redirect_to("#{topic.url}?class_name=lee-af&embed_mode=true")
end
it "redirects blank-slug topics to a slugless URL" do
topic_embed = Fabricate(:topic_embed, embed_url: embed_url)
topic_embed.topic.update_columns(title: "", slug: nil)
topic_embed.topic.reload
get "/embed/comments",
params: {
embed_url: embed_url,
full_app: "true",
},
headers: {
"REFERER" => embed_url,
}
expect(response).to redirect_to("#{topic_embed.topic.url}?embed_mode=true")
end
it "does not redirect when embed_full_app is disabled" do
SiteSetting.embed_full_app = false
topic_embed = Fabricate(:topic_embed, embed_url: embed_url)
get "/embed/comments",
params: {
embed_url: embed_url,
full_app: "true",
},
headers: {
"REFERER" => embed_url,
}
expect(response.status).to eq(200)
end
end
context "with a host" do
fab!(:embeddable_host)
it "doesn't raise an error with no referer" do
get "/embed/comments", params: { embed_url: embed_url }
expect(response.body).not_to match(I18n.t("embed.error"))
end
it "includes CSS from embedded_scss field" do
theme = Fabricate(:theme)
theme.set_default!
ThemeField.create!(
theme_id: theme.id,
name: "embedded_scss",
target_id: 0,
type_id: 1,
value: ".test-osama-15 { color: red }",
)
topic_embed = Fabricate(:topic_embed, embed_url: embed_url)
Fabricate(:post, topic: topic_embed.topic)
get "/embed/comments", params: { embed_url: embed_url }, headers: { "REFERER" => embed_url }
html = Nokogiri::HTML5.fragment(response.body)
get html.at("link[data-target=embedded_theme]").attribute("href").value
expect(response.status).to eq(200)
expect(response.body).to include(".test-osama-15")
end
it "includes HTML from embedded_header field" do
theme = Fabricate(:theme)
theme.set_default!
ThemeField.create!(
theme_id: theme.id,
name: "embedded_header",
target_id: 0,
type_id: 0,
value: "<strong class='custom-text'>hey there!</strong>\n",
)
topic_embed = Fabricate(:topic_embed, embed_url: embed_url)
post = Fabricate(:post, topic: topic_embed.topic)
get "/embed/comments", params: { embed_url: embed_url }, headers: headers
html = Nokogiri::HTML5.fragment(response.body)
custom_header = html.at(".custom-text")
expect(custom_header.name).to eq("strong")
expect(custom_header.text).to eq("hey there!")
end
context "with success" do
before { Jobs.run_immediately! }
it "tells the topic retriever to work when no previous embed is found" do
TopicRetriever.any_instance.expects(:retrieve)
get "/embed/comments",
params: {
embed_url: embed_url,
},
headers: {
"REFERER" => embed_url,
}
expect(response.status).to eq(200)
expect(response.headers["X-Frame-Options"]).to be_nil
end
it "displays the right view" do
topic_embed = Fabricate(:topic_embed, embed_url: embed_url)
get "/embed/comments",
params: {
embed_url: embed_url_secure,
},
headers: {
"REFERER" => embed_url,
}
expect(response.status).to eq(200)
expect(response.headers["X-Frame-Options"]).to be_nil
expect(response.body).to match(I18n.t("embed.start_discussion"))
end
it "creates a topic view when a topic_id is found" do
topic_embed = Fabricate(:topic_embed, embed_url: embed_url)
post = Fabricate(:post, topic: topic_embed.topic)
get "/embed/comments",
params: {
embed_url: embed_url,
},
headers: {
"REFERER" => embed_url,
}
expect(response.status).to eq(200)
expect(response.headers["X-Frame-Options"]).to be_nil
expect(response.body).to match(I18n.t("embed.continue"))
expect(response.body).to match(post.cooked)
expect(response.body).to match("<span class='replies'>1 reply</span>")
small_action = Fabricate(:small_action, topic: topic_embed.topic)
get "/embed/comments",
params: {
embed_url: embed_url,
},
headers: {
"REFERER" => embed_url,
}
expect(response.status).to eq(200)
expect(response.headers["X-Frame-Options"]).to be_nil
expect(response.body).not_to match("post-#{small_action.id}")
expect(response.body).to match("<span class='replies'>1 reply</span>")
end
it "does not forward user-supplied discourse_username to TopicRetriever" do
captured_opts = nil
original_new = TopicRetriever.method(:new)
TopicRetriever
.stubs(:new)
.with do |url, opts|
captured_opts = opts
true
end
.returns(stub(retrieve: nil))
get "/embed/comments",
params: {
embed_url: embed_url,
discourse_username: "admin",
},
headers: {
"REFERER" => embed_url,
}
expect(response.status).to eq(200)
expect(captured_opts).to be_present
expect(captured_opts[:author_username]).to be_nil
end
end
end
context "with multiple hosts" do
fab!(:embeddable_host_1, :embeddable_host)
fab!(:embeddable_host_2) { Fabricate(:embeddable_host, host: "http://discourse.org") }
fab!(:embeddable_host_3) do
Fabricate(:embeddable_host, host: "https://example.com/1234", class_name: "example")
end
context "with success" do
it "works with the first host" do
get "/embed/comments",
params: {
embed_url: embed_url,
},
headers: {
"REFERER" => "http://eviltrout.com/wat/1-2-3.html",
}
expect(response.status).to eq(200)
end
it "works with the second host" do
get "/embed/comments",
params: {
embed_url: embed_url,
},
headers: {
"REFERER" => "https://discourse.org/blog-entry-1",
}
expect(response.status).to eq(200)
end
it "works with a host with a path" do
get "/embed/comments",
params: {
embed_url: embed_url,
},
headers: {
"REFERER" => "https://example.com/some-other-path",
}
expect(response.status).to eq(200)
end
it "contains custom class name" do
get "/embed/comments",
params: {
embed_url: embed_url,
},
headers: {
"REFERER" => "https://example.com/some-other-path",
}
expect(response.body).to match('class="example"')
end
it "contains custom class name from params" do
get "/embed/comments",
params: {
embed_url: embed_url,
class_name: "param-class-name",
},
headers: {
"REFERER" => "https://example.com/some-other-path",
}
expect(response.body).to match('class="param-class-name"')
end
end
context "with CSP frame-ancestors enabled" do
before { SiteSetting.content_security_policy_frame_ancestors = true }
it "includes all the hosts" do
get "/embed/comments",
params: {
embed_url: embed_url,
},
headers: {
"REFERER" => "http://eviltrout.com/wat/1-2-3.html",
}
expect(response.headers["Content-Security-Policy"]).to match(
%r{frame-ancestors.*https://discourse\.org},
)
expect(response.headers["Content-Security-Policy"]).to match(
%r{frame-ancestors.*https://example\.com},
)
end
end
end
end
describe "#count" do
fab!(:embeddable_host)
it "returns counts for public topics" do
topic_embed = Fabricate(:topic_embed, embed_url: "http://eviltrout.com/public-article")
get "/embed/count.json", params: { embed_url: ["http://eviltrout.com/public-article"] }
expect(response.status).to eq(200)
expect(response.parsed_body["counts"]).to have_key("http://eviltrout.com/public-article")
end
it "does not return counts for topics in restricted categories" do
restricted_category = Fabricate(:category)
restricted_category.set_permissions(staff: :full)
restricted_category.save!
restricted_topic = Fabricate(:topic, category: restricted_category, posts_count: 5)
Fabricate(
:topic_embed,
post: Fabricate(:post, topic: restricted_topic),
topic: restricted_topic,
embed_url: "http://eviltrout.com/private-article",
)
get "/embed/count.json", params: { embed_url: ["http://eviltrout.com/private-article"] }
expect(response.status).to eq(200)
expect(response.parsed_body["counts"]).not_to have_key("http://eviltrout.com/private-article")
end
it "returns counts only for visible topics when both public and restricted are requested" do
public_topic = Fabricate(:topic, posts_count: 3)
Fabricate(
:topic_embed,
post: Fabricate(:post, topic: public_topic),
topic: public_topic,
embed_url: "http://eviltrout.com/public-post",
)
restricted_category = Fabricate(:category)
restricted_category.set_permissions(staff: :full)
restricted_category.save!
restricted_topic = Fabricate(:topic, category: restricted_category, posts_count: 5)
Fabricate(
:topic_embed,
post: Fabricate(:post, topic: restricted_topic),
topic: restricted_topic,
embed_url: "http://eviltrout.com/secret-post",
)
get "/embed/count.json",
params: {
embed_url: %w[http://eviltrout.com/public-post http://eviltrout.com/secret-post],
}
expect(response.status).to eq(200)
counts = response.parsed_body["counts"]
expect(counts).to have_key("http://eviltrout.com/public-post")
expect(counts).not_to have_key("http://eviltrout.com/secret-post")
end
end
end