Compare commits

..
18 Commits
Author SHA1 Message Date
Stanko Krtalić 71ffeeea78 Merge pull request #147 from basecamp/fix-sgid-decoding
Try to decodde SGIDs in multiple ways
2025-12-15 17:05:01 +01:00
Stanko K.R. 77bcad65b5 Try to decode SGIDs in multiple ways
This should avoid message decoding failures between different versions
of sgids
2025-12-15 17:00:50 +01:00
Mike Dalessio 238f73c26f Merge pull request #146 from basecamp/flavorjones/fix-account-creation-race
Address race condition during "first run" account creation
2025-12-12 11:14:52 -05:00
Mike Dalessio 1feb2d94b9 Address race condition during "first run" account creation 2025-12-12 10:51:28 -05:00
Jason Zimdars 49c0ce496c Merge pull request #144 from basecamp/user-self
User self
2025-12-08 23:03:07 -06:00
Jason Zimdars 88f3f942f7 Ensure edit (not trash) is displayed
Fix conditional
2025-12-08 22:57:38 -06:00
Jason Zimdars 6f256f5f2d Show admin toggle, but disable for current user
You shouldn't be able to demote yourself but displaying the disabled
toggle helps explain the group of admins at the top of the list. Builds
upon #140
2025-12-08 22:53:11 -06:00
Jason Zimdars 089a8b35c0 Merge pull request #140 from ashwin47/admin-ordering
Sort users with administrators appearing first in ordered scope
2025-12-08 22:33:49 -06:00
Ashwin M b52c318518 Group administrators separately from members with visual divider 2025-12-09 08:42:00 +05:30
Stanko Krtalić de5493d8a9 Merge pull request #124 from ashwin47/ban_typo
Fix button label to 'UnBan' for clarity
2025-12-06 11:42:50 +01:00
Ashwin M 74346342df Rewrite label 2025-12-06 16:09:13 +05:30
Stanko Krtalić e4a49d52b2 Merge pull request #125 from mphalliday/involvement-fix
Allow non-admins to update their room involvements
2025-12-04 21:43:31 +01:00
Michael Halliday b8919161a8 Allow non-admins to update their room involvements 2025-12-03 09:56:15 -05:00
Ashwin M 80585a9585 Fix button label to 'UnBan' for clarity 2025-12-03 17:50:16 +05:30
Stanko K.R. b3d97ecb0e Add safety checks to release script 2025-12-03 08:24:04 +01:00
Stanko Krtalić 94692026d3 Merge pull request #122 from basecamp/unfurl-blind-ssrf
Security: disallow blind SSRF to link-local IPs via URL unfurling
2025-12-03 08:12:24 +01:00
Stanko K.R. 0672673916 Disallow SSRF via IPv6 addresses mapped to IPv4 addresses 2025-12-03 08:08:34 +01:00
Jeremy Daer 5667262d1c Security: disallow blind SSRF to link-local IPs via URL unfurling 2025-12-02 21:33:44 -08:00
16 changed files with 146 additions and 16 deletions

No files matched your search

+7
View File
@@ -127,6 +127,13 @@
} }
} }
&:has(input[type=checkbox][disabled]) {
cursor: not-allowed;
filter: grayscale(100%) contrast(0.5);
opacity: 0.5;
pointer-events: none;
}
[disabled] &[type=submit], [disabled] &[type=submit],
&[type=submit]:disabled { &[type=submit]:disabled {
cursor: not-allowed; cursor: not-allowed;
+3 -1
View File
@@ -3,7 +3,9 @@ class AccountsController < ApplicationController
before_action :set_account before_action :set_account
def edit def edit
set_page_and_extract_portion_from account_users.ordered, per_page: 500 users = account_users.ordered.without_bots
@administrators, @members = users.partition(&:administrator?)
set_page_and_extract_portion_from users, per_page: 500
end end
def update def update
+2
View File
@@ -11,6 +11,8 @@ class FirstRunsController < ApplicationController
user = FirstRun.create!(user_params) user = FirstRun.create!(user_params)
start_new_session_for user start_new_session_for user
redirect_to root_url
rescue ActiveRecord::RecordNotUnique
redirect_to root_url redirect_to root_url
end end
@@ -1,8 +1,6 @@
class Rooms::InvolvementsController < ApplicationController class Rooms::InvolvementsController < ApplicationController
include RoomScoped include RoomScoped
before_action :ensure_can_administer, only: %i[ update ]
def show def show
@involvement = @membership.involvement @involvement = @membership.involvement
end end
+7 -1
View File
@@ -100,7 +100,13 @@
<menu class="flex flex-column gap margin-none pad"> <menu class="flex flex-column gap margin-none pad">
<turbo-frame id="account_users"> <turbo-frame id="account_users">
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %> <%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
<% if @administrators.any? && @members.any? %>
<hr class="separator full-width" style="--border-style: solid">
<% end %>
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %> <%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
</turbo-frame> </turbo-frame>
</menu> </menu>
+10 -7
View File
@@ -9,23 +9,26 @@
<hr class="separator" aria-hidden="true"> <hr class="separator" aria-hidden="true">
<% if Current.user.can_administer? && user != Current.user && user.active? %> <% if Current.user.can_administer? && user.active? %>
<% unless user.bot? %> <% unless user.bot? %>
<%= form_with model: user, url: account_user_path(user), data: { controller: "form" }, method: :patch do | form | %> <%= form_with model: user, url: account_user_path(user), data: { controller: "form" }, method: :patch do | form | %>
<label class="btn txt-small flex-item-no-shrink" for="<%= dom_id(user, :role) %>"> <label class="btn txt-small flex-item-no-shrink" for="<%= dom_id(user, :role) %>">
<span class="for-screen-reader">Role: <%= user.administrator? ? "Administrator" : "Member" %></span> <span class="for-screen-reader">Role: <%= user.administrator? ? "Administrator" : "Member" %></span>
<%= image_tag "crown.svg", size: 20, aria: { hidden: "true" } %> <%= image_tag "crown.svg", size: 20, aria: { hidden: "true" } %>
<%= form.check_box :role, { data: { action: "form#submit" }, hidden: true, id: dom_id(user, :role) }, "administrator", "member" %> <%= form.check_box :role, { data: { action: "form#submit" }, hidden: true, id: dom_id(user, :role), disabled: user == Current.user }, "administrator", "member" %>
</label> </label>
<% end %> <% end %>
<% end %> <% end %>
<%= button_to account_user_path(user), method: :delete, class: "btn txt-small flex-item-no-shrink btn--negative", data: { <% unless user == Current.user %>
turbo_confirm: "Are you sure you want to permanently remove this person from the account? This can’t be undone."} do %> <%= button_to account_user_path(user), method: :delete, class: "btn txt-small flex-item-no-shrink btn--negative", data: {
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %> turbo_confirm: "Are you sure you want to permanently remove this person from the account? This can’t be undone."} do %>
<span class="for-screen-reader">Delete <%= user.name %></span> <%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Delete <%= user.name %></span>
<% end %>
<% end %> <% end %>
<% elsif user == Current.user %> <% end %>
<% if user == Current.user %>
<%= link_to user_profile_path, class: "btn txt-small flex-item-no-shrink", target: "_top" do %> <%= link_to user_profile_path, class: "btn txt-small flex-item-no-shrink", target: "_top" do %>
<%= image_tag "pencil.svg", size: 20, aria: { hidden: "true" } %> <%= image_tag "pencil.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">My settings</span> <span class="for-screen-reader">My settings</span>
+1 -1
View File
@@ -9,7 +9,7 @@
<%= button_to user_ban_path(user), method: :delete, <%= button_to user_ban_path(user), method: :delete,
class: "btn btn--negative full-width", class: "btn btn--negative full-width",
data: { turbo_confirm: "Are you sure you want to remove the ban on this user?" } do %> data: { turbo_confirm: "Are you sure you want to remove the ban on this user?" } do %>
<%= image_tag "cancel.svg", aria: { hidden: "true", label: "Ban #{@user.name}" } %> <%= image_tag "cancel.svg", aria: { hidden: "true", label: "Remove Ban #{@user.name}" } %>
<span>Remove ban</span> <span>Remove ban</span>
<% end %> <% end %>
<% end %> <% end %>
+11
View File
@@ -30,6 +30,17 @@ 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`" unless VERSION
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
if current_branch != "main"
print "\e[1;33mWarning: You are on branch '#{current_branch}', not 'main'. Continue? [y/N] \e[0m"
abort "Release aborted." unless $stdin.gets.strip.downcase.chars.first == "y"
end
unless `git status --porcelain`.strip.empty?
print "\e[1;33mWarning: You have uncommitted changes. Continue? [y/N] \e[0m"
abort "Release aborted." unless $stdin.gets.strip.downcase.chars.first == "y"
end
def announcing(message) def announcing(message)
puts "\n\e[1;36m#{message}\e[0m" puts "\n\e[1;36m#{message}\e[0m"
yield yield
@@ -0,0 +1,6 @@
class AddSingletonConstraintToAccounts < ActiveRecord::Migration[8.2]
def change
add_column :accounts, :singleton_guard, :integer, default: 0, null: false
add_index :accounts, :singleton_guard, unique: true
end
end
Generated
+3 -1
View File
@@ -10,14 +10,16 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_11_26_130131) do ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
create_table "accounts", force: :cascade do |t| create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.text "custom_styles" t.text "custom_styles"
t.string "join_code", null: false t.string "join_code", null: false
t.string "name", null: false t.string "name", null: false
t.json "settings" t.json "settings"
t.integer "singleton_guard", default: 0, null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["singleton_guard"], name: "index_accounts_on_singleton_guard", unique: true
end end
create_table "action_text_rich_texts", force: :cascade do |t| create_table "action_text_rich_texts", force: :cascade do |t|
+8 -2
View File
@@ -12,7 +12,7 @@ ActiveSupport.on_load(:action_text_content) do
def attachable_from_possibly_expired_sgid(sgid) def attachable_from_possibly_expired_sgid(sgid)
if message = sgid&.split("--")&.first if message = sgid&.split("--")&.first
encoded_message = JSON.parse Base64.strict_decode64(message) encoded_message = JSON.parse(decode_base64(message))
decoded_gid = if data = encoded_message.dig("_rails", "data") decoded_gid = if data = encoded_message.dig("_rails", "data")
data data
@@ -20,7 +20,7 @@ ActiveSupport.on_load(:action_text_content) do
# Rails 7 used an older format of GID that serialized the payload using Marshall # Rails 7 used an older format of GID that serialized the payload using Marshall
# Since we intentionally skip signature verification, we can't safely unmarshal the data # Since we intentionally skip signature verification, we can't safely unmarshal the data
# To work around this, we manually extract the GID from the marshaled data # To work around this, we manually extract the GID from the marshaled data
Base64.urlsafe_decode64(data).match(%r{(gid://campfire/[^/]+/\d+)})&.to_s decode_base64(data).match(%r{(gid://campfire/[^/]+/\d+)})&.to_s
else else
nil nil
end end
@@ -32,6 +32,12 @@ ActiveSupport.on_load(:action_text_content) do
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
nil nil
end end
def decode_base64(message)
Base64.strict_decode64(message)
rescue => _e
Base64.urlsafe_decode64(message)
end
end end
end end
end end
+1 -1
View File
@@ -16,7 +16,7 @@ module RestrictedHTTP
def private_ip?(ip) def private_ip?(ip)
IPAddr.new(ip).then do |ipaddr| IPAddr.new(ip).then do |ipaddr|
ipaddr.private? || ipaddr.loopback? || LOCAL_IP.include?(ipaddr) ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || LOCAL_IP.include?(ipaddr)
end end
rescue IPAddr::InvalidAddressError rescue IPAddr::InvalidAddressError
true true
@@ -10,6 +10,38 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_response :ok assert_response :ok
end end
test "edit groups administrators separately from members with a divider" do
get edit_account_url
assert_response :ok
# Verify the divider exists between administrator and member sections
assert_select "turbo-frame#account_users hr.separator.full-width"
# Verify administrators appear before the divider and members appear after
# by checking the order of user names in the response body
administrators = users(:david, :jason).map(&:name)
members = users(:jz, :kevin).map(&:name)
response_body = response.body
# Find positions of divider and user names
divider_position = response_body.index('hr class="separator full-width"')
assert divider_position, "Divider should exist in the response"
administrators.each do |name|
name_position = response_body.index("<strong>#{name}</strong>")
assert name_position, "Administrator #{name} should appear in the response"
assert name_position < divider_position, "Administrator #{name} should appear before the divider"
end
members.each do |name|
name_position = response_body.index("<strong>#{name}</strong>")
assert name_position, "Member #{name} should appear in the response"
assert name_position > divider_position, "Member #{name} should appear after the divider"
end
end
test "update" do test "update" do
assert users(:david).administrator? assert users(:david).administrator?
@@ -30,4 +30,28 @@ class FirstRunsControllerTest < ActionDispatch::IntegrationTest
assert parsed_cookies.signed[:session_token] assert parsed_cookies.signed[:session_token]
end end
test "create is not vulnerable to race conditions" do
num_attackers = 5
url = first_run_url
barrier = Concurrent::CyclicBarrier.new(num_attackers)
num_attackers.times.map do |i|
Thread.new do
session = ActionDispatch::Integration::Session.new(Rails.application)
barrier.wait # All threads wait here, then fire simultaneously
session.post url, params: {
user: {
name: "Attacker#{i}",
email_address: "attacker#{i}@example.com",
password: "password123"
}
}
end
end.each(&:join)
assert_equal 1, Account.count, "Race condition allowed #{Account.count} accounts to be created!"
assert_equal 1, User.where(role: :administrator).count, "Race condition allowed multiple admin users!"
end
end end
@@ -43,4 +43,13 @@ class Rooms::InvolvementsControllerTest < ActionDispatch::IntegrationTest
end end
end end
end end
test "a non-admin can update their room involvement" do
sign_in :jz
assert_changes -> { memberships(:jz_designers).reload.involvement }, from: "everything", to: "mentions" do
put room_involvement_url(rooms(:designers)), params: { involvement: "mentions" }
assert_redirected_to room_involvement_url(rooms(:designers))
end
end
end end
+22
View File
@@ -21,6 +21,28 @@ class Opengraph::LocationTest < ActiveSupport::TestCase
assert_equal [ "is not public" ], location.errors[:url] assert_equal [ "is not public" ], location.errors[:url]
end end
test "link-local addresses are blocked" do
Resolv.stubs(:getaddress).with("metadata.internal").returns("169.254.169.254")
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
assert_equal [ "is not public" ], location.errors[:url]
end
test "IPv6 addresses mapped to IPv4 addresses are blocked" do
Resolv.stubs(:getaddress).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")
location = Opengraph::Location.new("https://metadata.internal")
assert_not location.valid?
assert_equal [ "is not public" ], location.errors[:url]
end
test "avoid reading file urls when expecting HTML" do test "avoid reading file urls when expecting HTML" do
large_file = Opengraph::Location.new("https://www.example.com/100gb.zip") large_file = Opengraph::Location.new("https://www.example.com/100gb.zip")