Merge current Rails main during performance review

# Conflicts:
#	app/views/messages/_message.html.erb
This commit is contained in:
GPT on behalf of DHH
2026-10-07 11:34:17 +02:00
9 changed files with 194 additions and 6 deletions
+150
View File
@@ -0,0 +1,150 @@
require "application_system_test_case"
# Visual proof for GHSA-3v99-4vxh-xg84 under the server-id fix.
#
# Before the fix a message rendered with the DOM id "message_<client_message_id>",
# where the client_message_id is chosen by the browser. create/update/destroy Turbo
# Stream broadcasts targeted that DOM id and a browser resolves it to the FIRST
# matching element, so an attacker (a non-admin room member) who reused the victim's
# client_message_id could displace the victim's message in every connected browser.
#
# This fix derives every message DOM id from the record's primary key instead, so two
# distinct records can never share a DOM id no matter what client_message_id they carry.
# The attacker's colliding create still succeeds (there is no uniqueness constraint and
# none is needed) but lands as its own element with its own PK-based id; editing it
# broadcasts to the attacker's own presentation id and cannot touch the victim.
#
# The test drives two real headless-Chromium sessions (victim + attacker) against one
# server and screenshots the outcome to tmp/screenshots/.
class MessageIdSpoofingTest < ApplicationSystemTestCase
SCREENSHOT_DIR = Rails.root.join("tmp/screenshots")
setup do
FileUtils.mkdir_p(SCREENSHOT_DIR)
end
test "a reused client_message_id cannot hijack the victim message" do
room = rooms(:designers)
# Victim (jz) posts a real message through the composer and keeps the room open.
sign_in "jz@37signals.com"
join_room room
send_message "Victim's real message"
assert_selector ".message[data-message-id]", text: "Victim's real message"
# The browser chose the client_message_id; read it (and the record id) back
# from the persisted message the victim just posted.
victim_message = room.messages.where(creator: users(:jz)).order(:created_at).last
victim_client_id = victim_message.client_message_id
victim_db_id = victim_message.id
save_screenshot(SCREENSHOT_DIR.join("spoof-before.png"))
# Attacker (kevin), a different non-admin member of the same room, posts a message
# reusing the victim's client_message_id, then edits their own message.
attacker_status = nil
using_session("attacker") do
sign_in "kevin@37signals.com"
join_room room
attacker_status = post_message_via_browser(room, "HIJACKED BY ATTACKER", victim_client_id)
assert (200..299).include?(attacker_status), "the colliding create should still succeed (got #{attacker_status})"
attacker_db_id = find(".message[data-message-id]", text: "HIJACKED BY ATTACKER")["data-message-id"]
edit_message_via_browser(room, attacker_db_id, "EDITED BY ATTACKER")
end
attacker_message = room.messages.where(creator: users(:kevin)).order(:created_at).last
attacker_db_id = attacker_message.id
# Both messages carry the SAME client_message_id...
assert_equal victim_client_id, attacker_message.client_message_id
assert_not_equal victim_db_id, attacker_db_id
# ...but in the victim's browser they render as two DISTINCT elements. Elements
# are located by record id (data-message-id), not by DOM id, so this fails on
# the vulnerable code for the real reason: the victim's element is gone. Wait
# for the attacker's edit to arrive in the victim's browser, then confirm the
# victim's own message is untouched and still under the victim's name.
assert_selector message_selector(attacker_db_id), text: "EDITED BY ATTACKER"
assert_selector "#{message_selector(victim_db_id)} .message__body", text: "Victim's real message"
assert_selector "#{message_selector(victim_db_id)} .message__author", text: "JZ"
assert_selector ".message[data-message-id]", count: room.messages.count
# The victim's element never took on the attacker's text.
assert_no_selector "#{message_selector(victim_db_id)} .message__body", text: /ATTACKER/
# And the victim's record is untouched on the server.
assert_equal "Victim's real message", victim_message.reload.body.to_plain_text.strip
save_screenshot(SCREENSHOT_DIR.join("spoof-after-fixed.png"))
end
test "a distinct client_message_id never alters the victim message" do
room = rooms(:designers)
sign_in "jz@37signals.com"
join_room room
send_message "Victim's untouched message"
assert_selector ".message[data-message-id]", text: "Victim's untouched message"
victim_db_id = room.messages.where(creator: users(:jz)).order(:created_at).last.id
attacker_db_id = nil
using_session("attacker") do
sign_in "kevin@37signals.com"
join_room room
status = post_message_via_browser(room, "Attacker's own message", "a-distinct-id")
assert (200..299).include?(status), "a non-colliding create should succeed (got #{status})"
attacker_db_id = find(".message[data-message-id]", text: "Attacker's own message")["data-message-id"]
edit_message_via_browser(room, attacker_db_id, "EDITED BY ATTACKER")
end
# The attacker's edit lands only on the attacker's own message. The victim's
# message is untouched.
assert_selector message_selector(attacker_db_id), text: "EDITED BY ATTACKER"
assert_selector "#{message_selector(victim_db_id)} .message__body", text: "Victim's untouched message"
assert_no_selector "#{message_selector(victim_db_id)} .message__body", text: "EDITED BY ATTACKER"
end
private
def message_selector(message_id)
".message[data-message-id='#{message_id}']"
end
# POST a message through the attacker's live authenticated browser session,
# forcing a chosen client_message_id. Synchronous XHR so we get the real HTTP
# status back. Returns the status code.
def post_message_via_browser(room, body, client_message_id)
page.evaluate_script(<<~JS)
(function() {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/rooms/#{room.id}/messages', false);
var token = document.querySelector('meta[name="csrf-token"]');
if (token) { xhr.setRequestHeader('X-CSRF-Token', token.content); }
xhr.setRequestHeader('Accept', 'text/vnd.turbo-stream.html');
var fd = new FormData();
fd.append('message[body]', #{body.to_json});
fd.append('message[client_message_id]', #{client_message_id.to_json});
xhr.send(fd);
return xhr.status;
})()
JS
end
# PATCH the attacker's own message through their live browser session. Fired
# async (the caller then waits on the resulting DOM broadcast); the update's
# replace broadcast targets the attacker's own presentation DOM id.
def edit_message_via_browser(room, message_id, body)
page.execute_script(<<~JS)
(function() {
var fd = new FormData();
fd.append('message[body]', #{body.to_json});
var headers = { 'Accept': 'text/html' };
var token = document.querySelector('meta[name="csrf-token"]');
if (token) { headers['X-CSRF-Token'] = token.content; }
fetch('/rooms/#{room.id}/messages/#{message_id}', { method: 'PATCH', headers: headers, body: fd });
})()
JS
end
end
+5
View File
@@ -15,6 +15,11 @@ class SendingMessagesTest < ApplicationSystemTestCase
join_room rooms(:designers)
send_message "Is this thing on?"
# The optimistic pending message is reconciled with the server broadcast even
# though they no longer share a DOM id: exactly one element, no duplicate.
assert_selector ".message__body", text: "Is this thing on?", count: 1
assert_no_selector "[data-pending-message]"
using_session("Kevin") do
join_room rooms(:designers)
assert_message_text "Is this thing on?"