diff --git a/app/helpers/calendar_helper.rb b/app/helpers/calendar_helper.rb index ad4116fc3da824cad6644c4ca3d2c748e9d6cd4c..d70a860d468bee0a9a46cf544c9f35801e856c01 100644 --- a/app/helpers/calendar_helper.rb +++ b/app/helpers/calendar_helper.rb @@ -3,7 +3,7 @@ module CalendarHelper def calendar_url_options { format: :ics, - feed_token: current_user.try(:feed_token), + feed_token: generate_feed_token(:ics), due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name, sort: 'closest_future_date' } end diff --git a/app/helpers/feed_token_helper.rb b/app/helpers/feed_token_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..751a8df4782ac210989f8394eaedc327c1bd63c3 --- /dev/null +++ b/app/helpers/feed_token_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module FeedTokenHelper + def generate_feed_token(type) + feed_token = current_user&.feed_token + return unless feed_token + + final_path = "#{current_request.path}.#{type}" + digest = OpenSSL::HMAC.hexdigest("SHA256", feed_token, final_path) + "#{User::FEED_TOKEN_PREFIX}#{digest}-#{current_user.id}" + end +end diff --git a/app/helpers/rss_helper.rb b/app/helpers/rss_helper.rb index 67c7d244f11b5e4c249f15a3a83f040ddbda3647..90dd4e8fedb0ba1c2c22e877c5dc50324aa816e2 100644 --- a/app/helpers/rss_helper.rb +++ b/app/helpers/rss_helper.rb @@ -2,6 +2,6 @@ module RssHelper def rss_url_options - { format: :atom, feed_token: current_user.try(:feed_token) } + { format: :atom, feed_token: generate_feed_token(:atom) } end end diff --git a/doc/security/token_overview.md b/doc/security/token_overview.md index 5a772106562fcb01f7ec672fe30e55243d0debe7..80dbe8c03ccfff4414b66a47586aa22958184bf7 100644 --- a/doc/security/token_overview.md +++ b/doc/security/token_overview.md @@ -139,7 +139,10 @@ Each user has a long-lived feed token that does not expire. This token allows au - RSS readers to load a personalized RSS feed. - Calendar applications to load a personalized calendar. -This token is visible in those feed URLs. You cannot use this token to access any other data. +You cannot use this token to access any other data. + +The user-scoped feed token can be used for all feeds, however feed and calendar URLs are generated +with a different token that is only valid for one feed. Anyone who has your token can read activity and issue RSS feeds or your calendar feed as if they were you, including confidential issues. If that happens, [reset the token](../user/profile/contributions_calendar.md#reset-the-user-activity-feed-token). diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 4a610b26290a07e0e3f2012aa6adde48d2b73c3e..111fac6f8a57ff2b9f49ce09e0a1c180548a3471 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -30,6 +30,7 @@ module AuthFinders DEPLOY_TOKEN_HEADER = 'HTTP_DEPLOY_TOKEN' RUNNER_TOKEN_PARAM = :token RUNNER_JOB_TOKEN_PARAM = :token + PATH_DEPENDENT_FEED_TOKEN_REGEX = /\A#{User::FEED_TOKEN_PREFIX}(\h{64})-(\d+)\z/ # Check the Rails session for valid authentication details def find_user_from_warden @@ -54,7 +55,7 @@ def find_user_from_feed_token(request_format) token = current_request.params[:feed_token].presence || current_request.params[:rss_token].presence return unless token - User.find_by_feed_token(token) || raise(UnauthorizedError) + find_feed_token_user(token) || raise(UnauthorizedError) end def find_user_from_bearer_token @@ -277,6 +278,30 @@ def find_personal_access_token_from_http_basic_auth PersonalAccessToken.find_by_token(password) end + def find_feed_token_user(token) + find_user_from_path_feed_token(token) || User.find_by_feed_token(token) + end + + def find_user_from_path_feed_token(token) + glft = token.match(PATH_DEPENDENT_FEED_TOKEN_REGEX) + + return unless glft + + # make sure that user id uses decimal notation + user_id = glft[2].to_i(10) + digest = glft[1] + + user = User.find_by_id(user_id) + return unless user + + feed_token = user.feed_token + our_digest = OpenSSL::HMAC.hexdigest("SHA256", feed_token, current_request.path) + + return unless ActiveSupport::SecurityUtils.secure_compare(digest, our_digest) + + user + end + def parsed_oauth_token Doorkeeper::OAuth::Token.from_request(current_request, *Doorkeeper.configuration.access_token_methods) end diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb index 964ac2f714dfd659eb78bf3cf19feeea092674d5..ab3aa29a3aa9a7978ce1395e5f6cf83ff475c750 100644 --- a/spec/features/dashboard/issues_filter_spec.rb +++ b/spec/features/dashboard/issues_filter_spec.rb @@ -61,10 +61,15 @@ auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) - expect(params).to include('feed_token' => [user.feed_token]) + feed_token_param = params['feed_token'] + expect(feed_token_param).to match([Gitlab::Auth::AuthFinders::PATH_DEPENDENT_FEED_TOKEN_REGEX]) + expect(feed_token_param.first).to end_with(user.id.to_s) expect(params).to include('milestone_title' => ['']) expect(params).to include('assignee_username' => [user.username.to_s]) - expect(auto_discovery_params).to include('feed_token' => [user.feed_token]) + + feed_token_param = auto_discovery_params['feed_token'] + expect(feed_token_param).to match([Gitlab::Auth::AuthFinders::PATH_DEPENDENT_FEED_TOKEN_REGEX]) + expect(feed_token_param.first).to end_with(user.id.to_s) expect(auto_discovery_params).to include('milestone_title' => ['']) expect(auto_discovery_params).to include('assignee_username' => [user.username.to_s]) end diff --git a/spec/helpers/calendar_helper_spec.rb b/spec/helpers/calendar_helper_spec.rb index 08993dd1dd0842286af39fadc8cd62ae75aa7cd0..a18ed4794654757fafabf4a2e594cee2cc003c5c 100644 --- a/spec/helpers/calendar_helper_spec.rb +++ b/spec/helpers/calendar_helper_spec.rb @@ -8,7 +8,10 @@ it "includes the current_user's feed_token" do current_user = create(:user) allow(helper).to receive(:current_user).and_return(current_user) - expect(helper.calendar_url_options).to include feed_token: current_user.feed_token + + feed_token = helper.calendar_url_options[:feed_token] + expect(feed_token).to match(Gitlab::Auth::AuthFinders::PATH_DEPENDENT_FEED_TOKEN_REGEX) + expect(feed_token).to end_with(current_user.id.to_s) end end diff --git a/spec/helpers/feed_token_helper_spec.rb b/spec/helpers/feed_token_helper_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4382758965cb96a93216d973584db96855dbc267 --- /dev/null +++ b/spec/helpers/feed_token_helper_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe FeedTokenHelper, feature_category: :system_access do + describe '#generate_feed_token' do + context 'with type :atom' do + let(:current_user) { build(:user, feed_token: 'KNOWN VALUE') } + + it "returns the current_user's atom feed_token" do + allow(helper).to receive(:current_user).and_return(current_user) + allow(helper).to receive(:current_request).and_return(instance_double(ActionDispatch::Request, path: 'url')) + + expect(helper.generate_feed_token(:atom)) + # The middle part is the output of OpenSSL::HMAC.hexdigest("SHA256", 'KNOWN VALUE', 'url.atom') + .to eq("glft-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693-#{current_user.id}") + end + end + + context 'when signed out' do + it "returns nil" do + allow(helper).to receive(:current_user).and_return(nil) + + expect(helper.generate_feed_token(:atom)).to be_nil + end + end + end +end diff --git a/spec/helpers/rss_helper_spec.rb b/spec/helpers/rss_helper_spec.rb index 05f6ebb6c1bb25537f6ec831bbd99a993950b7f6..f99a1f6d5470479ffaf9009def89a777446eaf27 100644 --- a/spec/helpers/rss_helper_spec.rb +++ b/spec/helpers/rss_helper_spec.rb @@ -8,7 +8,10 @@ it "includes the current_user's feed_token" do current_user = create(:user) allow(helper).to receive(:current_user).and_return(current_user) - expect(helper.rss_url_options).to include feed_token: current_user.feed_token + + feed_token = helper.rss_url_options[:feed_token] + expect(feed_token).to match(Gitlab::Auth::AuthFinders::PATH_DEPENDENT_FEED_TOKEN_REGEX) + expect(feed_token).to end_with(current_user.id.to_s) end end diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index 4498e369695db8edeec7f92a13cc7668247b90c7..1a8a2ec2980c06e09e53bb9f724cbb6ce8dbd7ff 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -181,7 +181,7 @@ def set_bearer_token(token) set_header('HTTP_ACCEPT', 'application/atom+xml') end - context 'when feed_token param is provided' do + context 'when old format feed_token param is provided' do it 'returns user if valid feed_token' do set_param(:feed_token, user.feed_token) @@ -206,7 +206,44 @@ def set_bearer_token(token) end end - context 'when rss_token param is provided' do + context 'when path-dependent format feed_token param is provided' do + let_it_be(:feed_user, freeze: true) { create(:user, feed_token: 'KNOWN VALUE').tap(&:feed_token) } + # The middle part is the output of OpenSSL::HMAC.hexdigest("SHA256", 'KNOWN VALUE', 'url.atom') + let(:feed_token) { "glft-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693-#{feed_user.id}" } + + it 'returns user if valid feed_token' do + set_param(:feed_token, feed_token) + + expect(find_user_from_feed_token(:rss)).to eq feed_user + end + + it 'returns nil if valid feed_token and disabled' do + allow(Gitlab::CurrentSettings).to receive_messages(disable_feed_token: true) + set_param(:feed_token, feed_token) + + expect(find_user_from_feed_token(:rss)).to be_nil + end + + it 'returns exception if token has same HMAC but different user ID' do + set_param(:feed_token, "glft-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693-#{user.id}") + + expect { find_user_from_feed_token(:rss) }.to raise_error(Gitlab::Auth::UnauthorizedError) + end + + it 'returns exception if token has wrong HMAC but same user ID' do + set_param(:feed_token, "glft-aaaaaaaaaade004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693-#{feed_user.id}") + + expect { find_user_from_feed_token(:rss) }.to raise_error(Gitlab::Auth::UnauthorizedError) + end + + it 'returns exception if user does not exist' do + set_param(:feed_token, "glft-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693-#{non_existing_record_id}") + + expect { find_user_from_feed_token(:rss) }.to raise_error(Gitlab::Auth::UnauthorizedError) + end + end + + context 'when old format rss_token param is provided' do it 'returns user if valid rss_token' do set_param(:rss_token, user.feed_token) diff --git a/spec/support/shared_examples/features/rss_shared_examples.rb b/spec/support/shared_examples/features/rss_shared_examples.rb index f6566214e32d8a2b338b589c836aaf5ef0c866f3..a6b9c98923a795aa17d9d918d38b08cfabd69d8b 100644 --- a/spec/support/shared_examples/features/rss_shared_examples.rb +++ b/spec/support/shared_examples/features/rss_shared_examples.rb @@ -2,20 +2,20 @@ RSpec.shared_examples "an autodiscoverable RSS feed with current_user's feed token" do it "has an RSS autodiscovery link tag with current_user's feed token" do - expect(page).to have_css("link[type*='atom+xml'][href*='feed_token=#{user.feed_token}']", visible: false) + expect(page).to have_css("link[type*='atom+xml'][href*='feed_token=glft-'][href*='-#{user.id}']", visible: false) end end RSpec.shared_examples "it has an RSS button with current_user's feed token" do it "shows the RSS button with current_user's feed token" do expect(page) - .to have_css("a:has([data-testid='rss-icon'])[href*='feed_token=#{user.feed_token}']") + .to have_css("a:has([data-testid='rss-icon'])[href*='feed_token=glft-'][href*='-#{user.id}']") end end RSpec.shared_examples "it has an RSS link with current_user's feed token" do it "shows the RSS link with current_user's feed token" do - expect(page).to have_link 'Subscribe to RSS feed', href: /feed_token=#{user.feed_token}/ + expect(page).to have_link 'Subscribe to RSS feed', href: /feed_token=glft-.*-#{user.id}/ end end @@ -51,11 +51,17 @@ auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) expected = { - 'feed_token' => [user.feed_token], 'assignee_id' => [user.id.to_s] } expect(params).to include(expected) + feed_token_param = params['feed_token'] + expect(feed_token_param).to match([Gitlab::Auth::AuthFinders::PATH_DEPENDENT_FEED_TOKEN_REGEX]) + expect(feed_token_param.first).to end_with(user.id.to_s) + expect(auto_discovery_params).to include(expected) + feed_token_param = auto_discovery_params['feed_token'] + expect(feed_token_param).to match([Gitlab::Auth::AuthFinders::PATH_DEPENDENT_FEED_TOKEN_REGEX]) + expect(feed_token_param.first).to end_with(user.id.to_s) end end