Compare commits

...
Author SHA1 Message Date
dependabot[bot] 24c21cf112 build(deps): bump turbo-rails from 30cd8fc to 37530c0
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from `30cd8fc` to `37530c0`.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3...37530c08780fa6f6dbb56a633de4b81169bdd174)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 37530c08780fa6f6dbb56a633de4b81169bdd174
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-22 04:52:03 +00:00
Jeremy Daer 9310b002d7 Drop the unsupported semver cooldown keys from the docker ecosystem (#251)
The docker block copied bundler's cooldown wholesale, but Dependabot only
accepts semver-major/minor/patch-days for ecosystems whose versions it
classifies as semver, and container tags aren't. One invalid property
invalidates the entire file rather than the block it sits in, so since
this config landed in #248 the version updater has not run for any
ecosystem at all:

  Your .github/dependabot.yml contained invalid details
  The property '#/updates/2/cooldown/semver-major-days' is not supported
  for the package ecosystem 'docker'. (and -minor-, -patch-)

That is why #249's cooldown exclude for brakeman never took effect, and
why #250 had to bump the workflow linter pins by hand while every other
repo got a Dependabot PR. It also left `bin/brakeman --ensure-latest 15`
armed with nothing to disarm it: the lock pins brakeman 8.0.6, and CI
would have gone red roughly 15 days after 8.0.7 shipped.

Security updates were never affected — those don't read this file, which
is why the only four Dependabot runs here are single-gem security bumps.

docker keeps default-days, which is supported for every ecosystem.
2026-08-21 21:49:40 -07:00
Jeremy Daer dfd2dd2bca Bump actionlint and zizmor-action to current releases (#250)
Dependabot version updates are disabled for this repo, so the weekly
github-actions group never proposed these. The pins had been frozen since
March: zizmor-action v0.5.2 ships zizmor 1.23.1, which flags
secrets-outside-env at the default persona -- that moved to the auditor
persona in zizmor 1.24.0. v0.6.2 ships zizmor 1.29.0.

Both linters pass locally at the new versions.
2026-08-20 03:08:26 -07:00
Jeremy Daer c860b51109 Take the SSRF address policy from surfguard instead of keeping our own copy (#241)
* Take the SSRF address policy from surfguard instead of keeping our own copy

Four other apps carried this same classification and the five had drifted into
four different ideas of what "internal" means. It now comes from the surfguard
gem, which is their union. resolve and Violation keep their shapes, so the
opengraph callers are unchanged.

Two verdicts change.

SIIT (::ffff:0:0:0/96) is now recognised. It is the third way an IPv4 address
rides inside an IPv6 one and the only one Ruby has no predicate for --
ipv4_mapped?, ipv4_compat?, private?, loopback? and link_local? are all false
for ::ffff:0:a9fe:a9fe, so it fell through to the IPv6 branch unrecognised and
reached the metadata endpoint. Note the extra group: ::ffff:0:0:0/96 is not the
IPv4-mapped ::ffff:0:0/96 the guard already refused, and the two do not overlap.

The RFC 8215 local-use NAT64 block is now refused whole rather than decoded.
Reading its low 32 bits as an embedded IPv4 is only correct for a /96 Pref64;
the block can host any length from /32 to /96 and the position is not
recoverable from the address alone (RFC 6052 2.2), so the decode reads the
wrong octets. It is never globally routed, so refusing it costs nothing. The
well-known /96 is still decoded and re-checked, so DNS64 for public sites on
IPv6-only hosts keeps working.

Resolution moves from Resolv.getaddress to Resolv.getaddresses, so the guard
sees every address a host answers with rather than only the first.

* Distinguish a DNS lookup failure from a private-IP block in the guard

Advance the surfguard pin so resolve_public_ips raises Unresolvable when a
host resolves to nothing and returns an empty list only when it resolves to a
blocked address. The shim lets Unresolvable propagate as a lookup failure --
matching the old Resolv.getaddress behavior -- and reserves Violation for a
resolved-but-blocked address, so a transient DNS miss is no longer reported as
an SSRF attempt.
2026-08-20 01:59:19 -07:00
Jeremy Daer 487125dc4c Bump brakeman to 8.0.6 and stop --ensure-latest reddening CI (#249)
* Bump brakeman to 8.0.6

Brakeman 8.0.6 shipped 2026-08-12: corrected Rails 8.0 EOL date, added
Rails 8.1 and Ruby 4.0 EOL dates, and fixed command-injection false
positives.

brakeman's only runtime dependency is racc and its required ruby is
>= 3.2.0, both unchanged since 7.1.2, so this is a version bump with no
other movement in the graph.

* Give brakeman's --ensure-latest a 15-day grace period

Bare --ensure-latest exits 5 the moment a newer brakeman exists, so a
release turns this build red before anyone has a chance to react. That
is what happened on 2026-08-12 when 8.0.6 shipped.

The flag takes an optional minimum age in days and only complains once
the latest release is at least that old. 15 is the maximum it accepts;
brakeman rejects anything outside 1-15.

* Exempt brakeman from the dependabot cooldown

The grace period on --ensure-latest is only headroom if the bump lands
inside it. A weekly schedule plus a 7-day cooldown can take 14 days to
so much as open the PR, leaving a single day to merge it.

Excluding brakeman from the cooldown caps the delay at the weekly
schedule, comfortably inside the 15 days.
2026-08-19 09:27:49 -07:00
Jeremy Daer e33b7e50c9 Update mail to 2.9.1 for GHSA-mvxr-6m87-mv2q (#248)
The advisory (email address spoofing via malformed RFC 2047
encoded-words, CVE-2026-63435) landed in ruby-advisory-db on
2026-08-18, turning the gem audit red since the audit pulls a live
advisory DB. 2.9.1 is the patch release for exactly this CVE.
2026-08-18 16:47:08 -07:00
Mike Dalessio f1aec61336 dep: update sqlite3 to 2.9.6 (#243) 2026-08-12 10:03:33 -04:00
Stanko Krtalić baeccebd90 Merge pull request #238 from basecamp/dependabot/bundler/json-2.21.2
Bump json from 2.20.0 to 2.21.2
2026-08-11 14:41:14 +02:00
Stanko Krtalić 7ab769a9f9 Merge pull request #242 from basecamp/feat/bot-boost-update
Allow bots to delete their own boosts
2026-08-11 14:40:45 +02:00
Stanko KrtalićandCopilot Autofix powered by AI 1e9f681b3a Scope boosts to the current message
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-11 14:33:05 +02:00
Stanko K.R. 656215b186 Allow bots to delete their own boosts 2026-08-11 14:13:01 +02:00
Stanko Krtalić 39bab76e38 Merge pull request #239 from ronaldlokers/feat/bot-message-update
Allow bots to update and destroy their own messages
2026-08-11 13:45:27 +02:00
Stanko K.R. e8251401ce Adjust to match in-house style
- Remove comments that explain expected behaviour
- Use respond_to instead of separate methods
- Return the updated object on update
2026-08-11 13:42:48 +02:00
Ronald Lokers 3ca1dcbf77 Allow bots to update and destroy their own messages
Bots can only create. A lifecycle notification — an alert that fires and then
resolves, a deploy that starts and finishes, a backup that runs — therefore has
to post a second message, and the room becomes an append-only log of states
rather than a view of the current one.

Adds PATCH and DELETE inside the existing bot_key scope, routed to
Messages::ByBotsController. The body is read the way create reads it, so
updating a message is the same request shape as posting one.

No new authorization: both actions already run through ensure_can_administer,
and can_administer? grants access only to a record the user created, so a bot
key reaches that bot's own messages and no others. set_room narrows it again by
looking the room up through the bot's own memberships. A leaked bot key gains
what it could already do by posting: write to rooms that bot belongs to.

update answers head :ok rather than the redirect, which meant extracting the
update and its broadcast into update_message — calling super and then head
would double render, since the parent redirects inside the action. destroy
needs no split, because the parent renders implicitly like create does.
2026-08-11 13:33:01 +02:00
Stanko Krtalić 766bffae56 Merge pull request #190 from jpshackelford/feature/bot-read-messages
feat: Add bot API endpoints for reading messages and adding reactions
2026-08-11 13:24:19 +02:00
Stanko K.R. 80c9e7fbfe Adjust to the in-house style
- Use jbuilder instead of hashes
- Use resource instead of direct HTTP verbs
    Verbs only make sense if you have one or two routes, if there are
    multiple that emulate what resource does then it's better to use resource.
- Paginate using link headers
- Cache responses
2026-08-11 13:15:38 +02:00
Jeremy Daer d3f22d78e1 Harden message content filtering + bump thruster to 0.1.23 (#237)
* Bump thruster 0.1.15 → 0.1.23

* Scrub disallowed attributes on allowed tags in message rendering

SanitizeTags removes disallowed tags from message presentation, but
attributes on the tags it allows passed through untouched. Extend the
content-filter chain with a SanitizeAttributes filter that runs Rails'
safe-list sanitizer over the remaining markup, stripping event-handler
attributes and unsafe URI schemes as defense-in-depth alongside the
existing Content-Security-Policy.

Running it after SanitizeTags with the same allowed-tags list makes the
sanitizer's tag pass a no-op, preserving SanitizeTags' remove-not-unwrap
semantics while scrubbing attributes. The attribute allowlist is the
standard ActionText set (which keeps attachments intact) plus class,
which presentation styling relies on.
2026-08-10 15:55:02 -07:00
dependabot[bot] 6ed9331a97 Bump json from 2.20.0 to 2.21.2
Bumps [json](https://github.com/ruby/json) from 2.20.0 to 2.21.2.
- [Release notes](https://github.com/ruby/json/releases)
- [Changelog](https://github.com/ruby/json/blob/master/CHANGES.md)
- [Commits](https://github.com/ruby/json/compare/v2.20.0...v2.21.2)

---
updated-dependencies:
- dependency-name: json
  dependency-version: 2.21.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 20:17:26 +00:00
Stanko K.R. 2aa4141077 Port over automatic note generation 2026-08-04 17:13:07 +02:00
Stanko Krtalić 27e9257b2a Merge pull request #231 from basecamp/once-backup-hooks
Add ONCE backup and restore hooks
2026-08-04 17:11:19 +02:00
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
Kevin McConnell 2d749057f9 Add ONCE backup and restore hooks
By default, ONCE pauses the application container when taking a backup.
This guarantees a consistent snapshot of the data, but for a large
installation and/or a slow destination path (like a network mount), that
pause could be a bit disruptive to live traffic.

But ONCE also support a pause-free path: if a `pre-backup` hook is
present in the container, ONCE will run that and skip the pause. So by
supplying necessary hooks, we can avoid any disruption from backups.

- `pre-backup` runs the existing script/admin/prepare-backup, which uses
  SQLite's online backup API to get a consistent snapshop.

- `post-restore` moves our snaphotted file back into place, and removes
  the orphaned sidecar files
2026-08-03 12:41:01 +01: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
John-Mason Shackelfordandopenhands 20ab5b1bc6 feat: Add bot API endpoint for adding reactions (boosts) to messages
Adds a new endpoint that allows bots to add emoji reactions (boosts) to messages:
POST /rooms/:room_id/:bot_key/messages/:message_id/boosts

This enables bots to acknowledge messages with reactions like 👀 (eyes) when
mentioned, providing immediate feedback to users before generating a full response.

The endpoint:
- Validates the bot is a member of the room
- Validates the message exists in the room
- Broadcasts the boost to connected clients via Turbo Streams
- Returns 201 Created on success, 404 if room/message not found

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 18:53:03 -04:00
John-Mason Shackelfordandopenhands e8c1349aac style: Use bot? predicate instead of role comparison
Changed `message.creator.role == "bot"` to `message.creator.bot?` per
Copilot code review. This is the idiomatic Rails pattern for enum checks
and consistent with how other role checks are done in the codebase.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 14:51:21 -04:00
John-Mason Shackelfordandopenhands c82a8d38c0 fix: Use correct room messages in pagination tests
Fixed test bug identified by Copilot code review: pagination tests were
using Message.first and Message.last which could return messages from
different rooms than the test's @room (watercooler).

Now explicitly using messages(:fourth) and messages(:thirteenth) which
are fixtures belonging to the watercooler room where bender bot is a member.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 14:45:02 -04:00
John-Mason Shackelfordandopenhands 057d56513e fix: Align create and index to both return 404 for non-member rooms
Both create and index now return HTTP 404 Not Found when a bot tries to
access a room it's not a member of. This is consistent with REST API
security best practices (not revealing resource existence) and ensures
read and write permissions are handled identically.

Changed create action to no longer call super (which rendered HTML) and
instead directly handle the request with proper JSON API error responses.

Added test to verify create returns 404 for non-member rooms.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 13:59:56 -04:00
John-Mason Shackelfordandopenhands 5340f3da01 fix: Ensure bot can only read messages from rooms it is a member of
Added explicit RecordNotFound handling to return 404 when a bot tries to
read messages from a room it's not a member of. This matches the security
model used by the create action.

Added tests to verify:
- Bot gets 404 when trying to read from room it's not a member of
- Bot can successfully read from room it IS a member of

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 13:56:02 -04:00
John-Mason Shackelfordandopenhands d4a56784e0 feat: Add bot API endpoint for reading room messages
Adds a new GET endpoint at /rooms/:room_id/:bot_key/messages that allows
bots to read messages from rooms they are members of.

The endpoint returns JSON with:
- Room info (id, name)
- Messages array with body (plain/html), created_at, and creator info
- Pagination info (oldest_id, newest_id, has_more)

Supports pagination via ?before=:id and ?after=:id query parameters,
consistent with the existing pagination in the messages controller.

This enables AI bots and other automated agents to understand conversation
context when responding to messages, rather than only receiving the single
message that triggered the webhook.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 13:53:17 -04:00
66 changed files with 1352 additions and 114 deletions

No files matched your search

+2 -3
View File
@@ -20,13 +20,12 @@ updates:
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
exclude:
- "brakeman"
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
cooldown:
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
+2 -2
View File
@@ -60,10 +60,10 @@ jobs:
persist-credentials: false
- name: Run actionlint
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
- name: Run zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
with:
advanced-security: false
+13
View File
@@ -67,6 +67,17 @@ jobs:
CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}"
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
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
@@ -94,6 +105,8 @@ jobs:
build-args: |
OCI_SOURCE=${{ env.SOURCE_URL }}
OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }}
APP_VERSION=${{ steps.version.outputs.version }}
GIT_REVISION=${{ github.sha }}
platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+3
View File
@@ -66,6 +66,9 @@ ENV HTTP_WRITE_TIMEOUT=300
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
COPY --from=build --chown=rails:rails /rails /rails
# Install ONCE backup/restore hooks
COPY --chmod=755 hooks /hooks
# Set version and revision
ARG APP_VERSION
ENV APP_VERSION=$APP_VERSION
+1
View File
@@ -41,6 +41,7 @@ gem "rails_autolink"
gem "geared_pagination"
gem "jbuilder"
gem "net-http-persistent"
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
gem "kredis"
gem "platform_agent"
gem "thruster"
+45 -34
View File
@@ -1,8 +1,14 @@
GIT
remote: https://github.com/basecamp/surfguard.git
revision: 910be917fd0ab782c5ea939698d44f26694a501d
specs:
surfguard (0.1.0)
GIT
remote: https://github.com/hotwired/turbo-rails.git
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
revision: 37530c08780fa6f6dbb56a633de4b81169bdd174
specs:
turbo-rails (2.0.16)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
@@ -136,8 +142,8 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.5.0)
bigdecimal (3.3.1)
brakeman (8.0.5)
bigdecimal (4.1.2)
brakeman (8.0.6)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -153,7 +159,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.8)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -164,7 +170,7 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.3)
erb (6.0.4)
erb (6.0.7)
erubi (1.13.1)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
@@ -178,20 +184,21 @@ GEM
globalid (1.3.0)
activesupport (>= 6.1)
hashdiff (1.2.0)
i18n (1.14.7)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.3)
io-console (0.9.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.20.0)
json (2.21.2)
jwt (3.2.0)
base64
kredis (1.8.0)
@@ -204,7 +211,7 @@ GEM
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
@@ -215,7 +222,9 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.26.2)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
mono_logger (1.1.2)
@@ -251,18 +260,15 @@ GEM
platform_agent (1.0.1)
activesupport (>= 5.2.0)
useragent (~> 0.16.3)
pp (0.6.3)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.4.0)
psych (5.2.6)
date
stringio
prism (1.9.0)
public_suffix (6.0.2)
puma (7.2.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
rack (3.2.7)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
@@ -272,7 +278,7 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
@@ -286,10 +292,15 @@ GEM
activesupport (> 3.1)
railties (> 3.1)
rainbow (3.1.1)
rake (13.3.1)
rdoc (6.16.1)
rake (13.4.2)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
psych (>= 4.0.0)
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
@@ -298,7 +309,7 @@ GEM
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.11.2)
reline (0.6.3)
reline (0.7.0)
io-console (~> 0.5)
resque (2.7.0)
mono_logger (~> 1)
@@ -367,18 +378,17 @@ GEM
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.5-x86_64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.6-x86_64-darwin)
sqlite3 (2.9.6-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
thor (1.4.0)
thruster (0.1.15-aarch64-linux)
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
thor (1.5.0)
thruster (0.1.23-aarch64-linux)
thruster (0.1.23-arm64-darwin)
thruster (0.1.23-x86_64-darwin)
thruster (0.1.23-x86_64-linux)
tilt (2.6.1)
timeout (0.6.1)
tsort (0.2.0)
@@ -403,7 +413,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.3)
zeitwerk (2.8.3)
PLATFORMS
aarch64-linux
@@ -442,6 +452,7 @@ DEPENDENCIES
sentry-ruby
sqlite3
stimulus-rails
surfguard!
thruster
turbo-rails!
web-push
+4
View File
@@ -53,3 +53,7 @@ If you'd rather run the Docker image yourself, you can read more about that in t
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
# 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
stream_from "unread_rooms"
stream_from self.class.stream_name_for(current_user.id)
end
end
+4 -8
View File
@@ -8,9 +8,8 @@ class Accounts::LogosController < ApplicationController
if stale?(etag: Current.account)
expires_in 5.minutes, public: true, stale_while_revalidate: 1.week
if Current.account&.logo&.attached?
logo = Current.account.logo.variant(logo_variant).processed
send_png_file ActiveStorage::Blob.service.path_for(logo.key)
if (logo_variant = Current.account&.logo_variant(logo_size))
send_png_file ActiveStorage::Blob.service.path_for(logo_variant.key)
else
send_stock_icon
end
@@ -23,9 +22,6 @@ class Accounts::LogosController < ApplicationController
end
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)
send_file path, content_type: "image/png", disposition: :inline
end
@@ -38,8 +34,8 @@ class Accounts::LogosController < ApplicationController
end
end
def logo_variant
small_logo? ? SMALL_SQUARE_PNG_VARIANT : LARGE_SQUARE_PNG_VARIANT
def logo_size
small_logo? ? :small : :large
end
def small_logo?
@@ -0,0 +1,12 @@
module RawRequestBody
extend ActiveSupport::Concern
private
def raw_request_body
request.body.rewind
request.body.read.force_encoding("UTF-8")
ensure
request.body.rewind
end
end
@@ -0,0 +1,39 @@
class Messages::Boosts::ByBotsController < Messages::BoostsController
include RawRequestBody
allow_bot_access only: %i[ create destroy ]
before_action :ensure_content_present, only: :create
def create
@boost = @message.boosts.create!(boost_params)
broadcast_create
render :show, status: :created
end
private
def set_message
if room = Current.user.rooms.find_by(id: params[:room_id])
@message = room.messages.find_by(id: params[:message_id])
end
head :not_found unless @message
end
def set_boost
super
rescue ActiveRecord::RecordNotFound
head :not_found
end
def ensure_content_present
if raw_request_body.blank?
head :unprocessable_content
end
end
def boost_params
{ content: raw_request_body }
end
end
@@ -1,5 +1,6 @@
class Messages::BoostsController < ApplicationController
before_action :set_message
before_action :set_boost, only: :destroy
def index
end
@@ -15,7 +16,6 @@ class Messages::BoostsController < ApplicationController
end
def destroy
@boost = Current.user.boosts.find(params[:id])
@boost.destroy!
broadcast_remove
@@ -26,6 +26,10 @@ class Messages::BoostsController < ApplicationController
@message = Current.user.reachable_messages.find(params[:message_id])
end
def set_boost
@boost = @message.boosts.find_by!(id: params[:id], booster: Current.user)
end
def boost_params
params.require(:boost).permit(:content)
end
+49 -9
View File
@@ -1,24 +1,64 @@
class Messages::ByBotsController < MessagesController
allow_bot_access only: :create
include RawRequestBody
allow_bot_access only: %i[ index create update destroy ]
before_action :set_room
before_action :set_message, only: %i[ update destroy ]
before_action :ensure_can_administer, only: %i[ update destroy ]
before_action :ensure_body_or_attachment_present, only: :create
def index
@messages = find_paged_messages
set_pagination_headers
end
def create
super
head :created, location: message_url(@message)
end
def destroy
super
head :no_content
end
private
def set_room
@room = Current.user.rooms.find_by(id: params[:room_id])
head :not_found unless @room
end
def ensure_body_or_attachment_present
if params[:attachment].blank? && raw_request_body.blank?
head :unprocessable_content
end
end
def set_pagination_headers
headers["X-Total-Count"] = @room.messages.count.to_s
if next_page = next_page_params
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
end
end
def next_page_params
if @messages.any?
if params[:after].present?
{ after: @messages.last.id } if @room.messages.after(@messages.last).exists?
else
{ before: @messages.first.id } if @room.messages.before(@messages.first).exists?
end
end
end
def message_params
if params[:attachment]
params.permit(:attachment)
else
reading(request.body) { |body| { body: body } }
{ body: raw_request_body }
end
end
def reading(io)
io.rewind
yield io.read.force_encoding("UTF-8")
ensure
io.rewind
end
end
+5 -1
View File
@@ -37,7 +37,11 @@ class MessagesController < ApplicationController
@message.update!(message_params)
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
redirect_to room_message_url(@room, @message)
respond_to do |format|
format.html { redirect_to room_message_url(@room, @message) }
format.json { render :show }
end
end
def destroy
@@ -42,6 +42,12 @@ class Rooms::ClosedsController < RoomsController
@room = @room.becomes!(Rooms::Closed)
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
User.where(id: grantee_ids)
end
+6 -1
View File
@@ -29,8 +29,13 @@ class Rooms::DirectsController < RoomsController
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
true
end
def room_scope
Current.user.rooms.directs
end
end
@@ -40,6 +40,12 @@ class Rooms::OpensController < RoomsController
@room = @room.becomes!(Rooms::Open)
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)
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
end
+7 -1
View File
@@ -20,13 +20,19 @@ class RoomsController < ApplicationController
private
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
else
redirect_to root_url, alert: "Room not found or inaccessible"
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
head :forbidden unless Current.user.can_administer?(@room)
end
+1 -4
View File
@@ -9,8 +9,7 @@ class Users::AvatarsController < ApplicationController
if stale?(etag: @user)
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
if @user.avatar.attached?
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
if (avatar_variant = @user.avatar_variant)
send_webp_blob_file avatar_variant.key
elsif @user.bot?
render_default_bot
@@ -26,8 +25,6 @@ class Users::AvatarsController < ApplicationController
end
private
SQUARE_WEBP_VARIANT = { resize_to_limit: [ 512, 512 ], format: :webp }
def send_webp_blob_file(key)
send_file ActiveStorage::Blob.service.path_for(key), content_type: "image/webp", disposition: :inline
end
+1 -1
View File
@@ -1,3 +1,3 @@
module ContentFilters
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags, SanitizeAttributes)
end
@@ -0,0 +1,47 @@
class ContentFilters::SanitizeAttributes < ActionText::Content::Filter
def applicable?
true
end
# Scrub attributes on the tags that SanitizeTags allows, using Rails' safe-list
# sanitizer so unsafe URI schemes and event-handler attributes are stripped.
# Runs after SanitizeTags, so passing the same allowed tags makes the tag pass
# a no-op and only attributes are scrubbed.
def apply
sanitizer.sanitize fragment.to_html, tags: allowed_tags, attributes: allowed_attributes
end
private
# Presentation styling relies on class attributes (e.g. unfurled link embeds),
# which the standard ActionText set doesn't include.
EXTRA_ALLOWED_ATTRIBUTES = %w[ class ]
# ActionText::ContentHelper.sanitizer is a single process-wide instance, and
# rails-html-sanitizer reuses one mutable permit scrubber that it reconfigures
# from the tags/attributes on every #sanitize call. Sharing it here would race
# our stricter tag set against ActionText's default rendering on concurrent
# requests, so use a dedicated instance of the same sanitizer class to keep the
# scrubber isolated.
def sanitizer
sanitizer_class.new
end
def sanitizer_class
ActionText::ContentHelper.sanitizer.class
end
def allowed_tags
ContentFilters::SanitizeTags::ALLOWED_TAGS
end
def allowed_attributes
standard_allowed_attributes + EXTRA_ALLOWED_ATTRIBUTES
end
# Mirrors ActionText::ContentHelper#sanitizer_allowed_attributes, which isn't
# exposed at the module level.
def standard_allowed_attributes
ActionText::ContentHelper.allowed_attributes ||
(sanitizer_class.allowed_attributes + ActionText::Attachment::ATTRIBUTES).to_a
end
end
+9 -1
View File
@@ -1,6 +1,14 @@
class Account < ApplicationRecord
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
def logo_variant(size)
logo.variant(size).processed if logo.variable?
end
end
+10 -1
View File
@@ -1,10 +1,19 @@
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
broadcast_unread_room
end
def broadcast_remove
broadcast_remove_to room, :messages
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
+11
View File
@@ -22,6 +22,8 @@ class Room < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
validate :direct_rooms_keep_their_type, on: :update
scope :opens, -> { where(type: "Rooms::Open") }
scope :closeds, -> { where(type: "Rooms::Closed") }
scope :directs, -> { where(type: "Rooms::Direct") }
@@ -65,6 +67,15 @@ class Room < ApplicationRecord
end
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)
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
end
+7 -1
View File
@@ -2,7 +2,9 @@ module User::Avatar
extend ActiveSupport::Concern
included do
has_one_attached :avatar
has_one_attached :avatar do |attachable|
attachable.variant :square, resize_to_limit: [ 512, 512 ], format: :webp
end
end
class_methods do
@@ -14,4 +16,8 @@ module User::Avatar
def avatar_token
signed_id(purpose: :avatar)
end
def avatar_variant
avatar.variant(:square).processed if avatar.variable?
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 }
end
# No PrivateNetworkGuard, unlike Opengraph::Fetch: only an administrator sets this URL,
# and operators legitimately point bots at their own internal services.
def http
Net::HTTP.new(uri.host, uri.port).tap do |http|
http.use_ssl = (uri.scheme == "https")
+2 -1
View File
@@ -1,6 +1,7 @@
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<% cache message do %>
<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %>
<% cache [ message, "presentation-v2" ] do %>
<%= message_tag message do %>
<h2 class="message__day-separator"><%= local_datetime_tag message.created_at, style: :date %></h2>
+18
View File
@@ -0,0 +1,18 @@
json.cache! message do
json.(message, :id)
json.created_at message.created_at.utc
json.body do
json.plain_text message.plain_text_body
json.html message.body.to_s
end
json.creator message.creator, partial: "users/user", as: :user
json.room do
json.id message.room_id
end
json.url room_message_url(message.room, message)
end
@@ -0,0 +1,12 @@
json.cache! boost do
json.(boost, :id, :content)
json.created_at boost.created_at.utc
json.booster boost.booster, partial: "users/user", as: :user
json.message do
json.id boost.message_id
json.url room_message_url(boost.message.room, boost.message)
end
end
@@ -0,0 +1 @@
json.partial! "messages/boosts/boost", boost: @boost
@@ -0,0 +1 @@
json.array! @messages, partial: "messages/message", as: :message
@@ -0,0 +1 @@
json.partial! "messages/message", message: @message
+1 -1
View File
@@ -17,7 +17,7 @@
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
<%= turbo_stream_from @room, :messages %>
<%= turbo_stream_from @room, :messages, channel: "RoomMessagesChannel" %>
<%= button_to_jump_to_newest_message %>
<% end %>
+5
View File
@@ -0,0 +1,5 @@
json.cache! user do
json.(user, :id, :name, :role)
json.avatar_url fresh_user_avatar_url(user)
end
+1 -1
View File
@@ -2,6 +2,6 @@
require "rubygems"
require "bundler/setup"
ARGV.unshift("--ensure-latest")
ARGV.unshift("--ensure-latest", "15")
load Gem.bin_path("brakeman", "brakeman")
+17 -10
View File
@@ -15,6 +15,7 @@ include SSHKit::DSL
ARCH = "linux/amd64,linux/arm64"
BUILDER_NAME = "campfire-builder"
GIT_SHA = `git rev-parse HEAD`.strip
MANUAL_NOTES = ARGV.delete("--notes")
VERSION = ARGV[0]
DOCKER_IMAGE_NAME = "registry.once.com/campfire"
@@ -28,7 +29,7 @@ EXPORT_REMOTE_USER = "app"
EXPORT_DEPLOY_HOSTS = %w[ once-store-app-101 once-store-app-102 ]
EXPORT_DEPLOY_LOCATION = "/local/app/product_releases/"
abort "You must specify a version, e.g. `bin/release 1.0.0`" unless VERSION
abort "You must specify a version, e.g. `bin/release 1.0.0` (pass --notes to write release notes manually)" unless VERSION
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
if current_branch != "main"
@@ -57,19 +58,25 @@ on(:local) do
end
announcing "Creating GitHub release v#{VERSION}..." do
notes_file = "tmp/release_notes.md"
on(:local) do
execute :mkdir, "-p", "tmp"
if MANUAL_NOTES
notes_file = "tmp/release_notes.md"
File.write(notes_file, "# What's Changed\n\n- \n")
system ENV["EDITOR"] || "vim", notes_file
execute :mkdir, "-p", "tmp"
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
abort "Failed to create release; check that the version doesn't already exist"
File.write(notes_file, "# What's Changed\n\n- \n")
system ENV["EDITOR"] || "vim", notes_file
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--notes-file", notes_file
abort "Failed to create release; check that the version doesn't already exist"
end
execute :rm, notes_file
else
unless system "gh", "release", "create", "v#{VERSION}", "--title", "v#{VERSION}", "--generate-notes"
abort "Failed to create release; check that the version doesn't already exist"
end
end
execute :rm, notes_file
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"]
+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 ]
+7 -1
View File
@@ -62,7 +62,13 @@ Rails.application.routes.draw do
resources :rooms do
resources :messages
post ":bot_key/messages", to: "messages/by_bots#create", as: :bot_messages
nested do
scope path: ":bot_key", as: :bot, defaults: { format: :json } do
resources :messages, controller: "messages/by_bots", only: %i[ index create update destroy ] do
resources :boosts, controller: "messages/boosts/by_bots", only: %i[ create destroy ]
end
end
end
scope module: "rooms" do
resource :refresh, only: :show
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -euo pipefail
db_name="${RAILS_ENV:-production}.sqlite3"
snapshot="/rails/storage/backups/$db_name"
db="/rails/storage/db/$db_name"
if [ -f "$snapshot" ]; then
mkdir -p "$(dirname "$db")"
cp "$snapshot" "$db"
rm -f "$db-wal" "$db-shm"
fi
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exec /rails/script/admin/prepare-backup
+15 -11
View File
@@ -1,25 +1,29 @@
require "resolv"
require "surfguard"
module RestrictedHTTP
class Violation < StandardError; end
# The address policy lives in the surfguard gem so this app, Basecamp, HEY and
# Fizzy classify "internal" the same way instead of each keeping a copy that
# drifts. The callers here hand a bare hostname in and pin the address that
# comes back, so this stays a hostname-in, address-out shim.
module PrivateNetworkGuard
extend self
LOCAL_IP = IPAddr.new("0.0.0.0/8") # "This" network
# A hostname that resolves to nothing (NXDOMAIN, timeout, empty answer)
# raises Surfguard::Unresolvable, which we let propagate as a lookup failure
# -- the same way the old Resolv.getaddress guard raised Resolv::ResolvError,
# and the callers here already treat it as a fetch failure. The Violation is
# reserved for a host that resolves to a blocked address (an empty list back
# from resolve_public_ips), so a transient DNS miss is never misreported as
# an SSRF attempt.
def resolve(hostname)
Resolv.getaddress(hostname).tap do |ip|
raise Violation.new("Attempt to access private IP via #{hostname}") if ip && private_ip?(ip)
end
Surfguard.resolve_public_ips(hostname).first or
raise Violation.new("Attempt to access private IP via #{hostname}")
end
def private_ip?(ip)
IPAddr.new(ip).then do |ipaddr|
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr)
end
rescue IPAddr::InvalidAddressError
true
Surfguard.blocked_address?(ip)
end
end
end
@@ -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
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
accounts(:signal).update! logo: fixture_file_upload("moon.jpg", "image/jpeg")
@@ -0,0 +1,112 @@
require "test_helper"
class Messages::Boosts::ByBotsControllerTest < ActionDispatch::IntegrationTest
setup do
@room = rooms(:watercooler)
@message = messages(:fourth)
@bot = users(:bender)
end
test "create adds a boost to the message and returns it" do
assert_difference -> { @message.boosts.count }, +1 do
post room_bot_message_boosts_url(@room, @bot.bot_key, @message), params: +"👀"
assert_response :created
end
boost = @message.boosts.last
assert_equal "👀", boost.content
assert_equal @bot, boost.booster
json = JSON.parse(response.body)
assert_equal boost.id, json["id"]
assert_equal "👀", json["content"]
assert_equal @bot.id, json["booster"]["id"]
assert_equal @message.id, json["message"]["id"]
assert_equal room_message_url(@room, @message), json["message"]["url"]
end
test "create with text content" do
assert_difference -> { Boost.count }, +1 do
post room_bot_message_boosts_url(@room, @bot.bot_key, @message), params: +"Nice!"
assert_response :created
end
assert_equal "Nice!", @message.boosts.last.content
end
test "create broadcasts the boost" do
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
post room_bot_message_boosts_url(@room, @bot.bot_key, @message), params: +"👍"
end
end
test "create without content" do
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(@room, @bot.bot_key, @message)
assert_response :unprocessable_content
post room_bot_message_boosts_url(@room, @bot.bot_key, @message), params: +" "
assert_response :unprocessable_content
end
end
test "create requires a valid bot key" do
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(@room, "invalid-bot-key", @message), params: +"👀"
end
assert_response :redirect
end
test "create is not found for a room the bot is not a member of" do
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(rooms(:designers), @bot.bot_key, messages(:first)), params: +"👀"
end
assert_response :not_found
end
test "create is not found for a message outside the room" do
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(@room, @bot.bot_key, messages(:first)), params: +"👀"
end
assert_response :not_found
end
test "create can't be abused to post boosts as a regular user" do
bot_key = "#{users(:kevin).id}-"
assert_no_difference -> { Boost.count } do
post room_bot_message_boosts_url(@room, bot_key, @message), params: +"👀"
end
assert_response :redirect
end
test "destroy removes the bot's own boost" do
assert_difference -> { Boost.count }, -1 do
delete room_bot_message_boost_url(@room, @bot.bot_key, @message, boosts(:fourth_by_bender))
end
assert_response :no_content
end
test "destroy broadcasts the removal" do
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
delete room_bot_message_boost_url(@room, @bot.bot_key, @message, boosts(:fourth_by_bender))
end
end
test "destroy can't touch a boost the bot did not make" do
assert_no_difference -> { Boost.count } do
delete room_bot_message_boost_url(@room, @bot.bot_key, messages(:thirteenth), boosts(:thirteenth))
end
assert_response :not_found
end
test "destroy requires a valid bot key" do
assert_no_difference -> { Boost.count } do
delete room_bot_message_boost_url(@room, "invalid-bot-key", @message, boosts(:fourth_by_bender))
end
assert_response :redirect
end
end
@@ -1,6 +1,6 @@
require "test_helper"
class Messages::ByBotsControlleTest < ActionDispatch::IntegrationTest
class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
setup do
@room = rooms(:watercooler)
end
@@ -40,6 +40,16 @@ class Messages::ByBotsControlleTest < ActionDispatch::IntegrationTest
end
end
test "create without a body or attachment" do
assert_no_difference -> { Message.count } do
post room_bot_messages_url(@room, users(:bender).bot_key)
assert_response :unprocessable_content
post room_bot_messages_url(@room, users(:bender).bot_key), params: +" "
assert_response :unprocessable_content
end
end
test "create can't be abused to post messages as any user" do
user = users(:kevin)
bot_key = "#{user.id}-"
@@ -51,8 +61,175 @@ class Messages::ByBotsControlleTest < ActionDispatch::IntegrationTest
assert_response :redirect
end
test "denied index" do
get room_messages_url(@room, bot_key: users(:bender).bot_key, format: :json)
test "index returns the room's messages in the order they were sent" do
get room_bot_messages_url(@room, users(:bender).bot_key)
assert_response :success
json = JSON.parse(response.body)
assert_equal @room.messages.ordered.map(&:id), json.map { it["id"] }
end
test "index includes message details" do
post room_bot_messages_url(@room, users(:bender).bot_key), params: +"Hello from Bender!"
get room_bot_messages_url(@room, users(:bender).bot_key)
assert_response :success
message = Message.last
json_message = JSON.parse(response.body).last
assert_equal message.id, json_message["id"]
assert_equal "Hello from Bender!", json_message["body"]["plain_text"]
assert_includes json_message["body"]["html"], "Hello from Bender!"
assert_equal message.created_at.utc.iso8601(3), json_message["created_at"]
assert_equal users(:bender).id, json_message["creator"]["id"]
assert_equal "Bender Bot", json_message["creator"]["name"]
assert_equal "bot", json_message["creator"]["role"]
assert_equal @room.id, json_message["room"]["id"]
assert_equal room_message_url(@room, message), json_message["url"]
end
test "index pages through older messages with the Link header" do
(Message::PAGE_SIZE - @room.messages.count + 1).times do |i|
@room.messages.create!(body: "Filler #{i}", creator: users(:jason), client_message_id: "filler-#{i}")
end
get room_bot_messages_url(@room, users(:bender).bot_key)
assert_response :success
json = JSON.parse(response.body)
assert_equal Message::PAGE_SIZE, json.size
assert_equal "41", response.headers["X-Total-Count"]
assert_not_includes json.map { it["id"] }, messages(:fourth).id
get response.headers["Link"][/<(.*)>/, 1]
assert_response :success
json = JSON.parse(response.body)
assert_equal [ messages(:fourth).id ], json.map { it["id"] }
assert_nil response.headers["Link"]
end
test "index pages newer messages with after" do
get room_bot_messages_url(@room, users(:bender).bot_key, after: messages(:tenth).id)
assert_response :success
json = JSON.parse(response.body)
assert_equal %i[ eleventh twelfth thirteenth ].map { messages(it).id }, json.map { it["id"] }
assert_nil response.headers["Link"]
end
test "index in a room with no messages" do
get room_bot_messages_url(rooms(:bender_and_kevin), users(:bender).bot_key)
assert_response :success
assert_equal [], JSON.parse(response.body)
assert_equal "0", response.headers["X-Total-Count"]
assert_nil response.headers["Link"]
end
test "index requires a valid bot key" do
get room_bot_messages_url(@room, "invalid-bot-key")
assert_response :redirect
end
test "index is not found for a room the bot is not a member of" do
get room_bot_messages_url(rooms(:designers), users(:bender).bot_key)
assert_response :not_found
end
test "create is not found for a room the bot is not a member of" do
assert_no_difference -> { Message.count } do
post room_bot_messages_url(rooms(:designers), users(:bender).bot_key), params: +"Hello!"
end
assert_response :not_found
end
test "regular messages index remains denied for bots" do
get room_messages_url(@room, bot_key: users(:bender).bot_key)
assert_response :forbidden
end
test "update" do
message = post_bot_message "Deploying..."
assert_no_difference -> { Message.count } do
patch room_bot_message_url(@room, users(:bender).bot_key, message), params: +"Deployed."
end
assert_response :ok
assert_equal "Deployed.", message.reload.plain_text_body
json = JSON.parse(response.body)
assert_equal message.id, json["id"]
assert_equal "Deployed.", json["body"]["plain_text"]
assert_equal users(:bender).id, json["creator"]["id"]
assert_equal room_message_url(@room, message), json["url"]
end
test "update with UTF-8 content" do
message = post_bot_message "Deploying..."
patch room_bot_message_url(@room, users(:bender).bot_key, message), params: +"Deployed 🚀!"
assert_response :ok
assert_equal "Deployed 🚀!", message.reload.plain_text_body
assert_equal "Deployed 🚀!", JSON.parse(response.body)["body"]["plain_text"]
end
test "update can't touch a message the bot did not create" do
message = messages(:fourth)
original = message.plain_text_body
patch room_bot_message_url(@room, users(:bender).bot_key, message), params: +"Hijacked!"
assert_response :forbidden
assert_equal original, message.reload.plain_text_body
end
test "update is not found for a room the bot is not a member of" do
message = messages(:first)
original = message.plain_text_body
patch room_bot_message_url(rooms(:designers), users(:bender).bot_key, message), params: +"Hijacked!"
assert_response :not_found
assert_equal original, message.reload.plain_text_body
end
test "update can't be abused to edit messages as any user" do
message = messages(:fourth)
bot_key = "#{users(:jz).id}-"
original = message.plain_text_body
patch room_bot_message_url(@room, bot_key, message), params: +"Hijacked!"
assert_response :redirect
assert_equal original, message.reload.plain_text_body
end
test "destroy" do
message = post_bot_message "Deploying..."
assert_difference -> { Message.count }, -1 do
delete room_bot_message_url(@room, users(:bender).bot_key, message)
end
assert_response :no_content
end
test "destroy can't touch a message the bot did not create" do
message = messages(:fourth)
assert_no_difference -> { Message.count } do
delete room_bot_message_url(@room, users(:bender).bot_key, message)
end
assert_response :forbidden
end
private
def post_bot_message(body)
post room_bot_messages_url(@room, users(:bender).bot_key), params: +body
Message.last
end
end
+16 -3
View File
@@ -57,9 +57,22 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest
end
end
test "creating a message broadcasts unread room" do
assert_broadcasts "unread_rooms", 1 do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New one", client_message_id: 999 } }
test "creating a message broadcasts unread room to each member" do
@room.users.each do |member|
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
@@ -66,6 +66,18 @@ class Rooms::ClosedsControllerTest < ActionDispatch::IntegrationTest
assert rooms(:designers).reload.name, "Designers"
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
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 ] })
@@ -29,4 +29,34 @@ class Rooms::DirectsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_url
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
@@ -57,4 +57,23 @@ class Rooms::OpensControllerTest < ActionDispatch::IntegrationTest
put rooms_open_url(rooms(:designers)), params: { room: { name: "Doesn't matter" } }
assert_equal rooms(:designers).memberships.count, User.count
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
@@ -18,6 +18,14 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest
assert_equal "image/webp", @response.content_type
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
get user_avatar_url("not-a-valid-token")
+4
View File
@@ -6,3 +6,7 @@ thirteenth:
message: thirteenth
booster: jason
content: 💯
fourth_by_bender:
message: fourth
booster: bender
content: 👀
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 B

+69
View File
@@ -61,6 +61,75 @@ class ContentFiltersTest < ActionView::TestCase
assert_equal "Hello World", filtered.to_html
end
test "message with a link using an unsafe URI scheme" do
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match /javascript:/, filtered.to_html
assert_match /<a>x<\/a>/, filtered.to_html
end
test "message with an event handler attribute on an allowed tag" do
message = Message.create! room: rooms(:pets), body: '<div><a href="/x" onmouseover="alert(1)">x</a> <span onclick="alert(2)">y</span></div>', client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match /onmouseover/, filtered.to_html
assert_no_match /onclick/, filtered.to_html
assert_match /<a href="\/x">x<\/a>/, filtered.to_html
assert_match /<span>y<\/span>/, filtered.to_html
end
test "message with a data URI link" do
message = Message.create! room: rooms(:pets), body: '<div><a href="data:text/html,pwned">x</a></div>', client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_no_match /data:/, filtered.to_html
assert_match /<a>x<\/a>/, filtered.to_html
end
test "message with a safe link and formatting is preserved" do
body = '<div><a href="https://example.com">example</a> <strong>bold</strong> <code>code</code><ul><li>one</li><li>two</li></ul></div>'
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
assert_match /<a href="https:\/\/example\.com">example<\/a>/, filtered.to_html
assert_match /<strong>bold<\/strong>/, filtered.to_html
assert_match /<code>code<\/code>/, filtered.to_html
assert_match /<ul>\s*<li>one<\/li>\s*<li>two<\/li>\s*<\/ul>/, filtered.to_html
end
test "SanitizeAttributes uses an isolated sanitizer rather than the shared ActionText instance" do
filter = ContentFilters::SanitizeAttributes.new(ActionText::Content.new("<div>x</div>"))
isolated = filter.send(:sanitizer)
assert_not_same ActionText::ContentHelper.sanitizer, isolated,
"must not reuse the process-wide ActionText sanitizer, whose permit scrubber is mutated per call"
assert_not_same isolated, filter.send(:sanitizer),
"each call must build a fresh sanitizer so concurrent requests never share scrubber state"
assert_kind_of ActionText::ContentHelper.sanitizer.class, isolated
end
test "SanitizeAttributes neutralizes unsafe input and preserves benign content on its isolated sanitizer" do
body = <<~HTML.squish
<div><a href="javascript:alert(1)" onclick="x()">link</a>
<a href="data:text/html,pwned">data</a>
<span class="cf-twitter-avatar" onmouseover="y()">avatar</span>
<img src="https://evil.example/x.svg"> Hey #{mention_attachment_for(:david)}</div>
HTML
message = Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason)
filtered = ContentFilters::SanitizeAttributes.apply(message.body.body).to_html
assert_no_match /javascript:/, filtered
assert_no_match /data:text\/html/, filtered
assert_no_match /onclick/, filtered
assert_no_match /onmouseover/, filtered
assert_no_match /evil\.example/, filtered
assert_match /<span class="cf-twitter-avatar">avatar<\/span>/, filtered
assert_match />link</, filtered
assert_match /<action-text-attachment sgid="#{users(:david).attachable_sgid}"/, filtered
end
test "message with a mention attachment" do
message = Message.create! room: rooms(:pets), body: "<div>Hey #{mention_attachment_for(:david)}</div>", creator: users(:jason)
+27
View File
@@ -0,0 +1,27 @@
require "test_helper"
class MessagesHelperTest < ActionView::TestCase
test "message_presentation neutralizes unsafe URI schemes in links" do
message = Message.create! room: rooms(:pets), body: '<div><a href="javascript:alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
presentation = view.message_presentation(message)
assert_no_match /javascript:/, presentation
assert_match /<a>x<\/a>/, presentation
end
test "message_presentation strips event handler attributes from allowed tags" do
message = Message.create! room: rooms(:pets), body: '<div><a href="/x" onmouseover="alert(1)">x</a></div>', client_message_id: "0015", creator: users(:jason)
presentation = view.message_presentation(message)
assert_no_match /onmouseover/, presentation
assert_match /<a href="\/x">x<\/a>/, presentation
end
test "message_presentation preserves safe links and formatting" do
message = Message.create! room: rooms(:pets), body: '<div><a href="https://example.com">example</a> <strong>bold</strong></div>', client_message_id: "0015", creator: users(:jason)
presentation = view.message_presentation(message)
assert_match /<a href="https:\/\/example\.com"[^>]*>example<\/a>/, presentation
assert_match /<strong>bold<\/strong>/, presentation
end
end
@@ -64,23 +64,89 @@ class RestrictedHTTP::PrivateNetworkGuardTest < ActiveSupport::TestCase
assert_private_ip "::93.184.216.34"
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
# The local-use block is refused whole rather than decoded. A Pref64 inside it
# can be any length from /32 to /96 and the position is not recoverable from
# the address alone (RFC 6052 §2.2), so reading the low 32 bits reads the
# wrong octets -- 64:ff9b:1::808:808 only looks like 8.8.8.8 under a /96
# reading. The block is never globally routed, so nothing legitimate is lost.
test "private_ip? returns true for the whole local-use NAT64 block (RFC8215)" do
assert_private_ip "64:ff9b:1::a00:1" # reads as 10.0.0.1 under a /96
assert_private_ip "64:ff9b:1::808:808" # reads as 8.8.8.8 under a /96
assert_private_ip "64:ff9b:1:ffff::1"
end
# SIIT is the third way an IPv4 address rides inside an IPv6 one and the only
# one Ruby has no predicate for: ipv4_mapped?, ipv4_compat?, private?,
# loopback? and link_local? are all false here. Note the extra group --
# ::ffff:0:0:0/96 is not the IPv4-mapped ::ffff:0:0/96, and they do not
# overlap -- so this reached the metadata endpoint unrecognised.
test "private_ip? returns true for SIIT IPv4-translated addresses (RFC2765)" do
assert_private_ip "::ffff:0:169.254.169.254"
assert_private_ip "::ffff:0:a9fe:a9fe" # the same address, hex spelling
assert_private_ip "::ffff:0:127.0.0.1"
assert_private_ip "::ffff:0:192.168.0.1"
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
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("not-an-ip")
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?("")
end
test "resolve raises Violation for private hostname" do
Resolv.stubs(:getaddress).returns("192.168.1.1")
Resolv.stubs(:getaddresses).returns([ "192.168.1.1" ])
assert_raises RestrictedHTTP::Violation do
RestrictedHTTP::PrivateNetworkGuard.resolve("private.example.com")
end
end
test "resolve returns IP for public hostname" do
Resolv.stubs(:getaddress).returns("93.184.216.34")
Resolv.stubs(:getaddresses).returns([ "93.184.216.34" ])
assert_equal "93.184.216.34", RestrictedHTTP::PrivateNetworkGuard.resolve("example.com")
end
test "resolve raises Unresolvable, not Violation, when the host resolves to nothing" do
Resolv.stubs(:getaddresses).returns([])
assert_raises Surfguard::Unresolvable do
RestrictedHTTP::PrivateNetworkGuard.resolve("nxdomain.example.com")
end
end
private
def assert_private_ip(address)
assert RestrictedHTTP::PrivateNetworkGuard.private_ip?(address),
+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" })
assert_not accounts(:signal).reload.settings.restrict_room_creation_to_administrators?
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
+7 -7
View File
@@ -37,7 +37,7 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
WebMock.stub_request(:get, "https://www.other.com/")
.to_return(status: 200, body: "<body>ok<body>", headers: { content_type: "text/html" })
Resolv.stubs(:getaddress).with("www.other.com").returns("127.0.0.1")
Resolv.stubs(:getaddresses).with("www.other.com").returns([ "127.0.0.1" ])
assert_raises RestrictedHTTP::Violation do
@fetch.fetch_document(@url, ip: "1.2.3.4")
@@ -48,9 +48,9 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
# Allow but interrupt a real connection to demonstrate that we connect
# to a resolved IP, not a hostname to re-resolve.
WebMock.disable_net_connect! allow: [ @url.host ]
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("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
Resolv.stubs(:getaddresses).with(@url.host).returns([ "1.2.3.4" ], [ "127.0.0.1" ])
TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
assert_throws :dns_not_rebound do
@fetch.fetch_document(@url)
@@ -65,9 +65,9 @@ class Opengraph::FetchTest < ActiveSupport::TestCase
# Allow but interrupt a real connection to demonstrate that we connect
# to a resolved IP, not a hostname to re-resolve.
WebMock.disable_net_connect! allow: [ @url.host ]
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("1.2.3.4", 443, nil, nil).throws(:dns_not_rebound)
Resolv.stubs(:getaddresses).with(@url.host).returns([ "1.2.3.4" ], [ "127.0.0.1" ])
TCPSocket.expects(:open).with { |*args, **| args.first == @url.host }.never
TCPSocket.expects(:open).with { |*args, **| args.first == "1.2.3.4" && args[1] == 443 }.throws(:dns_not_rebound)
assert_throws :dns_not_rebound do
@fetch.fetch_document(URI.parse("https://www.other.com/"), ip: "1.2.3.4")
+4 -4
View File
@@ -14,7 +14,7 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
end
test "private network urls" do
Resolv.stubs(:getaddress).with("www.example.com").returns("172.16.0.0")
Resolv.stubs(:getaddresses).with("www.example.com").returns([ "172.16.0.0" ])
location = Opengraph::Location.new("https://www.example.com")
assert_not location.valid?
@@ -22,7 +22,7 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
end
test "link-local addresses are blocked" do
Resolv.stubs(:getaddress).with("metadata.internal").returns("169.254.169.254")
Resolv.stubs(:getaddresses).with("metadata.internal").returns([ "169.254.169.254" ])
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
@@ -30,13 +30,13 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
end
test "IPv6 addresses mapped to IPv4 addresses are blocked" do
Resolv.stubs(:getaddress).with("metadata.internal").returns("::ffff:192.168.1.1")
Resolv.stubs(:getaddresses).with("metadata.internal").returns([ "::ffff:192.168.1.1" ])
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
assert_equal [ "is not public" ], location.errors[:url]
Resolv.stubs(:getaddress).with("metadata.internal").returns("::ffff:c0a8:0101")
Resolv.stubs(:getaddresses).with("metadata.internal").returns([ "::ffff:c0a8:0101" ])
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
+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