0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/app/controllers/list_controller.rb
Alan Guo Xiang Tan c35b9ff110
SECURITY: Harden ListController top period query against SQL injection [backport 2026.1] (#41723)
Backport of #41718 to release/2026.1.

---

A category's `default_top_period` was interpolated raw into the
top-topic
score predicate in `ListController` before the period was validated
against
the allowlist. For anonymous requests to a category whose default view
is
`top`, an attacker-planted value reached the SQL predicate directly. The
response never returns the query's data, but it changes depending on
whether
an injected condition holds, so an attacker can read data one yes/no
answer at
a time.

This is hardening rather than a critical fix because the column is
`varchar(20)`: PostgreSQL rejects an over-long value, leaving no room
for a
subquery or a second statement. An attacker could only probe one value
at a
time, for example reading the PostgreSQL session role character by
character,
not perform arbitrary reads or writes.

Harden the path in depth:

- Sanitise the category-derived period in `best_period_for`, falling
back to
the site default when it is not a known period, so the untrusted value
never
  reaches SQL.
- Build the score column in `best_period_with_topics_for` through the
validating `TopTopic.score_column_for_period` helper instead of raw
string
  interpolation.
- Clear `Category#default_top_period` on validation when it is not a
known
period, so an invalid value is never stored and the category falls back
to
  the site default at read time.
2026-07-15 12:18:25 +08:00

533 lines
18 KiB
Ruby
Vendored

# frozen_string_literal: true
class ListController < ApplicationController
include TopicListResponder
include TopicQueryParams
skip_before_action :check_xhr
before_action :set_category,
only: [
:category_default,
# filtered topics lists
Discourse.filters.map { |f| :"category_#{f}" },
Discourse.filters.map { |f| :"category_none_#{f}" },
# top summaries
:category_top,
:category_none_top,
# top pages (ie. with a period)
TopTopic.periods.map { |p| :"category_top_#{p}" },
TopTopic.periods.map { |p| :"category_none_top_#{p}" },
# category feeds
:category_feed,
].flatten
before_action :ensure_logged_in,
except: [
:topics_by,
# anonymous filters
Discourse.anonymous_filters,
Discourse.anonymous_filters.map { |f| "#{f}_feed" },
# anonymous categorized filters
:category_default,
Discourse.anonymous_filters.map { |f| :"category_#{f}" },
Discourse.anonymous_filters.map { |f| :"category_none_#{f}" },
# category feeds
:category_feed,
# user topics feed
:user_topics_feed,
# top summaries
:top,
:category_top,
:category_none_top,
# top pages (ie. with a period)
TopTopic.periods.map { |p| :"top_#{p}" },
TopTopic.periods.map { |p| :"top_#{p}_feed" },
TopTopic.periods.map { |p| :"category_top_#{p}" },
TopTopic.periods.map { |p| :"category_none_top_#{p}" },
:group_topics,
:filter,
].flatten
rescue_from ActionController::Redirecting::UnsafeRedirectError do
rescue_discourse_actions(:not_found, 404)
end
# Create our filters
Discourse.filters.each do |filter|
define_method(filter) do |options = nil|
list_opts = build_topic_list_options
list_opts.merge!(options) if options
user = current_user
if params[:category].blank? && filter == :latest &&
!SiteSetting.show_category_definitions_in_topic_lists
list_opts[:no_definitions] = true
end
list = TopicQuery.new(user, list_opts).public_send("list_#{filter}")
if guardian.can_create_shared_draft? && @category.present?
if @category.id == SiteSetting.shared_drafts_category.to_i
# On shared drafts, show the destination category
list.topics.each { |t| t.includes_destination_category = t.shared_draft.present? }
else
# When viewing a non-shared draft category, find topics whose
# destination are this category
shared_drafts =
TopicQuery.new(
user,
category: SiteSetting.shared_drafts_category,
destination_category_id: list_opts[:category],
).list_latest
if shared_drafts.present? && shared_drafts.topics.present?
list.shared_drafts = shared_drafts.topics
end
end
end
list.more_topics_url = construct_url_with(:next, list_opts)
list.prev_topics_url = construct_url_with(:prev, list_opts)
if Discourse.anonymous_filters.include?(filter)
@description = SiteSetting.site_description
@rss = filter
@rss_description = filter
# Note the first is the default and we don't add a title
if (filter.to_s != current_homepage) && use_crawler_layout?
filter_title = I18n.t("js.filters.#{filter}.title", count: 0)
if list_opts[:category] && @category
@title =
I18n.t("js.filters.with_category", filter: filter_title, category: @category.name)
else
@title = I18n.t("js.filters.with_topics", filter: filter_title)
end
@title << " - #{SiteSetting.title}"
elsif @category.blank? && (filter.to_s == current_homepage) &&
SiteSetting.short_site_description.present?
@title = "#{SiteSetting.title} - #{SiteSetting.short_site_description}"
end
end
respond_with_list(list)
end
define_method("category_#{filter}") do
canonical_url "#{Discourse.base_url_no_prefix}#{@category.url}"
self.public_send(filter, category: @category.id)
end
define_method("category_none_#{filter}") do
self.public_send(filter, category: @category.id, no_subcategories: true)
end
end
def filter
topic_query_opts = { no_definitions: !SiteSetting.show_category_definitions_in_topic_lists }
%i[page q].each do |key|
if params.key?(key.to_s)
value = params[key]
raise Discourse::InvalidParameters.new(key) if !TopicQuery.validate?(key, value)
topic_query_opts[key] = value
end
end
user = current_user
list = TopicQuery.new(user, topic_query_opts).list_filter
list.more_topics_url = construct_url_with(:next, topic_query_opts)
list.prev_topics_url = construct_url_with(:prev, topic_query_opts)
respond_with_list(list)
end
def category_default
canonical_url "#{Discourse.base_url_no_prefix}#{@category.url}"
view_method = @category.default_view
view_method = "latest" if %w[hot latest top].exclude?(view_method)
self.public_send(view_method, category: @category.id)
end
def topics_by
list_opts = build_topic_list_options
target_user =
fetch_user_from_params(
{
include_inactive:
current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts),
},
%i[user_stat user_option],
)
ensure_can_see_profile!(target_user)
list = generate_list_for("topics_by", target_user, list_opts)
list.more_topics_url = construct_url_with(:next, list_opts)
list.prev_topics_url = construct_url_with(:prev, list_opts)
respond_with_list(list)
end
def group_topics
group = Group.find_by(name: params[:group_name])
raise Discourse::NotFound unless group
guardian.ensure_can_see_group!(group)
guardian.ensure_can_see_group_members!(group)
list_opts = build_topic_list_options
list = generate_list_for("group_topics", group, list_opts)
list.more_topics_url = construct_url_with(:next, list_opts)
list.prev_topics_url = construct_url_with(:prev, list_opts)
respond_with_list(list)
end
def self.generate_message_route(action)
define_method action do
message_route(action)
end
end
def message_route(action)
target_user =
fetch_user_from_params(
{ include_inactive: current_user.try(:staff?) },
%i[user_stat user_option],
)
case action
when :private_messages_unread, :private_messages_new, :private_messages_group_new,
:private_messages_group_unread
raise Discourse::NotFound if target_user.id != current_user.id
when :private_messages_tag
raise Discourse::NotFound if target_user.id != current_user.id
raise Discourse::NotFound if !guardian.can_tag_pms?
when :private_messages_warnings
guardian.ensure_can_see_warnings!(target_user)
when :private_messages_group, :private_messages_group_archive
group = Group.find_by("LOWER(name) = ?", params[:group_name].downcase)
raise Discourse::NotFound if !group
raise Discourse::NotFound unless guardian.can_see_group_messages?(group)
else
guardian.ensure_can_see_private_messages!(target_user.id)
end
list_opts = build_topic_list_options
list = generate_list_for(action.to_s, target_user, list_opts)
url_prefix = "topics"
list.more_topics_url = construct_url_with(:next, list_opts, url_prefix)
list.prev_topics_url = construct_url_with(:prev, list_opts, url_prefix)
respond_with_list(list)
end
%i[
private_messages
private_messages_sent
private_messages_unread
private_messages_new
private_messages_archive
private_messages_group
private_messages_group_new
private_messages_group_unread
private_messages_group_archive
private_messages_warnings
private_messages_tag
].each { |action| generate_message_route(action) }
def latest_feed
discourse_expires_in 1.minute
options = { order: "created" }.merge(build_topic_list_options)
@title = "#{SiteSetting.title} - #{I18n.t("rss_description.latest")}"
@link = "#{Discourse.base_url}/latest"
@atom_link = "#{Discourse.base_url}/latest.rss"
@description = I18n.t("rss_description.latest")
@topic_list = TopicQuery.new(nil, options).list_latest
render "list", formats: [:rss]
end
def top_feed
discourse_expires_in 1.minute
@title = "#{SiteSetting.title} - #{I18n.t("rss_description.top")}"
@link = "#{Discourse.base_url}/top"
@atom_link = "#{Discourse.base_url}/top.rss"
@description = I18n.t("rss_description.top")
period = params[:period] || SiteSetting.top_page_default_timeframe.to_sym
TopTopic.validate_period(period)
@topic_list = TopicQuery.new(nil).list_top_for(period)
render "list", formats: [:rss]
end
def hot_feed
discourse_expires_in 1.minute
@title = "#{SiteSetting.title} - #{I18n.t("rss_description.hot")}"
@link = "#{Discourse.base_url}/hot"
@atom_link = "#{Discourse.base_url}/hot.rss"
@description = I18n.t("rss_description.hot")
@topic_list = TopicQuery.new(nil).list_hot
render "list", formats: [:rss]
end
def category_feed
guardian.ensure_can_see!(@category)
discourse_expires_in 1.minute
@title = "#{@category.name} - #{SiteSetting.title}"
@link = "#{Discourse.base_url_no_prefix}#{@category.url}"
@atom_link = "#{Discourse.base_url_no_prefix}#{@category.url}.rss"
@description =
"#{I18n.t("topics_in_category", category: @category.name)} #{@category.description}"
@topic_list = TopicQuery.new(current_user).list_new_in_category(@category)
render "list", formats: [:rss]
end
def user_topics_feed
discourse_expires_in 1.minute
target_user = fetch_user_from_params
ensure_can_see_profile!(target_user)
@title =
"#{SiteSetting.title} - #{I18n.t("rss_description.user_topics", username: target_user.username)}"
@link = "#{target_user.full_url}/activity/topics"
@atom_link = "#{target_user.full_url}/activity/topics.rss"
@description = I18n.t("rss_description.user_topics", username: target_user.username)
@topic_list = TopicQuery.new(nil, order: "created").public_send("list_topics_by", target_user)
render "list", formats: [:rss]
end
def top(options = nil)
options ||= {}
period = params[:period]
period ||=
ListController.best_period_for(current_user.try(:previous_visit_at), options[:category])
TopTopic.validate_period(period)
public_send("top_#{period}", options)
end
def category_top
top(category: @category.id)
end
def category_none_top
top(category: @category.id, no_subcategories: true)
end
TopTopic.periods.each do |period|
define_method("top_#{period}") do |options = nil|
top_options = build_topic_list_options
top_options.merge!(options) if options
top_options[:per_page] = top_options[:per_page].presence ||
SiteSetting.topics_per_period_in_top_page
user = current_user
list = TopicQuery.new(user, top_options).list_top_for(period)
list.for_period = period
list.more_topics_url = construct_url_with(:next, top_options)
list.prev_topics_url = construct_url_with(:prev, top_options)
@rss = "top"
@params = { period: period }
@rss_description = "top_#{period}"
respond_with_list(list)
end
define_method("category_top_#{period}") do
self.public_send("top_#{period}", category: @category.id)
end
define_method("category_none_top_#{period}") do
self.public_send("top_#{period}", category: @category.id, no_subcategories: true)
end
# rss feed
define_method("top_#{period}_feed") do |options = nil|
discourse_expires_in 1.minute
@description = I18n.t("rss_description.top_#{period}")
@title = "#{SiteSetting.title} - #{@description}"
@link = "#{Discourse.base_url}/top?period=#{period}"
@atom_link = "#{Discourse.base_url}/top.rss?period=#{period}"
@topic_list = TopicQuery.new(nil).list_top_for(period)
render "list", formats: [:rss]
end
end
protected
def next_page_params
page_params.merge(page: params[:page].to_i + 1)
end
def prev_page_params
pg = params[:page].to_i
if pg > 1
page_params.merge(page: pg - 1)
else
page_params.merge(page: nil)
end
end
private
def page_params
route_params = { format: "json" }
if @category.present?
slug_path = @category.slug_path
route_params[:category_slug_path_with_id] = (slug_path + [@category.id.to_s]).join("/")
end
route_params[:username] = UrlHelper.encode_component(params[:username]) if params[
:username
].present?
route_params[:period] = params[:period] if params[:period].present?
route_params
end
def set_category
category_slug_path_with_id = params.require(:category_slug_path_with_id)
@category = Category.find_by_slug_path_with_id(category_slug_path_with_id)
raise Discourse::NotFound.new("category not found", check_permalinks: true) if @category.nil?
params[:category] = @category.id.to_s
if !guardian.can_see?(@category)
if SiteSetting.detailed_404
raise Discourse::InvalidAccess
else
raise Discourse::NotFound
end
end
# Check if the category slug is incorrect and redirect to a link containing
# the correct one.
current_slug = category_slug_path_with_id
if SiteSetting.slug_generation_method == "encoded"
current_slug = current_slug.split("/").map { |slug| CGI.escape(slug) }.join("/")
end
real_slug = @category.full_slug("/")
if CGI.unescape(current_slug) != CGI.unescape(real_slug)
path = CGI.unescape(request.path)
query = request.query_string
new_path = path.gsub(current_slug, real_slug)
url = query.present? ? "#{new_path}?#{query}" : new_path
if ActionController::Base.config.relative_url_root
url = url.sub(ActionController::Base.config.relative_url_root, "")
end
return redirect_to path(url), status: :moved_permanently
end
@description_meta =
if @category.uncategorized?
I18n.t("category.uncategorized_description", locale: SiteSetting.default_locale)
elsif @category.description_text.present?
@category.description_text
else
SiteSetting.site_description
end
if use_crawler_layout?
@subcategories = @category.subcategories.select { |c| guardian.can_see?(c) }
end
end
def generate_list_for(action, target_user, opts)
TopicQuery.new(current_user, opts).public_send("list_#{action}", target_user)
end
def construct_url_with(action, opts, url_prefix = nil)
method = url_prefix.blank? ? "#{action_name}_path" : "#{url_prefix}_#{action_name}_path"
page_params =
case action
when :prev
prev_page_params
when :next
next_page_params
else
raise "unreachable"
end
opts = opts.dup
if SiteSetting.unicode_usernames && opts[:group_name]
opts[:group_name] = UrlHelper.encode_component(opts[:group_name])
end
opts.delete(:category) if page_params.include?(:category_slug_path_with_id)
url = public_send(method, opts.merge(page_params)).sub(".json?", "?")
# Unicode usernames need to be encoded when calling Rails' path helper. However, it means that the already
# encoded username are encoded again which we do not want. As such, we unencode the path once when unicode usernames
# have been enabled.
if SiteSetting.unicode_usernames
uri = URI.parse(url)
uri.path = UrlHelper.unencode(uri.path)
url = uri.to_s
end
url
end
def ensure_can_see_profile!(target_user = nil)
raise Discourse::NotFound unless guardian.can_see_profile?(target_user)
end
def self.best_period_for(previous_visit_at, category_id = nil)
default_period =
(
(category_id && Category.where(id: category_id).pick(:default_top_period)) ||
SiteSetting.top_page_default_timeframe
).to_sym
default_period = SiteSetting.top_page_default_timeframe.to_sym if TopTopic.periods.exclude?(
default_period,
)
best_period_with_topics_for(previous_visit_at, category_id, default_period) || default_period
end
def self.best_period_with_topics_for(
previous_visit_at,
category_id = nil,
default_period = SiteSetting.top_page_default_timeframe
)
best_periods_for(previous_visit_at, default_period.to_sym).find do |period|
top_topics = TopTopic.where("#{TopTopic.score_column_for_period(period)} > 0")
top_topics =
top_topics.joins(:topic).where("topics.category_id = ?", category_id) if category_id
top_topics = top_topics.limit(SiteSetting.topics_per_period_in_top_page)
top_topics.count == SiteSetting.topics_per_period_in_top_page
end
end
def self.best_periods_for(date, default_period = :all)
return [default_period, :all].uniq unless date
periods = []
periods << :daily if date > (1.week + 1.day).ago
periods << :weekly if date > (1.month + 1.week).ago
periods << :monthly if date > (3.months + 3.weeks).ago
periods << :quarterly if date > (1.year + 1.month).ago
periods << :yearly if date > 3.years.ago
periods << :all
periods
end
end