From 2ef1b5e02d7512f819d277eadfcc029132c647d7 Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Tue, 27 Oct 2026 13:44:28 +0900 Subject: [PATCH 1/2] Tighten SPI request handling --- app/controllers/spi/base_controller.rb | 10 ++++++++ test/controllers/spi/base_controller_test.rb | 27 ++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 test/controllers/spi/base_controller_test.rb diff --git a/app/controllers/spi/base_controller.rb b/app/controllers/spi/base_controller.rb index 4d387f66fe..b8f0c73799 100644 --- a/app/controllers/spi/base_controller.rb +++ b/app/controllers/spi/base_controller.rb @@ -12,6 +12,16 @@ class BaseController < ApplicationController skip_after_action :set_csp_header skip_after_action :set_link_header + prepend_before_action :ensure_canonical_path! + layout false + + private + def ensure_canonical_path! + path = request.original_fullpath.split("?", 2).first + return if path == "/spi" || path.start_with?("/spi/") + + head :not_found + end end end diff --git a/test/controllers/spi/base_controller_test.rb b/test/controllers/spi/base_controller_test.rb new file mode 100644 index 0000000000..87810b79c4 --- /dev/null +++ b/test/controllers/spi/base_controller_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class SPI::BaseControllerTest < ActionDispatch::IntegrationTest + test "serves requests on the canonical spi path" do + user = create :user + + client = mock + Exercism.expects(:ses_client).returns(client) + client.expects(:put_suppressed_destination) + User::UnsubscribeFromAllEmails.expects(:call).with(user) + + patch "/spi/unsubscribe_user", params: { email: user.email, reason: :bounce } + + assert_response :no_content + end + + test "returns 404 for non-canonical paths" do + user = create :user + User::UnsubscribeFromAllEmails.expects(:call).never + + patch "/spi/unsubscribe_user", + params: { email: user.email, reason: :bounce }, + env: { "PATH_INFO" => "//spi/unsubscribe_user" } + + assert_response :not_found + end +end From a540cd5346c3cf25a3545852cc0c1e27089030df Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Tue, 27 Oct 2026 17:18:13 +0900 Subject: [PATCH 2/2] Only serve SPI routes on the SPI host --- config/routes/spi.rb | 13 ++++++++----- test/controllers/spi/base_controller_test.rb | 14 ++++++++++++++ .../spi/profile_image_data_controller_test.rb | 4 ++++ .../spi/solution_image_data_controller_test.rb | 4 ++++ .../spi/tooling_jobs_controller_test.rb | 6 +++++- test/controllers/spi/unsubscribe_user_test.rb | 4 ++++ 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/config/routes/spi.rb b/config/routes/spi.rb index a507cded4a..8cd96a8c82 100644 --- a/config/routes/spi.rb +++ b/config/routes/spi.rb @@ -1,9 +1,12 @@ # ### # # SPI # # ### # -namespace :spi do - resources :tooling_jobs, only: :update - get "solution_image_data/:track_slug/:exercise_slug/:user_handle" => "solution_image_data#show" - get "profile_image_data/:user_handle" => "profile_image_data#show" - patch "unsubscribe_user" => "unsubscribe_users#unsubscribe_by_email", as: "unsubscribe_user" +# These routes only answer on the internal SPI host. +constraints(->(request) { request.host == URI(Exercism.config.spi_url).host }) do + namespace :spi do + resources :tooling_jobs, only: :update + get "solution_image_data/:track_slug/:exercise_slug/:user_handle" => "solution_image_data#show" + get "profile_image_data/:user_handle" => "profile_image_data#show" + patch "unsubscribe_user" => "unsubscribe_users#unsubscribe_by_email", as: "unsubscribe_user" + end end diff --git a/test/controllers/spi/base_controller_test.rb b/test/controllers/spi/base_controller_test.rb index 87810b79c4..9259a8cabf 100644 --- a/test/controllers/spi/base_controller_test.rb +++ b/test/controllers/spi/base_controller_test.rb @@ -1,6 +1,10 @@ require "test_helper" class SPI::BaseControllerTest < ActionDispatch::IntegrationTest + setup do + host! URI(Exercism.config.spi_url).host + end + test "serves requests on the canonical spi path" do user = create :user @@ -24,4 +28,14 @@ class SPI::BaseControllerTest < ActionDispatch::IntegrationTest assert_response :not_found end + + test "is not routed on the website host" do + user = create :user + User::UnsubscribeFromAllEmails.expects(:call).never + host! URI(Rails.application.routes.default_url_options[:host]).host + + assert_raises ActionController::RoutingError do + patch "/spi/unsubscribe_user", params: { email: user.email, reason: :bounce } + end + end end diff --git a/test/controllers/spi/profile_image_data_controller_test.rb b/test/controllers/spi/profile_image_data_controller_test.rb index 2b7de6076f..3f39505f54 100644 --- a/test/controllers/spi/profile_image_data_controller_test.rb +++ b/test/controllers/spi/profile_image_data_controller_test.rb @@ -1,6 +1,10 @@ require "test_helper" class SPI::ProfileImageDataControllerTest < ActionDispatch::IntegrationTest + setup do + host! URI(Exercism.config.spi_url).host + end + test "returns the data needed to draw the image" do user = create :user, handle: 'ihid', name: 'Jeremy Walker', flair: :founder create(:user_profile, user:) diff --git a/test/controllers/spi/solution_image_data_controller_test.rb b/test/controllers/spi/solution_image_data_controller_test.rb index e3202cb5aa..55daa06e15 100644 --- a/test/controllers/spi/solution_image_data_controller_test.rb +++ b/test/controllers/spi/solution_image_data_controller_test.rb @@ -1,6 +1,10 @@ require "test_helper" class SPI::SolutionImageDataControllerTest < ActionDispatch::IntegrationTest + setup do + host! URI(Exercism.config.spi_url).host + end + test "returns the data needed to draw the image" do track = create :track, slug: 'ruby', title: 'Ruby' exercise = create :practice_exercise, track:, slug: 'bob', title: 'Bob' diff --git a/test/controllers/spi/tooling_jobs_controller_test.rb b/test/controllers/spi/tooling_jobs_controller_test.rb index 78046d394e..a4b90f8881 100644 --- a/test/controllers/spi/tooling_jobs_controller_test.rb +++ b/test/controllers/spi/tooling_jobs_controller_test.rb @@ -1,10 +1,14 @@ require "test_helper" class SPI::ToolingJobsControllerTest < ActionDispatch::IntegrationTest + setup do + host! URI(Exercism.config.spi_url).host + end + test "update proxies to command" do id = SecureRandom.uuid ToolingJob::Process.expects(:call).with(id) - patch spi_tooling_job_url(id) + patch spi_tooling_job_path(id) end end diff --git a/test/controllers/spi/unsubscribe_user_test.rb b/test/controllers/spi/unsubscribe_user_test.rb index cda9a05cc4..a29e80317c 100644 --- a/test/controllers/spi/unsubscribe_user_test.rb +++ b/test/controllers/spi/unsubscribe_user_test.rb @@ -1,6 +1,10 @@ require "test_helper" class SPI::UnsubscribeUserTest < ActionDispatch::IntegrationTest + setup do + host! URI(Exercism.config.spi_url).host + end + test "unsubscribes correctly" do user = create :user