diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/authentication/allowed_hosts_validator.rb b/components/abstractions/lib/microsoft_kiota_abstractions/authentication/allowed_hosts_validator.rb index 4463bac..4c403a0 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/authentication/allowed_hosts_validator.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/authentication/allowed_hosts_validator.rb @@ -6,6 +6,8 @@ module MicrosoftKiotaAbstractions # Maintains a list of valid hosts and allows authentication providers to check whether # a host is valid before authenticating a request class AllowedHostsValidator + LOCALHOST_STRINGS = ['localhost', '::1', '[::1]', '127.0.0.1'].freeze + # creates a new AllocatedHostsValidator with provided values def initialize(allowed_hosts) self.allowed_hosts = allowed_hosts @@ -24,15 +26,25 @@ def allowed_hosts=(val) # checks whether the provided host is valid def url_host_valid?(url) - return true if @allowed_hosts.empty? + return false if url.nil? parsed_url = URI(url) return false if parsed_url.host.nil? - return false unless parsed_url.is_a?(URI::HTTPS) + is_https = parsed_url.is_a?(URI::HTTPS) + is_http_localhost = parsed_url.is_a?(URI::HTTP) && localhost?(parsed_url) + return false unless is_https || is_http_localhost + + return true if @allowed_hosts.empty? + + host = parsed_url.host.downcase + return true if @allowed_hosts.key?(host) + + stripped_host = host.delete_prefix('[').delete_suffix(']') + return true if @allowed_hosts.key?(stripped_host) - hostname = parsed_url.host.downcase - return true if @allowed_hosts.key? hostname + hostname = (parsed_url.hostname || host).downcase + return true if @allowed_hosts.key?(hostname) @allowed_hosts.any? { |allowed_host, _| allowed_host.start_with?('.') && hostname.end_with?(allowed_host) } rescue URI::InvalidURIError @@ -41,5 +53,19 @@ def url_host_valid?(url) # gets the list of valid hosts attr_reader :allowed_hosts + + private + + def localhost?(parsed_url) + host = parsed_url.host + return false if host.nil? + + return true if LOCALHOST_STRINGS.include?(host.downcase) + + hostname = parsed_url.hostname + return false if hostname.nil? + + LOCALHOST_STRINGS.include?(hostname.downcase) + end end end diff --git a/components/abstractions/lib/microsoft_kiota_abstractions/authentication/base_bearer_token_authentication_provider.rb b/components/abstractions/lib/microsoft_kiota_abstractions/authentication/base_bearer_token_authentication_provider.rb index 6d476d0..7bbe83c 100644 --- a/components/abstractions/lib/microsoft_kiota_abstractions/authentication/base_bearer_token_authentication_provider.rb +++ b/components/abstractions/lib/microsoft_kiota_abstractions/authentication/base_bearer_token_authentication_provider.rb @@ -24,7 +24,8 @@ def authenticate_request(request, additional_properties = {}) end else Fiber.new do - token = @access_token_provider.get_authorization_token(request.uri, additional_properties).resume + auth_fiber = @access_token_provider.get_authorization_token(request.uri, additional_properties) + token = auth_fiber.resume if auth_fiber request.headers.add(AUTHORIZATION_HEADER_KEY, "Bearer #{token}") unless token.nil? || token.empty? end end diff --git a/components/abstractions/spec/base_bearer_token_authentication_provider_spec.rb b/components/abstractions/spec/base_bearer_token_authentication_provider_spec.rb new file mode 100644 index 0000000..a7fee58 --- /dev/null +++ b/components/abstractions/spec/base_bearer_token_authentication_provider_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'uri' +require 'microsoft_kiota_abstractions' +require_relative 'access_token_provider_mock' + +RSpec.describe MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider do + it 'creates a token authentication provider with existing authorization header' do + access_token_provider = AccessTokenProviderMock.new + token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(access_token_provider) + expect(token_provider).not_to be nil + request_obj = MicrosoftKiotaAbstractions::RequestInformation.new + request_obj.headers.add( + MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY, 'SAMPLE' + ) + token_provider.authenticate_request(request_obj).resume + expect(request_obj.headers.get(MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY).at(0)).to eq 'SAMPLE' + end + + it 'creates a token authentication provider with mock access token' do + access_token_provider = AccessTokenProviderMock.new + token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(access_token_provider) + expect(token_provider).not_to be nil + request_obj = MicrosoftKiotaAbstractions::RequestInformation.new + request_obj.uri = 'http://sample.com' + token_provider.authenticate_request(request_obj).resume + expect(request_obj.headers.get(MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY).at(0)).to eq "Bearer #{AccessTokenProviderMock::DUMMY_TOKEN}" + end + + it 'safely handles nil token or nil fiber from access token provider' do + nil_provider = double('AccessTokenProvider', get_authorization_token: nil) + token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(nil_provider) + request_obj = MicrosoftKiotaAbstractions::RequestInformation.new + request_obj.uri = 'https://example.com' + header_key = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY + expect { token_provider.authenticate_request(request_obj).resume }.not_to raise_error + expect(request_obj.headers.get_all.key?(header_key)).to be(false) + end +end diff --git a/components/abstractions/spec/microsoft_kiota_abstractions_spec.rb b/components/abstractions/spec/microsoft_kiota_abstractions_spec.rb index 6218035..3002227 100644 --- a/components/abstractions/spec/microsoft_kiota_abstractions_spec.rb +++ b/components/abstractions/spec/microsoft_kiota_abstractions_spec.rb @@ -21,32 +21,6 @@ token_provider.authenticate_request(nil).resume end - it 'creates a token authentication provider with existing authorization header' do - access_token_provider = AccessTokenProviderMock.new - token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(access_token_provider) - expect(token_provider).not_to be nil - request_obj = MicrosoftKiotaAbstractions::RequestInformation.new - request_obj.headers.add( - MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY, 'SAMPLE' - ) - # Authentication does nothing besides returning an empty Fiber - token_provider.authenticate_request(request_obj).resume - # The request header should not have changed - expect(request_obj.headers.get(MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY).at(0)).to eq 'SAMPLE' - end - - it 'creates a token authentication provider with mock access token' do - access_token_provider = AccessTokenProviderMock.new - token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(access_token_provider) - expect(token_provider).not_to be nil - request_obj = MicrosoftKiotaAbstractions::RequestInformation.new - # We have to set a URI, otherwise the code will fail ("undefined method 'chars' for nil") - request_obj.uri = 'http://sample.com' - token_provider.authenticate_request(request_obj).resume - # The request header should now contain the "DUMMY_TOKEN" value: - expect(request_obj.headers.get(MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY).at(0)).to eq "Bearer #{AccessTokenProviderMock::DUMMY_TOKEN}" - end - it 'returns the raw URI when set via setter' do request_obj = MicrosoftKiotaAbstractions::RequestInformation.new request_obj.path_parameters['term'] = 'search' diff --git a/components/abstractions/spec/request_information_uri_spec.rb b/components/abstractions/spec/request_information_uri_spec.rb index 994c362..ecd49ef 100644 --- a/components/abstractions/spec/request_information_uri_spec.rb +++ b/components/abstractions/spec/request_information_uri_spec.rb @@ -64,4 +64,27 @@ def uri_for(template, path_parameters = {}, query_parameters = {}) expect { validator.allowed_hosts = ['https://bad.com'] }.to raise_error(ArgumentError) expect(validator.url_host_valid?('https://anything.com')).to be(false) end + + it 'validates host with https scheme and allows http for localhost' do + validator = described_class.new(['example.com', 'localhost']) + expect(validator.url_host_valid?('http://example.com')).to be(false) + expect(validator.url_host_valid?('http://localhost:8080')).to be(true) + expect(validator.url_host_valid?('https://example.com')).to be(true) + expect(validator.url_host_valid?('https://other.com')).to be(false) + expect(validator.url_host_valid?('http://other.com')).to be(false) + end + + it 'validates IPv6 loopback hosts with both bracketed and unbracketed allowlist entries' do + bracketed_validator = described_class.new(['[::1]']) + expect(bracketed_validator.url_host_valid?('https://[::1]:8080/v1.0')).to be(true) + expect(bracketed_validator.url_host_valid?('http://[::1]:8080/v1.0')).to be(true) + expect(bracketed_validator.url_host_valid?('http://example.com')).to be(false) + expect(bracketed_validator.url_host_valid?('https://example.com')).to be(false) + + unbracketed_validator = described_class.new(['::1']) + expect(unbracketed_validator.url_host_valid?('https://[::1]:8080/v1.0')).to be(true) + expect(unbracketed_validator.url_host_valid?('http://[::1]:8080/v1.0')).to be(true) + expect(unbracketed_validator.url_host_valid?('http://example.com')).to be(false) + expect(unbracketed_validator.url_host_valid?('https://example.com')).to be(false) + end end diff --git a/components/authentication/oauth/lib/microsoft_kiota_authentication_oauth/oauth_access_token_provider.rb b/components/authentication/oauth/lib/microsoft_kiota_authentication_oauth/oauth_access_token_provider.rb index fbb3b60..206d4f0 100644 --- a/components/authentication/oauth/lib/microsoft_kiota_authentication_oauth/oauth_access_token_provider.rb +++ b/components/authentication/oauth/lib/microsoft_kiota_authentication_oauth/oauth_access_token_provider.rb @@ -12,6 +12,8 @@ module MicrosoftKiotaAuthenticationOAuth # Access Token Provider class implementation class OAuthAccessTokenProvider + LOCALHOST_STRINGS = ['localhost', '::1', '[::1]', '127.0.0.1'].freeze + # This is the initializer for OAuthAccessTokenProvider. # :params # token_request_context: a instance of one of our token request context or a custom implementation @@ -48,11 +50,24 @@ def initialize(token_request_context, allowed_hosts = [], scopes = []) # additional_params: hash of symbols to string values, ie { response_mode: 'fragment', prompt: 'login' } # default is empty hash def get_authorization_token(uri, _additional_properties = {}) - nil if !uri || !@host_validator.url_host_valid?(uri) + return Fiber.new { nil } if !uri || uri.to_s.empty? + + begin + parsed_url = URI(uri) + rescue URI::InvalidURIError, ArgumentError + return Fiber.new { nil } + end - parsed_url = URI(uri) + return Fiber.new { nil } if parsed_url.nil? || parsed_url.host.nil? - raise StandardError, 'Only https is supported' if parsed_url.scheme != 'https' + https_version = parsed_url.dup + https_version.scheme = 'https' + return Fiber.new { nil } unless @host_validator.url_host_valid?(https_version.to_s) + + is_localhost = localhost?(parsed_url) + is_https = parsed_url.scheme == 'https' + is_http_localhost = parsed_url.scheme == 'http' && is_localhost + raise StandardError, 'Only https is supported' unless is_https || is_http_localhost @scopes << "#{parsed_url.scheme}://#{parsed_url.host}/.default" if @scopes.empty? @token_request_context.initialize_scopes(@scopes) @@ -83,5 +98,19 @@ def get_authorization_token(uri, _additional_properties = {}) protected attr_writer :host_validator, :token_credential, :scopes, :cached_token + + private + + def localhost?(parsed_url) + host = parsed_url.host + return false if host.nil? + + return true if LOCALHOST_STRINGS.include?(host.downcase) + + hostname = parsed_url.hostname + return false if hostname.nil? + + LOCALHOST_STRINGS.include?(hostname.downcase) + end end end diff --git a/components/authentication/oauth/spec/oauth_access_token_provider_spec.rb b/components/authentication/oauth/spec/oauth_access_token_provider_spec.rb new file mode 100644 index 0000000..a0caf9b --- /dev/null +++ b/components/authentication/oauth/spec/oauth_access_token_provider_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'uri' +require 'microsoft_kiota_abstractions' +require_relative 'spec_helper' + +RSpec.describe MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider do + it 'returns a fiber resolving to nil when uri is nil or not in allowed hosts' do + context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new( + 'tenant_id', 'client_id', 'client_secret', {} + ) + provider = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new(context, ['graph.microsoft.com']) + + fiber_nil = provider.get_authorization_token(nil) + expect(fiber_nil).to be_a(Fiber) + expect(fiber_nil.resume).to be_nil + + fiber_untrusted = provider.get_authorization_token('https://untrusted.com/v1.0') + expect(fiber_untrusted).to be_a(Fiber) + expect(fiber_untrusted.resume).to be_nil + + fiber_http_untrusted = provider.get_authorization_token('http://untrusted.com/v1.0') + expect(fiber_http_untrusted).to be_a(Fiber) + expect(fiber_http_untrusted.resume).to be_nil + + fiber_loopback_unapproved = provider.get_authorization_token('http://localhost:8080/v1.0') + expect(fiber_loopback_unapproved).to be_a(Fiber) + expect(fiber_loopback_unapproved.resume).to be_nil + end + + it 'raises error for allowlisted external HTTP target' do + context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new( + 'tenant_id', 'client_id', 'client_secret', {} + ) + provider = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new(context, ['graph.microsoft.com']) + + expect do + provider.get_authorization_token('http://graph.microsoft.com/v1.0') + end.to raise_error(StandardError, 'Only https is supported') + end + + it 'allows http on localhost and loopback addresses when allowed hosts is empty or includes loopback' do + context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new( + 'tenant_id', 'client_id', 'client_secret', {} + ) + allow(context).to receive(:get_token).and_return(double('token', to_hash: {}, token: 'test_token')) + + ['localhost', '127.0.0.1', '[::1]'].each do |host| + url = "http://#{host}:8080/v1.0" + + provider_empty = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new(context, []) + fiber_empty = provider_empty.get_authorization_token(url) + expect(fiber_empty).to be_a(Fiber) + expect(fiber_empty.resume).to eq('test_token') + + [host, host.delete_prefix('[').delete_suffix(']')].uniq.each do |allowed_entry| + provider_allowlisted = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new( + context, [allowed_entry] + ) + fiber_allowlisted = provider_allowlisted.get_authorization_token(url) + expect(fiber_allowlisted).to be_a(Fiber) + expect(fiber_allowlisted.resume).to eq('test_token') + end + end + end + + it 'raises error for unsupported schemes even on localhost' do + context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new( + 'tenant_id', 'client_id', 'client_secret', {} + ) + provider = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new(context, []) + + ['ftp://localhost:8080/v1.0', 'ws://localhost:8080/v1.0', '//localhost:8080/v1.0'].each do |url| + expect do + provider.get_authorization_token(url) + end.to raise_error(StandardError, 'Only https is supported') + end + end + + it 'leaves request unauthenticated when host is not allowed via OAuthAuthenticationProvider' do + context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new( + 'tenant_id', 'client_id', 'client_secret', {} + ) + auth_provider = MicrosoftKiotaAuthenticationOAuth::OAuthAuthenticationProvider.new( + context, ['graph.microsoft.com'], [] + ) + request = MicrosoftKiotaAbstractions::RequestInformation.new + request.uri = 'https://untrusted.com/v1.0' + + expect { auth_provider.authenticate_request(request).resume }.not_to raise_error + expect(request.headers.get_all.key?('Authorization')).to be(false) + end + + it 'authenticates http localhost request via OAuthAuthenticationProvider' do + context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new( + 'tenant_id', 'client_id', 'client_secret', {} + ) + allow(context).to receive(:get_token).and_return(double('token', to_hash: {}, token: 'test_token')) + + auth_provider = MicrosoftKiotaAuthenticationOAuth::OAuthAuthenticationProvider.new( + context, ['localhost'], [] + ) + request = MicrosoftKiotaAbstractions::RequestInformation.new + request.uri = 'http://localhost:8080/v1.0' + + auth_provider.authenticate_request(request).resume + expect(request.headers.get('Authorization').first).to eq('Bearer test_token') + end +end