Files
once-campfire/test/controllers/messages_controller_test.rb
T
Donal McBreen 8a6e4290d8 Merge commit from fork
* Derive message DOM ids from the server id, not client_message_id

A message's DOM id was derived from the browser-chosen client_message_id
via a Message#to_key override, so dom_id(message) was
"message_<client_message_id>". Turbo's append de-dups by DOM id, so a room
member who posted a message reusing a victim's client_message_id displaced
the victim's message element in every connected member's live view; editing
the attacker's own message then broadcast onto the victim's presentation id.

Drop the to_key override so every message DOM id and broadcast target derives
from the record's primary key. Two distinct records can no longer share a DOM
id regardless of stored client_message_id, so the collision is impossible with
no data migration and no uniqueness constraint. to_param and the fragment
cache key already used the primary key, so message URLs and per-message cache
entries are unchanged.

The composer's optimistic pending message still uses client_message_id as its
placeholder DOM id, which no longer matches the server broadcast's PK-based id.
Reconcile instead by rendering data-client-message-id on the real message and
having the messages controller drop the matching pending placeholder on
connect. Only client-side placeholders (data-pending-message) are removed, so a
message another member posts reusing the same client_message_id can never
displace a real one through the reconciliation path either.

Also point the boost broadcast target at the PK-based dom_id(message, :boosts)
to match the rebuilt container id.

GHSA-3v99-4vxh-xg84

* Bust cached message fragments rendered with client_message_id DOM ids

The message fragment cache keys on the record and the template digest, and
removing the to_key override changes neither. Fragments cached by an earlier
release would keep their message_<client_message_id> ids, so edit, delete and
boost broadcasts, which now target primary-key ids, would miss those messages
in other members' live views until the cache entry expired.

* Locate messages by record id in the client_message_id collision tests

Assert on data-message-id rather than the new primary-key DOM ids, so the tests
describe the behavior instead of the fix and fail on the vulnerable code for the
real reason. Edit the attacker's message to new text and wait for it to arrive,
so the edit path is exercised rather than passing vacuously. Add a request test
that two messages sharing a client_message_id render as distinct elements.

---------

Co-authored-by: Jeremy Daer <jeremy@37signals.com>
2026-10-07 01:41:12 -07:00

180 lines
6.1 KiB
Ruby

require "test_helper"
class MessagesControllerTest < ActionDispatch::IntegrationTest
setup do
host! "once.campfire.test"
sign_in :david
@room = rooms(:watercooler)
@messages = @room.messages.ordered.to_a
end
test "index returns the last page by default" do
get room_messages_url(@room)
assert_response :success
ensure_messages_present @messages.last
end
test "index returns a page before the specified message" do
get room_messages_url(@room, before: @messages.third)
assert_response :success
ensure_messages_present @messages.first, @messages.second
ensure_messages_not_present @messages.third, @messages.fourth, @messages.fifth
end
test "index returns a page after the specified message" do
get room_messages_url(@room, after: @messages.third)
assert_response :success
ensure_messages_present @messages.fourth, @messages.fifth
ensure_messages_not_present @messages.first, @messages.second, @messages.third
end
test "index returns no_content when there are no messages" do
@room.messages.destroy_all
get room_messages_url(@room)
assert_response :no_content
end
test "get renders a single message belonging to the user" do
message = @room.messages.where(creator: users(:david)).first
get room_message_url(@room, message)
assert_response :success
end
test "creating a message broadcasts the message to the room" do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do
assert_select ".message__body", text: /New one/
assert_copy_link_button room_at_message_url(@room, Message.last, host: "once.campfire.test")
end
end
test "creating a message broadcasts unread room to each member" do
@room.users.each do |member|
assert_broadcasts UnreadRoomsChannel.stream_name_for(member.id), 1 do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one #{member.id}", client_message_id: member.id } }
end
end
end
test "creating a message doesn't broadcast unread room to non-members" do
outsiders = User.where.not(id: @room.users.map(&:id))
assert outsiders.any?, "need someone outside the room for this test to mean anything"
outsiders.each do |outsider|
assert_no_broadcasts UnreadRoomsChannel.stream_name_for(outsider.id) do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
end
end
end
test "update updates a message belonging to the user" do
message = @room.messages.where(creator: users(:david)).first
Turbo::StreamsChannel.expects(:broadcast_replace_to).once
put room_message_url(@room, message), params: { message: { body: "Updated body" } }
assert_redirected_to room_message_url(@room, message)
assert_equal "Updated body", message.reload.plain_text_body
end
test "admin updates a message belonging to another user" do
message = @room.messages.where(creator: users(:jason)).first
Turbo::StreamsChannel.expects(:broadcast_replace_to).once
put room_message_url(@room, message), params: { message: { body: "Updated body" } }
assert_redirected_to room_message_url(@room, message)
assert_equal "Updated body", message.reload.plain_text_body
end
test "destroy destroys a message belonging to the user" do
message = @room.messages.where(creator: users(:david)).first
assert_difference -> { Message.count }, -1 do
Turbo::StreamsChannel.expects(:broadcast_remove_to).once
delete room_message_url(@room, message, format: :turbo_stream)
assert_response :success
end
end
test "admin destroy destroys a message belonging to another user" do
assert users(:david).administrator?
message = @room.messages.where(creator: users(:jason)).first
assert_difference -> { Message.count }, -1 do
Turbo::StreamsChannel.expects(:broadcast_remove_to).once
delete room_message_url(@room, message, format: :turbo_stream)
assert_response :success
end
end
test "ensure non-admin can't update a message belonging to another user" do
sign_in :jz
assert_not users(:jz).administrator?
room = rooms(:designers)
message = room.messages.where(creator: users(:jason)).first
put room_message_url(room, message), params: { message: { body: "Updated body" } }
assert_response :forbidden
end
test "ensure non-admin can't destroy a message belonging to another user" do
sign_in :jz
assert_not users(:jz).administrator?
room = rooms(:designers)
message = room.messages.where(creator: users(:jason)).first
delete room_message_url(room, message, format: :turbo_stream)
assert_response :forbidden
end
test "mentioning a bot triggers a webhook" do
WebMock.stub_request(:post, webhooks(:bender).url).to_return(status: 200)
assert_enqueued_jobs 1, only: Bot::WebhookJob do
post room_messages_url(@room, format: :turbo_stream), params: { message: {
body: "<div>Hey #{mention_attachment_for(:bender)}</div>", client_message_id: 999 } }
end
end
test "messages sharing a client_message_id render as distinct elements" do
victim = @messages.last
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "Collision", client_message_id: victim.client_message_id } }
colliding = @room.messages.ordered.last
assert_not_equal victim, colliding
get room_messages_url(@room)
ids = css_select(".message[data-message-id]").map { it["id"] }
assert_equal ids.uniq, ids
ensure_messages_present victim, colliding
end
private
def ensure_messages_present(*messages, count: 1)
messages.each do |message|
assert_select "#" + dom_id(message), count:
end
end
def ensure_messages_not_present(*messages)
ensure_messages_present *messages, count: 0
end
def assert_copy_link_button(url)
assert_select ".btn[title='Copy link'][data-copy-to-clipboard-content-value='#{url}']"
end
end