diff --git a/app/controllers/first_runs_controller.rb b/app/controllers/first_runs_controller.rb index c5603b6..7773a25 100644 --- a/app/controllers/first_runs_controller.rb +++ b/app/controllers/first_runs_controller.rb @@ -11,6 +11,8 @@ class FirstRunsController < ApplicationController user = FirstRun.create!(user_params) start_new_session_for user + redirect_to root_url + rescue ActiveRecord::RecordNotUnique redirect_to root_url end diff --git a/db/migrate/20251212154340_add_singleton_constraint_to_accounts.rb b/db/migrate/20251212154340_add_singleton_constraint_to_accounts.rb new file mode 100644 index 0000000..4527710 --- /dev/null +++ b/db/migrate/20251212154340_add_singleton_constraint_to_accounts.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 658630d..e7f1540 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,14 +10,16 @@ # # 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| t.datetime "created_at", null: false t.text "custom_styles" t.string "join_code", null: false t.string "name", null: false t.json "settings" + t.integer "singleton_guard", default: 0, null: false t.datetime "updated_at", null: false + t.index ["singleton_guard"], name: "index_accounts_on_singleton_guard", unique: true end create_table "action_text_rich_texts", force: :cascade do |t| diff --git a/test/controllers/first_runs_controller_test.rb b/test/controllers/first_runs_controller_test.rb index 4413d6b..13fa73d 100644 --- a/test/controllers/first_runs_controller_test.rb +++ b/test/controllers/first_runs_controller_test.rb @@ -30,4 +30,28 @@ class FirstRunsControllerTest < ActionDispatch::IntegrationTest assert parsed_cookies.signed[:session_token] 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