0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 21:45:25 +08:00
discourse/app/controllers/static_controller.rb
Juan David Martinez 8c8cae07c9 SECURITY: Validate sso_destination_url cookie to prevent open redirect [backport 2026.1]
Backport of #561 to release/2026.1.

---

**Description**

Adds validation to ensure the `sso_destination_url` cookie value matches configured SSO provider domains before allowing external redirects. Previously, an attacker who could set cookies could redirect users to arbitrary external domains, enabling phishing attacks. This PR fixes that by validating the destination URL against the enabled URL domains in the `enable_discourse_connect_provider` SiteSetting.

---

**Security Advisory:** https://github.com/discourse/discourse/security/advisories/GHSA-378j-ccw4-4fwh
2026-03-31 15:12:50 +01:00

334 lines
10 KiB
Ruby
Vendored

# frozen_string_literal: true
class StaticController < ApplicationController
skip_before_action :check_xhr, :redirect_to_login_if_required, :redirect_to_profile_if_required
skip_before_action :verify_authenticity_token,
only: %i[cdn_asset enter favicon llms_txt service_worker_asset]
skip_before_action :preload_json, only: %i[cdn_asset enter favicon llms_txt service_worker_asset]
skip_before_action :handle_theme, only: %i[cdn_asset enter favicon llms_txt service_worker_asset]
before_action :apply_cdn_headers, only: %i[cdn_asset enter favicon service_worker_asset]
PAGES_WITH_EMAIL_PARAM = %w[login password_reset signup]
MODAL_PAGES = %w[password_reset signup]
DEFAULT_PAGES = {
"faq" => {
redirect: "faq_url",
topic_id: "guidelines_topic_id",
},
"tos" => {
redirect: "tos_url",
topic_id: "tos_topic_id",
},
"privacy" => {
redirect: "privacy_policy_url",
topic_id: "privacy_topic_id",
},
}
CUSTOM_PAGES = {} # Add via `#add_topic_static_page` in plugin API
def extract_redirect_param
return "/" if params[:redirect].blank?
uri = URI(params[:redirect])
return "/" unless valid_redirect_uri?(uri)
uri.query ? "#{uri.path}?#{uri.query}" : uri.path
rescue URI::Error, ArgumentError
"/"
end
def valid_redirect_uri?(uri)
return false if uri.path.blank?
return false if uri.path.starts_with?("#{Discourse.base_path}/login")
return false if uri.host.present? && uri.host != URI(Discourse.base_url).host
return false if !uri.path.match?(%r{\A/[^\.\s]*\z})
true
end
def show
if params[:id] == "login"
destination = extract_redirect_param
if current_user
return redirect_to(path(destination), allow_other_host: false)
elsif destination != "/"
cookies[:destination_url] = path(destination)
end
elsif params[:id] == "signup" && current_user
return redirect_to path("/")
end
if SiteSetting.login_required? && current_user.nil? && %w[faq guidelines].include?(params[:id])
return redirect_to path("/login")
end
rename_faq =
UpcomingChanges.enabled_for_user?(:experimental_rename_faq_to_guidelines, current_user)
if rename_faq
redirect_paths = %w[/rules /conduct]
redirect_paths << "/faq" if SiteSetting.faq_url.blank?
return redirect_to(path("/guidelines")) if redirect_paths.include?(request.path)
end
map = DEFAULT_PAGES.deep_merge(CUSTOM_PAGES)
@page = params[:id]
if map.has_key?(@page)
site_setting_key = map[@page][:redirect]
url = SiteSetting.get(site_setting_key) if site_setting_key
return redirect_to(url, allow_other_host: true) if url.present?
end
# The /guidelines route ALWAYS shows our FAQ, ignoring the faq_url site setting.
@page = "faq" if @page == "guidelines"
# Don't allow paths like ".." or "/" or anything hacky like that
@page = @page.gsub(/[^a-z0-9\_\-]/, "")
if map.has_key?(@page)
topic_id = map[@page][:topic_id]
topic_id = instance_exec(&topic_id) if topic_id.is_a?(Proc)
@topic = Topic.find_by_id(SiteSetting.get(topic_id))
raise Discourse::NotFound unless @topic
page_name =
if @page == "faq"
rename_faq ? "guidelines" : "faq"
else
@page
end
title_prefix =
if I18n.exists?("js.#{page_name}")
I18n.t("js.#{page_name}")
else
@topic.title
end
@title = "#{title_prefix} - #{SiteSetting.title}"
@body = @topic.posts.first.cooked
@faq_overridden = SiteSetting.faq_url.present?
@experimental_rename_faq_to_guidelines = rename_faq
render :show, layout: !request.xhr?, formats: [:html]
return
end
@title = SiteSetting.title.dup
if SiteSetting.short_site_description.present?
@title << " - #{SiteSetting.short_site_description}"
end
if I18n.exists?("static.#{@page}")
render html: I18n.t("static.#{@page}"), layout: !request.xhr?, formats: [:html]
return
end
if PAGES_WITH_EMAIL_PARAM.include?(@page) && params[:email]
cookies[:email] = { value: params[:email], expires: 1.day.from_now }
end
if lookup_context.find_all("static/#{@page}").any?
render "static/#{@page}", layout: !request.xhr?, formats: [:html]
return
end
if MODAL_PAGES.include?(@page)
render html: nil, layout: true
return
end
raise Discourse::NotFound
end
# This method just redirects to a given url.
# It's used when an ajax login was successful but we want the browser to see
# a post of a login form so that it offers to remember your password.
def enter
params.delete(:username)
params.delete(:password)
destination = extract_redirect_param
allow_other_host = false
# We need this to redirect the user back when Discourse Connect Provider is used.
if cookies[:sso_destination_url]
sso_url = cookies.delete(:sso_destination_url)
begin
uri = URI(sso_url)
if valid_sso_redirect_uri?(uri)
destination = sso_url
allow_other_host = true
end
rescue URI::Error, ArgumentError
# Invalid URI, ignore and use default destination
end
end
destination = path(destination) if destination == "/"
redirect_to(destination, allow_other_host:)
end
FAVICON = -"favicon"
# We need to be able to draw our favicon on a canvas, this happens when you enable the feature
# that draws the notification count on top of favicon (per user default off)
#
# With s3 the original upload is going to be stored at s3, we don't have a local copy of the favicon.
# To allow canvas to work with s3 we are going to need to add special CORS headers and use
# a special crossorigin hint on the original, this is not easily workable.
#
# Forcing all consumers to set magic CORS headers on a CDN is also not workable for us.
#
# So we cache the favicon in redis and serve it out real quick with
# a huge expiry, we also cache these assets in nginx so it is bypassed if needed
def favicon
is_asset_path
hijack do
data =
DistributedMemoizer.memoize("FAVICON#{SiteIconManager.favicon_url}", 60 * 30) do
favicon = SiteIconManager.favicon
next "" unless favicon
if Discourse.store.external?
begin
file =
FileHelper.download(
Discourse.store.cdn_url(favicon.url),
max_file_size: favicon.filesize,
tmp_file_name: FAVICON,
follow_redirect: true,
)
file&.read || ""
rescue => e
ProblemCheckTracker[:bad_favicon_url].problem!
Rails.logger.debug("Failed to fetch favicon #{favicon.url}: #{e}\n#{e.backtrace}")
""
ensure
file&.unlink
end
else
File.read(Rails.public_path.join(favicon.url[1..-1]))
end
end
if data.bytesize == 0
@@default_favicon ||= File.read(Rails.root + "public/images/default-favicon.png")
response.headers["Content-Length"] = @@default_favicon.bytesize.to_s
render body: @@default_favicon, content_type: "image/png"
else
immutable_for 1.year
response.headers["Expires"] = 1.year.from_now.httpdate
response.headers["Content-Length"] = data.bytesize.to_s
response.headers["Last-Modified"] = Time.new(2000, 01, 01).httpdate
render body: data, content_type: "image/png"
end
end
end
def llms_txt
upload = SiteSetting.llms_txt
return head(:not_found) if upload.blank?
if Discourse.store.external?
content =
Discourse
.cache
.fetch("llms_txt_content:#{upload.sha1}") do
Discourse.store.download_safe(upload)&.path&.then { |path| File.read(path) }
end
return head(:not_found) if content.blank?
render plain: content, content_type: "text/plain"
else
path = Discourse.store.path_for(upload)
return head(:not_found) if path.blank? || !File.exist?(path)
send_file(path, type: "text/plain", disposition: "inline")
end
end
def cdn_asset
is_asset_path
serve_asset
end
def service_worker_asset
is_asset_path
respond_to do |format|
format.js do
# https://github.com/w3c/ServiceWorker/blob/master/explainer.md#updating-a-service-worker
# Maximum cache that the service worker will respect is 24 hours.
# However, ensure that these may be cached and served for longer on servers.
immutable_for 1.year
render "service-worker"
end
end
end
protected
def valid_sso_redirect_uri?(uri)
return false unless SiteSetting.enable_discourse_connect_provider
return false if uri.host.blank?
provider_domains =
SiteSetting
.discourse_connect_provider_secrets
.split("\n")
.map { |row| row.split("|", 2).first }
.compact
provider_domains.any? { |domain| WildcardDomainChecker.check_domain(domain, uri.host) }
end
def serve_asset(suffix = nil)
path = File.expand_path(Rails.root + "public/assets/#{params[:path]}#{suffix}")
# SECURITY what if path has /../
raise Discourse::NotFound unless path.start_with?(Rails.root.to_s + "/public/assets")
response.headers["Expires"] = 1.year.from_now.httpdate
response.headers["Access-Control-Allow-Origin"] = params[:origin] if params[:origin]
begin
response.headers["Last-Modified"] = File.ctime(path).httpdate
rescue Errno::ENOENT
begin
if GlobalSetting.fallback_assets_path.present?
path = File.expand_path("#{GlobalSetting.fallback_assets_path}/#{params[:path]}#{suffix}")
response.headers["Last-Modified"] = File.ctime(path).httpdate
else
raise
end
rescue Errno::ENOENT
expires_in 1.second, public: true, must_revalidate: false
render plain: "can not find #{params[:path]}", status: :not_found
return
end
end
response.headers["Content-Length"] = File.size(path).to_s
yield if block_given?
immutable_for 1.year
# disable NGINX mucking with transfer
request.env["sendfile.type"] = ""
opts = { disposition: nil }
opts[:type] = "application/javascript" if params[:path] =~ /\.js$/
send_file(path, opts)
end
end