Compare commits

..
24 Commits
Author SHA1 Message Date
Jeremy Daer dd247a623b Merge pull request #232 from basecamp/security/room-type-scoping
Scope room administration, authorize message streams, and make unread rooms per-user
2026-08-03 16:27:38 -07:00
Jeremy Daer 3b509f55ca Scope the unread rooms stream per user
UnreadRoomsChannel streamed from a hardcoded global name, and every message
published its room id to it. Any authenticated user, including one with no
memberships at all, could subscribe and watch which rooms were active and exactly
when, across every closed room and direct conversation on the account. The browser
filters ids it doesn't recognise, but that happens after delivery.

Its sibling ReadRoomsChannel is already scoped per user; this mirrors it, and
message creation fans the notice out to the room's members instead of broadcasting
it to everyone. No message content was exposed either way, only the timing.
2026-08-03 14:55:19 -07:00
Jeremy Daer ee37809220 Authorize the room message stream at subscribe time
Message content is delivered over turbo streams, which ran on the stock
Turbo::StreamsChannel. That channel verifies the signature on the stream name and
nothing else. The name carries no expiry and no binding to a user, so one read off
the page while a member kept working after the membership was revoked.

Revocation made this worse rather than better. Membership#after_destroy_commit
disconnects the user with reconnect: true, and the client replays its subscriptions
on the new socket: RoomChannel re-checks membership and rejects, while the turbo
subscription re-verified only the signature and was accepted.

RoomMessagesChannel re-checks membership on every subscribe, deriving the room from
the verified stream name so there is no parameter to point elsewhere. Since the
subscriber names the channel it wants, the stock channel would otherwise be a way
around that check, so it now turns these stream names away and this is the only door.
2026-08-03 14:55:12 -07:00
Jeremy Daer 5c5c82b27a Scope room lookup to the type each controller administers
Rooms::DirectsController relaxes ensure_can_administer to true, because every
participant in a direct room may administer it. set_room was inherited unscoped,
though, so that relaxation applied to any room the caller was merely a member of:
DELETE /rooms/directs/<id> destroyed open and closed rooms and all their messages.

The same unscoped lookup let a direct room be loaded by the opens and closeds
controllers, where force_room_type promoted it. Promoting a DM to open grants every
user on the account membership and republishes the whole conversation, including the
other participant's messages; converting it to closed lets the initiator revise who
is in it and lock the other participant out.

Each controller now narrows room_scope to the types it may act on. Opens and closeds
keep reach into each other, since converting between them is a feature. Neither can
reach a direct room, and directs can only reach directs.

Room also refuses to change type away from Rooms::Direct, so the invariant holds for
any future caller of becomes! rather than only these two controllers.
2026-08-03 14:55:04 -07:00
Mike Dalessio d28ab55bf4 Document the self-hosted trust model in SECURITY.md (#230)
Nothing in the repository records that unrestricted bot webhook delivery is
deliberate, so the missing private-network guard reads as an oversight next to
the guarded unfurl path. Researchers report it as server-side request forgery,
repeatedly.
2026-07-31 13:09:59 -04:00
Stanko Krtalić 50f25cea80 Merge pull request #229 from basecamp/fix-version-number
Fix version number for CI built images
2026-07-30 18:51:01 +02:00
Stanko K.R. d79225ecd1 Fix version number for CI built images 2026-07-30 18:34:54 +02:00
Mike Dalessio b065b40a34 Disable libvips unfuzzed operations (#226)
and add test coverage for (un)supported file types.

The avatar and logo variants move into the models and return nil for content
types that are no longer variable, so the controllers fall back to the initials
avatar and stock logo icon instead of raising `ActiveStorage::InvariableError`.
2026-07-28 11:57:57 -04:00
Donal McBreen 69e8cd7885 Merge pull request #225 from basecamp/security/ssrf-ipv6-transition-guard
Block IPv6 addresses that reach internal IPs in the unfurl guard
2026-07-21 12:34:38 +01:00
Donal McBreen c0cade51a1 Address Copilot review: require ipaddr and re-assert the socket port
The guard uses IPAddr but relied on something else loading it first;
require it explicitly. And the rebinding tests lost their port assertion
when the matchers were loosened for newer Net::HTTP keyword args, so
check the port alongside the IP again.
2026-07-21 11:53:37 +01:00
Donal McBreen 80fdd44622 Remove IPv4 ranges the IPAddr predicates already cover
The RFC1918, loopback, and link-local ranges in DISALLOWED_IPV4
duplicated the private?/loopback?/link_local? checks that run right
before the list scan, so they could never be the deciding factor. Keep
only the ranges the predicates don't catch.
2026-07-21 11:37:17 +01:00
Donal McBreen 4cfcc2a370 Re-check the IPv4 embedded in local-use NAT64 instead of blocking outright
Matches the fizzy guard: the local-use NAT64 prefix (64:ff9b:1::/48,
RFC 8215) embeds an IPv4 target in its low 32 bits just like the
well-known prefix, so run it through the same embedded-IPv4 recheck.
Local-use NAT64 to a public address now resolves (keeping unfurls
working for self-hosters on such networks) while local-use NAT64 to an
internal address stays blocked.
2026-07-20 17:13:18 +01:00
Donal McBreen 9085adcbb3 Block local-use NAT64 and IPv6 benchmarking ranges
The local-use NAT64 prefix (64:ff9b:1::/48, RFC 8215) embeds an IPv4
target like the well-known prefix does, but at a deployment-chosen
position we can't extract, so block the whole range outright.

Also block the IPv6 benchmarking range (2001:2::/48, RFC 5180) to match
the IPv4 benchmarking block on 198.18.0.0/15.
2026-07-20 16:49:40 +01:00
Donal McBreen 0de302c977 Fix unfurl rebinding tests for newer Ruby
Net::HTTP now passes an open_timeout: option to TCPSocket.open, so the mock
that matched exact positional arguments no longer matches. Match on the
host instead.
2026-07-20 16:20:15 +01:00
Donal McBreen 9fb419e469 Block IPv6 addresses that reach internal IPs in the unfurl guard
The guard blocked the usual private, loopback, and link-local ranges (and
the IPv4-mapped/-compatible IPv6 forms), but let through NAT64, 6to4, and
Teredo addresses, which can point at an internal IPv4, and CGNAT.

Now it pulls the IPv4 out of a NAT64 address and checks that (so NAT64 to a
public site still works), blocks 6to4 and Teredo outright, and adds the
missing IPv4 and IPv6 ranges.
2026-07-20 16:20:15 +01:00
Stanko K.R. df5ed25e14 Recommend the latest tag over main 2026-07-16 21:17:17 +02:00
Stanko K.R. 048ef90ae7 Add the missing development guide 2026-07-16 21:17:17 +02:00
Mike Dalessio ea994053ea Bump sqlite3 to 2.9.5 (#222)
to resolve security advisories
2026-07-16 02:13:05 -04:00
Mike Dalessio 018a0b5dd9 Bump websocket-driver to 0.8.2
Conservative lockfile-only bump clearing four advisories affecting
websocket-driver < 0.8.2: GHSA-2x63-gw47-w4mm, CVE-2026-54463,
CVE-2026-54464, CVE-2026-54465.

Notes: https://github.com/faye/websocket-driver-ruby/blob/main/CHANGELOG.md
2026-07-16 01:52:48 -04:00
Mike Dalessio 38741c53f1 Upgrade loofah to 2.25.2 and rails-html-sanitizer to 1.7.1 (#221)
Coordinated security releases; 1.7.1 requires loofah >= 2.25.2.

- https://github.com/flavorjones/loofah/releases/tag/v2.25.2
- https://github.com/rails/rails-html-sanitizer/releases/tag/v1.7.1
2026-07-16 00:51:35 -04:00
Stanko K.R. cf0a67fcaf Add a development guide 2026-07-15 13:43:14 +02:00
Stanko K.R. e945d4217b Explain how we tag docker images 2026-07-15 13:39:42 +02:00
Stanko K.R. cccfb8f774 Add backup and restore instructions 2026-07-15 13:34:09 +02:00
Stanko K.R. 55fbf16629 Improve self-hosting instructions 2026-07-15 13:29:13 +02:00
40 changed files with 1006 additions and 72 deletions

No files matched your search

+13
View File
@@ -67,6 +67,17 @@ jobs:
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}" CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT" echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT"
- name: Compute app version
id: version
shell: bash
run: |
set -eu
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
else
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- name: Extract Docker metadata (tags, labels) with arch suffix - name: Extract Docker metadata (tags, labels) with arch suffix
id: meta id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
@@ -94,6 +105,8 @@ jobs:
build-args: | build-args: |
OCI_SOURCE=${{ env.SOURCE_URL }} OCI_SOURCE=${{ env.SOURCE_URL }}
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }} OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
APP_VERSION=${{ steps.version.outputs.version }}
GIT_REVISION=${{ github.sha }}
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+8 -8
View File
@@ -201,7 +201,7 @@ GEM
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.24.1) loofah (2.25.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.0)
@@ -278,8 +278,8 @@ GEM
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.1)
loofah (~> 2.21) loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails_autolink (1.1.8) rails_autolink (1.1.8)
actionview (> 3.1) actionview (> 3.1)
@@ -367,10 +367,10 @@ GEM
rack-protection (= 4.2.1) rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3) rack-session (>= 2.0.0, < 3)
tilt (~> 2.0) tilt (~> 2.0)
sqlite3 (2.7.3-aarch64-linux-gnu) sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.7.3-arm64-darwin) sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.7.3-x86_64-darwin) sqlite3 (2.9.5-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu) sqlite3 (2.9.5-x86_64-linux-gnu)
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.8) stringio (3.1.8)
@@ -397,7 +397,7 @@ GEM
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.0) websocket-driver (0.8.2)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
+32 -34
View File
@@ -11,51 +11,49 @@ expect, including:
- @mentions - @mentions
- API, with support for bot integrations - API, with support for bot integrations
## Deploying with Docker ## Running your own Campfire instance
Campfire's Docker image contains everything needed for a fully-functional, Campfire's Docker image contains everything needed for a fully-functional,
single-machine deployment. This includes the web app, background jobs, caching, single-machine deployment. This includes the web app, background jobs, caching,
file serving, and SSL. file serving, and SSL. You can use our pre-built image at
`ghcr.io/basecamp/once-campfire:latest`, or build your own from this repo.
To persist storage of the database and file attachments, map a volume to `/rails/storage`. ### Deploying with ONCE
To configure additional features, you can set the following environment variables: The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once).
It will guide you through the initial set up and then keep your instance up to date automatically.
- `SSL_DOMAIN` - enable automatic SSL via Let's Encrypt for the given domain name If you don't already have `once` installed, run this on the machine you want to run Campfire on:
- `DISABLE_SSL` - alternatively, set `DISABLE_SSL` to serve over plain HTTP
- `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` - set these to a valid keypair to
allow sending Web Push notifications. You can generate a new keypair by running
`/script/admin/create-vapid-key`
- `SENTRY_DSN` - to enable error reporting to sentry in production, supply your
DSN here
For example: ```sh
curl https://get.once.com | sh
```
docker build -t campfire . `once` will launch as soon as the install is finished.
docker run \ Choose Campfire from the list of applications, follow the instructions, and ONCE will take care of the rest.
--publish 80:80 --publish 443:443 \
--restart unless-stopped \
--volume campfire:/rails/storage \
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
--env TLS_DOMAIN=chat.example.com \
campfire
## Running in development If you prefer the command line to the dashboard, you can deploy directly:
bin/setup ```sh
bin/rails server once deploy ghcr.io/basecamp/once-campfire --host chat.example.com
```
## Worth Noting ### Deploying with Docker
When you start Campfire for the first time, you’ll be guided through If you'd rather run the Docker image yourself, you can read more about that in the [self-hosting guide](docs/self-hosting.md).
creating an admin account.
The email address of this admin account will be shown on the login page
so that people who forget their password know who to contact for help.
(You can change this email later in the settings)
Campfire is single-tenant: any rooms designated "public" will be accessible by > [!TIP]
all users in the system. To support entirely distinct groups of customers, you > When you start Campfire for the first time, you'll be guided through a wizard to create an admin account.
would deploy multiple instances of the application. > The email address that you enter for the admin account will be visible on the sign-in page, it's there so
> that people have someone to contact if they need help with their account. If that bothers you, put in any
> email address you want and create yourself a new admin account.
## Development
You are welcome - and encouraged - to modify Campfire to your liking.
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
## Security
See [SECURITY.md](SECURITY.md) for how to report a vulnerability and a description of our trust model.
+22
View File
@@ -0,0 +1,22 @@
# Security
Report vulnerabilities through our [HackerOne program](https://hackerone.com/basecamp) or at
https://github.com/basecamp/once-campfire/security. Note that Campfire is in scope for our security
program, but is not bounty eligible.
## Trust model
Campfire is self-hosted and single-tenant, so the administrator is the server operator,
with shell, network, and database access already. Anything requiring the administrator
role grants nothing they do not already have, and is not a vulnerability.
We do want reports of anything a **non-administrator** can reach, and of any credential or
network path held by the Campfire process but not by the operator's own shell.
## Intentional behavior
Bot webhook URLs are unrestricted: an administrator can point one at any address,
including internal ones, because operators legitimately wire bots to their own services.
Link unfurling is different because any member can trigger it by pasting a URL, so it
validates destinations through `RestrictedHTTP::PrivateNetworkGuard`. The difference is who
picks the destination.
@@ -0,0 +1,13 @@
# Prepended onto Turbo::StreamsChannel. The subscriber names the channel it wants, so
# authorizing room messages only in RoomMessagesChannel would leave the stock channel as
# a way around it: same signed stream name, no membership check. Turn those names away
# here and RoomMessagesChannel becomes the only door.
module RoomStreamsAreAuthorized
def subscribed
if RoomMessagesChannel.guarded_stream?(verified_stream_name_from_params)
reject
else
super
end
end
end
+55
View File
@@ -0,0 +1,55 @@
# Authorizes the room message stream when the subscription is made, so that revoking a
# membership actually stops delivery.
#
# Turbo's stock channel verifies only the signature on the stream name. That name carries
# no expiry and no binding to a user, so one harvested while a member keeps working after
# the membership is gone. Reconnecting makes it worse rather than better: revoking a
# membership disconnects the user with reconnect: true, and the client then replays its
# subscriptions on the fresh socket.
#
# The room is derived from the verified stream name rather than taken as a parameter, so
# there's nothing for a subscriber to point somewhere else. The subscriber also doesn't
# get to choose the channel: Turbo::StreamsChannel turns these stream names away, so this
# is the only way onto them. See config/initializers/turbo_streams_authorization.rb.
class RoomMessagesChannel < ApplicationCable::Channel
extend Turbo::Streams::StreamName
include Turbo::Streams::StreamName::ClassMethods
STREAM_SUFFIX = "messages"
class << self
# True for the stream names this channel exists to guard, whoever is asking.
def guarded_stream?(stream_name)
stream_name.to_s.split(":", 2).second == STREAM_SUFFIX
end
def subscribable_room(user, stream_name)
gid_param, suffix = stream_name.to_s.split(":", 2)
if suffix == STREAM_SUFFIX && room = room_from(gid_param)
user.rooms.find_by(id: room.id)
end
end
private
def room_from(gid_param)
GlobalID::Locator.locate gid_param, only: Room
rescue ActiveRecord::RecordNotFound
nil
end
end
def subscribed
if stream_name = authorized_stream_name
stream_from stream_name
else
reject
end
end
private
def authorized_stream_name
stream_name = verified_stream_name_from_params
stream_name if stream_name.present? && self.class.subscribable_room(current_user, stream_name)
end
end
+8 -1
View File
@@ -1,5 +1,12 @@
class UnreadRoomsChannel < ApplicationCable::Channel class UnreadRoomsChannel < ApplicationCable::Channel
# Scoped per user, like ReadRoomsChannel. A single global stream would tell every
# authenticated connection when any room on the account is active, including closed
# rooms and direct conversations they're not part of.
def self.stream_name_for(user_id)
"user_#{user_id}_unreads"
end
def subscribed def subscribed
stream_from "unread_rooms" stream_from self.class.stream_name_for(current_user.id)
end end
end end
+4 -8
View File
@@ -8,9 +8,8 @@ class Accounts::LogosController < ApplicationController
if stale?(etag: Current.account) if stale?(etag: Current.account)
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
if Current.account&.logo&.attached? if (logo_variant = Current.account&.logo_variant(logo_size))
logo = Current.account.logo.variant(logo_variant).processed send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
else else
send_stock_icon send_stock_icon
end end
@@ -23,9 +22,6 @@ class Accounts::LogosController < ApplicationController
end end
private private
LARGE_SQUARE_PNG_VARIANT = { resize_to_limit: [ 512, 512 ], format: :png }
SMALL_SQUARE_PNG_VARIANT = { resize_to_limit: [ 192, 192 ], format: :png }
def send_png_file(path) def send_png_file(path)
send_file path, content_type: "image/png", disposition: :inline send_file path, content_type: "image/png", disposition: :inline
end end
@@ -38,8 +34,8 @@ class Accounts::LogosController < ApplicationController
end end
end end
def logo_variant def logo_size
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT small_logo? ? :small : :large
end end
def small_logo? def small_logo?
@@ -42,6 +42,12 @@ class Rooms::ClosedsController < RoomsController
@room = @room.becomes!(Rooms::Closed) @room = @room.becomes!(Rooms::Closed)
end end
# Open and closed rooms convert into each other, so both are in reach here. Direct
# rooms never are: converting one would let its creator revise who's in it.
def room_scope
Current.user.rooms.without_directs
end
def grantees def grantees
User.where(id: grantee_ids) User.where(id: grantee_ids)
end end
+6 -1
View File
@@ -29,8 +29,13 @@ class Rooms::DirectsController < RoomsController
end end
end end
# All users in a direct room can administer it # All users in a direct room can administer it. Only direct rooms, though: this
# relaxation is why room_scope below has to keep every other type out of reach.
def ensure_can_administer def ensure_can_administer
true true
end end
def room_scope
Current.user.rooms.directs
end
end end
@@ -40,6 +40,12 @@ class Rooms::OpensController < RoomsController
@room = @room.becomes!(Rooms::Open) @room = @room.becomes!(Rooms::Open)
end end
# Open and closed rooms convert into each other, so both are in reach here. Direct
# rooms never are: promoting one would republish its history to the whole account.
def room_scope
Current.user.rooms.without_directs
end
def broadcast_create_room(room) def broadcast_create_room(room)
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room } broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
end end
+7 -1
View File
@@ -20,13 +20,19 @@ class RoomsController < ApplicationController
private private
def set_room def set_room
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id]) if room = room_scope.find_by(id: params[:room_id] || params[:id])
@room = room @room = room
else else
redirect_to root_url, alert: "Room not found or inaccessible" redirect_to root_url, alert: "Room not found or inaccessible"
end end
end end
# Subclasses narrow this to the room types they're allowed to act on, so that one
# room namespace can't be used to reach another's rooms.
def room_scope
Current.user.rooms
end
def ensure_can_administer def ensure_can_administer
head :forbidden unless Current.user.can_administer?(@room) head :forbidden unless Current.user.can_administer?(@room)
end end
+1 -4
View File
@@ -9,8 +9,7 @@ class Users::AvatarsController < ApplicationController
if stale?(etag: @user) if stale?(etag: @user)
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
if @user.avatar.attached? if (avatar_variant = @user.avatar_variant)
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
send_webp_blob_file avatar_variant.key send_webp_blob_file avatar_variant.key
elsif @user.bot? elsif @user.bot?
render_default_bot render_default_bot
@@ -26,8 +25,6 @@ class Users::AvatarsController < ApplicationController
end end
private private
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
def send_webp_blob_file(key) def send_webp_blob_file(key)
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
end end
+9 -1
View File
@@ -1,6 +1,14 @@
class Account < ApplicationRecord class Account < ApplicationRecord
include Joinable include Joinable
has_one_attached :logo has_one_attached :logo do |attachable|
attachable.variant :large, resize_to_limit: [ 512, 512 ], format: :png
attachable.variant :small, resize_to_limit: [ 192, 192 ], format: :png
end
has_json :settings, restrict_room_creation_to_administrators: false has_json :settings, restrict_room_creation_to_administrators: false
def logo_variant(size)
logo.variant(size).processed if logo.variable?
end
end end
+10 -1
View File
@@ -1,10 +1,19 @@
module Message::Broadcasts module Message::Broadcasts
def broadcast_create def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ] broadcast_append_to room, :messages, target: [ room, :messages ]
ActionCable.server.broadcast("unread_rooms", { roomId: room.id }) broadcast_unread_room
end end
def broadcast_remove def broadcast_remove
broadcast_remove_to room, :messages broadcast_remove_to room, :messages
end end
private
# Fanned out to the room's members rather than published on one global stream, so
# that the timing of activity in a room only reaches people who are in it.
def broadcast_unread_room
room.memberships.pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
end
end
end end
+11
View File
@@ -22,6 +22,8 @@ class Room < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :creator, class_name: "User", default: -> { Current.user }
validate :direct_rooms_keep_their_type, on: :update
scope :opens, -> { where(type: "Rooms::Open") } scope :opens, -> { where(type: "Rooms::Open") }
scope :closeds, -> { where(type: "Rooms::Closed") } scope :closeds, -> { where(type: "Rooms::Closed") }
scope :directs, -> { where(type: "Rooms::Direct") } scope :directs, -> { where(type: "Rooms::Direct") }
@@ -65,6 +67,15 @@ class Room < ApplicationRecord
end end
private private
# Open and closed rooms convert into each other freely. A direct room can't become
# either: its participants agreed to a private conversation, not to one whose
# audience someone else gets to widen afterwards.
def direct_rooms_keep_their_type
if type_changed? && type_was == "Rooms::Direct"
errors.add :type, "can't be changed for a direct room"
end
end
def unread_memberships(message) def unread_memberships(message)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current) memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end end
+7 -1
View File
@@ -2,7 +2,9 @@ module User::Avatar
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
has_one_attached :avatar has_one_attached :avatar do |attachable|
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
end
end end
class_methods do class_methods do
@@ -14,4 +16,8 @@ module User::Avatar
def avatar_token def avatar_token
signed_id(purpose: :avatar) signed_id(purpose: :avatar)
end end
def avatar_variant
avatar.variant(:square).processed if avatar.variable?
end
end end
+2
View File
@@ -24,6 +24,8 @@ class Webhook < ApplicationRecord
Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload } Net::HTTP::Post.new(uri, "Content-Type" => "application/json").tap { |request| request.body = payload }
end end
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
# and operators legitimately point bots at their own internal services.
def http def http
Net::HTTP.new(uri.host, uri.port).tap do |http| Net::HTTP.new(uri.host, uri.port).tap do |http|
http.use_ssl = (uri.scheme == "https") http.use_ssl = (uri.scheme == "https")
+1 -1
View File
@@ -17,7 +17,7 @@
<%= render partial: "messages/message", collection: @messages, cached: true %> <%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %> <% end %>
<%= turbo_stream_from @room, :messages %> <%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
<%= button_to_jump_to_newest_message %> <%= button_to_jump_to_newest_message %>
<% end %> <% end %>
@@ -0,0 +1,3 @@
Rails.application.config.to_prepare do
Turbo::StreamsChannel.prepend RoomStreamsAreAuthorized
end
+1 -1
View File
@@ -1,2 +1,2 @@
Rails.application.config.app_version = ENV.fetch("APP_VERSION", "0") Rails.application.config.app_version = ENV["APP_VERSION"].presence || ENV["GIT_REVISION"].presence || "0"
Rails.application.config.git_revision = ENV["GIT_REVISION"] Rails.application.config.git_revision = ENV["GIT_REVISION"]
+9
View File
@@ -0,0 +1,9 @@
# Disable unfuzzed libvips operations.
#
# To block loaders we need to call `Vips.block` after Rails and image_processing set their
# defaults. Force the order of operations by autoloading the file now.
ActiveStorage::Transformers::Vips
Vips.block_untrusted(true)
Vips.block("VipsForeignLoadOpenslide", true) # prevent sqlite segfault in forked parallel workers
Rails.application.config.active_storage.variable_content_types -=
%w[ image/bmp image/vnd.microsoft.icon image/vnd.adobe.photoshop ]
+69
View File
@@ -0,0 +1,69 @@
## Development
### Setting up
First, get everything installed and configured with:
```sh
bin/setup
```
This installs the system packages Campfire needs (SQLite, ffmpeg), the right Ruby version (via [mise](https://mise.jdx.dev)), and the app's gems; prepares the database; and starts Redis (in a Docker container called `campfire-redis`, if it isn't already running locally).
If you want to start over at any point, run:
```sh
bin/setup --reset
```
### Running the server
Start the development server with:
```sh
bin/dev
```
You'll be able to access the app at http://localhost:3000.
On first run you'll be guided through creating your admin account, and you can sign in with that account from then on.
Note that Campfire needs Redis (for Action Cable, caching, and background jobs), so if you've restarted your machine or stopped the container, `docker start campfire-redis` will bring it back.
### Web Push notifications
Campfire uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables:
- `VAPID_PRIVATE_KEY`
- `VAPID_PUBLIC_KEY`
You can generate a fresh pair (along with a secret key base, which you can ignore in development) by running:
```sh
script/admin/generate-secrets
```
### Running tests
Run the unit tests with:
```sh
bin/rails test
```
And the browser-based system tests with:
```sh
bin/rails test:system
```
Before pushing your changes, you can run the full CI suite locally - style checks, security audits, and all the tests - with a single command:
```sh
bin/ci
```
### Contributing
You are welcome - and encouraged - to modify Campfire to your liking.
If you'd like to contribute your changes back, please read our [contributing guide](../CONTRIBUTING.md) first.
+213
View File
@@ -0,0 +1,213 @@
## Self-hosting Campfire
Campfire's Docker image contains everything needed for a fully-functional, single-machine deployment.
This includes the web app, background jobs, caching, file serving, and SSL.
> [!TIP]
> The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once), which handles installation, updates, and backups for you. See the [README](../README.md#deploying-with-once) for details. This guide covers running the Docker image by hand.
We recommend using `ghcr.io/basecamp/once-campfire:latest`, which always points to the most recent tagged release - the most stable and battle-tested version of Campfire.
We provide a tagged release for every major, minor and patch version of Campfire, so you can also pin your deployment to a specific version if you want to avoid unexpected changes. For example:
```bash
# exactly version 1.4.4
ghcr.io/basecamp/once-campfire:1.4.4
# any 1.4.x version
# e.g. 1.4.4 or 1.4.5
# the last number is usually changed for bug fixes
ghcr.io/basecamp/once-campfire:1.4
# any 1.x version
# e.g. 1.4.4 or 1.5.0
# the middle number is usually changed for changes to, or addition of, features
ghcr.io/basecamp/once-campfire:1
```
If you want to live on the bleeding edge, the `main` tag tracks the main release branch instead.
It changes with every merged pull request, so it's the newest - but least battle-tested - version of Campfire.
To run it you'll need three things:
1. a machine that runs Docker
2. a mounted volume (so that your database and file attachments are kept around between restarts)
3. some environment variables for configuration
If you'd rather build the image yourself from your own copy of the source, you can do that too:
```sh
docker build -t campfire .
```
### Mounting a storage volume
Campfire keeps all of its storage - the database and uploaded file attachments - inside the path `/rails/storage`.
By default Docker containers don't persist storage between runs, so you'll want to mount a persistent volume into that location.
The simplest way to do this is with the `--volume` flag with `docker run`. For example:
```sh
docker run --volume campfire:/rails/storage ghcr.io/basecamp/once-campfire:latest
```
That will create a named volume (called `campfire`) and mount it into the correct path.
Docker will manage where that volume is actually stored on your server.
You can also specify the data location yourself, mount a network drive, and more.
Check the Docker documentation to find out more about what's available.
### Configuring with environment variables
To configure your Campfire installation, you can use environment variables.
At a minimum you'll want to configure your secret key and your SSL domain.
#### Secrets
Campfire needs a few secret values that are specific to your instance:
- `SECRET_KEY_BASE` - the basis for cryptographic features like signed cookies. This should be a long, unguessable random string.
- `VAPID_PRIVATE_KEY`/`VAPID_PUBLIC_KEY` - a key pair used for sending Web Push notifications.
You can generate them by running:
```sh
docker run --rm ghcr.io/basecamp/once-campfire:latest script/admin/generate-secrets
```
It prints a fresh set of values ready to set as environment variables:
```
SECRET_KEY_BASE=...
VAPID_PRIVATE_KEY=...
VAPID_PUBLIC_KEY=...
```
Keep them safe and reuse the same values across restarts and upgrades - changing them later will invalidate sessions and push notification subscriptions.
#### SSL
If you want the Campfire container to handle its own SSL (HTTPS) automatically (via Let's Encrypt), you just need to specify the domain name that you're running it on.
You can do that with the `TLS_DOMAIN` environment variable.
> [!NOTE]
> If you're using SSL, you'll want to allow traffic on ports 80 and 443.
So if you were running on `chat.example.com` you could enable SSL like this:
```sh
docker run --publish 80:80 --publish 443:443 --env TLS_DOMAIN=chat.example.com ...
```
If you are terminating SSL in some other proxy in front of Campfire, or aren't using SSL at all (for example, if you want to run it locally on your laptop), then you should set `DISABLE_SSL=true` instead and just publish port 80:
```sh
docker run --publish 80:80 --env DISABLE_SSL=true ...
```
#### Error reporting (optional)
To enable error reporting to Sentry in production, supply your DSN in the `SENTRY_DSN` environment variable.
To disable Sentry initialization entirely, set `SKIP_TELEMETRY=true`.
### Example
Putting it all together, here's a complete `docker run` invocation:
```sh
docker run \
--name campfire \
--publish 80:80 --publish 443:443 \
--restart unless-stopped \
--volume campfire:/rails/storage \
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
--env TLS_DOMAIN=chat.example.com \
ghcr.io/basecamp/once-campfire:latest
```
And here's an equivalent `docker-compose.yml` that you could use to run Campfire via `docker compose up`:
```yaml
services:
web:
image: ghcr.io/basecamp/once-campfire:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- SECRET_KEY_BASE=abcdefabcdef
- TLS_DOMAIN=chat.example.com
- VAPID_PRIVATE_KEY=myvapidprivatekey
- VAPID_PUBLIC_KEY=myvapidpublickey
volumes:
- campfire:/rails/storage
volumes:
campfire:
```
### First run
When you start Campfire for the first time, you'll be guided through creating an admin account.
> [!TIP]
> The email address of this admin account will be shown on the login page so that people who forget their password know who to contact for help.
> (You can change this email later in the settings.)
Campfire is single-tenant: any rooms designated "public" will be accessible by all users in the system.
To support entirely distinct groups of customers, you would deploy multiple instances of the application.
### Upgrading
All of Campfire's state lives in the mounted volume, so upgrading is a matter of pulling a newer image and recreating the container:
```sh
docker pull ghcr.io/basecamp/once-campfire:latest
```
Any pending database migrations run automatically when the container boots.
### Backups
To back up your instance, back up the contents of the `/rails/storage` volume.
Because the SQLite database may be written to at any moment, you shouldn't copy its files directly while Campfire is running.
Instead, first run `script/admin/prepare-backup` inside the running container to produce a consistent snapshot of the database (it's written to `storage/backups/` inside the volume):
```sh
docker exec campfire script/admin/prepare-backup
```
(If you're using Docker Compose, replace `docker exec campfire` with `docker compose exec web`)
Then archive the whole storage volume to a file on the host:
```sh
docker run --rm \
--user root \
--volume campfire:/rails/storage \
--volume "$PWD":/backup \
ghcr.io/basecamp/once-campfire:latest \
tar czf "/backup/campfire-backup.tar.gz" -C /rails storage
```
This gives you a `campfire-backup.tar.gz` in your current directory containing the database snapshot and all uploaded files.
Copy it somewhere safe, ideally off the machine.
To restore, extract the archive back into a (stopped) instance's volume, and replace the live database with the snapshot:
```sh
docker run --rm \
--user root \
--volume campfire:/rails/storage \
--volume "$PWD":/backup \
ghcr.io/basecamp/once-campfire:latest \
bash -c "tar xzf /backup/campfire-backup.tar.gz -C /rails &&
cp /rails/storage/backups/production.sqlite3 /rails/storage/db/production.sqlite3 &&
rm -f /rails/storage/db/production.sqlite3-wal /rails/storage/db/production.sqlite3-shm &&
chown -R rails:rails /rails/storage"
```
Then start Campfire again.
+54 -3
View File
@@ -1,3 +1,4 @@
require "ipaddr"
require "resolv" require "resolv"
module RestrictedHTTP module RestrictedHTTP
@@ -6,7 +7,32 @@ module RestrictedHTTP
module PrivateNetworkGuard module PrivateNetworkGuard
extend self extend self
LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network # IPv4 special-use ranges (RFC 5735/6890) not already covered by the
# private?/loopback?/link_local? predicates in #disallowed_ipv4?.
DISALLOWED_IPV4 = %w[
0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24
198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
].map { |cidr| IPAddr.new(cidr) }.freeze
# IPv6 special-use ranges not caught by the predicates. 6to4 (2002::/16) and
# Teredo (2001::/32) are deprecated transition mechanisms with no legitimate
# fetch target, so they are blocked outright. ULA (fc00::/7, incl. the AWS
# IMDSv6 address fd00:ec2::254), link-local, and loopback are covered by the
# predicates in #disallowed_ipv6?.
DISALLOWED_IPV6 = %w[
::/128 100::/64 2001::/32 2001:2::/48 2001:db8::/32 2002::/16
fec0::/10 ff00::/8
].map { |cidr| IPAddr.new(cidr) }.freeze
# NAT64 prefixes: the well-known prefix (RFC 6052/6146) and the local-use
# prefix (RFC 8215). An address here embeds an IPv4 target in its low 32
# bits; we extract it and re-check against the IPv4 rules so NAT64 to a
# public address still resolves while NAT64 to an internal address is
# blocked.
NAT64_PREFIXES = [
IPAddr.new("64:ff9b::/96"),
IPAddr.new("64:ff9b:1::/48")
].freeze
def resolve(hostname) def resolve(hostname)
Resolv.getaddress(hostname).tap do |ip| Resolv.getaddress(hostname).tap do |ip|
@@ -15,11 +41,36 @@ module RestrictedHTTP
end end
def private_ip?(ip) def private_ip?(ip)
IPAddr.new(ip).then do |ipaddr| ipaddr = IPAddr.new(ip)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr)
# DNS never legitimately returns these embedded forms, so block them all
# regardless of the address they wrap.
if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat?
true
elsif ipaddr.ipv4?
disallowed_ipv4?(ipaddr)
elsif NAT64_PREFIXES.any? { |prefix| prefix.include?(ipaddr) }
disallowed_ipv4?(embedded_ipv4(ipaddr))
else
disallowed_ipv6?(ipaddr)
end end
rescue IPAddr::InvalidAddressError rescue IPAddr::InvalidAddressError
true true
end end
private
def disallowed_ipv4?(ipaddr)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
DISALLOWED_IPV4.any? { |range| range.include?(ipaddr) }
end
def disallowed_ipv6?(ipaddr)
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) }
end
def embedded_ipv4(ipaddr)
IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join("."))
end
end end
end end
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env ruby
require "bundler/setup"
require "securerandom"
require "web_push"
vapid_key = WebPush.generate_key
puts "SECRET_KEY_BASE=#{SecureRandom.hex(64)}"
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
@@ -0,0 +1,86 @@
require "test_helper"
class RoomMessagesChannelTest < ActionCable::Channel::TestCase
tests RoomMessagesChannel
setup do
@room = rooms(:designers)
@signed_stream_name = Turbo::StreamsChannel.signed_stream_name [ @room, :messages ]
end
test "a member may subscribe to a room's message stream" do
stub_connection(current_user: users(:kevin))
subscribe signed_stream_name: @signed_stream_name
assert subscription.confirmed?
assert_has_stream Turbo.signed_stream_verifier.verified(@signed_stream_name)
end
test "a user who was never a member may not subscribe" do
stub_connection(current_user: users(:bender))
subscribe signed_stream_name: @signed_stream_name
assert subscription.rejected?
end
test "a revoked member may not re-subscribe with a stream name harvested while a member" do
stub_connection(current_user: users(:kevin))
subscribe signed_stream_name: @signed_stream_name
assert subscription.confirmed?, "kevin must start out able to subscribe"
@room.memberships.revoke_from users(:kevin)
subscribe signed_stream_name: @signed_stream_name
assert subscription.rejected?
end
test "an unsigned stream name is rejected" do
stub_connection(current_user: users(:kevin))
subscribe signed_stream_name: Turbo.signed_stream_verifier.verified(@signed_stream_name)
assert subscription.rejected?
end
test "a missing stream name is rejected" do
stub_connection(current_user: users(:kevin))
subscribe
assert subscription.rejected?
end
test "a validly signed stream name for another room the user isn't in is rejected" do
stub_connection(current_user: users(:bender))
subscribe signed_stream_name: Turbo::StreamsChannel.signed_stream_name([ rooms(:hq), :messages ])
assert subscription.rejected?
end
end
class RoomMessagesViaStockTurboChannelTest < ActionCable::Channel::TestCase
tests Turbo::StreamsChannel
# The subscriber picks the channel, so the stock channel has to turn these names away
# too. Otherwise a revoked member just names Turbo::StreamsChannel instead.
test "the stock turbo channel refuses to serve a room message stream" do
stub_connection(current_user: users(:kevin))
subscribe signed_stream_name: Turbo::StreamsChannel.signed_stream_name([ rooms(:designers), :messages ])
assert subscription.rejected?
end
test "the stock turbo channel still serves the room list stream" do
stub_connection(current_user: users(:kevin))
subscribe signed_stream_name: Turbo::StreamsChannel.signed_stream_name([ :rooms ])
assert subscription.confirmed?
end
end
@@ -0,0 +1,47 @@
require "test_helper"
class UnreadRoomsChannelTest < ActionCable::Channel::TestCase
test "streams only the subscriber's own unread stream" do
stub_connection(current_user: users(:jz))
subscribe
assert subscription.confirmed?
assert_has_stream "user_#{users(:jz).id}_unreads"
assert_not_includes subscription.streams, "unread_rooms"
end
test "an outsider is not told about activity in a room they can't see" do
direct = rooms(:bender_and_kevin)
assert_not direct.users.include?(users(:jz)), "jz must be an outsider for this test to mean anything"
broadcasts = capture_unread_broadcasts_for(users(:jz)) do
direct.messages.create!(body: "Private", creator: users(:kevin), client_message_id: "outsider").broadcast_create
end
assert_empty broadcasts
end
test "a member is told about activity in their own room" do
direct = rooms(:bender_and_kevin)
broadcasts = capture_unread_broadcasts_for(users(:kevin)) do
direct.messages.create!(body: "Private", creator: users(:bender), client_message_id: "member").broadcast_create
end
assert_equal [ direct.id ], broadcasts.collect { |broadcast| broadcast["roomId"] }
end
private
def capture_unread_broadcasts_for(user)
stub_connection(current_user: user)
subscribe
stream = subscription.streams.sole
before = ActionCable.server.pubsub.broadcasts(stream).size
yield
ActionCable.server.pubsub.broadcasts(stream).drop(before).collect { |broadcast| JSON.parse(broadcast) }
end
end
@@ -30,6 +30,13 @@ class Accounts::LogosControllerTest < ActionDispatch::IntegrationTest
assert_valid_png_response size: 192 assert_valid_png_response size: 192
end end
test "show stock when custom logo cannot be resized" do
accounts(:signal).update! logo: fixture_file_upload("pixel.bmp", "image/bmp")
get account_logo_url
assert_valid_png_response size: 512
end
test "destroy" do test "destroy" do
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg") accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
+16 -3
View File
@@ -57,9 +57,22 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
end end
end end
test "creating a message broadcasts unread room" do test "creating a message broadcasts unread room to each member" do
assert_broadcasts "unread_rooms", 1 do @room.users.each do |member|
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } } 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
end end
@@ -66,6 +66,18 @@ class Rooms::ClosedsControllerTest < ActionDispatch::IntegrationTest
assert rooms(:designers).reload.name, "Designers" assert rooms(:designers).reload.name, "Designers"
end end
test "a direct room can't be converted to closed and have its participants revised" do
sign_in :kevin
direct = rooms(:bender_and_kevin)
put rooms_closed_url(direct), params: {
room: { name: "Watercooler" }, user_ids: [ users(:kevin).id, users(:jz).id ]
}
assert_equal "Rooms::Direct", Room.find(direct.id).type
assert_equal [ users(:bender).id, users(:kevin).id ].sort, Room.find(direct.id).user_ids.sort
end
test "remove yourself" do test "remove yourself" do
assert_difference -> { users(:david).rooms.count }, -1 do assert_difference -> { users(:david).rooms.count }, -1 do
put rooms_closed_url(rooms(:designers), params: { room: { name: "Designers" }, user_ids: [ users(:jason).id, users(:jz).id ] }) put rooms_closed_url(rooms(:designers), params: { room: { name: "Designers" }, user_ids: [ users(:jason).id, users(:jz).id ] })
@@ -29,4 +29,34 @@ class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_url assert_redirected_to root_url
end end
end end
test "destroy can't reach a closed room the member didn't create" do
sign_in :kevin
assert_no_difference -> { Room.count } do
delete rooms_direct_url(rooms(:designers))
end
assert rooms(:designers).reload.persisted?
end
test "destroy can't reach an open room the member didn't create" do
sign_in :kevin
assert_no_difference -> { Room.count } do
delete rooms_direct_url(rooms(:hq))
end
assert rooms(:hq).reload.persisted?
end
test "destroy can't reach a room the member isn't in at all" do
sign_in :jz
assert_no_difference -> { Room.count } do
delete rooms_direct_url(rooms(:david_and_kevin))
end
assert rooms(:david_and_kevin).reload.persisted?
end
end end
@@ -57,4 +57,23 @@ class Rooms::OpensControllerTest < ActionDispatch::IntegrationTest
put rooms_open_url(rooms(:designers)), params: { room: { name: "Doesn't matter" } } put rooms_open_url(rooms(:designers)), params: { room: { name: "Doesn't matter" } }
assert_equal rooms(:designers).memberships.count, User.count assert_equal rooms(:designers).memberships.count, User.count
end end
test "a direct room can't be promoted to open by its creator" do
sign_in :kevin
direct = rooms(:bender_and_kevin)
put rooms_open_url(direct), params: { room: { name: "Watercooler" } }
assert_equal "Rooms::Direct", Room.find(direct.id).type
assert_equal [ users(:bender).id, users(:kevin).id ].sort, Room.find(direct.id).user_ids.sort
end
test "a direct room can't be promoted to open by an administrator either" do
direct = rooms(:david_and_kevin)
put rooms_open_url(direct), params: { room: { name: "Watercooler" } }
assert_equal "Rooms::Direct", Room.find(direct.id).type
assert_equal [ users(:david).id, users(:kevin).id ].sort, Room.find(direct.id).user_ids.sort
end
end end
@@ -18,6 +18,14 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
assert_equal "image/webp", @response.content_type assert_equal "image/webp", @response.content_type
end end
test "show initials when image cannot be resized" do
users(:kevin).update! avatar: fixture_file_upload("pixel.bmp", "image/bmp")
get user_avatar_url(users(:kevin).avatar_token)
assert_response :success
assert_select "text", text: "K"
end
test "show image with invalid token responds 404" do test "show image with invalid token responds 404" do
get user_avatar_url("not-a-valid-token") get user_avatar_url("not-a-valid-token")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 B

@@ -64,6 +64,50 @@ class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase
assert_private_ip "::93.184.216.34" assert_private_ip "::93.184.216.34"
end end
test "private_ip? returns true for carrier-grade NAT addresses (RFC6598)" do
assert_private_ip "100.64.0.1"
assert_private_ip "100.127.255.255"
end
test "private_ip? returns true for NAT64 addresses embedding a private IPv4" do
assert_private_ip "64:ff9b::a9fe:a9fe" # NAT64 -> 169.254.169.254 (AWS metadata)
assert_private_ip "64:ff9b::a00:5" # NAT64 -> 10.0.0.5
end
test "private_ip? returns true for local-use NAT64 addresses embedding a private IPv4 (RFC8215)" do
assert_private_ip "64:ff9b:1::a00:1" # local-use NAT64 -> 10.0.0.1
end
test "private_ip? returns false for local-use NAT64 addresses embedding a public IPv4 (RFC8215)" do
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b:1::808:808") # -> 8.8.8.8
end
test "private_ip? returns false for NAT64 addresses embedding a public IPv4" do
# DNS64 legitimately synthesizes these for public sites on IPv6-only hosts.
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("64:ff9b::808:808") # -> 8.8.8.8
end
test "private_ip? returns true for 6to4 and Teredo transition addresses" do
assert_private_ip "2002:a9fe:a9fe::" # 6to4 embedding 169.254.169.254
assert_private_ip "2001::1" # Teredo
end
test "private_ip? returns true for IPv6 loopback, ULA, and link-local" do
assert_private_ip "::1"
assert_private_ip "fd00:ec2::254" # AWS IMDSv6 (ULA)
assert_private_ip "fe80::1"
end
test "private_ip? returns true for IPv6 multicast, documentation, and benchmarking ranges" do
assert_private_ip "ff02::1"
assert_private_ip "2001:db8::1"
assert_private_ip "2001:2::1" # benchmarking (RFC5180), matches 198.18.0.0/15
end
test "private_ip? returns false for public IPv6 addresses" do
assert_not RestrictedHTTP::PrivateNetworkGuard.private_ip?("2606:4700:4700::1111")
end
test "private_ip? returns true for invalid addresses" do test "private_ip? returns true for invalid addresses" do
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip") assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip")
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("") assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("")
+118
View File
@@ -0,0 +1,118 @@
require "test_helper"
require "vips"
require "tempfile"
# libvips selects a loader from a file's actual bytes, not from its declared content type. These
# tests pin which loader is selected for each file type under the app's configured loader policy
# (config/initializers/vips.rb).
class VipsLoaderPolicyTest < ActiveSupport::TestCase
# Header bytes are enough for libvips to identify a format; native types are encoded live, exotic
# ones are represented by their magic bytes.
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
BMP = "BM" + [ 0, 0, 54 ].pack("V3") + "\x00" * 40
PSD = "8BPS" + [ 1 ].pack("n") + "\x00" * 26
ICO = "\x00\x00\x01\x00\x01\x00" + "\x00" * 16
SVG = %q(<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>)
test "loads PNG" do
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
end
test "loads GIF" do
assert_equal "VipsForeignLoadNsgifFile", loader_for(encode("gif"))
end
test "loads JPEG" do
assert_equal "VipsForeignLoadJpegFile", loader_for(encode("jpg"))
end
test "loads TIFF" do
assert_equal "VipsForeignLoadTiffFile", loader_for(encode("tif"))
end
test "loads WebP" do
assert_equal "VipsForeignLoadWebpFile", loader_for(encode("webp"))
end
test "loads AVIF" do
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_AVIF)
end
test "loads HEIC" do
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
end
test "denies BMP through magickload" do
assert_nil loader_for(BMP)
end
test "denies PSD through magickload" do
assert_nil loader_for(PSD)
end
test "denies ICO through magickload" do
assert_nil loader_for(ICO)
end
test "denies SVG through svgload" do
assert_nil loader_for(SVG)
end
test "denies OpenSlide files through openslideload" do
# OpenSlide files can segfault the embedded sqlite in forked parallel workers
assert_loader_blocked :openslideload, ".svs"
end
test "denies FITS files through fitsload" do
assert_loader_blocked :fitsload, ".fits"
end
test "denies MATLAB files through matload" do
assert_loader_blocked :matload, ".mat"
end
test "denies NIFTI files through niftiload" do
assert_loader_blocked :niftiload, ".nii"
end
test "denies RAW files through dcrawload" do
assert_loader_blocked :dcrawload, ".raw"
end
test "denies VIPS files through vipsload" do
assert_loader_blocked :vipsload, ".vips"
end
private
# Invoke a specific libvips loader directly and assert it is refused because the
# operation is blocked (rather than because the bytes are not a valid image).
def assert_loader_blocked(operation, extension)
Tempfile.create([ "blocked_loader", extension ], binmode: true) do |file|
file.write "not an image"
file.flush
error = assert_raises(Vips::Error) { Vips::Image.public_send(operation, file.path) }
actual = error.message.chomp
# note that exception message may include multiple errors on separate lines,
# so `^` and `$` anchors are used instead of `\A` and `\z`.
if actual =~ /^VipsOperation: class \"#{operation}\" not found$/
skip "libvips does not support #{operation} on this system"
end
assert_match(/^#{operation}: operation is blocked$/, actual)
end
end
def encode(ext)
Vips::Image.black(8, 8).add(128).cast("uchar").write_to_buffer(".#{ext}")
end
def loader_for(bytes)
Tempfile.create(%w[loader_probe .img], binmode: true) do |file|
file.write bytes
file.flush
Vips.vips_foreign_find_load(file.path)
end
end
end
+16
View File
@@ -15,4 +15,20 @@ class AccountTest < ActiveSupport::TestCase
accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" }) accounts(:signal).update!(settings: { "restrict_room_creation_to_administrators" => "false" })
assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators? assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
end end
test "logo_variant is a resized variant of a variable logo" do
accounts(:signal).logo.attach io: file_fixture("moon.jpg").open, filename: "moon.jpg", content_type: "image/jpeg"
assert_kind_of ActiveStorage::VariantWithRecord, accounts(:signal).logo_variant(:large)
end
test "logo_variant is nil when the logo cannot be resized" do
accounts(:signal).logo.attach io: file_fixture("pixel.bmp").open, filename: "pixel.bmp", content_type: "image/bmp"
assert_nil accounts(:signal).logo_variant(:large)
end
test "logo_variant is nil without a logo" do
assert_nil accounts(:signal).logo_variant(:large)
end
end end
+4 -4
View File
@@ -49,8 +49,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
# to a resolved IP, not a hostname to re-resolve. # to a resolved IP, not a hostname to re-resolve.
WebMock.disable_net_connect! allow: [ @url.host ] WebMock.disable_net_connect! allow: [ @url.host ]
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1") Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound) TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
assert_throws :dns_not_rebound do assert_throws :dns_not_rebound do
@fetch.fetch_document(@url) @fetch.fetch_document(@url)
@@ -66,8 +66,8 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
# to a resolved IP, not a hostname to re-resolve. # to a resolved IP, not a hostname to re-resolve.
WebMock.disable_net_connect! allow: [ @url.host ] WebMock.disable_net_connect! allow: [ @url.host ]
Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1") Resolv.stubs(:getaddress).with(@url.host).returns("1.2.3.4", "127.0.0.1")
TCPSocket.expects(:open).with(@url.host, 443, nil, nil).never TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
TCPSocket.expects(:open).with("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound) TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
assert_throws :dns_not_rebound do assert_throws :dns_not_rebound do
@fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4") @fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")
+19
View File
@@ -0,0 +1,19 @@
require "test_helper"
class User::AvatarTest < ActiveSupport::TestCase
test "avatar_variant is a resized variant of a variable avatar" do
users(:kevin).avatar.attach io: file_fixture("moon.jpg").open, filename: "moon.jpg", content_type: "image/jpeg"
assert_kind_of ActiveStorage::VariantWithRecord, users(:kevin).avatar_variant
end
test "avatar_variant is nil when the avatar cannot be resized" do
users(:kevin).avatar.attach io: file_fixture("pixel.bmp").open, filename: "pixel.bmp", content_type: "image/bmp"
assert_nil users(:kevin).avatar_variant
end
test "avatar_variant is nil without an avatar" do
assert_nil users(:kevin).avatar_variant
end
end