176 Commits
Author SHA1 Message Date
Jeremy Daer 20fbd3865c Check a cable connection's session while it's open (#376)
* Check a cable connection's session while it's open

Signing out or changing the password ends sessions and then disconnects
their connections. A connection that found its session just before isn't
subscribed to its disconnects yet, so it missed that and stayed open.

Each connection now looks its session up every minute while it's open, and
closes once the session is gone, asking the browser to reconnect so one
holding a current session comes back. That also covers any other way a
connection misses the disconnect, such as a subscription that takes long to
register, and a lookup that fails is simply tried again.

* Check the session on the server's heartbeat rather than a timer of its own

A connection's own timer could outlive it: closing can run before opening finishes, or a channel's teardown can fail before the connection's. The heartbeat only reaches connections that are open, so nothing is left to stop.

* Skip the session check on a rejected connection

A rejected connection stays registered until its socket closes, and has no session to check, so its heartbeat mustn't fail and stop the heartbeat reaching the connections after it.

(cherry picked from commit ce7b5d4ea0)
2026-10-20 20:45:38 -07:00
Jeremy Daer 9ca680de02 Sign the browser out when its new session is refused after a password change
If the password changes again between this browser's change and its new session, that session is refused, and the browser is sent to sign in instead of an error.

(cherry picked from commit a476cbfa24)
2026-10-20 20:45:36 -07:00
Jeremy Daer 09402c7752 Read the password digest past the query cache
A digest cached earlier in the request would let the check pass on a password that has since changed.

(cherry picked from commit c3faccb0f4)
2026-10-20 20:45:36 -07:00
Jeremy Daer 48da2b4e4e Refuse a session for a password that changed after it was checked
A password change ends every session, but a sign-in or sign-in link checked
against the old password just before the change could still save its session
just after, and that session outlived the change.

The session now checks, as it's saved, that the user's password is still the
one the request loaded. The save's transaction holds SQLite's write lock from
the start, so a change commits either before the check, which fails it, or
after the insert, and ends this session with the others. Sessions start
through the user's association so the check sees the user that was
authenticated, and a refused sign-in is answered like a wrong password.

(cherry picked from commit 19319a1cff)
2026-10-20 20:45:36 -07:00
Jeremy Daer 56e2a287ea Check the current password whenever the saved password changes
The profile asked for the current password when the request's password
field was filled in. The check now follows the save itself: the model
checks the challenge whenever the password digest is about to change,
and the profile always hands it the challenge. Editing a name, avatar
or email without a new password still needs none, and scripts that
reset a password, which set no challenge, are unaffected.

Only a string counts as a challenge, and the rate limit counts every
non-empty one, so what's limited is exactly what gets checked.

(cherry picked from commit 4a580302a5)
2026-10-19 15:12:25 -07:00
Jeremy Daer bf2b9cc61b Look the endpoint up past the query cache, and keep a subscription a race refused
The lookup under the write lock repeated the one made before
validation, so the query cache could answer it with that earlier miss
even after another connection had registered the endpoint. It runs
uncached now.

Enabling notifications unsubscribed the browser on any refusal, so a
session signed out between authenticating and saving lost the browser's
subscription too, and the next session had nothing to post. Only a
refused endpoint unsubscribes it now.

From #371.

(cherry picked from commit aa93269ceb)
2026-10-19 13:34:37 -07:00
Jeremy Daer fadf6f1859 Post the browser's push subscription from every page, and register it once
Only room pages posted the subscription again, so a browser that
changed its password and closed the profile page went without
notifications until it next opened a room. A small controller in the
layout does it now, still once per session, and the bell is back to how
it was.

Two tabs posting at once after a password change could both miss the
row and insert two, so the endpoint is looked up again under the same
write lock as the session check.

From #371.

(cherry picked from commit 3786ee5244)
2026-10-19 13:15:14 -07:00
Jeremy Daer 93718f7408 Remove push subscriptions on a password change, and post them again
A push subscription belongs to the user, not a session, so a device
signed out by a password change kept getting notifications. Changing
the password now removes every subscription along with the sessions,
in the same transaction.

A browser still signed in keeps its own subscription in its service
worker, so the notifications controller posts it once per session: the
bell names the session by a digest of its id, never its token, and the
browser remembers the last one it posted for. That post never
unsubscribes the browser when the server refuses it.

A request that authenticated just before the change could put a
subscription back after it. The create action now checks the session
still exists and saves in one immediate transaction, after validating
the endpoint outside it.

From #371.

(cherry picked from commit 6cab924097)
2026-10-19 12:39:14 -07:00
Jeremy Daer 417f087964 Ask for the current password to change it
Changing the password now ends every other session, so whoever holds
one could shut the owner out of their own account with a new password.
The profile asks for the current password whenever the password
changes; without it, or with a wrong one, nothing is saved and the page
says why. Other profile edits don't need it, and the admin
reset-password script still works without it.

Attempts are rate limited like signing in, 10 in 3 minutes per address,
so the current password can't be guessed here instead.

From #371. The cached-response test it touches isn't in 1.5.2.

(cherry picked from commit 44a92ff6c6)
2026-10-19 12:17:27 -07:00
Jeremy Daer 58a25c6281 End sign-in links made before a password change
A transfer link signed only the user id, so one copied before the
password changed still started a session for up to four hours after.
Bind it to the password salt with generates_token_for, so a new
password ends those links too. Links made before this upgrade stop
working; they last four hours at most.

From #371.

(cherry picked from commit 160ba295b5)
2026-10-19 11:37:05 -07:00
Jeremy Daer 00abff4088 Close live connections on the transaction that changed the password
An after_commit condition reads the record's last save, so another save
of the user in the same transaction skipped the disconnect even though
the sessions were gone. The callback that deletes them now registers
the disconnect on the transaction itself, which also leaves a rolled
back change alone. A password changed along with the avatar is covered
too: the attachment's touch doesn't hide the change.

From #371.

(cherry picked from commit e158388f3d)
2026-10-19 10:57:49 -07:00
Jeremy Daer b1122c7d6d Give the changing browser a new session rather than keeping its own
Keeping the session the password was changed from also kept any copy of
its cookie working, and a copied cookie is one of the ways someone else
gets into an account. Every session ends now, and the profile page
signs the browser that changed the password back in with a new one.

From #371.

(cherry picked from commit b01bb7a0a7)
2026-10-19 10:46:24 -07:00
Jeremy Daer 2fb599e21c Close live connections from the model on any password change
The sessions end wherever the password changes, but only the profile
page closed the live connections, so a change from a console left a
signed-out device receiving messages until it dropped. Close them after
commit from User, logging rather than failing if the realtime service
can't be reached, since the password is saved by then.

From #371.

(cherry picked from commit c6e0105491)
2026-10-19 10:33:48 -07:00
Jeremy Daer c78f03fda7 Sign out the account's other sessions when its password changes
Changing the password is how someone shuts out whoever else is signed
in to their account, but every session opened with the old password
stayed valid. Now the others end and their live connections close, as
on sign-out. The session the password was changed from stays signed in.

Changing the password outside a request, as from a console, ends every
session.

From #371. 1.5.2 has no record snapshot or response cache, so the test
that a cached session lookup doesn't outlive the password change is
left out; the rest applies as it is.

(cherry picked from commit c78c3fd7ef)
2026-10-19 10:28:55 -07:00
Rosa Gutierrez a1dd6e1d8a Don't let an http_proxy environment variable bypass the pinned address in link previews
Opengraph::Fetch connects to the IP it checked, not to a fresh lookup of
the host. With http_proxy set, Net::HTTP would instead send the hostname
to the proxy, which looks it up again, so the connection could end up
somewhere other than the checked address. Pass nil as the proxy address,
as web push delivery already does.

(cherry picked from commit a7e9ea53dd)
2026-10-17 01:11:38 -07:00
Jeremy Daer 7a5931fe2a Allow an asset host only up to its own asset directory
An asset host's path can contain the asset directory too, as in
https://cdn.example.com/assets, and cutting each URL at the first one would
allow everything the host serves under it. Cut at the last one instead.

(cherry picked from commit 2ba940c683)
2026-10-17 00:47:41 -07:00
Jeremy Daer 26b3d2abb0 Leave the scheme off this origin's script sources
Behind a proxy that terminates TLS without X-Forwarded-Proto, Rails sees
http while the page is https, and an http source on a non-default port
doesn't match. A source without a scheme takes the page's own.

(cherry picked from commit 0a494a10cb)
2026-10-17 00:47:41 -07:00
Jeremy Daer 681bd78ea2 Allow scripts only from the asset directory, not the whole origin
Uploads are served from this origin too, so script-src 'self' would run one
that a cache still holds under a script type from before the upload guard.
Application pages now allow each importmap URL up to its asset directory
(relative root and asset host included), the inline importmap by hash, and
the service worker by worker-src.

(cherry picked from commit 2be2227f40)
2026-10-17 00:47:41 -07:00
Jeremy Daer 8f249cbde4 Leave a request without a policy when the policy is turned off
With DISABLE_CONTENT_SECURITY_POLICY=true there's no application policy,
and the hash callback started an empty one, so pages sent an empty
Content-Security-Policy header. Skip the callback then.

(cherry picked from commit 2a449401ec)
2026-10-17 00:47:32 -07:00
Jeremy Daer c3616f81b4 Enforce a script policy that allows the importmap by hash
script-src 'self', object-src 'none' and base-uri 'self', enforced. The
two inline scripts the importmap helper emits are allowed by their
SHA-256, and the origins its URLs point to (a configured asset host, in
any form Rails accepts) are allowed by name. There is no per-request
nonce, so cached pages stay valid.

DISABLE_CONTENT_SECURITY_POLICY=true leaves the policy off, for an
install whose proxy or customization injects scripts.

(cherry picked from commit 562888251e)

Backport: 1.5.2 has no response cache, so the cached-page test is left out.
2026-10-17 00:47:32 -07:00
Jeremy Daer 049fc920d3 Quote a mention as text when replying
Replying swaps each mention in the quoted copy for its text. Assigning
that text to outerHTML parsed it as html; replace the mention with a
text node instead.

(cherry picked from commit 6052b123d5)
2026-10-17 00:46:37 -07:00
Jeremy Daer 6ba5d2ece3 Link URLs only in a message's text
auto_link finds URLs and email addresses with regular expressions over
the rendered html, which can't tell an attribute value from text, so it
could put a link inside an attribute. Hand it each text node on its own,
escaped the way the serializer writes text, after the final sanitize, so
links only ever land between tags. Write the result with
preserve_newline so a code block keeps its leading blank lines.

Bump the presentation cache version so nothing rendered the old way is
served again.

Backport of 7ea05e1, 4e29c6e and 9b271d0 from #364. 1.5.2 has no
plain-text fragment cache, so only the message partial's version moves.
2026-10-17 00:46:37 -07:00
Jeremy DaerandMarcello Costagliola 6e312c6028 Stop sending push notifications to banned users (#338)
Banning a user deletes their sessions and closes their connections but
keeps their push subscriptions, and Room::MessagePusher chose recipients
by membership alone. A banned user's browser or phone therefore went on
receiving the room name, sender and text of new direct messages,
mentions, and messages in rooms they had set to everything.

Choose subscriptions from active users only. The subscriptions are kept,
so unbanning brings notifications back without the user having to
subscribe again (the client doesn't resubscribe while the browser still
holds a subscription).

Co-authored-by: Marcello Costagliola <176920116+namespaceMarcello@users.noreply.github.com>
2026-10-07 11:25:16 -07:00
GPT on behalf of DHH 27f5461067 Render a complete auto-submit transfer form 2026-10-07 17:08:48 +02:00
GPT on behalf of DHH 8bbe129030 Preserve active ping editors during sidebar refreshes 2026-10-07 16:55:08 +02:00
GPT on behalf of DHH 765711f27d Preserve request ports in message copy links 2026-10-07 16:37:53 +02:00
GPT on behalf of DHH 3c022a1641 Preserve timestamp ordering of Rails unread notices 2026-10-07 16:19:10 +02:00
GPT on behalf of DHH 2b9685c35c Wait for sidebar frames before reloading on Cable connections 2026-10-07 16:18:39 +02:00
GPT on behalf of DHH dbc7620a76 Bound accessible search probes and reject invalid benchmark responses 2026-10-07 14:22:07 +02:00
GPT on behalf of DHH 7df98ac883 Merge current Rails main during performance review
# Conflicts:
#	app/views/messages/_message.html.erb
2026-10-07 11:34:17 +02:00
GPT on behalf of DHH 27065ef489 Keep same-second unread events and bound idle push connections 2026-10-07 11:00:47 +02:00
Donal McBreenandJeremy Daer 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
GPT on behalf of DHH 14a2f8f550 Merge pull request #329: Delete a room's messages in a job, one transaction each
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:33:57 +02:00
GPT on behalf of DHH 72648a8f41 Merge pull request #296: Fan the unread room notice out from a job
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:33:57 +02:00
GPT on behalf of DHH b409b9998f Merge pull request #328: Bound how long link unfurling can hold a request
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:33:57 +02:00
GPT on behalf of DHH 3005154114 Merge pull request #331: avoid preview generation for rich-text file embeds
Reviewed and merged by GPT on behalf of DHH. Retain all media regressions and invalidate presentation caches again.
2026-10-07 10:33:57 +02:00
GPT on behalf of DHH a991adb19d Merge pull request #330: bound attachment preview work
Reviewed and merged by GPT on behalf of DHH. Keep both boost-cache and preview-loading regressions.
2026-10-07 10:33:02 +02:00
GPT on behalf of DHH cbf52805bd Merge pull request #311: Post a message whose attachment can't be previewed instead of failing
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 0f2a1da560 Merge pull request #316: List one page of account members at a time
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 6ad4735b19 Merge pull request #324: Rewrite a message's search entry only when its body or attachment changes
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 819b3896fb Merge pull request #323: Count unread rooms for push badges once per batch
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH b6d307537e Merge pull request #325: Render the boosts a message has preloaded instead of querying them again
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH fdec7dfc9f Merge pull request #322: Cache boosts with their message instead of one by one
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 0d5998f057 Merge pull request #318: Query sidebar directs and shared rooms separately
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 87eafc025f Merge pull request #310: Find a direct room with one query instead of checking every one
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
GPT on behalf of DHH 1e0d353c60 Merge pull request #312: Find the messages a refresh replaces through an index
Reviewed and merged by GPT on behalf of DHH.
2026-10-07 10:32:12 +02:00
Marcello CostagliolaandClaude Opus 5.5 c48083dcfe Show a file in a message's rich text by its name, whatever its variants
Showing an image's preview when its variant had already been made tied the cached message to state
that changes without touching it: a variant made after the message was cached would never show. It also
cost a query per embedded image, since Action Text loads the embeds without their variant records.
Nothing in the app makes those variants, so the partial no longer looks at them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 16:02:35 +02:00
Marcello CostagliolaandClaude Opus 5.5 15f56134e7 Show a file in a message's rich text without making its preview on view
Files are posted as a message's own attachment, and the composer puts none in the rich text: it permits
only mentions and link embeds. The server still keeps any attachment whose signed id resolves, a blob
included, and Action Text's blob partial links to a representation URL that makes the preview on the
first request, without the limits the POST has, and on every request while it fails.

The app's own partial shows an image's preview only if it was already made, and otherwise the file's
name and size, as it does for a file that isn't an image. The cached presentation's version goes up,
since Action Text renders the partial by name and the template digest doesn't see it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:41:34 +02:00
Marcello CostagliolaandClaude Opus 5.5 5f198146e8 Give a video a poster only once its poster variant is made
ActiveStorage::Preview#processed? is true as soon as ffmpeg's frame is attached, but the poster is a
variant of that frame and can fail on its own: the POST rescues a Vips::Error and leaves the frame
attached. The view then emitted the poster's URL, and every view retried the resize. It now checks the
variant too, from the variant records with_attached_attachment already preloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:34:46 +02:00
Marcello CostagliolaandClaude Opus 5.5 9912e63d69 Bound the work of previewing an attachment
A video's preview and a picture's thumbnail are made inside the request that posts the message, and
nothing bounded how long either could take.

- The video preview filter also selects any frame from 5 seconds on. Rails' filter takes the second
  frame it selects, which a video with a single keyframe and no scene change only gives at its end, so
  ffmpeg decoded all of it.
- TimeLimitedVideoPreviewer gives ffmpeg 10 seconds of wall-clock time, kills it past that, and reports
  a failed preview, so the message is posted without one.
- Pictures and videos above 250 megapixels, or whose size couldn't be read, get no preview: decoding
  costs in proportion to the pixels, however small the file.
- The view shows a preview only if it was made when the message was posted. Its URL used to make it on
  view, so a preview that failed or was skipped would be attempted again on every view. The cached
  presentation's version goes up, so cached messages pick this up.
- A video's poster is made, when the message is posted, at the size the view shows it. The full-size WebP
  made until now wasn't shown anywhere, and encoding it costs in proportion to the frame's pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj8KnxpTf9sj2Ysa8aLAVa
2026-10-06 15:20:57 +02:00