Files
once-campfire/app/helpers/messages_helper.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

117 lines
4.4 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
module MessagesHelper
# auto_link re-sanitizes with Rails' default safe list, which lacks the
# formatting the rich text editor produces
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
def message_area_tag(room, &)
tag.div id: "message-area", class: "message-area", contents: true, data: {
controller: "messages presence drop-target",
action: [ messages_actions, drop_target_actions, presence_actions ].join(" "),
messages_first_of_day_class: "message--first-of-day",
messages_formatted_class: "message--formatted",
messages_me_class: "message--me",
messages_mentioned_class: "message--mentioned",
messages_threaded_class: "message--threaded",
messages_page_url_value: room_messages_url(room)
}, &
end
def messages_tag(room, &)
tag.div id: dom_id(room, :messages), class: "messages", data: {
controller: "maintain-scroll refresh-room",
action: [ maintain_scroll_actions, refresh_room_actions ].join(" "),
messages_target: "messages",
refresh_room_loaded_at_value: room.updated_at.to_fs(:epoch),
refresh_room_url_value: room_refresh_url(room)
}, &
end
def message_tag(message, &)
message_timestamp_milliseconds = message.created_at.to_fs(:epoch)
tag.div id: dom_id(message),
class: "message #{"message--emoji" if message.plain_text_body.all_emoji?}",
data: {
controller: "reply",
user_id: message.creator_id,
message_id: message.id,
client_message_id: message.client_message_id,
message_timestamp: message_timestamp_milliseconds,
message_updated_at: message.updated_at.to_fs(:epoch),
sort_value: message_timestamp_milliseconds,
messages_target: "message",
search_results_target: "message",
refresh_room_target: "message",
reply_composer_outlet: "#composer"
}, &
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
Rails.logger.error "Exception while rendering message #{message.class.name}##{message.id}, failed with: #{e.class} `#{e.message}`"
render "messages/unrenderable"
end
def message_timestamp(message, **attributes)
local_datetime_tag message.created_at, **attributes
end
def message_presentation(message)
case message.content_type
when "attachment"
message_attachment_presentation(message)
when "sound"
message_sound_presentation(message)
else
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)),
html: { target: "_blank" }, sanitize_options: { tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES }
end
rescue Exception => e
Sentry.capture_exception(e, extra: { message: message })
Rails.logger.error "Exception while generating message representation for #{message.class.name}##{message.id}, failed with: #{e.class} `#{e.message}`"
""
end
private
def messages_actions
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
end
def maintain_scroll_actions
"turbo:before-stream-render@document->maintain-scroll#beforeStreamRender"
end
def refresh_room_actions
"visibilitychange@document->refresh-room#visibilityChanged online@window->refresh-room#online"
end
def presence_actions
"visibilitychange@document->presence#visibilityChanged"
end
def message_attachment_presentation(message)
Messages::AttachmentPresentation.new(message, context: self).render
end
def message_sound_presentation(message)
sound = message.sound
tag.div class: "sound", data: { controller: "sound", action: "messages:play->sound#play", sound_url_value: asset_path(sound.asset_path) } do
play_button + (sound.image ? sound_image_tag(sound.image) : sound.text)
end
end
def play_button
tag.button "🔊", class: "btn btn--plain", data: { action: "sound#play" }
end
def sound_image_tag(image)
image_tag image.asset_path, width: image.width, height: image.height, class: "align--middle"
end
def message_author_title(author)
[ author.name, author.bio ].compact_blank.join(" – ")
end
end