discourse/app/models/api_key_scope.rb
OsamaSayegh 2f7a307237 FIX: Prevent duplicates in API scope allowed URLs
It's possible in Rails to map a single route to multiple controller
actions with different constraints. We do this in at least 1 place in
our application for the root route (/) to make it possible to change the
page that root route displays.

This means that if you get the list of routes of your application,
you'll get the same route for each time the route is defined. And if
there's an API scope for 2 (or more) controller actions that map to the
same route, the route will be listed twice in the Allowed URLs list of
the scope.

To prevent this, this PR adds the allowed URLs in a set so that
duplicate routes are automatically removed.
2022-04-07 02:09:11 +03:00

174 lines
5.7 KiB
Ruby

# frozen_string_literal: true
class ApiKeyScope < ActiveRecord::Base
validates_presence_of :resource
validates_presence_of :action
class << self
def list_actions
actions = %w[list#category_feed]
%i[latest unread new top].each { |f| actions.concat(["list#category_#{f}", "list##{f}"]) }
actions
end
def default_mappings
return @default_mappings unless @default_mappings.nil?
mappings = {
global: {
read: { methods: %i[get] }
},
topics: {
write: { actions: %w[posts#create], params: %i[topic_id] },
update: { actions: %w[topics#update], params: %i[topic_id] },
read: {
actions: %w[topics#show topics#feed topics#posts],
params: %i[topic_id], aliases: { topic_id: :id }
},
read_lists: {
actions: list_actions, params: %i[category_id],
aliases: { category_id: :category_slug_path_with_id }
},
wordpress: { actions: %w[topics#wordpress], params: %i[topic_id] }
},
posts: {
edit: { actions: %w[posts#update], params: %i[id] }
},
categories: {
list: { actions: %w[categories#index] },
show: { actions: %w[categories#show], params: %i[id] }
},
uploads: {
create: {
actions: %w[
uploads#create
uploads#generate_presigned_put
uploads#complete_external_upload
uploads#create_multipart
uploads#batch_presign_multipart_parts
uploads#abort_multipart
uploads#complete_multipart
]
}
},
users: {
bookmarks: { actions: %w[users#bookmarks], params: %i[username] },
sync_sso: { actions: %w[admin/users#sync_sso], params: %i[sso sig] },
show: { actions: %w[users#show], params: %i[username external_id external_provider] },
check_emails: { actions: %w[users#check_emails], params: %i[username] },
update: { actions: %w[users#update], params: %i[username] },
log_out: { actions: %w[admin/users#log_out] },
anonymize: { actions: %w[admin/users#anonymize] },
delete: { actions: %w[admin/users#destroy] },
list: { actions: %w[admin/users#index] },
},
email: {
receive_emails: { actions: %w[admin/email#handle_mail] }
},
badges: {
create: { actions: %w[admin/badges#create] },
show: { actions: %w[badges#show] },
update: { actions: %w[admin/badges#update] },
delete: { actions: %w[admin/badges#destroy] },
list_user_badges: { actions: %w[user_badges#username], params: %i[username] },
assign_badge_to_user: { actions: %w[user_badges#create], params: %i[username] },
revoke_badge_from_user: { actions: %w[user_badges#destroy] },
}
}
parse_resources!(mappings)
@default_mappings = mappings
end
def scope_mappings
plugin_mappings = DiscoursePluginRegistry.api_key_scope_mappings
return default_mappings if plugin_mappings.empty?
default_mappings.deep_dup.tap do |mappings|
plugin_mappings.each do |plugin_mapping|
parse_resources!(plugin_mapping)
mappings.deep_merge!(plugin_mapping)
end
end
end
def parse_resources!(mappings)
mappings.each_value do |resource_actions|
resource_actions.each_value do |action_data|
action_data[:urls] = find_urls(actions: action_data[:actions], methods: action_data[:methods])
end
end
end
def find_urls(actions:, methods:)
urls = Set.new
if actions.present?
route_sets = [Rails.application.routes]
Rails::Engine.descendants.each do |engine|
next if engine == Rails::Application # abstract engine, can't call routes on it
next if engine == Discourse::Application # equiv. to Rails.application
route_sets << engine.routes
end
route_sets.each do |set|
engine_mount_path = set.find_script_name({}).presence
engine_mount_path = nil if engine_mount_path == "/"
set.routes.each do |route|
defaults = route.defaults
action = "#{defaults[:controller].to_s}##{defaults[:action]}"
path = route.path.spec.to_s.gsub(/\(\.:format\)/, '')
api_supported_path = (
path.end_with?('.rss') ||
!route.path.requirements[:format] ||
route.path.requirements[:format].match?('json')
)
excluded_paths = %w[/new-topic /new-message /exception]
if actions.include?(action) && api_supported_path && !excluded_paths.include?(path)
urls << "#{engine_mount_path}#{path} (#{route.verb})"
end
end
end
end
if methods.present?
methods.each do |method|
urls << "* (#{method})"
end
end
urls.to_a
end
end
def permits?(env)
RouteMatcher.new(**mapping.except(:urls), allowed_param_values: allowed_parameters).match?(env: env)
end
private
def mapping
@mapping ||= self.class.scope_mappings.dig(resource.to_sym, action.to_sym)
end
end
# == Schema Information
#
# Table name: api_key_scopes
#
# id :bigint not null, primary key
# api_key_id :integer not null
# resource :string not null
# action :string not null
# allowed_parameters :json
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_api_key_scopes_on_api_key_id (api_key_id)
#