From 9d544dc2dd0cfceb36811facfafa567855449c48 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Mon, 12 Jan 2026 10:01:20 +0800 Subject: [PATCH] DEV: Add /dev-mode endpoint for MiniProfiler auth cookie (#37027) What is the problem? Developers need to profile pages while browsing as anonymous users or while impersonating other users. Currently, MiniProfiler authorization is tied to the session, so it's lost when the session changes. What is the solution? Add a `/dev-mode` endpoint that sets an encrypted cookie to persist MiniProfiler authorization for 1 hour, independent of the session: - New `DevModeController` with CSRF-protected POST form - Only accessible to users in the Developer group - Cookie validated on each request by checking timestamp, user existence, and developer status --- app/controllers/application_controller.rb | 21 +++++- app/controllers/dev_mode_controller.rb | 34 +++++++++ app/views/dev_mode/index.html.erb | 14 ++++ config/locales/server.en.yml | 4 + config/routes.rb | 3 + spec/requests/application_controller_spec.rb | 78 ++++++++++++++++++++ spec/requests/dev_mode_controller_spec.rb | 69 +++++++++++++++++ 7 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 app/controllers/dev_mode_controller.rb create mode 100644 app/views/dev_mode/index.html.erb create mode 100644 spec/requests/dev_mode_controller_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index af37a8d0f90..01e858004a0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -63,6 +63,8 @@ class ApplicationController < ActionController::Base HONEYPOT_KEY = "HONEYPOT_KEY" CHALLENGE_KEY = "CHALLENGE_KEY" + MINI_PROFILER_AUTH_COOKIE_EXPIRES_IN = 1.hour + MINI_PROFILER_CLASS = defined?(Rack::MiniProfiler) ? Rack::MiniProfiler : nil layout :set_layout @@ -684,12 +686,25 @@ class ApplicationController < ActionController::Base end def mini_profiler_enabled? - defined?(Rack::MiniProfiler) && (guardian.is_developer? || Rails.env.development?) + return false unless MINI_PROFILER_CLASS + return true if Rails.env.development? + return true if guardian.is_developer? + + if auth = cookies.encrypted[:_mp_auth] + user_id = auth[:user_id] + issued_at = auth[:issued_at] + + if issued_at && issued_at > MINI_PROFILER_AUTH_COOKIE_EXPIRES_IN.ago.to_i + user = User.find_by(id: user_id) + return true if user && Guardian.new(user).is_developer? + end + end + + false end def authorize_mini_profiler - return unless mini_profiler_enabled? - Rack::MiniProfiler.authorize_request + MINI_PROFILER_CLASS.authorize_request if mini_profiler_enabled? end def check_xhr diff --git a/app/controllers/dev_mode_controller.rb b/app/controllers/dev_mode_controller.rb new file mode 100644 index 00000000000..fed70cb33fc --- /dev/null +++ b/app/controllers/dev_mode_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class DevModeController < ApplicationController + layout "no_ember" + skip_before_action :preload_json, :check_xhr + + before_action :ensure_developer + + def index + response.headers["X-Robots-Tag"] = "noindex, nofollow" + end + + def enter + if params["enable_rack_mini_profiler"] == "true" + cookies.encrypted[:_mp_auth] = { + value: { + user_id: current_user.id, + issued_at: Time.now.to_i, + }, + expires: MINI_PROFILER_AUTH_COOKIE_EXPIRES_IN.from_now, + httponly: true, + secure: SiteSetting.force_https, + same_site: :strict, + } + end + redirect_to path("/") + end + + private + + def ensure_developer + raise Discourse::NotFound unless guardian.is_developer? + end +end diff --git a/app/views/dev_mode/index.html.erb b/app/views/dev_mode/index.html.erb new file mode 100644 index 00000000000..953270bb8e3 --- /dev/null +++ b/app/views/dev_mode/index.html.erb @@ -0,0 +1,14 @@ +
+

<%= t 'dev_mode.title' %>

+ <%= form_tag(dev_mode_enter_path) do %> +

+ +

+

+ <%= submit_tag t('dev_mode.enter'), class: 'btn btn-primary' %> +

+ <% end %> +
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 95478f923b3..562bae24efe 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -5539,6 +5539,10 @@ en: deprecation_error_label: Make Javascript deprecations raise an error enter: "Enter Safe Mode" must_select: "You must select at least one option to enter safe mode." + dev_mode: + title: "Developer Mode" + enable_rack_mini_profiler: "Enable Rack Mini Profiler to persist across session changes for 1 hour" + enter: "Enter" wizard: title: "Discourse Setup" step: diff --git a/config/routes.rb b/config/routes.rb index 194ded7eed9..70861e0437d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1775,6 +1775,9 @@ Discourse::Application.routes.draw do get "/safe-mode" => "safe_mode#index" post "/safe-mode" => "safe_mode#enter", :as => "safe_mode_enter" + get "/dev-mode" => "dev_mode#index" + post "/dev-mode" => "dev_mode#enter", :as => "dev_mode_enter" + get "/theme-qunit" => "qunit#theme" get "/theme-tests", to: redirect("/theme-qunit") diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index 1e2baedc0e0..fb15cec5404 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -1997,4 +1997,82 @@ RSpec.describe ApplicationController do ) end end + + describe "when authorizing mini_profiler" do + mini_profiler_stub = Class.new { def self.authorize_request = nil } + + around do |example| + stub_const(ApplicationController, :MINI_PROFILER_CLASS, mini_profiler_stub) { example.run } + end + + fab!(:developer) { Fabricate(:admin).tap { |u| Developer.create!(user_id: u.id) } } + fab!(:user) + fab!(:admin) + + before { allow(mini_profiler_stub).to receive(:authorize_request) } + after { Developer.rebuild_cache } + + it "authorizes mini_profiler for developer user" do + sign_in(developer) + + get "/latest" + + expect(mini_profiler_stub).to have_received(:authorize_request) + end + + it "does not authorize mini_profiler for non-developer user" do + sign_in(admin) + + get "/latest" + + expect(mini_profiler_stub).not_to have_received(:authorize_request) + end + + describe "using the mini_profiler auth cookie" do + def set_mini_profiler_auth_cookie(user, issued_at: Time.now.to_i) + data = { user_id: user.id, issued_at: issued_at } + jar = ActionDispatch::Cookies::CookieJar.build(ActionDispatch::TestRequest.create, {}) + jar.encrypted[:_mp_auth] = { value: data } + cookies[:_mp_auth] = jar[:_mp_auth] + end + + it "authorizes mini_profiler for anon user with valid cookie" do + set_mini_profiler_auth_cookie(developer) + + get "/latest" + + expect(mini_profiler_stub).to have_received(:authorize_request) + end + + it "does not authorize with expired cookie" do + set_mini_profiler_auth_cookie( + developer, + issued_at: + (ApplicationController::MINI_PROFILER_AUTH_COOKIE_EXPIRES_IN + 1.hour).ago.to_i, + ) + + get "/latest" + + expect(mini_profiler_stub).not_to have_received(:authorize_request) + end + + it "does not authorize if user no longer exists" do + set_mini_profiler_auth_cookie(developer) + developer.destroy! + + get "/latest" + + expect(mini_profiler_stub).not_to have_received(:authorize_request) + end + + it "does not authorize if user is no longer a developer" do + set_mini_profiler_auth_cookie(developer) + Developer.find_by(user_id: developer.id).destroy! + + get "/latest" + + expect(mini_profiler_stub).not_to have_received(:authorize_request) + end + end + end end diff --git a/spec/requests/dev_mode_controller_spec.rb b/spec/requests/dev_mode_controller_spec.rb new file mode 100644 index 00000000000..136957c781a --- /dev/null +++ b/spec/requests/dev_mode_controller_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +RSpec.describe DevModeController do + fab!(:developer) { Fabricate(:admin).tap { |u| Developer.create!(user_id: u.id) } } + fab!(:user) + fab!(:admin) + + after { Developer.rebuild_cache } + + describe "#index" do + it "returns 200 for developers" do + sign_in(developer) + get "/dev-mode" + expect(response.status).to eq(200) + end + + it "returns 404 for anonymous users" do + get "/dev-mode" + expect(response.status).to eq(404) + end + + it "returns 404 for normal users" do + sign_in(user) + get "/dev-mode" + expect(response.status).to eq(404) + end + + it "returns 404 for admins who are not developers" do + sign_in(admin) + get "/dev-mode" + expect(response.status).to eq(404) + end + end + + describe "#enter" do + it "sets _mp_auth cookie when enable_rack_mini_profiler param is present" do + sign_in(developer) + post "/dev-mode", params: { enable_rack_mini_profiler: "true" } + + expect(response.cookies["_mp_auth"]).to be_present + expect(response).to redirect_to("/") + end + + it "does not set cookie when enable_rack_mini_profiler param is absent" do + sign_in(developer) + post "/dev-mode" + + expect(response.cookies["_mp_auth"]).to be_nil + expect(response).to redirect_to("/") + end + + it "returns 404 for anonymous users" do + post "/dev-mode", params: { enable_rack_mini_profiler: "true" } + expect(response.status).to eq(404) + end + + it "returns 404 for normal users" do + sign_in(user) + post "/dev-mode", params: { enable_rack_mini_profiler: "true" } + expect(response.status).to eq(404) + end + + it "returns 404 for admins who are not developers" do + sign_in(admin) + post "/dev-mode", params: { enable_rack_mini_profiler: "true" } + expect(response.status).to eq(404) + end + end +end