mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-09 08:10:08 +09:00
8a6e4290d8
* 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>
211 lines
6.0 KiB
JavaScript
211 lines
6.0 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
import { nextEventLoopTick } from "helpers/timing_helpers"
|
|
import ClientMessage from "models/client_message"
|
|
import MessageFormatter, { ThreadStyle } from "models/message_formatter"
|
|
import MessagePaginator from "models/message_paginator"
|
|
import ScrollManager from "models/scroll_manager"
|
|
|
|
export default class extends Controller {
|
|
static targets = [ "latest", "message", "body", "messages", "template" ]
|
|
static classes = [ "firstOfDay", "formatted", "me", "mentioned", "threaded" ]
|
|
static values = { pageUrl: String }
|
|
|
|
#clientMessage
|
|
#paginator
|
|
#formatter
|
|
#scrollManager
|
|
|
|
// Lifecycle
|
|
|
|
initialize() {
|
|
this.#formatter = new MessageFormatter(Current.user.id, {
|
|
firstOfDay: this.firstOfDayClass,
|
|
formatted: this.formattedClass,
|
|
me: this.meClass,
|
|
mentioned: this.mentionedClass,
|
|
threaded: this.threadedClass,
|
|
})
|
|
}
|
|
|
|
connect() {
|
|
this.#clientMessage = new ClientMessage(this.templateTarget)
|
|
this.#paginator = new MessagePaginator(this.messagesTarget, this.pageUrlValue, this.#formatter, this.#allContentViewed.bind(this))
|
|
this.#scrollManager = new ScrollManager(this.messagesTarget)
|
|
|
|
if (this.#hasSearchResult) {
|
|
this.#highlightSearchResult()
|
|
} else {
|
|
this.#scrollManager.autoscroll(true)
|
|
}
|
|
|
|
this.#paginator.monitor()
|
|
}
|
|
|
|
disconnect() {
|
|
this.#paginator.disconnect()
|
|
}
|
|
|
|
messageTargetConnected(target) {
|
|
this.#reconcilePendingMessage(target)
|
|
this.#formatter.format(target, ThreadStyle.thread)
|
|
}
|
|
|
|
bodyTargetConnected(target) {
|
|
this.#formatter.formatBody(target)
|
|
}
|
|
|
|
// Actions
|
|
|
|
async beforeStreamRender(event) {
|
|
const target = event.detail.newStream.getAttribute("target")
|
|
|
|
if (target === this.messagesTarget.id) {
|
|
const render = event.detail.render
|
|
const upToDate = this.#paginator.upToDate
|
|
|
|
if (upToDate) {
|
|
event.detail.render = async (streamElement) => {
|
|
const didScroll = await this.#scrollManager.autoscroll(false, async () => {
|
|
await render(streamElement)
|
|
await nextEventLoopTick()
|
|
|
|
this.#positionLastMessage()
|
|
this.#playSoundForLastMessage()
|
|
this.#paginator.trimExcessMessages(true)
|
|
})
|
|
if (!didScroll) {
|
|
this.latestTarget.hidden = false
|
|
}
|
|
}
|
|
} else {
|
|
this.latestTarget.hidden = false
|
|
}
|
|
}
|
|
}
|
|
|
|
async returnToLatest() {
|
|
this.latestTarget.hidden = true
|
|
await this.#ensureUpToDate()
|
|
this.#scrollManager.autoscroll(true)
|
|
}
|
|
|
|
async editMyLastMessage() {
|
|
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
|
|
|
|
if (editorEmpty && this.#paginator.upToDate) {
|
|
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
|
}
|
|
}
|
|
|
|
|
|
// Outlet actions
|
|
|
|
async insertPendingMessage(clientMessageId, node) {
|
|
await this.#ensureUpToDate()
|
|
|
|
return this.#scrollManager.autoscroll(true, async () => {
|
|
const message = this.#clientMessage.render(clientMessageId, node)
|
|
this.messagesTarget.insertAdjacentHTML("beforeend", message)
|
|
})
|
|
}
|
|
|
|
updatePendingMessage(clientMessageId, body) {
|
|
this.#clientMessage.update(clientMessageId, body)
|
|
}
|
|
|
|
failPendingMessage(clientMessageId) {
|
|
this.#clientMessage.failed(clientMessageId)
|
|
}
|
|
|
|
// Callbacks
|
|
|
|
#allContentViewed() {
|
|
this.latestTarget.hidden = true
|
|
}
|
|
|
|
|
|
// Internal
|
|
|
|
// The composer inserts a pending message client-side using the client_message_id
|
|
// as its DOM id. The server broadcast now renders the real message with a DOM id
|
|
// derived from the record's primary key, so it no longer replaces the pending
|
|
// element by id. Reconcile by matching the server message's client_message_id and
|
|
// dropping our own pending placeholder. Only elements flagged data-pending-message
|
|
// (client-side placeholders, never broadcast) are removed, so a message another
|
|
// member posted reusing this client_message_id can never displace a real one.
|
|
#reconcilePendingMessage(target) {
|
|
const clientMessageId = target.dataset.clientMessageId
|
|
|
|
if (target.dataset.messageId && clientMessageId) {
|
|
const selector = `[data-pending-message][data-client-message-id="${CSS.escape(clientMessageId)}"]`
|
|
|
|
this.messagesTarget.querySelectorAll(selector).forEach((pending) => {
|
|
if (pending !== target) pending.remove()
|
|
})
|
|
}
|
|
}
|
|
|
|
async #ensureUpToDate() {
|
|
if (!this.#paginator.upToDate) {
|
|
await this.#paginator.resetToLastPage()
|
|
}
|
|
}
|
|
|
|
#highlightSearchResult() {
|
|
const highlightId = location.pathname.split("@").pop()
|
|
const highlightMessage = this.messagesTarget.querySelector(`.message[data-message-id="${highlightId}"]`)
|
|
if (highlightMessage) {
|
|
highlightMessage.classList.add("search-highlight")
|
|
highlightMessage.scrollIntoView({ behavior: "instant", block: "center" })
|
|
}
|
|
|
|
this.#paginator.upToDate = false
|
|
}
|
|
|
|
get #hasSearchResult() {
|
|
return location.pathname.includes("@")
|
|
}
|
|
|
|
get #lastMessage() {
|
|
return this.messagesTarget.children[this.messagesTarget.children.length - 1]
|
|
}
|
|
|
|
get #myLastMessage() {
|
|
const myMessages = this.messagesTarget.querySelectorAll(`.${this.meClass}`)
|
|
return myMessages[myMessages.length - 1]
|
|
}
|
|
|
|
#positionLastMessage() {
|
|
const followingMessage = this.#followingMessage(this.#lastMessage)
|
|
|
|
if (followingMessage) {
|
|
followingMessage.before(this.#lastMessage)
|
|
}
|
|
}
|
|
|
|
#playSoundForLastMessage() {
|
|
const soundTarget = this.#lastMessage.querySelector(".sound")
|
|
|
|
if (soundTarget) {
|
|
this.dispatch("play", { target: soundTarget })
|
|
}
|
|
}
|
|
|
|
#followingMessage(message) {
|
|
const messageSortValue = this.#sortValue(message)
|
|
let followingMessage = null
|
|
let previousMessage = message.previousElementSibling
|
|
|
|
while (messageSortValue < this.#sortValue(previousMessage)) {
|
|
followingMessage = previousMessage
|
|
previousMessage = previousMessage.previousElementSibling;
|
|
}
|
|
|
|
return followingMessage
|
|
}
|
|
|
|
#sortValue(node) {
|
|
return (node && parseInt(node.dataset.sortValue)) || 0
|
|
}
|
|
}
|