mirror of
https://github.com/discourse/discourse.git
synced 2026-08-09 21:45:25 +08:00
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
334 lines
10 KiB
Ruby
Vendored
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
|