diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/legacy_ruby.yml b/.github/workflows/legacy_ruby.yml new file mode 100644 index 0000000..39ae42c --- /dev/null +++ b/.github/workflows/legacy_ruby.yml @@ -0,0 +1,28 @@ +name: Legacy Ruby specs + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ master, develop ] + +permissions: + contents: read + +jobs: + test: + name: Specs + runs-on: ubuntu-20.04 + strategy: + matrix: + ruby-version: ['2.1', '2.2', '2.3', '2.4', '2.5', '2.6'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + - name: Run specs + run: bundle exec rake diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 0000000..297bc39 --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,35 @@ +name: Ruby specs + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ master, develop ] + +permissions: + contents: read + +jobs: + test: + name: Specs + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['2.7', '3.0', '3.1', '3.2'] + channel: ['stable'] + + include: + - ruby-version: 'head' + channel: ['experimental'] + + continue-on-error: ${{ matrix.channel != 'stable' }} + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + - name: Run specs + run: bundle exec rake diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 58d55a8..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -dist: xenial -os: linux -language: ruby -rvm: - - 2.1 - - 2.2 - - 2.3 - - 2.4 - - 2.5 - - 2.6 - - 2.7 - - ruby-edge -branches: - only: - - master -before_install: - - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true - - gem install bundler -v '< 2' -jobs: - allow_failures: - - rvm: ruby-edge diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ddaaf..682d477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](http://keepachangelog.com/) and this -project adheres to [Semantic Versioning](http://semver.org/) +The format is based on [Keep a Changelog](https://keepachangelog.com/) and this +project adheres to [Semantic Versioning](https://semver.org/) ## 2.0.0 - 2010-11-14 @@ -17,6 +17,19 @@ project adheres to [Semantic Versioning](http://semver.org/) * Forward success response to `fetch_raw_info` callback ([#51](https://github.com/dlindahl/omniauth-cas/pull/51)) * Relax development dependencies to the latest versions +## 1.2.1 (IFAD) - 2018-11-27 + +### Changed + +* Safer request.host call + +## 1.2.0 (IFAD) - 2018-11-27 + +### Added + +* service_validator: use custom callback_url if provided +* Add url_by_request_host option + ## 1.1.1 - 2016-09-19 ### Changed diff --git a/Gemfile b/Gemfile index 1c04832..d4295d7 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,5 @@ source 'https://rubygems.org' +gem 'byebug' # Specify your gem's dependencies in omniauth-cas.gemspec gemspec diff --git a/README.md b/README.md index d291d40..da38f64 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ -# OmniAuth CAS Strategy [![Gem Version][version_badge]][version] [![Build Status][travis_status]][travis] +# OmniAuth CAS Strategy [![Gem Version][version_badge]][version] [![Build Status][github_actions_status]][github_actions] [![Build Status][github_legacy_actions_status]][github_actions] [version_badge]: https://badge.fury.io/rb/omniauth-cas.svg [version]: https://badge.fury.io/rb/omniauth-cas -[travis]: https://travis-ci.org/dlindahl/omniauth-cas -[travis_status]: https://secure.travis-ci.org/dlindahl/omniauth-cas.svg +[github_actions]: https://github.com/ifad/omniauth-cas/actions +[github_actions_status]: https://github.com/ifad/omniauth-cas/actions/workflows/ruby.yml/badge.svg +[github_actions_legacy_status]: https://github.com/ifad/omniauth-cas/actions/workflows/legacy_ruby.yml/badge.svg [releases]: https://github.com/dlindahl/omniauth-cas/releases This is a OmniAuth 1.0 compatible port of the previously available @@ -74,6 +75,19 @@ Other configuration options: extra_info } ``` + * `url_by_request_host` - Optional. Hash keyed by request host, to use + different CAS Server URLs depending on the request host. *Requires* `url` + or `host` to be set anyway, that'll be used as defaults if no host + matches. + + ```ruby + provider :cas, + url: 'https://cas.example.org', + url_by_request_host: { + 'host1.example.org' => 'https://host1.cas.example.org', + 'host2.example.org' => 'https://host2.cas.example.org', + } + ``` Configurable options for values returned by CAS: diff --git a/lib/omniauth/strategies/cas.rb b/lib/omniauth/strategies/cas.rb index 3b00a0b..19a866d 100644 --- a/lib/omniauth/strategies/cas.rb +++ b/lib/omniauth/strategies/cas.rb @@ -9,6 +9,8 @@ class CAS # Custom Exceptions class MissingCASTicket < StandardError; end class InvalidCASTicket < StandardError; end + class MissingReturnURL < StandardError; end + class InvalidReturnURL < StandardError; end autoload :ServiceTicketValidator, 'omniauth/strategies/cas/service_ticket_validator' autoload :LogoutRequest, 'omniauth/strategies/cas/logout_request' @@ -91,14 +93,18 @@ def callback_phase def request_phase service_url = append_params(callback_url, return_url) - [ - 302, - { - 'Location' => login_url(service_url), - 'Content-Type' => 'text/plain' - }, - ["You are being redirected to CAS for sign-in."] - ] + if validate_service_url!(service_url) + [ + 302, + { + 'Location' => login_url(service_url), + 'Content-Type' => 'text/plain' + }, + ["You are being redirected to CAS for sign-in."] + ] + else + [ 400, {}, [ "Bad request" ] ] + end end def on_sso_path? @@ -115,14 +121,30 @@ def single_sign_out_phase def cas_url extract_url if options['url'] validate_cas_setup - @cas_url ||= begin - uri = Addressable::URI.new - uri.host = options.host - uri.scheme = options.ssl ? 'https' : 'http' - uri.port = options.port - uri.path = options.path - uri.to_s - end + + by_host_cas_url || static_cas_url + end + + def by_host_cas_url + return unless options.url_by_request_host && \ + options.url_by_request_host.respond_to?(:fetch) + + uri = options.url_by_request_host.fetch(request.host) + + Addressable::URI.parse(uri).to_s + rescue + nil # When request.host is not defined or it raises, + # or when Addressable raises, we can only resort + # to the default. + end + + def static_cas_url + uri = Addressable::URI.new + uri.host = options.host + uri.scheme = options.ssl ? 'https' : 'http' + uri.port = options.port + uri.path = options.path + uri.to_s end def extract_url @@ -141,6 +163,33 @@ def validate_cas_setup end end + # Checks that the callback URL is within the scope of the target + # service url, to protect against redirects to phishing pages. + # + def validate_service_url!(service_url) + service_url = Addressable::URI.parse(service_url) + + return_url = service_url.query_values['url'] + + if return_url.nil? || return_url.empty? + fail!(:missing_return_url, MissingReturnURL.new('Missing Return URL')) + return false + end + + return_url = Addressable::URI.parse(return_url) + + # Check that the return URL host, if present, is equal to the service + # URL host. If the return_url host is nil, it means this is a relative + # url - and we can accept it. + # + if !return_url.host.nil? && (return_url.host != service_url.host) + fail!(:invalid_return_url, InvalidReturnURL.new('Invalid Return URL')) + return false + end + + return true + end + # Build a service-validation URL from +service+ and +ticket+. # If +service+ has a ticket param, first remove it. URL-encode # +service+ and add it and the +ticket+ as paraemters to the diff --git a/spec/omniauth/strategies/cas_spec.rb b/spec/omniauth/strategies/cas_spec.rb index 422f932..3efe7e4 100644 --- a/spec/omniauth/strategies/cas_spec.rb +++ b/spec/omniauth/strategies/cas_spec.rb @@ -80,6 +80,42 @@ expect(provider.options).to include path:'/a/path' end end + + context 'with a URL by host mapping' do + let(:params) { super().merge( + url: 'https://default.cas.host', + url_by_request_host: { + 'host1.example.org' => 'https://host1.cas.host', + 'host2.example.org' => 'https://host2.cas.host', + }) + } + + let(:request_host) { nil } + + before do + allow_any_instance_of(MyCasProvider) + .to receive(:request) + .and_return(Rack::Request.new('HTTP_HOST' => request_host)) + end + + it { true } + + context 'and an host in the map' do + let(:request_host) { 'host1.example.org' } + + it 'returns the corresponding CAS host in the map' do + expect(subject).to eq('https://host1.cas.host') + end + end + + context 'and an host not in the map' do + let(:request_host) { 'foo.bar' } + + it 'returns the default CAS host' do + expect(subject).to eq('https://default.cas.host') + end + end + end end describe 'defaults' do @@ -89,7 +125,15 @@ end describe 'GET /auth/cas' do - let(:return_url) { 'http://myapp.com/admin/foo' } + let(:return_url) { 'http://example.org/admin/foo' } + + context 'with a return url on a different host than the service url' do + before { get '/auth/cas?url=http://attack.example.org/' } + + subject { last_response } + + it { should be_bad_request } + end context 'with a referer' do let(:url) { '/auth/cas' }