diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-29 12:54:43 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-29 12:54:49 +0000 |
commit | ef37104bc0f26d2fd509f49d8e16c5a45b392b43 (patch) | |
tree | e71a0146422048320b2ec273ab4077c26996f28f | |
parent | aa5cc6421acfe210c256cdcc840a853fed7cd824 (diff) | |
download | gitlab-ce-ef37104bc0f26d2fd509f49d8e16c5a45b392b43.tar.gz |
Add latest changes from gitlab-org/security/gitlab@14-1-stable-ee
19 files changed, 601 insertions, 26 deletions
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue new file mode 100644 index 00000000000..280c222c380 --- /dev/null +++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue @@ -0,0 +1,98 @@ +<script> +import { GlFormInput, GlFormGroup, GlButton, GlForm } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { __ } from '~/locale'; + +export const i18n = { + currentPassword: __('Current password'), + confirmWebAuthn: __( + 'Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.', + ), + confirm: __('Are you sure? This will invalidate your registered applications and U2F devices.'), + disableTwoFactor: __('Disable two-factor authentication'), + regenerateRecoveryCodes: __('Regenerate recovery codes'), +}; + +export default { + name: 'ManageTwoFactorForm', + i18n, + components: { + GlForm, + GlFormInput, + GlFormGroup, + GlButton, + }, + inject: [ + 'webauthnEnabled', + 'profileTwoFactorAuthPath', + 'profileTwoFactorAuthMethod', + 'codesProfileTwoFactorAuthPath', + 'codesProfileTwoFactorAuthMethod', + ], + data() { + return { + method: '', + action: '#', + }; + }, + computed: { + confirmText() { + if (this.webauthnEnabled) { + return i18n.confirmWebAuthn; + } + + return i18n.confirm; + }, + }, + methods: { + handleFormSubmit(event) { + this.method = event.submitter.dataset.formMethod; + this.action = event.submitter.dataset.formAction; + }, + }, + csrf, +}; +</script> + +<template> + <gl-form + class="gl-display-inline-block" + method="post" + :action="action" + @submit="handleFormSubmit($event)" + > + <input type="hidden" name="_method" data-testid="test-2fa-method-field" :value="method" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + + <gl-form-group :label="$options.i18n.currentPassword" label-for="current-password"> + <gl-form-input + id="current-password" + type="password" + name="current_password" + required + data-qa-selector="current_password_field" + /> + </gl-form-group> + + <gl-button + type="submit" + class="btn-danger gl-mr-3 gl-display-inline-block" + data-testid="test-2fa-disable-button" + variant="danger" + :data-confirm="confirmText" + :data-form-action="profileTwoFactorAuthPath" + :data-form-method="profileTwoFactorAuthMethod" + > + {{ $options.i18n.disableTwoFactor }} + </gl-button> + <gl-button + type="submit" + class="gl-display-inline-block" + data-testid="test-2fa-regenerate-codes-button" + :data-form-action="codesProfileTwoFactorAuthPath" + :data-form-method="codesProfileTwoFactorAuthMethod" + > + {{ $options.i18n.regenerateRecoveryCodes }} + </gl-button> + </gl-form> +</template> diff --git a/app/assets/javascripts/authentication/two_factor_auth/index.js b/app/assets/javascripts/authentication/two_factor_auth/index.js index 5e59c44e8cd..f663c0705e6 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/index.js +++ b/app/assets/javascripts/authentication/two_factor_auth/index.js @@ -1,8 +1,39 @@ import Vue from 'vue'; import { updateHistory, removeParams } from '~/lib/utils/url_utility'; +import ManageTwoFactorForm from './components/manage_two_factor_form.vue'; import RecoveryCodes from './components/recovery_codes.vue'; import { SUCCESS_QUERY_PARAM } from './constants'; +export const initManageTwoFactorForm = () => { + const el = document.querySelector('.js-manage-two-factor-form'); + + if (!el) { + return false; + } + + const { + webauthnEnabled = false, + profileTwoFactorAuthPath = '', + profileTwoFactorAuthMethod = '', + codesProfileTwoFactorAuthPath = '', + codesProfileTwoFactorAuthMethod = '', + } = el.dataset; + + return new Vue({ + el, + provide: { + webauthnEnabled, + profileTwoFactorAuthPath, + profileTwoFactorAuthMethod, + codesProfileTwoFactorAuthPath, + codesProfileTwoFactorAuthMethod, + }, + render(createElement) { + return createElement(ManageTwoFactorForm); + }, + }); +}; + export const initRecoveryCodes = () => { const el = document.querySelector('.js-2fa-recovery-codes'); diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js index 186072531b8..00b5309ea85 100644 --- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js +++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js @@ -1,5 +1,5 @@ import { mount2faRegistration } from '~/authentication/mount_2fa'; -import { initRecoveryCodes } from '~/authentication/two_factor_auth'; +import { initRecoveryCodes, initManageTwoFactorForm } from '~/authentication/two_factor_auth'; import { parseBoolean } from '~/lib/utils/common_utils'; document.addEventListener('DOMContentLoaded', () => { @@ -16,3 +16,5 @@ document.addEventListener('DOMContentLoaded', () => { }); initRecoveryCodes(); + +initManageTwoFactorForm(); diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index e2f8baa8226..5cbab55722f 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -2,6 +2,9 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController skip_before_action :check_two_factor_requirement + + before_action :validate_current_password, only: [:create, :codes, :destroy] + before_action do push_frontend_feature_flag(:webauthn) end @@ -129,6 +132,14 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController private + def validate_current_password + return if current_user.valid_password?(params[:current_password]) + + current_user.increment_failed_attempts! + + redirect_to profile_two_factor_auth_path, alert: _('You must provide a valid current password') + end + def build_qr_code uri = current_user.otp_provisioning_uri(account_string, issuer: issuer_host) RQRCode.render_qrcode(uri, :svg, level: :m, unit: 3) diff --git a/app/helpers/external_link_helper.rb b/app/helpers/external_link_helper.rb index 058302d1ed8..c951d0daf96 100644 --- a/app/helpers/external_link_helper.rb +++ b/app/helpers/external_link_helper.rb @@ -1,9 +1,12 @@ # frozen_string_literal: true module ExternalLinkHelper + include ActionView::Helpers::TextHelper + def external_link(body, url, options = {}) - link_to url, { target: '_blank', rel: 'noopener noreferrer' }.merge(options) do + link = link_to url, { target: '_blank', rel: 'noopener noreferrer' }.merge(options) do "#{body}#{sprite_icon('external-link', css_class: 'gl-ml-1')}".html_safe end + sanitize(link, tags: %w(a svg use), attributes: %w(target rel data-testid class href).concat(options.stringify_keys.keys)) end end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 096a3f2269b..c38b4a7aedf 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -44,7 +44,7 @@ module IconsHelper content_tag( :svg, - content_tag(:use, '', { 'xlink:href' => "#{sprite_icon_path}##{icon_name}" } ), + content_tag(:use, '', { 'href' => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes.join(' '), data: { testid: "#{icon_name}-icon" } ) diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 927b6d4edef..d1d6b6301b8 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -17,13 +17,7 @@ = _("You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication.") %p = _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.') - %div - = link_to _('Disable two-factor authentication'), profile_two_factor_auth_path, - method: :delete, - data: { confirm: webauthn_enabled ? _('Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.') : _('Are you sure? This will invalidate your registered applications and U2F devices.') }, - class: 'gl-button btn btn-danger gl-mr-3' - = form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f| - = submit_tag _('Regenerate recovery codes'), class: 'gl-button btn btn-default' + .js-manage-two-factor-form{ data: { webauthn_enabled: webauthn_enabled, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } } - else %p @@ -53,6 +47,11 @@ .form-group = label_tag :pin_code, _('Pin code'), class: "label-bold" = text_field_tag :pin_code, nil, class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' } + .form-group + = label_tag :current_password, _('Current password'), class: 'label-bold' + = password_field_tag :current_password, nil, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } + %p.form-text.text-muted + = _('Your current password is required to register a two-factor authenticator app.') .gl-mt-3 = submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'register_2fa_app_button' } diff --git a/config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb b/config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb index 760fcba5935..1ede92609a9 100644 --- a/config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb +++ b/config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb @@ -1,14 +1,46 @@ # frozen_string_literal: true +# See https://github.com/omniauth/omniauth-oauth2/blob/v1.7.1/lib/omniauth/strategies/oauth2.rb#L84-L101 +# for the original version of this code. +# +# Note: We need to override `callback_phase` directly (instead of using a module with `include` or `prepend`), +# because the method has a `super` call which needs to go to the `OmniAuth::Strategy` module, +# and it also deletes `omniauth.state` from the session as a side effect. + module OmniAuth module Strategies class OAuth2 - alias_method :original_callback_phase, :callback_phase - - # Monkey patch until PR is merged and released upstream - # https://github.com/omniauth/omniauth-oauth2/pull/129 def callback_phase - original_callback_phase + error = request.params["error_reason"].presence || request.params["error"].presence + # Monkey patch #1: + # + # Swap the order of these conditions around so the `state` param is verified *first*, + # before using the error params returned by the provider. + # + # This avoids content spoofing attacks by crafting a URL with malicious messages, + # because the `state` param is only present in the session after a valid OAuth2 authentication flow. + if !options.provider_ignores_state && (request.params["state"].to_s.empty? || request.params["state"] != session.delete("omniauth.state")) + fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected")) + elsif error + fail!(error, CallbackError.new(request.params["error"], request.params["error_description"].presence || request.params["error_reason"].presence, request.params["error_uri"])) + else + self.access_token = build_access_token + self.access_token = access_token.refresh! if access_token.expired? + super + end + rescue ::OAuth2::Error, CallbackError => e + fail!(:invalid_credentials, e) + rescue ::Timeout::Error, ::Errno::ETIMEDOUT => e + fail!(:timeout, e) + rescue ::SocketError => e + fail!(:failed_to_connect, e) + # Monkey patch #2: + # + # Also catch errors from Faraday. + # See https://github.com/omniauth/omniauth-oauth2/pull/129 + # and https://github.com/oauth-xx/oauth2/issues/152 + # + # This can be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/340933 rescue ::Faraday::TimeoutError, ::Faraday::ConnectionFailed => e fail!(:timeout, e) end diff --git a/doc/api/dependencies.md b/doc/api/dependencies.md index c85a3521aed..cebb1ad5cef 100644 --- a/doc/api/dependencies.md +++ b/doc/api/dependencies.md @@ -11,6 +11,9 @@ This API is in an alpha stage and considered unstable. The response payload may be subject to change or breakage across GitLab releases. +> - Introduced in GitLab 12.1. +> - Pagination introduced in 14.4. + Every call to this endpoint requires authentication. To perform this call, user should be authorized to read repository. To see vulnerabilities in response, user should be authorized to read [Project Security Dashboard](../user/application_security/security_dashboard/index.md#project-security-dashboard). @@ -60,3 +63,10 @@ Example response: } ] ``` + +## Dependencies pagination + +By default, `GET` requests return 20 results at a time because the API results +are paginated. + +Read more on [pagination](index.md#pagination). diff --git a/doc/user/profile/account/two_factor_authentication.md b/doc/user/profile/account/two_factor_authentication.md index 597170540ab..d0160e64339 100644 --- a/doc/user/profile/account/two_factor_authentication.md +++ b/doc/user/profile/account/two_factor_authentication.md @@ -64,6 +64,7 @@ To enable 2FA: 1. **In GitLab:** 1. Enter the six-digit pin number from the entry on your device into the **Pin code** field. + 1. Enter your current password. 1. Select **Submit**. If the pin you entered was correct, a message displays indicating that @@ -354,7 +355,8 @@ If you ever need to disable 2FA: 1. Sign in to your GitLab account. 1. Go to your [**User settings**](../index.md#access-your-user-settings). 1. Go to **Account**. -1. Click **Disable**, under **Two-Factor Authentication**. +1. Select **Manage two-factor authentication**. +1. Under **Two-Factor Authentication**, enter your current password and select **Disable**. This clears all your two-factor authentication registrations, including mobile applications and U2F / WebAuthn devices. @@ -449,7 +451,7 @@ To regenerate 2FA recovery codes, you need access to a desktop browser: 1. Go to your [**User settings**](../index.md#access-your-user-settings). 1. Select **Account > Two-Factor Authentication (2FA)**. 1. If you've already configured 2FA, click **Manage two-factor authentication**. -1. In the **Register Two-Factor Authenticator** pane, click **Regenerate recovery codes**. +1. In the **Register Two-Factor Authenticator** pane, enter your current password and select **Regenerate recovery codes**. NOTE: If you regenerate 2FA recovery codes, save them. You can't use any previously created 2FA codes. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 70a16069eb0..30dc6fc2176 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -37971,6 +37971,9 @@ msgstr "" msgid "Your commit email is used for web based operations, such as edits and merges." msgstr "" +msgid "Your current password is required to register a two-factor authenticator app." +msgstr "" + msgid "Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}." msgstr "" diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index 59eb33f4bc6..2fbc529037c 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -10,6 +10,27 @@ RSpec.describe Profiles::TwoFactorAuthsController do allow(subject).to receive(:current_user).and_return(user) end + shared_examples 'user must enter a valid current password' do + let(:current_password) { '123' } + + it 'requires the current password', :aggregate_failures do + go + + expect(response).to redirect_to(profile_two_factor_auth_path) + expect(flash[:alert]).to eq(_('You must provide a valid current password')) + end + + context 'when the user is on the last sign in attempt' do + it do + user.update!(failed_attempts: User.maximum_attempts.pred) + + go + + expect(user.reload).to be_access_locked + end + end + end + describe 'GET show' do let(:user) { create(:user) } @@ -39,9 +60,10 @@ RSpec.describe Profiles::TwoFactorAuthsController do describe 'POST create' do let(:user) { create(:user) } let(:pin) { 'pin-code' } + let(:current_password) { user.password } def go - post :create, params: { pin_code: pin } + post :create, params: { pin_code: pin, current_password: current_password } end context 'with valid pin' do @@ -99,28 +121,38 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(response).to render_template(:show) end end + + it_behaves_like 'user must enter a valid current password' end describe 'POST codes' do let(:user) { create(:user, :two_factor) } + let(:current_password) { user.password } + it 'presents plaintext codes for the user to save' do expect(user).to receive(:generate_otp_backup_codes!).and_return(%w(a b c)) - post :codes + post :codes, params: { current_password: current_password } expect(assigns[:codes]).to match_array %w(a b c) end it 'persists the generated codes' do - post :codes + post :codes, params: { current_password: current_password } user.reload expect(user.otp_backup_codes).not_to be_empty end + + it_behaves_like 'user must enter a valid current password' do + let(:go) { post :codes, params: { current_password: current_password } } + end end describe 'DELETE destroy' do - subject { delete :destroy } + subject { delete :destroy, params: { current_password: current_password } } + + let(:current_password) { user.password } context 'for a user that has 2FA enabled' do let(:user) { create(:user, :two_factor) } @@ -143,6 +175,10 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(flash[:notice]) .to eq _('Two-factor authentication has been disabled successfully!') end + + it_behaves_like 'user must enter a valid current password' do + let(:go) { delete :destroy, params: { current_password: current_password } } + end end context 'for a user that does not have 2FA enabled' do diff --git a/spec/features/profiles/two_factor_auths_spec.rb b/spec/features/profiles/two_factor_auths_spec.rb new file mode 100644 index 00000000000..e1feca5031a --- /dev/null +++ b/spec/features/profiles/two_factor_auths_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Two factor auths' do + context 'when signed in' do + before do + allow(Gitlab).to receive(:com?) { true } + end + + context 'when user has two-factor authentication disabled' do + let(:user) { create(:user ) } + + before do + sign_in(user) + end + + it 'requires the current password to set up two factor authentication', :js do + visit profile_two_factor_auth_path + + register_2fa(user.reload.current_otp, '123') + + expect(page).to have_content('You must provide a valid current password') + + register_2fa(user.reload.current_otp, user.password) + + expect(page).to have_content('Please copy, download, or print your recovery codes before proceeding.') + + click_button 'Copy codes' + click_link 'Proceed' + + expect(page).to have_content('Status: Enabled') + end + end + + context 'when user has two-factor authentication enabled' do + let(:user) { create(:user, :two_factor) } + + before do + sign_in(user) + end + + it 'requires the current_password to disable two-factor authentication', :js do + visit profile_two_factor_auth_path + + fill_in 'current_password', with: '123' + + click_button 'Disable two-factor authentication' + + page.accept_alert + + expect(page).to have_content('You must provide a valid current password') + + fill_in 'current_password', with: user.password + + click_button 'Disable two-factor authentication' + + page.accept_alert + + expect(page).to have_content('Two-factor authentication has been disabled successfully!') + expect(page).to have_content('Enable two-factor authentication') + end + + it 'requires the current_password to regernate recovery codes', :js do + visit profile_two_factor_auth_path + + fill_in 'current_password', with: '123' + + click_button 'Regenerate recovery codes' + + expect(page).to have_content('You must provide a valid current password') + + fill_in 'current_password', with: user.password + + click_button 'Regenerate recovery codes' + + expect(page).to have_content('Please copy, download, or print your recovery codes before proceeding.') + end + end + + def register_2fa(pin, password) + fill_in 'pin_code', with: pin + fill_in 'current_password', with: password + + click_button 'Register with two-factor app' + end + end +end diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 6c38d5d8b24..5597bc82309 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -796,6 +796,7 @@ RSpec.describe 'Login' do expect(current_path).to eq(profile_two_factor_auth_path) fill_in 'pin_code', with: user.reload.current_otp + fill_in 'current_password', with: user.password click_button 'Register with two-factor app' click_button 'Copy codes' diff --git a/spec/frontend/authentication/two_factor_auth/components/__snapshots__/manage_two_factor_form_spec.js.snap b/spec/frontend/authentication/two_factor_auth/components/__snapshots__/manage_two_factor_form_spec.js.snap new file mode 100644 index 00000000000..3fe0e570a54 --- /dev/null +++ b/spec/frontend/authentication/two_factor_auth/components/__snapshots__/manage_two_factor_form_spec.js.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ManageTwoFactorForm Disable button renders the component correctly 1`] = ` +VueWrapper { + "_emitted": Object {}, + "_emittedByOrder": Array [], + "isFunctionalComponent": undefined, +} +`; + +exports[`ManageTwoFactorForm Disable button renders the component correctly 2`] = ` +<form + action="#" + class="gl-display-inline-block" + method="post" +> + <input + data-testid="test-2fa-method-field" + name="_method" + type="hidden" + /> + + <input + name="authenticity_token" + type="hidden" + /> + + <div + class="form-group gl-form-group" + id="__BVID__15" + role="group" + > + <label + class="d-block col-form-label" + for="current-password" + id="__BVID__15__BV_label_" + > + Current password + </label> + <div + class="bv-no-focus-ring" + > + <input + aria-required="true" + class="gl-form-input form-control" + data-qa-selector="current_password_field" + id="current-password" + name="current_password" + required="required" + type="password" + /> + <!----> + <!----> + <!----> + </div> + </div> + + <button + class="btn btn-danger gl-mr-3 gl-display-inline-block btn-danger btn-md gl-button" + data-confirm="Are you sure? This will invalidate your registered applications and U2F devices." + data-form-action="2fa_auth_path" + data-form-method="2fa_auth_method" + data-testid="test-2fa-disable-button" + type="submit" + > + <!----> + + <!----> + + <span + class="gl-button-text" + > + + Disable two-factor authentication + + </span> + </button> + + <button + class="btn gl-display-inline-block btn-default btn-md gl-button" + data-form-action="2fa_codes_path" + data-form-method="2fa_codes_method" + data-testid="test-2fa-regenerate-codes-button" + type="submit" + > + <!----> + + <!----> + + <span + class="gl-button-text" + > + + Regenerate recovery codes + + </span> + </button> +</form> +`; diff --git a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js new file mode 100644 index 00000000000..384579c6876 --- /dev/null +++ b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js @@ -0,0 +1,98 @@ +import { within } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import ManageTwoFactorForm, { + i18n, +} from '~/authentication/two_factor_auth/components/manage_two_factor_form.vue'; + +describe('ManageTwoFactorForm', () => { + let wrapper; + + const createComponent = (options = {}) => { + wrapper = extendedWrapper( + mount(ManageTwoFactorForm, { + provide: { + webauthnEnabled: options?.webauthnEnabled || false, + profileTwoFactorAuthPath: '2fa_auth_path', + profileTwoFactorAuthMethod: '2fa_auth_method', + codesProfileTwoFactorAuthPath: '2fa_codes_path', + codesProfileTwoFactorAuthMethod: '2fa_codes_method', + }, + }), + ); + }; + + const queryByText = (text, options) => within(wrapper.element).queryByText(text, options); + const queryByLabelText = (text, options) => + within(wrapper.element).queryByLabelText(text, options); + + beforeEach(() => { + createComponent(); + }); + + describe('Current password field', () => { + it('renders the current password field', () => { + expect(queryByLabelText(i18n.currentPassword).tagName).toEqual('INPUT'); + }); + }); + + describe('Disable button', () => { + it('renders the component correctly', () => { + expect(wrapper).toMatchSnapshot(); + expect(wrapper.element).toMatchSnapshot(); + }); + + it('has the right confirm text', () => { + expect(wrapper.findByTestId('test-2fa-disable-button').element.dataset.confirm).toEqual( + i18n.confirm, + ); + }); + + describe('when webauthnEnabled', () => { + beforeEach(() => { + createComponent({ + webauthnEnabled: true, + }); + }); + + it('has the right confirm text', () => { + expect(wrapper.findByTestId('test-2fa-disable-button').element.dataset.confirm).toEqual( + i18n.confirmWebAuthn, + ); + }); + }); + + it('modifies the form action and method when submitted through the button', async () => { + const form = wrapper.find('form'); + const disableButton = wrapper.findByTestId('test-2fa-disable-button').element; + const methodInput = wrapper.findByTestId('test-2fa-method-field').element; + + form.trigger('submit', { submitter: disableButton }); + + await wrapper.vm.$nextTick(); + + expect(form.element.getAttribute('action')).toEqual('2fa_auth_path'); + expect(methodInput.getAttribute('value')).toEqual('2fa_auth_method'); + }); + }); + + describe('Regenerate recovery codes button', () => { + it('renders the button', () => { + expect(queryByText(i18n.regenerateRecoveryCodes)).toEqual(expect.any(HTMLElement)); + }); + + it('modifies the form action and method when submitted through the button', async () => { + const form = wrapper.find('form'); + const regenerateCodesButton = wrapper.findByTestId('test-2fa-regenerate-codes-button') + .element; + const methodInput = wrapper.findByTestId('test-2fa-method-field').element; + + form.trigger('submit', { submitter: regenerateCodesButton }); + + await wrapper.vm.$nextTick(); + + expect(form.element.getAttribute('action')).toEqual('2fa_codes_path'); + expect(methodInput.getAttribute('value')).toEqual('2fa_codes_method'); + }); + }); +}); diff --git a/spec/helpers/external_link_helper_spec.rb b/spec/helpers/external_link_helper_spec.rb index f5bb0568824..b746cb04ab3 100644 --- a/spec/helpers/external_link_helper_spec.rb +++ b/spec/helpers/external_link_helper_spec.rb @@ -13,8 +13,14 @@ RSpec.describe ExternalLinkHelper do it 'allows options when creating external link with icon' do link = external_link('https://gitlab.com', 'https://gitlab.com', { "data-foo": "bar", class: "externalLink" }).to_s - expect(link).to start_with('<a target="_blank" rel="noopener noreferrer" data-foo="bar" class="externalLink" href="https://gitlab.com">https://gitlab.com') expect(link).to include('data-testid="external-link-icon"') end + + it 'sanitizes and returns external link with icon' do + link = external_link('sanitized link content', 'javascript:alert()').to_s + expect(link).not_to include('href="javascript:alert()"') + expect(link).to start_with('<a target="_blank" rel="noopener noreferrer">sanitized link content') + expect(link).to include('data-testid="external-link-icon"') + end end diff --git a/spec/helpers/icons_helper_spec.rb b/spec/helpers/icons_helper_spec.rb index 4784d0aff26..af2957d72c7 100644 --- a/spec/helpers/icons_helper_spec.rb +++ b/spec/helpers/icons_helper_spec.rb @@ -35,22 +35,22 @@ RSpec.describe IconsHelper do it 'returns svg icon html with DEFAULT_ICON_SIZE' do expect(sprite_icon(icon_name).to_s) - .to eq "<svg class=\"s#{IconsHelper::DEFAULT_ICON_SIZE}\" data-testid=\"#{icon_name}-icon\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>" + .to eq "<svg class=\"s#{IconsHelper::DEFAULT_ICON_SIZE}\" data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>" end it 'returns svg icon html without size class' do expect(sprite_icon(icon_name, size: nil).to_s) - .to eq "<svg data-testid=\"#{icon_name}-icon\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>" + .to eq "<svg data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>" end it 'returns svg icon html + size classes' do expect(sprite_icon(icon_name, size: 72).to_s) - .to eq "<svg class=\"s72\" data-testid=\"#{icon_name}-icon\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>" + .to eq "<svg class=\"s72\" data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>" end it 'returns svg icon html + size classes + additional class' do expect(sprite_icon(icon_name, size: 72, css_class: 'icon-danger').to_s) - .to eq "<svg class=\"s72 icon-danger\" data-testid=\"#{icon_name}-icon\"><use xlink:href=\"#{icons_path}##{icon_name}\"></use></svg>" + .to eq "<svg class=\"s72 icon-danger\" data-testid=\"#{icon_name}-icon\"><use href=\"#{icons_path}##{icon_name}\"></use></svg>" end describe 'non existing icon' do diff --git a/spec/initializers/100_patch_omniauth_oauth2_spec.rb b/spec/initializers/100_patch_omniauth_oauth2_spec.rb new file mode 100644 index 00000000000..0c436e4ef45 --- /dev/null +++ b/spec/initializers/100_patch_omniauth_oauth2_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'OmniAuth::Strategies::OAuth2', type: :strategy do + let(:strategy) { [OmniAuth::Strategies::OAuth2] } + + it 'verifies the gem version' do + current_version = OmniAuth::OAuth2::VERSION + expected_version = '1.7.1' + + expect(current_version).to eq(expected_version), <<~EOF + New version #{current_version} of the `omniauth-oauth2` gem detected! + + Please check if the monkey patches in `config/initializers_before_autoloader/100_patch_omniauth_oauth2.rb` + are still needed, and either update/remove them, or bump the version in this spec. + + EOF + end + + context 'when a custom error message is passed from an OAuth2 provider' do + let(:message) { 'Please go to https://evil.com' } + let(:state) { 'secret' } + let(:callback_path) { '/users/auth/oauth2/callback' } + let(:params) { { state: state, error: 'evil_key', error_description: message } } + let(:error) { last_request.env['omniauth.error'] } + + before do + env('rack.session', { 'omniauth.state' => state }) + end + + it 'returns the custom error message if the state is valid' do + get callback_path, **params + + expect(error.message).to eq("evil_key | #{message}") + end + + it 'returns the custom `error_reason` message if the `error_description` is blank' do + get callback_path, **params.merge(error_description: ' ', error_reason: 'custom reason') + + expect(error.message).to eq('evil_key | custom reason') + end + + it 'returns a CSRF error if the state is invalid' do + get callback_path, **params.merge(state: 'invalid') + + expect(error.message).to eq('csrf_detected | CSRF detected') + end + + it 'returns a CSRF error if the state is missing' do + get callback_path, **params.without(:state) + + expect(error.message).to eq('csrf_detected | CSRF detected') + end + end +end |