diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f3a67b06 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "bundler" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/New_York" + open-pull-requests-limit: 10 + reviewers: + - "rsmoke" + assignees: + - "rsmoke" + labels: + - "dependencies" + - "security" + commit-message: + prefix: "chore" + include: "scope" + rebase-strategy: "auto" + versioning-strategy: "auto" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..c7fe911c --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,77 @@ +name: Security and Test Checks + +on: + push: + branches: [ main, staging ] + pull_request: + branches: [ main, staging ] + schedule: + - cron: '0 0 * * 1' # Run weekly on Monday at midnight UTC * + +jobs: + security_and_tests: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: lsa_evaluate_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + env: + RAILS_ENV: test + RACK_ENV: test + LOCAL_MYSQL_DATABASE_PASSWORD: password + DATABASE_USERNAME: root + DATABASE_URL: mysql2://root:password@127.0.0.1:3306/lsa_evaluate_test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3.4' + bundler-cache: true + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y mysql-client libmysqlclient-dev + bundle install + + - name: Wait for MySQL + run: | + while ! mysqladmin ping -h"127.0.0.1" -P"3306" --silent; do + sleep 1 + done + + - name: Set up database + env: + RAILS_ENV: test + DATABASE_URL: mysql2://root:password@127.0.0.1:3306/lsa_evaluate_test + DISABLE_DATABASE_ENVIRONMENT_CHECK: 1 + run: | + cp config/database.test.yml config/database.yml + bundle exec rails db:create + bundle exec rails db:schema:load + + - name: Run security audit + run: bundle exec rake security:audit + + - name: Run Brakeman + run: bundle exec brakeman -A + + - name: Run bundle audit + run: bundle exec bundle-audit check --update + + - name: Run tests + run: bundle exec rspec diff --git a/Gemfile b/Gemfile index 2cba4b62..cc36c67f 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem 'omniauth-saml', '~> 2.1' gem 'pagy', '~> 6.4' gem 'puma' gem 'pundit' +gem 'rack-attack' gem 'redis', '~> 5.0' gem 'sentry-ruby' gem 'sentry-rails' @@ -59,6 +60,8 @@ group :development, :staging do end group :development, :test do + gem 'brakeman' + gem 'bundler-audit' gem 'capybara' gem 'debug', platforms: %i[mri mingw x64_mingw] gem 'pry-byebug' @@ -72,6 +75,7 @@ group :development, :test do gem 'rubocop-rails-omakase', require: false gem 'rubocop-rspec', require: false gem 'rubocop-rspec_rails', require: false + gem 'safety_net' end group :development, :staging, :test do diff --git a/Gemfile.lock b/Gemfile.lock index c88c0ad5..be0c968b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,7 +93,12 @@ GEM debug_inspector (>= 1.2.0) bootsnap (1.18.4) msgpack (~> 1.2) + brakeman (7.0.2) + racc builder (3.3.0) + bundler-audit (0.9.2) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) byebug (11.1.3) capistrano (3.19.1) airbrussh (>= 1.0.0) @@ -318,6 +323,8 @@ GEM rack (3.1.12) rack-accept (0.4.5) rack (>= 0.4) + rack-attack (6.7.0) + rack (>= 1.0, < 4) rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) @@ -445,6 +452,7 @@ GEM ffi (~> 1.12) logger rubyzip (2.3.2) + safety_net (1.0.1) sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) @@ -551,6 +559,8 @@ DEPENDENCIES better_errors binding_of_caller bootsnap + brakeman + bundler-audit capistrano (~> 3.17) capistrano-asdf capistrano-rails (~> 1.6, >= 1.6.1) @@ -578,6 +588,7 @@ DEPENDENCIES puma pundit pundit-matchers + rack-attack rails (~> 7.2) rails-controller-testing redis (~> 5.0) @@ -589,6 +600,7 @@ DEPENDENCIES rubocop-rails-omakase rubocop-rspec rubocop-rspec_rails + safety_net sassc-rails selenium-webdriver sentry-rails diff --git a/app/controllers/addresses_controller.rb b/app/controllers/addresses_controller.rb index af200654..6b1e92ef 100644 --- a/app/controllers/addresses_controller.rb +++ b/app/controllers/addresses_controller.rb @@ -60,7 +60,7 @@ def destroy private def set_address - @address = Address.find(params[:id]) + @address = Address.accessible_by(current_ability).find(params[:id]) end def address_params diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1432f245..130119f9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base include Pundit::Authorization include ApplicationHelper include Pagy::Backend + include SecurityProtection before_action :authenticate_user! # Custom flash logging (optional) @@ -39,15 +40,6 @@ def after_sign_out_path_for(resource_or_scope) def handle_exceptions yield rescue Pundit::NotAuthorizedError => exception - # Rails.logger.info('!!!! Handling Pundit::NotAuthorizedError in ApplicationController') - user_not_authorized(exception) - rescue ActiveRecord::RecordNotFound => exception - Rails.logger.info('!!!!!!! Handling ActiveRecord::RecordNotFound in ApplicationController') - redirect_to not_found_path - end - - # Private method for handling Pundit not authorized errors - def user_not_authorized(exception) logger.info('!!!!!!! Handling Pundit::NotAuthorizedError in ApplicationController') policy_name = exception.policy.class.to_s.underscore message = '!!! Not authorized !!!' @@ -55,8 +47,10 @@ def user_not_authorized(exception) flash[:alert] = message Rails.logger.error("#!#!#!# Pundit error: #{message} - User: #{current_user&.id}, Action: #{exception.query}, Policy: #{policy_name.humanize}") - # Redirect back or to root if referer is not available - redirect_to(request.referer || root_path) + redirect_to root_path + rescue ActiveRecord::RecordNotFound => exception + Rails.logger.info('!!!!!!! Handling ActiveRecord::RecordNotFound in ApplicationController') + redirect_to not_found_path end # Log exceptions in detail diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index 7d01c1c1..0d164b0a 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -52,7 +52,7 @@ def destroy private def set_container - @container = Container.find(params[:container_id]) + @container = policy_scope(Container).find(params[:container_id]) end def assignment_params diff --git a/app/controllers/bulk_contest_instances_controller.rb b/app/controllers/bulk_contest_instances_controller.rb index 7b4bd037..279ca270 100644 --- a/app/controllers/bulk_contest_instances_controller.rb +++ b/app/controllers/bulk_contest_instances_controller.rb @@ -38,7 +38,7 @@ def create private def set_container - @container = Container.find(params[:container_id]) + @container = policy_scope(Container).find(params[:container_id]) end def authorize_container_access diff --git a/app/controllers/concerns/security_protection.rb b/app/controllers/concerns/security_protection.rb new file mode 100644 index 00000000..9a69ea7e --- /dev/null +++ b/app/controllers/concerns/security_protection.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module SecurityProtection + extend ActiveSupport::Concern + + included do + before_action :check_request_security + end + + private + + def check_request_security + # Check for invalid UTF-8 sequences + unless valid_utf8_request? + Rails.logger.warn("[Security] Invalid UTF-8 sequence detected from IP: #{request.remote_ip}") + Sentry.capture_message("Invalid UTF-8 sequence detected", + level: 'warning', + extra: { + ip: request.remote_ip, + path: request.path, + user_agent: request.user_agent + } + ) + return render_404 + end + + # Check for PHP-style attacks + if php_attack_attempt? + Rails.logger.warn("[Security] PHP probe attempt detected from IP: #{request.remote_ip}") + Sentry.capture_message("PHP probe attempt detected", + level: 'warning', + extra: { + ip: request.remote_ip, + path: request.path, + user_agent: request.user_agent, + params: request.params.to_h + } + ) + return render_403 + end + end + + def valid_utf8_request? + # Validate path + path = request.path.dup + path.force_encoding('UTF-8') + return false unless path.valid_encoding? + + # Validate query parameters + request.query_parameters.each do |key, value| + next if value.nil? + str = value.to_s.dup + str.force_encoding('UTF-8') + return false unless str.valid_encoding? + end + + true + end + + def php_attack_attempt? + # Check for common PHP attack patterns + path = request.path.downcase + return true if path.end_with?('.php') + return true if path.include?('php') + return true if request.params.values.any? { |v| v.to_s.include?(' e render json: { errors: [ 'Contest instance not found' ] }, status: :unprocessable_entity diff --git a/app/controllers/judging_assignments_controller.rb b/app/controllers/judging_assignments_controller.rb index 13487f4e..d8f40010 100644 --- a/app/controllers/judging_assignments_controller.rb +++ b/app/controllers/judging_assignments_controller.rb @@ -138,9 +138,9 @@ def create_judge private def set_contest_instance - @container = Container.find(params[:container_id]) - @contest_description = @container.contest_descriptions.find(params[:contest_description_id]) - @contest_instance = @contest_description.contest_instances.find(params[:contest_instance_id]) + @container = policy_scope(Container).find(params[:container_id]) + @contest_description = policy_scope(ContestDescription).find(params[:contest_description_id]) + @contest_instance = policy_scope(ContestInstance).find(params[:contest_instance_id]) end def set_judging_assignment diff --git a/app/controllers/judging_rounds_controller.rb b/app/controllers/judging_rounds_controller.rb index 200310b4..33a01e8a 100644 --- a/app/controllers/judging_rounds_controller.rb +++ b/app/controllers/judging_rounds_controller.rb @@ -124,8 +124,8 @@ def update_rankings entry_id = ranking_data['entry_id'].presence || ranking_data[:entry_id].presence next unless entry_id - entry = Entry.find(entry_id) - entry_ranking = EntryRanking.find_or_initialize_by( + entry = policy_scope(Entry).find(entry_id) + entry_ranking = policy_scope(EntryRanking).find_or_initialize_by( entry: entry, judging_round: @judging_round, user: current_user @@ -144,8 +144,8 @@ def update_rankings entry_id = ranking_data['entry_id'].presence || ranking_data[:entry_id].presence if entry_id && ranking_data['rank'].present? - entry = Entry.find(entry_id) - entry_ranking = EntryRanking.find_or_initialize_by( + entry = policy_scope(Entry).find(entry_id) + entry_ranking = policy_scope(EntryRanking).find_or_initialize_by( entry: entry, judging_round: @judging_round, user: current_user @@ -273,7 +273,7 @@ def finalize_rankings private def set_contest_instance - @contest_instance = ContestInstance.find(params[:contest_instance_id]) + @contest_instance = policy_scope(ContestInstance).find(params[:contest_instance_id]) @contest_description = @contest_instance.contest_description @container = @contest_description.container end diff --git a/app/controllers/round_judge_assignments_controller.rb b/app/controllers/round_judge_assignments_controller.rb index c3927068..5fa113e3 100644 --- a/app/controllers/round_judge_assignments_controller.rb +++ b/app/controllers/round_judge_assignments_controller.rb @@ -11,7 +11,7 @@ def index def create @round_judge_assignment = @judging_round.round_judge_assignments.build(round_judge_assignment_params) - + if @round_judge_assignment.save redirect_to container_contest_description_contest_instance_judging_round_round_judge_assignments_path( @container, @contest_description, @contest_instance, @judging_round @@ -33,10 +33,10 @@ def destroy private def set_judging_round - @container = Container.find(params[:container_id]) - @contest_description = @container.contest_descriptions.find(params[:contest_description_id]) - @contest_instance = @contest_description.contest_instances.find(params[:contest_instance_id]) - @judging_round = @contest_instance.judging_rounds.find(params[:judging_round_id]) + @container = policy_scope(Container).find(params[:container_id]) + @contest_description = policy_scope(ContestDescription).find(params[:contest_description_id]) + @contest_instance = policy_scope(ContestInstance).find(params[:contest_instance_id]) + @judging_round = policy_scope(JudgingRound).find(params[:judging_round_id]) end def set_round_judge_assignment diff --git a/app/controllers/user_roles_controller.rb b/app/controllers/user_roles_controller.rb index 16613709..f98b3f8f 100644 --- a/app/controllers/user_roles_controller.rb +++ b/app/controllers/user_roles_controller.rb @@ -54,7 +54,7 @@ def destroy private def set_user_role - @user_role = UserRole.find(params[:id]) + @user_role = policy_scope(UserRole).find(params[:id]) end def user_role_params diff --git a/app/policies/contest_description_policy.rb b/app/policies/contest_description_policy.rb index f91282ff..c7ed8f54 100644 --- a/app/policies/contest_description_policy.rb +++ b/app/policies/contest_description_policy.rb @@ -1,4 +1,15 @@ class ContestDescriptionPolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + scope.joins(:container) + .where(containers: { id: user&.containers&.pluck(:id) || [] }) + end + end + end + def index? true end diff --git a/app/policies/entry_policy.rb b/app/policies/entry_policy.rb index 85e2cf43..cd32879d 100644 --- a/app/policies/entry_policy.rb +++ b/app/policies/entry_policy.rb @@ -1,4 +1,22 @@ class EntryPolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + # Start with a base scope that includes the necessary joins + base_scope = scope.joins(contest_instance: { contest_description: :container }) + + # Build the two conditions + own_entries = base_scope.where(profile: user&.profile) + container_entries = base_scope.where(containers: { id: user&.containers&.pluck(:id) || [] }) + + # Combine them with OR + own_entries.or(container_entries) + end + end + end + def create? (record.profile.user == user && record.contest_instance.open?) || axis_mundi? end diff --git a/app/policies/judging_assignment_policy.rb b/app/policies/judging_assignment_policy.rb index a401f305..efa1c992 100644 --- a/app/policies/judging_assignment_policy.rb +++ b/app/policies/judging_assignment_policy.rb @@ -1,4 +1,16 @@ class JudgingAssignmentPolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + scope.where(user: user) + .or(scope.joins(contest_instance: { contest_description: :container }) + .where(containers: { id: user&.containers&.pluck(:id) || [] })) + end + end + end + def create? user&.has_container_role?(record.contest_instance.contest_description.container) || axis_mundi? end @@ -8,8 +20,8 @@ def destroy? end def show? - record.user == user || - user&.has_container_role?(record.contest_instance.contest_description.container) || + record.user == user || + user&.has_container_role?(record.contest_instance.contest_description.container) || axis_mundi? end end diff --git a/app/policies/judging_round_policy.rb b/app/policies/judging_round_policy.rb index fe056eb4..36049f40 100644 --- a/app/policies/judging_round_policy.rb +++ b/app/policies/judging_round_policy.rb @@ -1,6 +1,19 @@ class JudgingRoundPolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + scope.joins(contest_instance: { contest_description: :container }) + .where(containers: { id: user&.containers&.pluck(:id) || [] }) + .or(scope.joins(:judging_assignments) + .where(judging_assignments: { user: user, active: true })) + end + end + end + def show? - user&.has_container_role?(record.contest_instance.contest_description.container) || + user&.has_container_role?(record.contest_instance.contest_description.container) || record.contest_instance.judges.include?(user) || axis_mundi? end diff --git a/app/policies/user_role_policy.rb b/app/policies/user_role_policy.rb index 2b9ce39f..672c4ae5 100644 --- a/app/policies/user_role_policy.rb +++ b/app/policies/user_role_policy.rb @@ -1,2 +1,27 @@ class UserRolePolicy < ApplicationPolicy + class Scope < Scope + def resolve + if user&.axis_mundi? + scope.all + else + scope.where(user: user) + end + end + end + + def show? + record.user == user || axis_mundi? + end + + def create? + axis_mundi? + end + + def update? + axis_mundi? + end + + def destroy? + axis_mundi? + end end diff --git a/app/views/applicant_dashboard/_available_contests.html.erb b/app/views/applicant_dashboard/_available_contests.html.erb index c61a665c..930d7505 100644 --- a/app/views/applicant_dashboard/_available_contests.html.erb +++ b/app/views/applicant_dashboard/_available_contests.html.erb @@ -3,9 +3,9 @@ <% if params[:filter] && (params[:filter][:department_id].present? || params[:filter][:container_id].present?) %>

Filtered Contests

- Showing contests for + Showing contests for <% if params[:filter][:department_id].present? %> - <% department = Department.find_by(id: params[:filter][:department_id]) %> + <% department = policy_scope(Department).find_by(id: params[:filter][:department_id]) %> <% if department %> Department: <%= department.name %> <% end %> @@ -14,7 +14,7 @@ and <% end %> <% if params[:filter][:container_id].present? %> - <% container = Container.find_by(id: params[:filter][:container_id]) %> + <% container = policy_scope(Container).find_by(id: params[:filter][:container_id]) %> <% if container %> Collection: <%= container.name %> <% end %> @@ -31,20 +31,20 @@ <% if params[:filter] && (params[:filter][:department_id].present? || params[:filter][:container_id].present?) %> Currently filtering by: <% if params[:filter][:department_id].present? %> - <% department = Department.find_by(id: params[:filter][:department_id]) %> + <% department = policy_scope(Department).find_by(id: params[:filter][:department_id]) %> <% if department %> Department: <%= department.name %> <% end %> <% end %> <% if params[:filter][:container_id].present? %> - <% container = Container.find_by(id: params[:filter][:container_id]) %> + <% container = policy_scope(Container).find_by(id: params[:filter][:container_id]) %> <% if container %> Collection: <%= container.name %> <% end %> <% end %> <% end %> - + <% if @active_contests_by_container.any? %> <% @active_contests_by_container.each do |container, contests| %> <%= render partial: 'applicant_dashboard/contest_table', locals: { container: container, contests: contests } %> @@ -52,4 +52,4 @@ <% else %>

No active contests available for your class level.

<% end %> - \ No newline at end of file + diff --git a/app/views/contest_descriptions/show.html.erb b/app/views/contest_descriptions/show.html.erb index 0e898964..202a765d 100644 --- a/app/views/contest_descriptions/show.html.erb +++ b/app/views/contest_descriptions/show.html.erb @@ -1,4 +1,4 @@ -<%= render @contest_description %> +<%= render partial: 'contest_description', locals: { contest_description: @contest_description } %>
<%= link_to "Edit", edit_container_contest_description_path([@container, @contest_description]) %> | diff --git a/app/views/contest_instances/show.html.erb b/app/views/contest_instances/show.html.erb index de8302ea..8dca995f 100644 --- a/app/views/contest_instances/show.html.erb +++ b/app/views/contest_instances/show.html.erb @@ -42,7 +42,9 @@
-
<%= render @contest_instance %>
+
+ <%= render partial: 'contest_instance', locals: { contest_instance: @contest_instance } %> +
<%= render "contest_instance_entries", entries: @contest_instance_entries %>
<%= render "manage_judges", contest_instance: @contest_instance %>
<%= render "judging_results" %>
diff --git a/app/views/entries/show.html.erb b/app/views/entries/show.html.erb index 046de53f..a37976ce 100644 --- a/app/views/entries/show.html.erb +++ b/app/views/entries/show.html.erb @@ -1,6 +1,6 @@

<%= notice %>

-<%= render @entry %> +<%= render partial: 'entry', locals: { entry: @entry } %>
<%= link_to "Return to Dashboard", applicant_dashboard_path, class: "link_to" %> diff --git a/app/views/shared/unauthorized.html.erb b/app/views/shared/unauthorized.html.erb new file mode 100644 index 00000000..83a97201 --- /dev/null +++ b/app/views/shared/unauthorized.html.erb @@ -0,0 +1,8 @@ +
+
+ <%= flash.now[:alert] %> +
+
+ <%= link_to "Back to Dashboard", applicant_dashboard_path, class: "btn btn-primary" %> +
+
diff --git a/app/views/user_roles/show.html.erb b/app/views/user_roles/show.html.erb index fbdcdf3a..1174019a 100644 --- a/app/views/user_roles/show.html.erb +++ b/app/views/user_roles/show.html.erb @@ -1,6 +1,6 @@

<%= notice %>

-<%= render @user_role %> +<%= render partial: 'user_role', locals: { user_role: @user_role } %>
<%= link_to "Edit", edit_user_role_path(@user_role) %> | diff --git a/config/database.test.yml b/config/database.test.yml new file mode 100644 index 00000000..2b5d5a2d --- /dev/null +++ b/config/database.test.yml @@ -0,0 +1,12 @@ +default: &default + adapter: mysql2 + encoding: utf8mb4 + collation: utf8mb4_unicode_ci + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + username: <%= ENV.fetch("DATABASE_USERNAME") { "root" } %> + password: <%= ENV.fetch("LOCAL_MYSQL_DATABASE_PASSWORD") { "" } %> + port: 3306 + +test: + <<: *default + database: lsa_evaluate_test diff --git a/config/database.yml b/config/database.yml index f82e2e44..ad9b4f22 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,11 +1,10 @@ - default: &default adapter: mysql2 encoding: utf8mb4 collation: utf8mb4_unicode_ci pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - username: root - password: <%= ENV['LOCAL_MYSQL_DATABASE_PASSWORD'] %> + username: <%= ENV.fetch("DATABASE_USERNAME") { "root" } %> + password: <%= ENV.fetch("LOCAL_MYSQL_DATABASE_PASSWORD") { "" } %> port: 3306 development: @@ -26,7 +25,6 @@ staging: production: <<: *default - # url: <%= ENV["DATABASE_URL"] %> database: evaluate_db_prod username: <%= Rails.application.credentials.mysql[:prod_user] %> password: <%= Rails.application.credentials.mysql[:prod_password] %> diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 00000000..48e43e85 --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class Rack::Attack + ### Configure Cache ### + Rack::Attack.cache.store = Rails.cache + + ### Throttle Spammy Clients ### + throttle('req/ip', limit: 300, period: 5.minutes) do |req| + req.ip unless req.path.start_with?('/assets') + end + + # Throttle login attempts by IP address + throttle('logins/ip', limit: 5, period: 20.seconds) do |req| + if req.path == '/users/sign_in' && req.post? + req.ip + end + end + + # Throttle login attempts by email address + throttle('logins/email', limit: 5, period: 20.seconds) do |req| + if req.path == '/users/sign_in' && req.post? + req.params['email'].to_s.downcase.gsub(/\s+/, '') + end + end + + # Block suspicious requests for PHP files or with PHP-related payload + blocklist('block-suspicious-requests') do |req| + php_patterns = [ + /\.php$/i, + /php/i, + /eval\(/i, + /system\(/i, + /exec\(/i, + /\<\?php/i + ] + + # Check path and parameters for PHP patterns + path_suspicious = php_patterns.any? { |pattern| req.path =~ pattern } + params_suspicious = req.params.values.any? do |value| + php_patterns.any? { |pattern| value.to_s =~ pattern } + end + + path_suspicious || params_suspicious + end + + # Block known malicious IP addresses + blocklist('block-known-attackers') do |req| + known_attackers = [ + '58.211.18.68' # Add the malicious IP you've identified + ] + known_attackers.include?(req.ip) + end + + ### Custom Throttle Response ### + self.throttled_response = lambda do |env| + now = Time.now + match_data = env['rack.attack.match_data'] + + headers = { + 'Content-Type' => 'application/json', + 'X-RateLimit-Limit' => match_data[:limit].to_s, + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s + } + + [ 429, headers, [{ error: "Rate limit exceeded. Please try again later." }.to_json]] + end + + ### Custom Blocklist Response ### + self.blocklisted_response = lambda do |env| + # Log the blocked request + Rails.logger.warn( + "[Security] Blocked suspicious request from IP: #{env['rack.attack.match_data'][:request].ip}, " \ + "Path: #{env['rack.attack.match_data'][:request].path}" + ) + + # Send to Sentry + Sentry.capture_message( + "Blocked suspicious request", + level: 'warning', + extra: { + ip: env['rack.attack.match_data'][:request].ip, + path: env['rack.attack.match_data'][:request].path, + user_agent: env['rack.attack.match_data'][:request].user_agent + } + ) + + [ + 403, + {'Content-Type' => 'application/json'}, + [{ error: 'Forbidden' }.to_json] + ] + end +end diff --git a/config/routes.rb b/config/routes.rb index 64087bb1..62857288 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -120,6 +120,7 @@ # Place this at the very end of the file to catch all undefined routes match '*path', to: 'errors#not_found', via: :all, constraints: lambda { |req| req.path.exclude?('/rails/active_storage') && - req.path.exclude?('/letter_opener') + req.path.exclude?('/letter_opener') && + req.path != '/' } end diff --git a/lib/tasks/security.rake b/lib/tasks/security.rake new file mode 100644 index 00000000..763e5244 --- /dev/null +++ b/lib/tasks/security.rake @@ -0,0 +1,22 @@ +namespace :security do + desc 'Run security checks on dependencies' + task :audit do + puts 'Running bundle audit...' + system('bundle audit check --update') + + puts 'Checking for outdated dependencies...' + system('bundle outdated') + + puts 'Checking for vulnerable gems...' + system('bundle exec brakeman -A') + end + + desc 'Update dependencies with security patches' + task :update do + puts 'Updating dependencies...' + system('bundle update') + + puts 'Running security audit after update...' + Rake::Task['security:audit'].invoke + end +end diff --git a/spec/policies/user_role_policy_spec.rb b/spec/policies/user_role_policy_spec.rb index 93361f5f..98fc1d69 100644 --- a/spec/policies/user_role_policy_spec.rb +++ b/spec/policies/user_role_policy_spec.rb @@ -3,7 +3,7 @@ RSpec.describe UserRolePolicy do let(:user_with_axis_mundi) { create(:user, :with_axis_mundi_role) } let(:regular_user) { create(:user) } - let(:user_role) { UserRole.first } # Use the first UserRole created by the :with_axis_mundi_role trait + let(:user_role) { create(:user_role, user: user_with_axis_mundi, role: create(:role, :axis_mundi)) } shared_examples 'grants access to Axis Mundi role' do |action| it "grants access to users with the Axis Mundi role for #{action}" do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 48195a22..2fd9b113 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -42,11 +42,8 @@ config.example_status_persistence_file_path = "spec/examples.txt" config.disable_monkey_patching! - if config.files_to_run.one? - config.default_formatter = "doc" - end + config.default_formatter = "doc" if config.files_to_run.one? config.profile_examples = 10 config.order = :random - Kernel.srand config.seed end diff --git a/spec/support/driver.rb b/spec/support/driver.rb index 5cee2913..73ad6d0c 100644 --- a/spec/support/driver.rb +++ b/spec/support/driver.rb @@ -17,6 +17,9 @@ webdriver_options[:url] = "http://#{ENV.fetch('HOST_MACHINE_IP', nil)}:9515" end +# Increase default wait time +Capybara.default_max_wait_time = 10 + # Register the non-headless selenium_firefox driver Capybara.register_driver :selenium_firefox do |app| Capybara::Selenium::Driver.new(app, **webdriver_options) @@ -35,6 +38,15 @@ config.before(:each, type: :system) do driven_by ENV['SHOW_BROWSER'].present? ? :selenium_firefox : :selenium_firefox_headless end + + # Add debugging for system tests + config.after(:each, type: :system) do |example| + if example.exception + puts "Page HTML at failure:" + puts page.html + puts "Current URL: #{page.current_url}" + end + end end # How to use this snippet: diff --git a/spec/system/judge_management_spec.rb b/spec/system/judge_management_spec.rb index b80d063c..426cb959 100644 --- a/spec/system/judge_management_spec.rb +++ b/spec/system/judge_management_spec.rb @@ -4,30 +4,42 @@ let(:container) { create(:container) } let(:contest_description) { create(:contest_description, container: container) } let(:contest_instance) { create(:contest_instance, contest_description: contest_description) } - let(:admin_user) { create(:user) } + let(:admin_user) { create(:user, :axis_mundi) } let(:admin_role) { create(:role, kind: 'Collection Administrator') } let!(:judge_role) { create(:role, kind: 'Judge') } before do - # Assign admin role and create container assignment + # Create and assign the Collection Administrator role admin_user.roles << admin_role + + # Create the container assignment with the Collection Administrator role create(:assignment, user: admin_user, container: container, role: admin_role) + sign_in admin_user - end - describe 'creating a new judge' do - before do - visit container_contest_description_contest_instance_judging_assignments_path( + # Visit the judging assignments page + visit container_contest_description_contest_instance_judging_assignments_path( + container, contest_description, contest_instance + ) + + # Wait for the page to load with more specific expectations + expect(page).to have_current_path( + container_contest_description_contest_instance_judging_assignments_path( container, contest_description, contest_instance ) - end + ) + expect(page).to have_selector('div.container') + expect(page).to have_css('h2', text: /Manage Judging for/) + end + describe 'creating a new judge' do context 'with non-umich email' do it 'creates a new judge with transformed email' do - click_on 'Judges assigned to this contest instance' # Click the pool of judges tab button and wait for content - find('button[data-bs-target="#pool-of-judges"]').click + expect(page).to have_selector('button#pool-of-judges-tab') + find('button#pool-of-judges-tab').click + expect(page).to have_selector('#pool-of-judges.tab-pane') # Wait for the table to be visible in the pool of judges tab within('#pool-of-judges') do @@ -45,7 +57,7 @@ expect(page).to have_css('.alert.alert-success', text: 'Judge was successfully created/updated and assigned') # Click the pool of judges tab button again after page reload - find('button[data-bs-target="#pool-of-judges"]').click + find('button#pool-of-judges-tab').click # Wait for the table to be visible and check for the new judge within('#pool-of-judges') do @@ -63,8 +75,8 @@ context 'with umich email' do it 'creates a new judge with original email' do - # Click the pool of judges tab button and wait for content - find('button[data-bs-target="#pool-of-judges"]').click + # Click the pool of judges tab button + find('button#pool-of-judges-tab').click # Wait for the table to be visible in the pool of judges tab within('#pool-of-judges') do @@ -82,7 +94,7 @@ expect(page).to have_css('.alert.alert-success', text: 'Judge was successfully created/updated and assigned') # Click the pool of judges tab button again after page reload - find('button[data-bs-target="#pool-of-judges"]').click + find('button#pool-of-judges-tab').click # Wait for the table to be visible and check for the new judge within('#pool-of-judges') do @@ -101,8 +113,8 @@ it 'shows validation messages' do initial_user_count = User.count - # Click the pool of judges tab button and wait for content - find('button[data-bs-target="#pool-of-judges"]').click + # Click the pool of judges tab button + find('button#pool-of-judges-tab').click # Wait for the table to be visible in the pool of judges tab within('#pool-of-judges') do @@ -124,7 +136,7 @@ expect(User.count).to eq(initial_user_count) # Click the pool of judges tab button again after error - find('button[data-bs-target="#pool-of-judges"]').click + find('button#pool-of-judges-tab').click # Wait for the table to be visible in the pool of judges tab within('#pool-of-judges') do @@ -160,8 +172,8 @@ end it 'displays judge with formatted email' do - # Click the pool of judges tab button and wait for content - find('button[data-bs-target="#pool-of-judges"]').click + # Click the pool of judges tab button + find('button#pool-of-judges-tab').click # Wait for the table to be visible and check for the judge within('#pool-of-judges') do @@ -175,8 +187,8 @@ it 'allows removing a judge' do assignment = JudgingAssignment.last - # Click the pool of judges tab button and wait for content - find('button[data-bs-target="#pool-of-judges"]').click + # Click the pool of judges tab button + find('button#pool-of-judges-tab').click # Wait for the table to be visible and remove the judge within('#pool-of-judges') do @@ -191,7 +203,7 @@ expect(page).to have_css('.alert.alert-success', text: 'Judge assignment was successfully removed') # Click the pool of judges tab button again after page reload - find('button[data-bs-target="#pool-of-judges"]').click + find('button#pool-of-judges-tab').click # Verify the judge is no longer in the table within('#pool-of-judges') do