SECURITY: Preload data only when rendering application layout

This commit drops the `before_action :preload_json` callback in `ApplicationController` as it adds unnecessary complexity to `ApplicationController` as well as other controllers which has to skip this callback. The source of the complexity comes mainly from the following two conditionals in the `preload_json` method:

```
    # We don't preload JSON on xhr or JSON request
    return if request.xhr? || request.format.json?

    # if we are posting in makes no sense to preload
    return if request.method != "GET"
```

Basically, the conditionals solely exists for optimization purposes to ensure that we don't run the preloading code when the request is not a GET request and the response is not expected to be HTML. The key problem here is that the conditionals are trying to expect what the content type of the response will be and this has proven to be hard to get right. Instead, we can simplify this problem by running the preloading code in a more deterministic way which is to preload only when the `application` layout is being rendered and this is main change that this commit introduces.
This commit is contained in:
Alan Guo Xiang Tan 2025-01-03 12:21:17 +08:00 committed by Roman Rizzi
parent 8192aedd69
commit 5d60557e0f
No known key found for this signature in database
GPG Key ID: 64024A71CE7330D3
10 changed files with 200 additions and 191 deletions

View File

@ -43,6 +43,7 @@ class ApplicationController < ActionController::Base
before_action :block_if_requires_login before_action :block_if_requires_login
before_action :redirect_to_profile_if_required before_action :redirect_to_profile_if_required
before_action :preload_json before_action :preload_json
before_action :initialize_application_layout_preloader
before_action :check_xhr before_action :check_xhr
after_action :add_readonly_header after_action :add_readonly_header
after_action :perform_refresh_session after_action :perform_refresh_session
@ -277,6 +278,7 @@ class ApplicationController < ActionController::Base
def rescue_discourse_actions(type, status_code, opts = nil) def rescue_discourse_actions(type, status_code, opts = nil)
opts ||= {} opts ||= {}
show_json_errors = show_json_errors =
(request.format && request.format.json?) || (request.xhr?) || (request.format && request.format.json?) || (request.xhr?) ||
((params[:external_id] || "").ends_with? ".json") ((params[:external_id] || "").ends_with? ".json")
@ -339,8 +341,9 @@ class ApplicationController < ActionController::Base
rescue Discourse::InvalidAccess rescue Discourse::InvalidAccess
return render plain: message, status: status_code return render plain: message, status: status_code
end end
with_resolved_locale do with_resolved_locale do
error_page_opts[:layout] = (opts[:include_ember] && @preloaded) ? set_layout : "no_ember" error_page_opts[:layout] = opts[:include_ember] && @_preloaded ? set_layout : "no_ember"
render html: build_not_found_page(error_page_opts) render html: build_not_found_page(error_page_opts)
end end
end end
@ -424,31 +427,6 @@ class ApplicationController < ActionController::Base
I18n.with_locale(locale) { yield } I18n.with_locale(locale) { yield }
end end
def store_preloaded(key, json)
@preloaded ||= {}
# I dislike that there is a gsub as opposed to a gsub!
# but we can not be mucking with user input, I wonder if there is a way
# to inject this safety deeper in the library or even in AM serializer
@preloaded[key] = json.gsub("</", "<\\/")
end
# If we are rendering HTML, preload the session data
def preload_json
# We don't preload JSON on xhr or JSON request
return if request.xhr? || request.format.json?
# if we are posting in makes no sense to preload
return if request.method != "GET"
# TODO should not be invoked on redirection so this should be further deferred
preload_anonymous_data
if current_user
current_user.sync_notification_channel_position
preload_current_user_data
end
end
def set_mobile_view def set_mobile_view
session[:mobile_view] = params[:mobile_view] if params.has_key?(:mobile_view) session[:mobile_view] = params[:mobile_view] if params.has_key?(:mobile_view)
end end
@ -608,109 +586,36 @@ class ApplicationController < ActionController::Base
end end
def login_method def login_method
return if current_user.anonymous? return if !current_user || current_user.anonymous?
current_user.authenticated_with_oauth ? Auth::LOGIN_METHOD_OAUTH : Auth::LOGIN_METHOD_LOCAL current_user.authenticated_with_oauth ? Auth::LOGIN_METHOD_OAUTH : Auth::LOGIN_METHOD_LOCAL
end end
private private
def preload_anonymous_data # This method is intended to be a no-op.
store_preloaded("site", Site.json_for(guardian)) # The only reason this `before_action` callback continues to exist is for backwards compatibility purposes which we cannot easily
store_preloaded("siteSettings", SiteSetting.client_settings_json) # solve at this point. In the `rescue_discourse_actions` method, the `@_preloaded` instance variable is used to determine
store_preloaded("customHTML", custom_html_json) # if the `no_ember` or `application` layout should be used. To use the `no_ember` layout, controllers have been
store_preloaded("banner", banner_json) # setting `skip_before_action :preload_json`. This is however a flawed implementation as which layout is used for rendering
store_preloaded("customEmoji", custom_emoji) # errors should ideally not be set by skipping a `before_action` callback. To fix this properly will require some careful
store_preloaded("isReadOnly", get_or_check_readonly_mode.to_json) # planning which we do not intend to tackle at this point.
store_preloaded("isStaffWritesOnly", get_or_check_staff_writes_only_mode.to_json) def preload_json
store_preloaded("activatedThemes", activated_themes_json) return if request.format&.json? || request.xhr? || !request.get?
@_preloaded = {}
end end
def preload_current_user_data def initialize_application_layout_preloader
store_preloaded( @application_layout_preloader =
"currentUser", ApplicationLayoutPreloader.new(
MultiJson.dump( guardian:,
CurrentUserSerializer.new( theme_id: @theme_id,
current_user, theme_target: view_context.mobile_view? ? :mobile : :desktop,
scope: guardian, login_method:,
root: false,
login_method: login_method,
),
),
)
report = TopicTrackingState.report(current_user)
serializer = TopicTrackingStateSerializer.new(report, scope: guardian, root: false)
hash = serializer.as_json
store_preloaded("topicTrackingStates", MultiJson.dump(hash[:data]))
store_preloaded("topicTrackingStateMeta", MultiJson.dump(hash[:meta]))
if current_user.admin?
# This is used in the wizard so we can preload fonts using the FontMap JS API.
store_preloaded("fontMap", MultiJson.dump(load_font_map))
# Used to show plugin-specific admin routes in the sidebar.
store_preloaded(
"visiblePlugins",
MultiJson.dump(
Discourse
.plugins_sorted_by_name(enabled_only: false)
.map do |plugin|
{
name: plugin.name.downcase,
admin_route: plugin.admin_route,
enabled: plugin.enabled?,
}
end,
),
) )
end end
end
def custom_html_json def store_preloaded(key, json)
target = view_context.mobile_view? ? :mobile : :desktop @application_layout_preloader.store_preloaded(key, json)
data =
if @theme_id.present?
{
top: Theme.lookup_field(@theme_id, target, "after_header"),
footer: Theme.lookup_field(@theme_id, target, "footer"),
}
else
{}
end
data.merge! DiscoursePluginRegistry.custom_html if DiscoursePluginRegistry.custom_html
DiscoursePluginRegistry.html_builders.each do |name, _|
if name.start_with?("client:")
data[name.sub(/\Aclient:/, "")] = DiscoursePluginRegistry.build_html(name, self)
end
end
MultiJson.dump(data)
end
def self.banner_json_cache
@banner_json_cache ||= DistributedCache.new("banner_json")
end
def banner_json
return "{}" if !current_user && SiteSetting.login_required?
ApplicationController
.banner_json_cache
.defer_get_set("json") do
topic = Topic.where(archetype: Archetype.banner).first
banner = topic.present? ? topic.banner : {}
MultiJson.dump(banner)
end
end
def custom_emoji
serializer = ActiveModel::ArraySerializer.new(Emoji.custom, each_serializer: EmojiSerializer)
MultiJson.dump(serializer)
end end
# Render action for a JSON error. # Render action for a JSON error.
@ -946,7 +851,7 @@ class ApplicationController < ActionController::Base
def build_not_found_page(opts = {}) def build_not_found_page(opts = {})
if SiteSetting.bootstrap_error_pages? if SiteSetting.bootstrap_error_pages?
preload_json initialize_application_layout_preloader
opts[:layout] = "application" if opts[:layout] == "no_ember" opts[:layout] = "application" if opts[:layout] == "no_ember"
end end
@ -1063,13 +968,6 @@ class ApplicationController < ActionController::Base
end end
end end
def activated_themes_json
id = @theme_id
return "{}" if id.blank?
ids = Theme.transform_ids(id)
Theme.where(id: ids).pluck(:id, :name).to_h.to_json
end
def run_second_factor!(action_class, action_data: nil, target_user: current_user) def run_second_factor!(action_class, action_data: nil, target_user: current_user)
if current_user && target_user != current_user if current_user && target_user != current_user
# Anon can run 2fa against another target, but logged-in users should not. # Anon can run 2fa against another target, but logged-in users should not.
@ -1116,20 +1014,6 @@ class ApplicationController < ActionController::Base
request.get? && !(request.format && request.format.json?) && !request.xhr? request.get? && !(request.format && request.format.json?) && !request.xhr?
end end
def load_font_map
DiscourseFonts
.fonts
.each_with_object({}) do |font, font_map|
next if !font[:variants]
font_map[font[:key]] = font[:variants].map do |v|
{
url: "#{Discourse.base_url}/fonts/#{v[:filename]}?v=#{DiscourseFonts::VERSION}",
weight: v[:weight],
}
end
end
end
def fetch_limit_from_params(params: self.params, default:, max:) def fetch_limit_from_params(params: self.params, default:, max:)
fetch_int_from_params(:limit, params: params, default: default, max: max) fetch_int_from_params(:limit, params: params, default: default, max: max)
end end

View File

@ -71,7 +71,6 @@ class FinishInstallationController < ApplicationController
end end
def ensure_no_admins def ensure_no_admins
preload_anonymous_data
raise Discourse::InvalidAccess.new unless SiteSetting.has_login_hint? raise Discourse::InvalidAccess.new unless SiteSetting.has_login_hint?
end end
end end

View File

@ -16,15 +16,15 @@ class SiteController < ApplicationController
end end
def custom_html def custom_html
render json: custom_html_json render json: @application_layout_preloader.custom_html_json
end end
def banner def banner
render json: banner_json render json: @application_layout_preloader.banner_json
end end
def emoji def emoji
render json: custom_emoji render json: @application_layout_preloader.custom_emoji
end end
def basic_info def basic_info

View File

@ -684,8 +684,12 @@ module ApplicationHelper
end end
def preloaded_json def preloaded_json
return "{}" if @preloaded.blank? return "{}" if !@application_layout_preloader
@preloaded.transform_values { |value| escape_unicode(value) }.to_json
@application_layout_preloader
.preloaded_data
.transform_values { |value| escape_unicode(value) }
.to_json
end end
def client_side_setup_data def client_side_setup_data

View File

@ -405,7 +405,7 @@ class Topic < ActiveRecord::Base
banner = "banner" banner = "banner"
if archetype_before_last_save == banner || archetype == banner if archetype_before_last_save == banner || archetype == banner
ApplicationController.banner_json_cache.clear ApplicationLayoutPreloader.banner_json_cache.clear
end end
if tags_changed || saved_change_to_attribute?(:category_id) || if tags_changed || saved_change_to_attribute?(:category_id) ||
@ -1854,7 +1854,7 @@ class Topic < ActiveRecord::Base
def update_excerpt(excerpt) def update_excerpt(excerpt)
update_column(:excerpt, excerpt) update_column(:excerpt, excerpt)
ApplicationController.banner_json_cache.clear if archetype == "banner" ApplicationLayoutPreloader.banner_json_cache.clear if archetype == "banner"
end end
def pm_with_non_human_user? def pm_with_non_human_user?

View File

@ -0,0 +1,145 @@
# frozen_string_literal: true
class ApplicationLayoutPreloader
include ReadOnlyMixin
def self.banner_json_cache
@banner_json_cache ||= DistributedCache.new("banner_json")
end
def initialize(guardian:, theme_id:, theme_target:, login_method:)
@guardian = guardian
@theme_id = theme_id
@theme_target = theme_target
@login_method = login_method
@preloaded = {}
end
def store_preloaded(key, json)
# I dislike that there is a gsub as opposed to a gsub!
# but we can not be mucking with user input, I wonder if there is a way
# to inject this safety deeper in the library or even in AM serializer
@preloaded[key] = json.gsub("</", "<\\/")
end
def preloaded_data
preload_anonymous_data
if @guardian.authenticated?
@guardian.user.sync_notification_channel_position
preload_current_user_data
end
@preloaded
end
def banner_json
return "{}" if !@guardian.authenticated? && SiteSetting.login_required?
self
.class
.banner_json_cache
.defer_get_set("json") do
topic = Topic.where(archetype: Archetype.banner).first
banner = topic.present? ? topic.banner : {}
MultiJson.dump(banner)
end
end
def custom_html_json
data =
if @theme_id.present?
{
top: Theme.lookup_field(@theme_id, @theme_target, "after_header"),
footer: Theme.lookup_field(@theme_id, @theme_target, "footer"),
}
else
{}
end
data.merge! DiscoursePluginRegistry.custom_html if DiscoursePluginRegistry.custom_html
DiscoursePluginRegistry.html_builders.each do |name, _|
if name.start_with?("client:")
data[name.sub(/\Aclient:/, "")] = DiscoursePluginRegistry.build_html(name, self)
end
end
MultiJson.dump(data)
end
private
def preload_current_user_data
@preloaded["currentUser"] = MultiJson.dump(
CurrentUserSerializer.new(
@guardian.user,
scope: @guardian,
root: false,
login_method: @login_method,
),
)
report = TopicTrackingState.report(@guardian.user)
serializer = TopicTrackingStateSerializer.new(report, scope: @guardian, root: false)
hash = serializer.as_json
@preloaded["topicTrackingStates"] = MultiJson.dump(hash[:data])
@preloaded["topicTrackingStateMeta"] = MultiJson.dump(hash[:meta])
if @guardian.is_admin?
# This is used in the wizard so we can preload fonts using the FontMap JS API.
@preloaded["fontMap"] = MultiJson.dump(load_font_map)
# Used to show plugin-specific admin routes in the sidebar.
@preloaded["visiblePlugins"] = MultiJson.dump(
Discourse
.plugins_sorted_by_name(enabled_only: false)
.map do |plugin|
{
name: plugin.name.downcase,
admin_route: plugin.admin_route,
enabled: plugin.enabled?,
}
end,
)
end
end
def preload_anonymous_data
@preloaded["site"] = Site.json_for(@guardian)
@preloaded["siteSettings"] = SiteSetting.client_settings_json
@preloaded["customHTML"] = custom_html_json
@preloaded["banner"] = banner_json
@preloaded["customEmoji"] = custom_emoji
@preloaded["isReadOnly"] = get_or_check_readonly_mode.to_json
@preloaded["isStaffWritesOnly"] = get_or_check_staff_writes_only_mode.to_json
@preloaded["activatedThemes"] = activated_themes_json
end
def activated_themes_json
id = @theme_id
return "{}" if id.blank?
ids = Theme.transform_ids(id)
Theme.where(id: ids).pluck(:id, :name).to_h.to_json
end
def load_font_map
DiscourseFonts
.fonts
.each_with_object({}) do |font, font_map|
next if !font[:variants]
font_map[font[:key]] = font[:variants].map do |v|
{
url: "#{Discourse.base_url}/fonts/#{v[:filename]}?v=#{DiscourseFonts::VERSION}",
weight: v[:weight],
}
end
end
end
def custom_emoji
serializer = ActiveModel::ArraySerializer.new(Emoji.custom, each_serializer: EmojiSerializer)
MultiJson.dump(serializer)
end
end

View File

@ -128,7 +128,7 @@ class DbHelper
SiteSetting.refresh! SiteSetting.refresh!
Theme.expire_site_cache! Theme.expire_site_cache!
SiteIconManager.ensure_optimized! SiteIconManager.ensure_optimized!
ApplicationController.banner_json_cache.clear ApplicationLayoutPreloader.banner_json_cache.clear
end end
def self.find_text_columns(excluded_tables) def self.find_text_columns(excluded_tables)

View File

@ -565,14 +565,24 @@ RSpec.describe ApplicationHelper do
end end
describe "preloaded_json" do describe "preloaded_json" do
it "returns empty JSON if preloaded is empty" do fab!(:user)
@preloaded = nil
it "returns empty JSON if preloader is not initialized" do
@application_layout_preloader = nil
expect(helper.preloaded_json).to eq("{}") expect(helper.preloaded_json).to eq("{}")
end end
it "escapes and strips invalid unicode and strips in json body" do it "escapes and strips invalid unicode and strips in json body" do
@preloaded = { test: %{["< \x80"]} } @application_layout_preloader =
expect(helper.preloaded_json).to eq(%{{"test":"[\\"\\u003c \uFFFD\\"]"}}) ApplicationLayoutPreloader.new(
guardian: Guardian.new(user),
theme_id: nil,
theme_target: nil,
login_method: nil,
)
@application_layout_preloader.store_preloaded("test", %{["< \x80"]})
expect(helper.preloaded_json).to include(%{"test":"[\\"\\u003c \uFFFD\\"]"})
end end
end end

View File

@ -22,8 +22,8 @@ RSpec.describe Admin::BackupsController do
end end
def map_preloaded def map_preloaded
controller JSON
.instance_variable_get("@preloaded") .parse(Nokogiri.HTML5(response.body).at_css("[data-preloaded]")["data-preloaded"])
.map { |key, value| [key, JSON.parse(value)] } .map { |key, value| [key, JSON.parse(value)] }
.to_h .to_h
end end
@ -53,9 +53,11 @@ RSpec.describe Admin::BackupsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
preloaded = map_preloaded preloaded = map_preloaded
expect(preloaded["operations_status"].symbolize_keys).to eq( expect(preloaded["operations_status"].symbolize_keys).to eq(
BackupRestore.operations_status, BackupRestore.operations_status,
) )
expect(preloaded["logs"].size).to eq(BackupRestore.logs.size) expect(preloaded["logs"].size).to eq(BackupRestore.logs.size)
end end
end end

View File

@ -1265,41 +1265,6 @@ RSpec.describe ApplicationController do
get "/", headers: { "HTTP_USER_AGENT" => "iam badcrawler" } get "/", headers: { "HTTP_USER_AGENT" => "iam badcrawler" }
expect(response.status).to eq(429) expect(response.status).to eq(429)
end end
context "with XHR requests" do
before { global_setting :anon_cache_store_threshold, 1 }
def preloaded_data
response_html = Nokogiri::HTML5.fragment(response.body)
JSON.parse(response_html.css("#data-preloaded")[0]["data-preloaded"])
end
it "does not return the same preloaded data for XHR and non-XHR requests" do
# Request is stored in cache
get "/", headers: { "X-Requested-With" => "XMLHTTPrequest" }
expect(response.status).to eq(200)
expect(response.headers["X-Discourse-Cached"]).to eq("store")
expect(preloaded_data).not_to have_key("site")
# Request is served from cache
get "/", headers: { "X-Requested-With" => "xmlhttprequest" }
expect(response.status).to eq(200)
expect(response.headers["X-Discourse-Cached"]).to eq("true")
expect(preloaded_data).not_to have_key("site")
# Request is not served from cache because of different headers, but is stored
get "/"
expect(response.status).to eq(200)
expect(response.headers["X-Discourse-Cached"]).to eq("store")
expect(preloaded_data).to have_key("site")
# Request is served from cache
get "/", headers: { "X-Requested-With" => "xmlhttprequest" }
expect(response.status).to eq(200)
expect(response.headers["X-Discourse-Cached"]).to eq("true")
expect(preloaded_data).not_to have_key("site")
end
end
end end
end end