mirror of
https://github.com/discourse/discourse.git
synced 2026-08-11 02:59:07 +08:00
Backport of #41141 to release/2026.1. --- ## Summary The markdown engine generates poll options in an unbounded loop based on user input, enabling an attacker to trigger massive memory allocation and CPU usage in the V8 process. This blocks a global mutex and can cause worker crashes, effectively DoS-ing markdown processing for all users. ## Source - Patch Triage: https://patch.discourse.org/patch-triage/1120 - HackerOne report: https://hackerone.com/reports/3598542 --- 🤖 Auto-generated from the patch diff via Patch Triage. Review carefully before merging. Co-authored-by: discourse-patch-triage <272280883+discourse-patch-triage[bot]@users.noreply.github.com> Co-authored-by: Sam <sam.saffron@gmail.com> Co-authored-by: discourse-patch-triage[bot] <272280883+discourse-patch-triage[bot]@users.noreply.github.com>
625 lines
19 KiB
Ruby
Vendored
625 lines
19 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe DiscoursePoll::Poll do
|
|
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
|
|
fab!(:user_2, :user)
|
|
fab!(:user_3, :user)
|
|
|
|
fab!(:post_with_regular_poll) { Fabricate(:post, raw: <<~RAW) }
|
|
[poll]
|
|
* 1
|
|
* 2
|
|
[/poll]
|
|
RAW
|
|
|
|
fab!(:post_with_multiple_poll) { Fabricate(:post, raw: <<~RAW) }
|
|
[poll type=multiple min=2 max=3]
|
|
* 1
|
|
* 2
|
|
* 3
|
|
* 4
|
|
* 5
|
|
[/poll]
|
|
RAW
|
|
|
|
fab!(:post_with_ranked_choice_poll) { Fabricate(:post, raw: <<~RAW) }
|
|
[poll type=ranked_choice public=true]
|
|
* Red
|
|
* Blue
|
|
* Yellow
|
|
[/poll]
|
|
RAW
|
|
|
|
describe ".vote" do
|
|
it "should only allow one vote per user for a regular poll" do
|
|
poll = post_with_regular_poll.polls.first
|
|
|
|
expect do
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_regular_poll.id,
|
|
"poll",
|
|
poll.poll_options.map(&:digest),
|
|
)
|
|
end.to raise_error(DiscoursePoll::Error, I18n.t("poll.one_vote_per_user"))
|
|
end
|
|
|
|
it "should not allow a ranked vote with all abstentions" do
|
|
poll = post_with_ranked_choice_poll.polls.first
|
|
poll_options = poll.poll_options
|
|
|
|
expect do
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_ranked_choice_poll.id,
|
|
"poll",
|
|
{
|
|
"0": {
|
|
digest: poll_options.first.digest,
|
|
rank: "0",
|
|
},
|
|
"1": {
|
|
digest: poll_options.second.digest,
|
|
rank: "0",
|
|
},
|
|
"2": {
|
|
digest: poll_options.third.digest,
|
|
rank: "0",
|
|
},
|
|
},
|
|
)
|
|
end.to raise_error(
|
|
DiscoursePoll::Error,
|
|
I18n.t("poll.requires_that_at_least_one_option_is_ranked"),
|
|
)
|
|
end
|
|
|
|
it "should clean up bad votes for a regular poll" do
|
|
poll = post_with_regular_poll.polls.first
|
|
|
|
PollVote.create!(poll: poll, poll_option: poll.poll_options.first, user: user)
|
|
|
|
PollVote.create!(poll: poll, poll_option: poll.poll_options.last, user: user)
|
|
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_regular_poll.id,
|
|
"poll",
|
|
[poll.poll_options.first.digest],
|
|
)
|
|
|
|
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
|
|
poll.poll_options.first.id,
|
|
)
|
|
end
|
|
|
|
it "allows user to vote on multiple options correctly for a multiple poll" do
|
|
poll = post_with_multiple_poll.polls.first
|
|
poll_options = poll.poll_options
|
|
|
|
[poll_options.first, poll_options.second, poll_options.third].each do |poll_option|
|
|
PollVote.create!(poll: poll, poll_option: poll_option, user: user)
|
|
end
|
|
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_multiple_poll.id,
|
|
"poll",
|
|
[poll_options.first.digest, poll_options.second.digest],
|
|
)
|
|
|
|
DiscoursePoll::Poll.vote(
|
|
user_2,
|
|
post_with_multiple_poll.id,
|
|
"poll",
|
|
[poll_options.third.digest, poll_options.fourth.digest],
|
|
)
|
|
|
|
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
|
|
poll_options.first.id,
|
|
poll_options.second.id,
|
|
)
|
|
|
|
expect(PollVote.where(poll: poll, user: user_2).pluck(:poll_option_id)).to contain_exactly(
|
|
poll_options.third.id,
|
|
poll_options.fourth.id,
|
|
)
|
|
end
|
|
|
|
it "should respect the min/max votes per user for a multiple poll" do
|
|
poll = post_with_multiple_poll.polls.first
|
|
|
|
expect do
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_multiple_poll.id,
|
|
"poll",
|
|
poll.poll_options.map(&:digest),
|
|
)
|
|
end.to raise_error(DiscoursePoll::Error, I18n.t("poll.max_vote_per_user", count: poll.max))
|
|
|
|
expect do
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_multiple_poll.id,
|
|
"poll",
|
|
[poll.poll_options.first.digest],
|
|
)
|
|
end.to raise_error(DiscoursePoll::Error, I18n.t("poll.min_vote_per_user", count: poll.min))
|
|
end
|
|
|
|
it "should allow user to vote on a multiple poll even if min option is not configured" do
|
|
post_with_multiple_poll = Fabricate(:post, raw: <<~RAW)
|
|
[poll type=multiple max=3]
|
|
* 1
|
|
* 2
|
|
* 3
|
|
* 4
|
|
* 5
|
|
[/poll]
|
|
RAW
|
|
|
|
poll = post_with_multiple_poll.polls.first
|
|
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_multiple_poll.id,
|
|
"poll",
|
|
[poll.poll_options.first.digest],
|
|
)
|
|
|
|
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
|
|
poll.poll_options.first.id,
|
|
)
|
|
end
|
|
|
|
it "should allow user to vote on a multiple poll even if max option is not configured" do
|
|
post_with_multiple_poll = Fabricate(:post, raw: <<~RAW)
|
|
[poll type=multiple min=1]
|
|
* 1
|
|
* 2
|
|
* 3
|
|
* 4
|
|
* 5
|
|
[/poll]
|
|
RAW
|
|
|
|
poll = post_with_multiple_poll.polls.first
|
|
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_multiple_poll.id,
|
|
"poll",
|
|
[poll.poll_options.first.digest, poll.poll_options.second.digest],
|
|
)
|
|
|
|
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
|
|
poll.poll_options.first.id,
|
|
poll.poll_options.second.id,
|
|
)
|
|
end
|
|
|
|
it "allows user to vote on options correctly for a ranked choice poll and to vote again" do
|
|
poll = post_with_ranked_choice_poll.polls.first
|
|
poll_options = poll.poll_options
|
|
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_ranked_choice_poll.id,
|
|
"poll",
|
|
{
|
|
"0": {
|
|
digest: poll_options.first.digest,
|
|
rank: "2",
|
|
},
|
|
"1": {
|
|
digest: poll_options.second.digest,
|
|
rank: "1",
|
|
},
|
|
"2": {
|
|
digest: poll_options.third.digest,
|
|
rank: "0",
|
|
},
|
|
},
|
|
)
|
|
|
|
DiscoursePoll::Poll.vote(
|
|
user_2,
|
|
post_with_ranked_choice_poll.id,
|
|
"poll",
|
|
{
|
|
"0": {
|
|
digest: poll_options.first.digest,
|
|
rank: "0",
|
|
},
|
|
"1": {
|
|
digest: poll_options.second.digest,
|
|
rank: "2",
|
|
},
|
|
"2": {
|
|
digest: poll_options.third.digest,
|
|
rank: "1",
|
|
},
|
|
},
|
|
)
|
|
|
|
DiscoursePoll::Poll.vote(
|
|
user,
|
|
post_with_ranked_choice_poll.id,
|
|
"poll",
|
|
{
|
|
"0": {
|
|
digest: poll_options.first.digest,
|
|
rank: "1",
|
|
},
|
|
"1": {
|
|
digest: poll_options.second.digest,
|
|
rank: "2",
|
|
},
|
|
"2": {
|
|
digest: poll_options.third.digest,
|
|
rank: "0",
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(PollVote.count).to eq(6)
|
|
|
|
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
|
|
poll_options.first.id,
|
|
poll_options.second.id,
|
|
poll_options.third.id,
|
|
)
|
|
|
|
expect(PollVote.where(poll: poll, user: user_2).pluck(:poll_option_id)).to contain_exactly(
|
|
poll_options.first.id,
|
|
poll_options.second.id,
|
|
poll_options.third.id,
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "post_created" do
|
|
it "publishes on message bus if a there are polls" do
|
|
first_post = Fabricate(:post)
|
|
topic = first_post.topic
|
|
creator = PostCreator.new(user, topic_id: topic.id, raw: <<~RAW)
|
|
[poll]
|
|
* 1
|
|
* 2
|
|
[/poll]
|
|
RAW
|
|
|
|
messages = MessageBus.track_publish("/polls/#{topic.id}") { creator.create! }
|
|
|
|
expect(messages.count).to eq(1)
|
|
end
|
|
|
|
it "does not publish on message bus when a post with no polls is created" do
|
|
first_post = Fabricate(:post)
|
|
topic = first_post.topic
|
|
creator =
|
|
PostCreator.new(user, topic_id: topic.id, raw: "Just a post with definitely no polls")
|
|
|
|
messages = MessageBus.track_publish("/polls/#{topic.id}") { creator.create! }
|
|
|
|
expect(messages.count).to eq(0)
|
|
end
|
|
end
|
|
|
|
describe ".extract" do
|
|
it "skips the polls inside quote" do
|
|
raw = <<~RAW
|
|
[quote="username, post:1, topic:2"]
|
|
[poll type=regular result=always]
|
|
* 1
|
|
* 2
|
|
[/poll]
|
|
[/quote]
|
|
|
|
[poll type=regular result=always]
|
|
* 3
|
|
* 4
|
|
[/poll]
|
|
|
|
Post with a poll and a quoted poll.
|
|
RAW
|
|
|
|
expect(DiscoursePoll::Poll.extract(raw, 2)).to contain_exactly(
|
|
{
|
|
"name" => "poll",
|
|
"options" => [
|
|
{ "html" => "3", "id" => "68b434ff88aeae7054e42cd05a4d9056" },
|
|
{ "html" => "4", "id" => "aa2393b424f2f395abb63bf785760a3b" },
|
|
],
|
|
"status" => "open",
|
|
"type" => "regular",
|
|
},
|
|
)
|
|
end
|
|
|
|
it "limits generated number poll options to one more than the configured maximum" do
|
|
SiteSetting.poll_maximum_options = 3
|
|
|
|
raw = <<~RAW
|
|
[poll type=number min=1 max=100 step=1]
|
|
[/poll]
|
|
RAW
|
|
|
|
poll = DiscoursePoll::Poll.extract(raw, 2).first
|
|
|
|
expect(poll["options"].map { |option| option["html"] }).to eq(%w[1 2 3 4])
|
|
end
|
|
|
|
it "extracts poll when there are multiple quotes in the post" do
|
|
raw = <<~RAW
|
|
[quote="user1, post:1, topic:123"]
|
|
First quote content
|
|
[/quote]
|
|
|
|
[poll type=regular results=always]
|
|
* Option A
|
|
* Option B
|
|
[/poll]
|
|
|
|
[quote="user2, post:2, topic:123"]
|
|
Second quote content
|
|
[/quote]
|
|
|
|
Some text after.
|
|
RAW
|
|
|
|
polls = DiscoursePoll::Poll.extract(raw, 2)
|
|
expect(polls.size).to eq(1)
|
|
expect(polls.first["name"]).to eq("poll")
|
|
expect(polls.first["type"]).to eq("regular")
|
|
expect(polls.first["results"]).to eq("always")
|
|
expect(polls.first["options"].map { |o| o["html"] }).to contain_exactly(
|
|
"Option A",
|
|
"Option B",
|
|
)
|
|
end
|
|
end
|
|
|
|
describe ".serialized_voters" do
|
|
context "with a regular poll" do
|
|
let(:post) { post_with_regular_poll }
|
|
let(:poll) { post.polls.first }
|
|
let(:poll_options) { poll.poll_options }
|
|
let(:votes) do
|
|
{
|
|
user => [poll_options.first.digest],
|
|
user_2 => [poll_options.second.digest],
|
|
user_3 => [poll_options.first.digest],
|
|
}
|
|
end
|
|
|
|
before do
|
|
votes.each_pair { |user, options| DiscoursePoll::Poll.vote(user, post.id, "poll", options) }
|
|
end
|
|
|
|
it "returns all serialized voters" do
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll)
|
|
expect(voters).to eq(
|
|
{
|
|
poll_options.first.digest => [
|
|
UserNameSerializer.new(user).serializable_hash,
|
|
UserNameSerializer.new(user_3).serializable_hash,
|
|
],
|
|
poll_options.second.digest => [UserNameSerializer.new(user_2).serializable_hash],
|
|
},
|
|
)
|
|
end
|
|
|
|
it "correctly paginates voters" do
|
|
opts = { page: 1, limit: 2 }.with_indifferent_access
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll, opts)
|
|
expect(voters).to eq(
|
|
{
|
|
poll_options.first.digest => [
|
|
UserNameSerializer.new(user).serializable_hash,
|
|
UserNameSerializer.new(user_3).serializable_hash,
|
|
],
|
|
poll_options.second.digest => [UserNameSerializer.new(user_2).serializable_hash],
|
|
},
|
|
)
|
|
|
|
opts = { page: 2, limit: 2 }.with_indifferent_access
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll, opts)
|
|
expect(voters).to be_nil
|
|
end
|
|
end
|
|
|
|
context "with a multi-choice poll" do
|
|
let(:post) { post_with_multiple_poll }
|
|
let(:poll) { post.polls.first }
|
|
let(:poll_options) { poll.poll_options }
|
|
let(:votes) do
|
|
{
|
|
user => [poll_options.first.digest, poll_options.second.digest],
|
|
user_2 => [poll_options.second.digest, poll_options.third.digest],
|
|
user_3 => [
|
|
poll_options.second.digest,
|
|
poll_options.third.digest,
|
|
poll_options.fourth.digest,
|
|
],
|
|
}
|
|
end
|
|
|
|
before do
|
|
votes.each_pair { |user, options| DiscoursePoll::Poll.vote(user, post.id, "poll", options) }
|
|
end
|
|
|
|
it "returns all serialized voters" do
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll)
|
|
expect(voters).to eq(
|
|
{
|
|
poll_options.first.digest => [UserNameSerializer.new(user).serializable_hash],
|
|
poll_options.second.digest => [
|
|
UserNameSerializer.new(user).serializable_hash,
|
|
UserNameSerializer.new(user_2).serializable_hash,
|
|
UserNameSerializer.new(user_3).serializable_hash,
|
|
],
|
|
poll_options.third.digest => [
|
|
UserNameSerializer.new(user_2).serializable_hash,
|
|
UserNameSerializer.new(user_3).serializable_hash,
|
|
],
|
|
poll_options.fourth.digest => [UserNameSerializer.new(user_3).serializable_hash],
|
|
},
|
|
)
|
|
end
|
|
|
|
it "correctly paginates voters" do
|
|
opts = { page: 1, limit: 2 }.with_indifferent_access
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll, opts)
|
|
expect(voters).to eq(
|
|
{
|
|
poll_options.first.digest => [UserNameSerializer.new(user).serializable_hash],
|
|
poll_options.second.digest => [
|
|
UserNameSerializer.new(user).serializable_hash,
|
|
UserNameSerializer.new(user_2).serializable_hash,
|
|
],
|
|
poll_options.third.digest => [
|
|
UserNameSerializer.new(user_2).serializable_hash,
|
|
UserNameSerializer.new(user_3).serializable_hash,
|
|
],
|
|
poll_options.fourth.digest => [UserNameSerializer.new(user_3).serializable_hash],
|
|
},
|
|
)
|
|
|
|
opts = { page: 2, limit: 2 }.with_indifferent_access
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll, opts)
|
|
expect(voters).to eq(
|
|
{ poll_options.second.digest => [UserNameSerializer.new(user_3).serializable_hash] },
|
|
)
|
|
|
|
opts = { page: 3, limit: 2 }.with_indifferent_access
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll, opts)
|
|
expect(voters).to be_nil
|
|
end
|
|
end
|
|
|
|
context "with a ranked choice poll" do
|
|
let(:post) { post_with_ranked_choice_poll }
|
|
let(:poll) { post.polls.first }
|
|
let(:poll_options) { poll.poll_options }
|
|
let(:votes) do
|
|
{
|
|
user => {
|
|
"0": {
|
|
digest: poll_options.first.digest,
|
|
rank: "0",
|
|
},
|
|
"1": {
|
|
digest: poll_options.second.digest,
|
|
rank: "1",
|
|
},
|
|
"2": {
|
|
digest: poll_options.third.digest,
|
|
rank: "2",
|
|
},
|
|
},
|
|
user_2 => {
|
|
"0": {
|
|
digest: poll_options.second.digest,
|
|
rank: "0",
|
|
},
|
|
"1": {
|
|
digest: poll_options.third.digest,
|
|
rank: "1",
|
|
},
|
|
"2": {
|
|
digest: poll_options.first.digest,
|
|
rank: "2",
|
|
},
|
|
},
|
|
user_3 => {
|
|
"0": {
|
|
digest: poll_options.third.digest,
|
|
rank: "0",
|
|
},
|
|
"1": {
|
|
digest: poll_options.first.digest,
|
|
rank: "1",
|
|
},
|
|
"2": {
|
|
digest: poll_options.second.digest,
|
|
rank: "2",
|
|
},
|
|
},
|
|
}
|
|
end
|
|
|
|
before do
|
|
votes.each_pair { |user, options| DiscoursePoll::Poll.vote(user, post.id, "poll", options) }
|
|
end
|
|
|
|
it "returns all serialized voters" do
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll)
|
|
voters.transform_values! { |users| users.sort_by { |ranked_u| ranked_u[:user][:username] } }
|
|
expect(voters).to eq(
|
|
{
|
|
poll_options.first.digest => [
|
|
{ user: UserNameSerializer.new(user).serializable_hash, rank: "Abstain" },
|
|
{ user: UserNameSerializer.new(user_2).serializable_hash, rank: "2" },
|
|
{ user: UserNameSerializer.new(user_3).serializable_hash, rank: "1" },
|
|
],
|
|
poll_options.second.digest => [
|
|
{ user: UserNameSerializer.new(user).serializable_hash, rank: "1" },
|
|
{ user: UserNameSerializer.new(user_2).serializable_hash, rank: "Abstain" },
|
|
{ user: UserNameSerializer.new(user_3).serializable_hash, rank: "2" },
|
|
],
|
|
poll_options.third.digest => [
|
|
{ user: UserNameSerializer.new(user).serializable_hash, rank: "2" },
|
|
{ user: UserNameSerializer.new(user_2).serializable_hash, rank: "1" },
|
|
{ user: UserNameSerializer.new(user_3).serializable_hash, rank: "Abstain" },
|
|
],
|
|
},
|
|
)
|
|
end
|
|
|
|
it "correctly paginates voters" do
|
|
opts = { page: 1, limit: 2 }.with_indifferent_access
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll, opts)
|
|
voters.transform_values! { |users| users.sort_by { |ranked_u| ranked_u[:user][:username] } }
|
|
expect(voters).to eq(
|
|
{
|
|
poll_options.first.digest => [
|
|
{ user: UserNameSerializer.new(user).serializable_hash, rank: "Abstain" },
|
|
{ user: UserNameSerializer.new(user_2).serializable_hash, rank: "2" },
|
|
],
|
|
poll_options.second.digest => [
|
|
{ user: UserNameSerializer.new(user).serializable_hash, rank: "1" },
|
|
{ user: UserNameSerializer.new(user_2).serializable_hash, rank: "Abstain" },
|
|
],
|
|
poll_options.third.digest => [
|
|
{ user: UserNameSerializer.new(user).serializable_hash, rank: "2" },
|
|
{ user: UserNameSerializer.new(user_2).serializable_hash, rank: "1" },
|
|
],
|
|
},
|
|
)
|
|
|
|
opts = { page: 2, limit: 2 }.with_indifferent_access
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll, opts)
|
|
voters.transform_values! { |users| users.sort_by { |ranked_u| ranked_u[:user][:username] } }
|
|
expect(voters).to eq(
|
|
{
|
|
poll_options.first.digest => [
|
|
{ user: UserNameSerializer.new(user_3).serializable_hash, rank: "1" },
|
|
],
|
|
poll_options.second.digest => [
|
|
{ user: UserNameSerializer.new(user_3).serializable_hash, rank: "2" },
|
|
],
|
|
poll_options.third.digest => [
|
|
{ user: UserNameSerializer.new(user_3).serializable_hash, rank: "Abstain" },
|
|
],
|
|
},
|
|
)
|
|
|
|
opts = { page: 3, limit: 2 }.with_indifferent_access
|
|
voters = DiscoursePoll::Poll.serialized_voters(poll, opts)
|
|
expect(voters).to be_nil
|
|
end
|
|
end
|
|
end
|
|
end
|