mirror of
https://github.com/discourse/discourse.git
synced 2026-08-05 23:04:22 +08:00
## Summary The /clicks/track endpoint allowed users to manipulate click counts for links inside private messages and restricted topics they could not access. This integrity issue is resolved by passing the user's guardian to the model layer and verifying visibility before recording the click. ## Source - Patch Triage: https://patch.discourse.org/patch-triage/1148 - HackerOne report: https://hackerone.com/reports/3701904 --- 🤖 Auto-generated from the patch diff via Patch Triage. Review carefully before merging. Co-authored-by: discourse-patch-triage <272280883+discourse-patch-triage[bot]@users.noreply.github.com> --------- Co-authored-by: discourse-patch-triage[bot] <272280883+discourse-patch-triage[bot]@users.noreply.github.com>
137 lines
3.9 KiB
Ruby
Vendored
137 lines
3.9 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "ipaddr"
|
|
require "url_helper"
|
|
|
|
class TopicLinkClick < ActiveRecord::Base
|
|
belongs_to :topic_link, counter_cache: :clicks
|
|
belongs_to :user
|
|
|
|
validates :topic_link_id, presence: true
|
|
|
|
ALLOWED_REDIRECT_HOSTNAMES = Set.new(%W[www.youtube.com youtu.be])
|
|
|
|
# Create a click from a URL and post_id
|
|
def self.create_from(args = {})
|
|
url = args[:url][0...TopicLink.max_url_length]
|
|
return nil if url.blank?
|
|
|
|
uri = UrlHelper.relaxed_parse(url)
|
|
urls = Set.new
|
|
urls << url
|
|
if url =~ /\Ahttp/
|
|
urls << url.sub(/\Ahttps/, "http")
|
|
urls << url.sub(/\Ahttp:/, "https:")
|
|
urls << UrlHelper.schemaless(url)
|
|
end
|
|
urls << UrlHelper.absolute_without_cdn(url)
|
|
urls << uri.path if uri.try(:host) == Discourse.current_hostname
|
|
|
|
query = url.index("?")
|
|
unless query.nil?
|
|
endpos = url.index("#") || url.size
|
|
urls << url[0..query - 1] + url[endpos..-1]
|
|
end
|
|
|
|
# link can have query params, and analytics can add more to the end:
|
|
i = url.length
|
|
while i = url.rindex("&", i - 1)
|
|
urls << url[0...i]
|
|
end
|
|
|
|
# add a cdn link
|
|
if uri
|
|
if Discourse.asset_host.present?
|
|
cdn_uri =
|
|
begin
|
|
URI.parse(Discourse.asset_host)
|
|
rescue URI::Error
|
|
end
|
|
|
|
if cdn_uri && cdn_uri.hostname == uri.hostname && uri.path.starts_with?(cdn_uri.path)
|
|
is_cdn_link = true
|
|
urls << uri.path[cdn_uri.path.length..-1]
|
|
end
|
|
end
|
|
|
|
if SiteSetting.Upload.s3_cdn_url.present?
|
|
cdn_uri =
|
|
begin
|
|
URI.parse(SiteSetting.Upload.s3_cdn_url)
|
|
rescue URI::Error
|
|
end
|
|
|
|
if cdn_uri && cdn_uri.hostname == uri.hostname && uri.path.starts_with?(cdn_uri.path)
|
|
is_cdn_link = true
|
|
path = uri.path[cdn_uri.path.length..-1]
|
|
urls << path
|
|
urls << "#{Discourse.store.absolute_base_url}#{path}"
|
|
end
|
|
end
|
|
end
|
|
|
|
# test for all possible URLs
|
|
link = TopicLink.where(url: urls)
|
|
|
|
# Find the forum topic link
|
|
link = link.where(post_id: args[:post_id]) if args[:post_id].present?
|
|
|
|
# If we don't have a post, just find the first occurrence of the link
|
|
link = link.where(topic_id: args[:topic_id]) if args[:topic_id].present?
|
|
|
|
# select the TopicLink associated to first url
|
|
link =
|
|
link.order(
|
|
"array_position(ARRAY[#{urls.map { |s| "#{ActiveRecord::Base.connection.quote(s)}" }.join(",")}], url::text)",
|
|
).first
|
|
|
|
# If no link is found...
|
|
if link.blank?
|
|
# ... return the url for relative links or when using the same host
|
|
return url if url =~ %r{\A/[^/]} || uri.try(:host) == Discourse.current_hostname
|
|
|
|
# If we have it somewhere else on the site, just allow the redirect.
|
|
# This is likely due to a onebox of another topic.
|
|
link = TopicLink.find_by(url: url)
|
|
return link.url if link.present?
|
|
|
|
return nil unless uri
|
|
|
|
# Only redirect to allowlisted hostnames
|
|
return url if ALLOWED_REDIRECT_HOSTNAMES.include?(uri.hostname) || is_cdn_link
|
|
|
|
return nil
|
|
end
|
|
|
|
guardian = args[:guardian]
|
|
return nil if guardian && !(guardian.can_see?(link.topic) && guardian.can_see?(link.post))
|
|
|
|
return url if args[:user_id] && link.user_id == args[:user_id]
|
|
|
|
# Rate limit the click counts to once in 24 hours
|
|
rate_key = "link-clicks:#{link.id}:#{args[:user_id] || args[:ip]}"
|
|
if Discourse.redis.setnx(rate_key, "1")
|
|
Discourse.redis.expire(rate_key, 1.day.to_i)
|
|
args[:ip] = nil if args[:user_id]
|
|
create!(topic_link_id: link.id, user_id: args[:user_id], ip_address: args[:ip])
|
|
end
|
|
|
|
url
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: topic_link_clicks
|
|
#
|
|
# id :integer not null, primary key
|
|
# ip_address :inet
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# topic_link_id :integer not null
|
|
# user_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# by_link (topic_link_id)
|
|
#
|