diff options
95 files changed, 1698 insertions, 460 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4d29fd535..31faa40327b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 14.3.1 (2021-09-30) + +### Security (29 changes) + +- [Fix permissions check on project members import](gitlab-org/security/gitlab@63ba9ad2a1067eb74df493e273707bb64a13a197) ([merge request](gitlab-org/security/gitlab!1858)) +- [Require password param for 2FA changes](gitlab-org/security/gitlab@f246cfbd15344ba74a0182276bf63f0b5f1a4a31) ([merge request](gitlab-org/security/gitlab!1813)) +- [Respect disabled import sources when initiating import via API](gitlab-org/security/gitlab@046e964b0151fc8c58063281a39af063ffb678bd) ([merge request](gitlab-org/security/gitlab!1846)) +- [Return 404 if model id wasn't passed to UploadsController](gitlab-org/security/gitlab@747e6f0e4aec39462f296fd56b37df1c255d29cb) ([merge request](gitlab-org/security/gitlab!1843)) +- [Scrub artifacts signed URL in SendEntry logs](gitlab-org/security/gitlab@f6c57892ddc9518efaace1021346b42b4c805a1c) ([merge request](gitlab-org/security/gitlab!1840)) +- [Prevent double-impersonation and impersonation breakout](gitlab-org/security/gitlab@615d418f9315ca3b3619689c47201f618cf6bde9) ([merge request](gitlab-org/security/gitlab!1834)) +- [Clear session access tokens when starting/stopping impersonation](gitlab-org/security/gitlab@62c2e0d3ed73f2d7ded90d04fe232ff6ae2f6136) ([merge request](gitlab-org/security/gitlab!1831)) +- [Prevent users from bypassing 2FA on certain pages](gitlab-org/security/gitlab@0b41838b36da09a9230de4d8449040a701464de7) ([merge request](gitlab-org/security/gitlab!1827)) +- [Use validated URL when sending request to Gitea Importer](gitlab-org/security/gitlab@26731d762f6503fe1b8b509be11c56e77601a552) ([merge request](gitlab-org/security/gitlab!1822)) +- [Fix XSS in Jira link](gitlab-org/security/gitlab@d41060acb2aa151119042db9162a102d4e2c15ab) ([merge request](gitlab-org/security/gitlab!1819)) **GitLab Enterprise Edition** +- [Fix fogbugz importer DNS Rebind SSRF](gitlab-org/security/gitlab@cc13d57c66cc65e6f920bdeab57b9fdb9d6baac1) ([merge request](gitlab-org/security/gitlab!1814)) +- [Remove related project access tokens when a project is deleted](gitlab-org/security/gitlab@d32c0d57d5b39601034c4c4ae983ea80c05db429) ([merge request](gitlab-org/security/gitlab!1810)) +- [Require group admin access to list pending invites](gitlab-org/security/gitlab@911bb0cb78e00934c491af59729fa84fffae7676) ([merge request](gitlab-org/security/gitlab!1793)) +- [Do not export and import repository_size_limit](gitlab-org/security/gitlab@0f3feca459895fc6665f8b0dfc16d4dcd7112944) ([merge request](gitlab-org/security/gitlab!1770)) +- [Escapes MR approval rule names correctly](gitlab-org/security/gitlab@4fcd97230bbc31780fe14d75694bb6433d57e677) ([merge request](gitlab-org/security/gitlab!1807)) +- [Filter shared groups autocomplete by permitted](gitlab-org/security/gitlab@b5144abb0516af61686402c2ad720967d11cb03c) ([merge request](gitlab-org/security/gitlab!1804)) **GitLab Enterprise Edition** +- [Require access token for git when 2fa is required](gitlab-org/security/gitlab@ea22f67b47bf0d6c801f2bf6c9672c0ea5afd30c) ([merge request](gitlab-org/security/gitlab!1794)) +- [Prohibit anonymous access for specific user API endpoint](gitlab-org/security/gitlab@c52890997ad574812ae4da968f2f6ecfd9f7ff59) ([merge request](gitlab-org/security/gitlab!1792)) +- [Disable exporting pipeline triggers on project export](gitlab-org/security/gitlab@f7f18fbdd8e81a9b3e0650250316c7bb17ac1956) ([merge request](gitlab-org/security/gitlab!1791)) +- [Add pagination to dependencies API](gitlab-org/security/gitlab@203328889059564ba6085663b21355149c01e501) ([merge request](gitlab-org/security/gitlab!1726)) **GitLab Enterprise Edition** +- [Do not allow status checks to exist with external protected branches](gitlab-org/security/gitlab@327d8080e7e7b0bc77b7933f8026ec0cf1abd99a) ([merge request](gitlab-org/security/gitlab!1788)) **GitLab Enterprise Edition** +- [Permission check issuable template API data](gitlab-org/security/gitlab@de7851c2ab58c31df49c8a406ed0c3f3ad779e26) ([merge request](gitlab-org/security/gitlab!1785)) **GitLab Enterprise Edition** +- [Apply account locking to password reset page](gitlab-org/security/gitlab@050dfa71191ffaea77a4a18e0dea1f3336f40db5) ([merge request](gitlab-org/security/gitlab!1782)) +- [Enforce configured scopes for Oauth applications](gitlab-org/security/gitlab@ce83bb14b5a4521f889086a439f1628041843589) ([merge request](gitlab-org/security/gitlab!1779)) +- [Verify state before using errors from OAuth2 OmniAuth providers](gitlab-org/security/gitlab@dcc2cad6c03255ac70f29ed9c0f6c8bc11ac1018) ([merge request](gitlab-org/security/gitlab!1776)) +- [Prevent moving epic issues to different group hierarchy](gitlab-org/security/gitlab@167601717f2ad46fee2320af6ac49674026501be) ([merge request](gitlab-org/security/gitlab!1772)) **GitLab Enterprise Edition** +- [Fix GFM autocomplete xss](gitlab-org/security/gitlab@8816ab6af1d1aa752f22da7850d4d1c983f2d43a) ([merge request](gitlab-org/security/gitlab!1767)) +- [Prevent showing not allowed subgroup epics](gitlab-org/security/gitlab@b841c78c47b6a56b618186720bffc26922807356) ([merge request](gitlab-org/security/gitlab!1764)) **GitLab Enterprise Edition** +- [Fix denial-of-service attack in Markdown parser](gitlab-org/security/gitlab@5e5973b5c28862381729408ba4df650c3d4f7ce0) ([merge request](gitlab-org/security/gitlab!1730)) + ## 14.3.0 (2021-09-21) ### Added (111 changes) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 1cbb0aa64c4..7ea95c24dd9 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -14.3.0
\ No newline at end of file +14.3.1
\ No newline at end of file @@ -1 +1 @@ -14.3.0
\ No newline at end of file +14.3.1
\ No newline at end of file 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/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 470c785f7e4..cb63c86a4fa 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '~/lib/utils/jquery_at_who'; -import { escape, sortBy, template } from 'lodash'; +import { escape as lodashEscape, sortBy, template } from 'lodash'; import * as Emoji from '~/emoji'; import axios from '~/lib/utils/axios_utils'; import { s__, __, sprintf } from '~/locale'; @@ -11,8 +11,21 @@ import { spriteIcon } from './lib/utils/common_utils'; import { parsePikadayDate } from './lib/utils/datetime_utility'; import glRegexp from './lib/utils/regexp'; -function sanitize(str) { - return str.replace(/<(?:.|\n)*?>/gm, ''); +/** + * Escapes user input before we pass it to at.js, which + * renders it as HTML in the autocomplete dropdown. + * + * at.js allows you to reference data using `${}` syntax + * (e.g. ${search}) which it replaces with the actual data + * before rendering it in the autocomplete dropdown. + * To prevent user input from executing this `${}` syntax, + * we also need to escape the $ character. + * + * @param string user input + * @return {string} escaped user input + */ +function escape(string) { + return lodashEscape(string).replace(/\$/g, '$'); } function createMemberSearchString(member) { @@ -44,8 +57,8 @@ export function membersBeforeSave(members) { return { username: member.username, avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, - title: sanitize(title), - search: sanitize(createMemberSearchString(member)), + title, + search: createMemberSearchString(member), icon: avatarIcon, availability: member?.availability, }; @@ -366,7 +379,7 @@ class GfmAutoComplete { } return { id: i.iid, - title: sanitize(i.title), + title: i.title, reference: i.reference, search: `${i.iid} ${i.title}`, }; @@ -404,7 +417,7 @@ class GfmAutoComplete { return { id: m.iid, - title: sanitize(m.title), + title: m.title, search: m.title, expired, dueDate, @@ -456,7 +469,7 @@ class GfmAutoComplete { } return { id: m.iid, - title: sanitize(m.title), + title: m.title, reference: m.reference, search: `${m.iid} ${m.title}`, }; @@ -492,7 +505,7 @@ class GfmAutoComplete { beforeSave(merges) { if (GfmAutoComplete.isLoading(merges)) return merges; return $.map(merges, (m) => ({ - title: sanitize(m.title), + title: m.title, color: m.color, search: m.title, set: m.set, @@ -586,7 +599,7 @@ class GfmAutoComplete { } return { id: m.id, - title: sanitize(m.title), + title: m.title, search: `${m.id} ${m.title}`, }; }); 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 50835333a54..f6f136f2402 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'; const twoFactorNode = document.querySelector('.js-two-factor-auth'); @@ -14,3 +14,5 @@ if (skippable) { mount2faRegistration(); initRecoveryCodes(); + +initManageTwoFactorForm(); diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 69b3c27173f..8ed92e6b948 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -842,7 +842,7 @@ UsersSelect.prototype.renderApprovalRules = function (elsClassName, approvalRule const [rule] = approvalRules; const countText = sprintf(__('(+%{count} rules)'), { count }); const renderApprovalRulesCount = count > 1 ? `<span class="ml-1">${countText}</span>` : ''; - const ruleName = rule.rule_type === 'code_owner' ? __('Code Owner') : rule.name; + const ruleName = rule.rule_type === 'code_owner' ? __('Code Owner') : escape(rule.name); return `<div class="gl-display-flex gl-font-sm"> <span class="gl-text-truncate" title="${ruleName}">${ruleName}</span> diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 9c556d16913..cdfb3a32f4c 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -45,10 +45,11 @@ class Admin::UsersController < Admin::ApplicationController end def impersonate - if can?(user, :log_in) + if can?(user, :log_in) && !impersonation_in_progress? session[:impersonator_id] = current_user.id warden.set_user(user, scope: :user) + clear_access_token_session_keys! log_impersonation_event @@ -57,7 +58,9 @@ class Admin::UsersController < Admin::ApplicationController redirect_to root_path else flash[:alert] = - if user.blocked? + if impersonation_in_progress? + _("You are already impersonating another user") + elsif user.blocked? _("You cannot impersonate a blocked user") elsif user.internal? _("You cannot impersonate an internal user") diff --git a/app/controllers/concerns/impersonation.rb b/app/controllers/concerns/impersonation.rb index a4f2c263eb4..539dd9ad69d 100644 --- a/app/controllers/concerns/impersonation.rb +++ b/app/controllers/concerns/impersonation.rb @@ -3,6 +3,12 @@ module Impersonation include Gitlab::Utils::StrongMemoize + SESSION_KEYS_TO_DELETE = %w( + github_access_token gitea_access_token gitlab_access_token + bitbucket_token bitbucket_refresh_token bitbucket_server_personal_access_token + bulk_import_gitlab_access_token fogbugz_token + ).freeze + def current_user user = super @@ -14,7 +20,7 @@ module Impersonation protected def check_impersonation_availability - return unless session[:impersonator_id] + return unless impersonation_in_progress? unless Gitlab.config.gitlab.impersonation_enabled stop_impersonation @@ -27,14 +33,25 @@ module Impersonation warden.set_user(impersonator, scope: :user) session[:impersonator_id] = nil + clear_access_token_session_keys! current_user end + def impersonation_in_progress? + session[:impersonator_id].present? + end + def log_impersonation_event Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{current_user.username}") end + def clear_access_token_session_keys! + access_tokens_keys = session.keys & SESSION_KEYS_TO_DELETE + + access_tokens_keys.each { |key| session.delete(key) } + end + def impersonator strong_memoize(:impersonator) do User.find(session[:impersonator_id]) if session[:impersonator_id] diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index 5a4eef352b8..32c9da67e90 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -66,11 +66,13 @@ class Import::GiteaController < Import::GithubController override :client_options def client_options - { host: provider_url, api_version: 'v1' } + verified_url, provider_hostname = verify_blocked_uri + + { host: verified_url.scheme == 'https' ? provider_url : verified_url.to_s, api_version: 'v1', hostname: provider_hostname } end def verify_blocked_uri - Gitlab::UrlBlocker.validate!( + @verified_url_and_hostname ||= Gitlab::UrlBlocker.validate!( provider_url, allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index 85e901eb3eb..c8c2dd1c7d6 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -47,6 +47,8 @@ class Profiles::PasswordsController < Profiles::ApplicationController password_attributes[:password_automatically_set] = false unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password]) + handle_invalid_current_password_attempt! + redirect_to edit_profile_password_path, alert: _('You must provide a valid current password') return end @@ -85,6 +87,12 @@ class Profiles::PasswordsController < Profiles::ApplicationController render_404 unless @user.allow_password_authentication? end + def handle_invalid_current_password_attempt! + Gitlab::AppLogger.info(message: 'Invalid current password when attempting to update user password', username: @user.username, ip: request.remote_ip) + + @user.increment_failed_attempts! + end + def user_params params.require(:user).permit(:current_password, :password, :password_confirmation) end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 5eb46421583..d1b9485f06d 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -3,6 +3,8 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController skip_before_action :check_two_factor_requirement before_action :ensure_verified_primary_email, only: [:show, :create] + before_action :validate_current_password, only: [:create, :codes, :destroy] + before_action do push_frontend_feature_flag(:webauthn) end @@ -134,6 +136,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/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index d0987492d2d..b979276437c 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -34,13 +34,13 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def import - @projects = current_user.authorized_projects.order_id_desc + @projects = Project.visible_to_user_and_access_level(current_user, Gitlab::Access::MAINTAINER).order_id_desc end def apply_import source_project = Project.find(params[:source_project_id]) - if can?(current_user, :read_project_member, source_project) + if can?(current_user, :admin_project_member, source_project) status = @project.team.import(source_project, current_user) notice = status ? "Successfully imported" : "Import failed" else diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index d7c1d87ae4b..de4e51a3a2f 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -19,6 +19,7 @@ class ProjectsController < Projects::ApplicationController before_action :redirect_git_extension, only: [:show] before_action :project, except: [:index, :new, :create, :resolve] before_action :repository, except: [:index, :new, :create, :resolve] + before_action :verify_git_import_enabled, only: [:create] before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :present_project, only: [:edit] before_action :authorize_download_code!, only: [:refs] @@ -495,6 +496,10 @@ class ProjectsController < Projects::ApplicationController url_for(safe_params) end + def verify_git_import_enabled + render_404 if project_params[:import_url] && !git_import_enabled? + end + def project_export_enabled render_404 unless Gitlab::CurrentSettings.project_export_enabled? end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 4077a3d3dac..d040ac7f76c 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -36,14 +36,10 @@ class UploadsController < ApplicationController end def find_model - return unless params[:id] - upload_model_class.find(params[:id]) end def authorize_access! - return unless model - authorized = case model when Note @@ -68,8 +64,6 @@ class UploadsController < ApplicationController end def authorize_create_access! - return unless model - authorized = case model when User diff --git a/app/graphql/types/group_invitation_type.rb b/app/graphql/types/group_invitation_type.rb index 06a997bbc14..9410253553c 100644 --- a/app/graphql/types/group_invitation_type.rb +++ b/app/graphql/types/group_invitation_type.rb @@ -3,7 +3,7 @@ module Types class GroupInvitationType < BaseObject expose_permissions Types::PermissionTypes::Group - authorize :read_group + authorize :admin_group implements InvitationInterface diff --git a/app/graphql/types/project_invitation_type.rb b/app/graphql/types/project_invitation_type.rb index 507dc2d59c3..b76f05e289f 100644 --- a/app/graphql/types/project_invitation_type.rb +++ b/app/graphql/types/project_invitation_type.rb @@ -9,7 +9,7 @@ module Types implements InvitationInterface - authorize :read_project + authorize :admin_project field :project, Types::ProjectType, null: true, description: 'Project ID for the project of the invitation.' 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/models/user.rb b/app/models/user.rb index b5f0251f639..a4c8d606911 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1883,7 +1883,8 @@ class User < ApplicationRecord def password_expired_if_applicable? return false if bot? - return false unless password_expired? && password_automatically_set? + return false unless password_expired? + return false if password_automatically_set? return false unless allow_password_authentication? true diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index e27ea7c07e5..afa8de04fca 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -117,6 +117,7 @@ module Projects trash_relation_repositories! trash_project_repositories! destroy_web_hooks! + destroy_project_bots! # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 @@ -149,6 +150,16 @@ module Projects end end + # The project can have multiple project bots with personal access tokens generated. + # We need to remove them when a project is deleted + # rubocop: disable CodeReuse/ActiveRecord + def destroy_project_bots! + project.members.includes(:user).references(:user).merge(User.project_bot).each do |member| + Users::DestroyService.new(current_user).execute(member.user, skip_authorization: true) + end + end + # rubocop: enable CodeReuse/ActiveRecord + def remove_registry_tags return true unless Gitlab.config.registry.enabled return false unless remove_legacy_registry_tags 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/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index eb4630b84d5..97f5cdb54e5 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -16,6 +16,7 @@ %li= _('Job logs and artifacts') %li= _('Container registry images') %li= _('CI variables') + %li= _('Pipeline triggers') %li= _('Webhooks') %li= _('Any encrypted tokens') - if project.export_status == :finished diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 477d419576a..25bf164c96a 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -51,6 +51,11 @@ Doorkeeper.configure do # Issue access tokens with refresh token (disabled by default) use_refresh_token + # Forbids creating/updating applications with arbitrary scopes that are + # not in configuration, i.e. `default_scopes` or `optional_scopes`. + # (disabled by default) + enforce_configured_scopes + # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled # by default in non-development environments). OAuth2 delegates security in # communication to the HTTPS protocol so it is wise to keep this enabled. 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/db/post_migrate/20210914095310_cleanup_orphan_project_access_tokens.rb b/db/post_migrate/20210914095310_cleanup_orphan_project_access_tokens.rb new file mode 100644 index 00000000000..4756bc3dca5 --- /dev/null +++ b/db/post_migrate/20210914095310_cleanup_orphan_project_access_tokens.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class CleanupOrphanProjectAccessTokens < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + TMP_INDEX_NAME = 'idx_users_on_user_type_project_bots_batched' + + def up + users_table = define_batchable_model('users') + + add_concurrent_index(:users, :id, name: TMP_INDEX_NAME, where: 'user_type = 6') + + accumulated_orphans = [] + users_table.where(user_type: 6).each_batch(of: 500) do |relation| + orphan_ids = relation.where("not exists(select 1 from members where members.user_id = users.id)").pluck(:id) + + orphan_ids.each_slice(10) do |ids| + users_table.where(id: ids).update_all(state: 'deactivated') + end + + accumulated_orphans += orphan_ids + end + + schedule_deletion(accumulated_orphans) + ensure + remove_concurrent_index_by_name(:users, TMP_INDEX_NAME) + end + + def down + remove_concurrent_index_by_name(:users, TMP_INDEX_NAME) if index_exists_by_name?(:users, TMP_INDEX_NAME) + end + + private + + def schedule_deletion(orphan_ids) + return unless deletion_worker + + orphan_ids.each_slice(100) do |ids| + job_arguments = ids.map do |orphan_id| + [orphan_id, orphan_id, { skip_authorization: true }] + end + + deletion_worker.bulk_perform_async(job_arguments) + end + rescue StandardError + # Ignore any errors or interface changes since this part of migration is optional + end + + def deletion_worker + @deletion_worker = "DeleteUserWorker".safe_constantize unless defined?(@deletion_worker) + + @deletion_worker + end +end diff --git a/db/schema_migrations/20210914095310 b/db/schema_migrations/20210914095310 new file mode 100644 index 00000000000..fee7e0b9719 --- /dev/null +++ b/db/schema_migrations/20210914095310 @@ -0,0 +1 @@ +6fcf3ff9867df68f5e9603ae0311b29bec33aa5c5b826786b094ab0960ebcd90
\ No newline at end of file diff --git a/doc/api/dependencies.md b/doc/api/dependencies.md index c8b928ab5b2..6e9c37980ac 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/api/users.md b/doc/api/users.md index dfd2f6cc87d..4ec609e62e9 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -259,7 +259,7 @@ GET /users?with_custom_attributes=true ## Single user -Get a single user. This endpoint can be accessed without authentication. +Get a single user. ### For user @@ -806,7 +806,7 @@ Example response: ### Followers and following -Get the followers of a user. This endpoint can be accessed without authentication. +Get the followers of a user. ```plaintext GET /users/:id/followers diff --git a/doc/user/profile/account/two_factor_authentication.md b/doc/user/profile/account/two_factor_authentication.md index 14e6f4dad3a..44537707db6 100644 --- a/doc/user/profile/account/two_factor_authentication.md +++ b/doc/user/profile/account/two_factor_authentication.md @@ -75,6 +75,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 @@ -365,7 +366,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. @@ -460,7 +462,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/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index 662d7e70910..69215e03f28 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -135,9 +135,11 @@ The following items are **not** exported: - Build traces and artifacts - Container registry images - CI/CD variables +- Pipeline triggers - Webhooks - Any encrypted tokens - Merge Request Approvers +- Repository size limits NOTE: For more details on the specific data persisted in a project export, see the @@ -255,13 +257,13 @@ reduce the repository size for another import attempt. git reflog expire --expire=now --all git gc --prune=now --aggressive - # Prepare recreating an importable file + # Prepare recreating an importable file git bundle create ../project.bundle smaller-tmp-main cd .. mv project/ ../"$EXPORT"-project cd .. - # Recreate an importable file + # Recreate an importable file tar -czf "$EXPORT"-smaller.tar.gz --directory="$EXPORT"/ . ``` diff --git a/lib/api/import_bitbucket_server.rb b/lib/api/import_bitbucket_server.rb index ecd78c6e6db..0f0d62dcbfb 100644 --- a/lib/api/import_bitbucket_server.rb +++ b/lib/api/import_bitbucket_server.rb @@ -4,6 +4,10 @@ module API class ImportBitbucketServer < ::API::Base feature_category :importers + before do + forbidden! unless Gitlab::CurrentSettings.import_sources&.include?('bitbucket_server') + end + helpers do def client @client ||= BitbucketServer::Client.new(credentials) diff --git a/lib/api/invitations.rb b/lib/api/invitations.rb index 1f437ad5bd3..5cade301d81 100644 --- a/lib/api/invitations.rb +++ b/lib/api/invitations.rb @@ -46,6 +46,8 @@ module API source = find_source(source_type, params[:id]) query = params[:query] + authorize_admin_source!(source_type, source) + invitations = paginate(retrieve_member_invitations(source, query)) present_member_invitations invitations diff --git a/lib/api/projects.rb b/lib/api/projects.rb index a92d904be84..34e0b528ced 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -89,6 +89,10 @@ module API Gitlab::AppLogger.info({ message: "File exceeds maximum size", file_bytes: file.size, project_id: user_project.id, project_path: user_project.full_path, upload_allowed: allowed }) end end + + def check_import_by_url_is_enabled + forbidden! unless Gitlab::CurrentSettings.import_sources&.include?('git') + end end helpers do @@ -267,6 +271,7 @@ module API attrs = declared_params(include_missing: false) attrs = translate_params_for_compatibility(attrs) filter_attributes_using_license!(attrs) + check_import_by_url_is_enabled if params[:import_url].present? project = ::Projects::CreateService.new(current_user, attrs).execute if project.saved? diff --git a/lib/api/users.rb b/lib/api/users.rb index e3271b8b9b2..944be990c2f 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -140,7 +140,10 @@ module API end # rubocop: disable CodeReuse/ActiveRecord get ":id", feature_category: :users do + forbidden!('Not authorized!') unless current_user + user = User.find_by(id: params[:id]) + not_found!('User') unless user && can?(current_user, :read_user, user) opts = { with: current_user&.admin? ? Entities::UserDetailsWithAdmin : Entities::User, current_user: current_user } @@ -156,6 +159,7 @@ module API end get ":user_id/status", requirements: API::USER_REQUIREMENTS, feature_category: :users do user = find_user(params[:user_id]) + not_found!('User') unless user && can?(current_user, :read_user, user) present user.status || {}, with: Entities::UserStatus @@ -203,6 +207,8 @@ module API use :pagination end get ':id/following', feature_category: :users do + forbidden!('Not authorized!') unless current_user + user = find_user(params[:id]) not_found!('User') unless user && can?(current_user, :read_user_profile, user) @@ -217,6 +223,8 @@ module API use :pagination end get ':id/followers', feature_category: :users do + forbidden!('Not authorized!') unless current_user + user = find_user(params[:id]) not_found!('User') unless user && can?(current_user, :read_user_profile, user) diff --git a/lib/banzai/filter/spaced_link_filter.rb b/lib/banzai/filter/spaced_link_filter.rb index ca26e6d1581..f8d03fd6e50 100644 --- a/lib/banzai/filter/spaced_link_filter.rb +++ b/lib/banzai/filter/spaced_link_filter.rb @@ -26,14 +26,17 @@ module Banzai # Pattern to match a standard markdown link # # Rubular: http://rubular.com/r/2EXEQ49rg5 - LINK_OR_IMAGE_PATTERN = %r{ - (?<preview_operator>!)? - \[(?<text>.+?)\] - \( - (?<new_link>.+?) - (?<title>\ ".+?")? - \) - }x.freeze + # + # This pattern is vulnerable to malicious inputs, so use Gitlab::UntrustedRegexp + # to place bounds on execution time + LINK_OR_IMAGE_PATTERN = Gitlab::UntrustedRegexp.new( + '(?P<preview_operator>!)?' \ + '\[(?P<text>.+?)\]' \ + '\(' \ + '(?P<new_link>.+?)' \ + '(?P<title>\ ".+?")?' \ + '\)' + ) # Text matching LINK_OR_IMAGE_PATTERN inside these elements will not be linked IGNORE_PARENTS = %w(a code kbd pre script style).to_set @@ -48,7 +51,7 @@ module Banzai doc.xpath(TEXT_QUERY).each do |node| content = node.to_html - next unless content.match(LINK_OR_IMAGE_PATTERN) + next unless LINK_OR_IMAGE_PATTERN.match(content) html = spaced_link_filter(content) diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 1afb2eda149..0970b92723b 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -172,7 +172,11 @@ module Gitlab user = find_with_user_password(login, password) return unless user - raise Gitlab::Auth::MissingPersonalAccessTokenError if user.two_factor_enabled? + verifier = TwoFactorAuthVerifier.new(user) + + if user.two_factor_enabled? || verifier.two_factor_authentication_enforced? + raise Gitlab::Auth::MissingPersonalAccessTokenError + end Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities) end diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index f6ee08defcf..9c33a5fc872 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -342,6 +342,10 @@ module Gitlab Gitlab::PathRegex.repository_git_lfs_route_regex.match?(current_request.path) end + def git_or_lfs_request? + git_request? || git_lfs_request? + end + def archive_request? current_request.path.include?('/-/archive/') end diff --git a/lib/gitlab/auth/request_authenticator.rb b/lib/gitlab/auth/request_authenticator.rb index dfc682e8a5c..08214bbd449 100644 --- a/lib/gitlab/auth/request_authenticator.rb +++ b/lib/gitlab/auth/request_authenticator.rb @@ -35,13 +35,31 @@ module Gitlab find_user_from_static_object_token(request_format) || find_user_from_basic_auth_job || find_user_from_job_token || - find_user_from_lfs_token || - find_user_from_personal_access_token || - find_user_from_basic_auth_password + find_user_from_personal_access_token_for_api_or_git || + find_user_for_git_or_lfs_request rescue Gitlab::Auth::AuthenticationError nil end + # To prevent Rack Attack from incorrectly rate limiting + # authenticated Git activity, we need to authenticate the user + # from other means (e.g. HTTP Basic Authentication) only if the + # request originated from a Git or Git LFS + # request. Repositories::GitHttpClientController or + # Repositories::LfsApiController normally does the authentication, + # but Rack Attack runs before those controllers. + def find_user_for_git_or_lfs_request + return unless git_or_lfs_request? + + find_user_from_lfs_token || find_user_from_basic_auth_password + end + + def find_user_from_personal_access_token_for_api_or_git + return unless api_request? || git_or_lfs_request? + + find_user_from_personal_access_token + end + def valid_access_token?(scopes: []) validate_access_token!(scopes: scopes) diff --git a/lib/gitlab/auth/two_factor_auth_verifier.rb b/lib/gitlab/auth/two_factor_auth_verifier.rb index 86552ef1267..5a203a1fe9c 100644 --- a/lib/gitlab/auth/two_factor_auth_verifier.rb +++ b/lib/gitlab/auth/two_factor_auth_verifier.rb @@ -9,6 +9,10 @@ module Gitlab @current_user = current_user end + def two_factor_authentication_enforced? + two_factor_authentication_required? && two_factor_grace_period_expired? + end + def two_factor_authentication_required? Gitlab::CurrentSettings.require_two_factor_authentication? || current_user&.require_two_factor_authentication_from_group? diff --git a/lib/gitlab/fogbugz_import.rb b/lib/gitlab/fogbugz_import.rb new file mode 100644 index 00000000000..a4a52edd83e --- /dev/null +++ b/lib/gitlab/fogbugz_import.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'fogbugz' + +module Gitlab + module FogbugzImport + # Custom adapter to validate the URL before each request + # This way we avoid DNS rebinds or other unsafe requests + ::Fogbugz.adapter[:http] = HttpAdapter + end +end diff --git a/lib/gitlab/fogbugz_import/client.rb b/lib/gitlab/fogbugz_import/client.rb index dd747a79673..024c1ae0439 100644 --- a/lib/gitlab/fogbugz_import/client.rb +++ b/lib/gitlab/fogbugz_import/client.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'fogbugz' - module Gitlab module FogbugzImport class Client diff --git a/lib/gitlab/fogbugz_import/http_adapter.rb b/lib/gitlab/fogbugz_import/http_adapter.rb new file mode 100644 index 00000000000..bfae7a10f5b --- /dev/null +++ b/lib/gitlab/fogbugz_import/http_adapter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Gitlab + module FogbugzImport + class HttpAdapter + def initialize(options = {}) + @root_url = options[:uri] + end + + def request(action, options = {}) + uri = Gitlab::Utils.append_path(@root_url, 'api.asp') + + params = { 'cmd' => action }.merge(options.fetch(:params, {})) + + response = Gitlab::HTTP.post(uri, body: params) + + response.body + end + end + end +end diff --git a/lib/gitlab/import_export/group/import_export.yml b/lib/gitlab/import_export/group/import_export.yml index 630f918a78b..f7ab1677001 100644 --- a/lib/gitlab/import_export/group/import_export.yml +++ b/lib/gitlab/import_export/group/import_export.yml @@ -37,6 +37,7 @@ excluded_attributes: - :trial_ends_on - :shared_runners_minute_limit - :extra_shared_runners_minutes_limit + - :repository_size_limit epics: - :state_id diff --git a/lib/gitlab/import_export/project/import_export.yml b/lib/gitlab/import_export/project/import_export.yml index fe0974d27a6..8046fedc4f3 100644 --- a/lib/gitlab/import_export/project/import_export.yml +++ b/lib/gitlab/import_export/project/import_export.yml @@ -88,7 +88,6 @@ tree: - :external_pull_request - :merge_request - :auto_devops - - :triggers - :pipeline_schedules - :container_expiration_policy - protected_branches: @@ -211,6 +210,7 @@ excluded_attributes: - :show_default_award_emojis - :services - :exported_protected_branches + - :repository_size_limit namespaces: - :runners_token - :runners_token_encrypted diff --git a/lib/gitlab/legacy_github_import/client.rb b/lib/gitlab/legacy_github_import/client.rb index 4482610523e..48a8e0ce6d7 100644 --- a/lib/gitlab/legacy_github_import/client.rb +++ b/lib/gitlab/legacy_github_import/client.rb @@ -8,9 +8,10 @@ module Gitlab attr_reader :access_token, :host, :api_version, :wait_for_rate_limit_reset - def initialize(access_token, host: nil, api_version: 'v3', wait_for_rate_limit_reset: true) + def initialize(access_token, host: nil, api_version: 'v3', wait_for_rate_limit_reset: true, hostname: nil) @access_token = access_token @host = host.to_s.sub(%r{/+\z}, '') + @hostname = hostname @api_version = api_version @users = {} @wait_for_rate_limit_reset = wait_for_rate_limit_reset @@ -28,7 +29,8 @@ module Gitlab # If there is no config, we're connecting to github.com and we # should verify ssl. connection_options: { - ssl: { verify: config ? config['verify_ssl'] : true } + ssl: { verify: config ? config['verify_ssl'] : true }, + headers: { host: @hostname }.compact } ) end diff --git a/lib/gitlab/string_regex_marker.rb b/lib/gitlab/string_regex_marker.rb index f1982ff914c..8e0167a433e 100644 --- a/lib/gitlab/string_regex_marker.rb +++ b/lib/gitlab/string_regex_marker.rb @@ -2,18 +2,20 @@ module Gitlab class StringRegexMarker < StringRangeMarker - # rubocop: disable CodeReuse/ActiveRecord def mark(regex, group: 0, &block) ranges = [] + offset = 0 - raw_line.scan(regex) do - begin_index, end_index = Regexp.last_match.offset(group) + while match = regex.match(raw_line[offset..]) + begin_index = match.begin(group) + offset + end_index = match.end(group) + offset ranges << (begin_index..(end_index - 1)) + + offset = end_index end super(ranges, &block) end - # rubocop: enable CodeReuse/ActiveRecord end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ae3fbe248bb..03a26c01cd5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -38380,6 +38380,9 @@ msgstr "" msgid "You are already a member of this %{member_source}." msgstr "" +msgid "You are already impersonating another user" +msgstr "" + msgid "You are an admin, which means granting access to %{client_name} will allow them to interact with GitLab as an admin as well. Proceed with caution." msgstr "" @@ -39091,6 +39094,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/admin/impersonations_controller_spec.rb b/spec/controllers/admin/impersonations_controller_spec.rb index 744c0712d6b..ccf4454c349 100644 --- a/spec/controllers/admin/impersonations_controller_spec.rb +++ b/spec/controllers/admin/impersonations_controller_spec.rb @@ -92,6 +92,14 @@ RSpec.describe Admin::ImpersonationsController do expect(warden.user).to eq(impersonator) end + + it 'clears token session keys' do + session[:bitbucket_token] = SecureRandom.hex(8) + + delete :destroy + + expect(session[:bitbucket_token]).to be_nil + end end # base case diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 015c36c9335..3a2b5dcb99d 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -794,6 +794,14 @@ RSpec.describe Admin::UsersController do expect(flash[:alert]).to eq("You are now impersonating #{user.username}") end + + it 'clears token session keys' do + session[:github_access_token] = SecureRandom.hex(8) + + post :impersonate, params: { id: user.username } + + expect(session[:github_access_token]).to be_nil + end end context "when impersonation is disabled" do @@ -807,5 +815,20 @@ RSpec.describe Admin::UsersController do expect(response).to have_gitlab_http_status(:not_found) end end + + context 'when impersonating an admin and attempting to impersonate again' do + let(:admin2) { create(:admin) } + + before do + post :impersonate, params: { id: admin2.username } + end + + it 'does not allow double impersonation', :aggregate_failures do + post :impersonate, params: { id: user.username } + + expect(flash[:alert]).to eq(_('You are already impersonating another user')) + expect(warden.user).to eq(admin2) + end + end end end diff --git a/spec/controllers/import/gitea_controller_spec.rb b/spec/controllers/import/gitea_controller_spec.rb index 3e4b159271a..568712d29cb 100644 --- a/spec/controllers/import/gitea_controller_spec.rb +++ b/spec/controllers/import/gitea_controller_spec.rb @@ -54,6 +54,48 @@ RSpec.describe Import::GiteaController do end end end + + context 'when DNS Rebinding protection is enabled' do + let(:token) { 'gitea token' } + + let(:ip_uri) { 'http://167.99.148.217' } + let(:uri) { 'try.gitea.io' } + let(:https_uri) { "https://#{uri}" } + let(:http_uri) { "http://#{uri}" } + + before do + session[:gitea_access_token] = token + + allow(Gitlab::UrlBlocker).to receive(:validate!).with(https_uri, anything).and_return([Addressable::URI.parse(https_uri), uri]) + allow(Gitlab::UrlBlocker).to receive(:validate!).with(http_uri, anything).and_return([Addressable::URI.parse(ip_uri), uri]) + + allow(Gitlab::LegacyGithubImport::Client).to receive(:new).and_return(double('Gitlab::LegacyGithubImport::Client', repos: [], orgs: [])) + end + + context 'when provided host url is using https' do + let(:host_url) { https_uri } + + it 'uses unchanged host url to send request to Gitea' do + expect(Gitlab::LegacyGithubImport::Client).to receive(:new).with(token, host: https_uri, api_version: 'v1', hostname: 'try.gitea.io') + + get :status, format: :json + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when provided host url is using http' do + let(:host_url) { http_uri } + + it 'uses changed host url to send request to Gitea' do + expect(Gitlab::LegacyGithubImport::Client).to receive(:new).with(token, host: 'http://167.99.148.217', api_version: 'v1', hostname: 'try.gitea.io') + + get :status, format: :json + + expect(response).to have_gitlab_http_status(:ok) + end + end + end end end diff --git a/spec/controllers/oauth/applications_controller_spec.rb b/spec/controllers/oauth/applications_controller_spec.rb index f21ef324884..5bf3b4c48bf 100644 --- a/spec/controllers/oauth/applications_controller_spec.rb +++ b/spec/controllers/oauth/applications_controller_spec.rb @@ -98,6 +98,19 @@ RSpec.describe Oauth::ApplicationsController do end describe 'POST #create' do + let(:oauth_params) do + { + doorkeeper_application: { + name: 'foo', + redirect_uri: redirect_uri, + scopes: scopes + } + } + end + + let(:redirect_uri) { 'http://example.org' } + let(:scopes) { ['api'] } + subject { post :create, params: oauth_params } it 'creates an application' do @@ -116,38 +129,42 @@ RSpec.describe Oauth::ApplicationsController do expect(response).to redirect_to(profile_path) end - context 'redirect_uri' do + context 'when redirect_uri is invalid' do + let(:redirect_uri) { 'javascript://alert()' } + render_views it 'shows an error for a forbidden URI' do - invalid_uri_params = { - doorkeeper_application: { - name: 'foo', - redirect_uri: 'javascript://alert()', - scopes: ['api'] - } - } - - post :create, params: invalid_uri_params + subject expect(response.body).to include 'Redirect URI is forbidden by the server' + expect(response).to render_template('doorkeeper/applications/index') end end context 'when scopes are not present' do + let(:scopes) { [] } + render_views it 'shows an error for blank scopes' do - invalid_uri_params = { - doorkeeper_application: { - name: 'foo', - redirect_uri: 'http://example.org' - } - } - - post :create, params: invalid_uri_params + subject expect(response.body).to include 'Scopes can't be blank' + expect(response).to render_template('doorkeeper/applications/index') + end + end + + context 'when scopes are invalid' do + let(:scopes) { %w(api foo) } + + render_views + + it 'shows an error for invalid scopes' do + subject + + expect(response.body).to include 'Scopes doesn't match configured on the server.' + expect(response).to render_template('doorkeeper/applications/index') end end @@ -185,14 +202,4 @@ RSpec.describe Oauth::ApplicationsController do def disable_user_oauth allow(Gitlab::CurrentSettings.current_application_settings).to receive(:user_oauth_applications?).and_return(false) end - - def oauth_params - { - doorkeeper_application: { - name: 'foo', - redirect_uri: 'http://example.org', - scopes: ['api'] - } - } - end end diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index 073180cbafd..a0e2cf671af 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -35,6 +35,27 @@ RSpec.describe Profiles::TwoFactorAuthsController do end 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_it_be_with_reload(:user) { create(:user) } @@ -69,9 +90,10 @@ RSpec.describe Profiles::TwoFactorAuthsController do let_it_be_with_reload(: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 @@ -136,21 +158,25 @@ RSpec.describe Profiles::TwoFactorAuthsController do end end + it_behaves_like 'user must enter a valid current password' + it_behaves_like 'user must first verify their primary email address' end describe 'POST codes' do let_it_be_with_reload(: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 @@ -159,12 +185,18 @@ RSpec.describe Profiles::TwoFactorAuthsController do it 'dismisses the `TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK` callout' do expect(controller.helpers).to receive(:dismiss_two_factor_auth_recovery_settings_check) - post :codes + post :codes, params: { current_password: current_password } + 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_it_be_with_reload(:user) { create(:user, :two_factor) } @@ -187,6 +219,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/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index be5c1f0d428..c352524ec14 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -624,9 +624,9 @@ RSpec.describe Projects::ProjectMembersController do end end - context 'when user can access source project members' do + context 'when user can admin source project members' do before do - another_project.add_guest(user) + another_project.add_maintainer(user) end include_context 'import applied' @@ -640,7 +640,11 @@ RSpec.describe Projects::ProjectMembersController do end end - context 'when user is not member of a source project' do + context "when user can't admin source project members" do + before do + another_project.add_developer(user) + end + include_context 'import applied' it 'does not import team members' do diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 8afb80d9cc5..9d070061850 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -408,6 +408,47 @@ RSpec.describe ProjectsController do end end + describe 'POST create' do + let!(:params) do + { + path: 'foo', + description: 'bar', + import_url: project.http_url_to_repo, + namespace_id: user.namespace.id + } + end + + subject { post :create, params: { project: params } } + + before do + sign_in(user) + end + + context 'when import by url is disabled' do + before do + stub_application_setting(import_sources: []) + end + + it 'does not create project and reports an error' do + expect { subject }.not_to change { Project.count } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when import by url is enabled' do + before do + stub_application_setting(import_sources: ['git']) + end + + it 'creates project' do + expect { subject }.to change { Project.count } + + expect(response).to have_gitlab_http_status(:redirect) + end + end + end + describe 'GET edit' do it 'allows an admin user to access the page', :enable_admin_mode do sign_in(create(:user, :admin)) diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 043fd97f1ad..2aa9b86b20e 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -666,6 +666,6 @@ RSpec.describe UploadsController do def post_authorize(verified: true) request.headers.merge!(workhorse_internal_api_request_header) if verified - post :authorize, params: { model: 'personal_snippet', id: model.id }, format: :json + post :authorize, params: params, format: :json end end diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb index c9059395377..893dd2c76e0 100644 --- a/spec/features/profiles/password_spec.rb +++ b/spec/features/profiles/password_spec.rb @@ -78,40 +78,80 @@ RSpec.describe 'Profile > Password' do end end - context 'Change passowrd' do + context 'Change password' do + let(:new_password) { '22233344' } + before do sign_in(user) visit(edit_profile_password_path) end - it 'does not change user passowrd without old one' do - page.within '.update-password' do - fill_passwords('22233344', '22233344') + shared_examples 'user enters an incorrect current password' do + subject do + page.within '.update-password' do + fill_in 'user_current_password', with: user_current_password + fill_passwords(new_password, new_password) + end end - page.within '.flash-container' do - expect(page).to have_content 'You must provide a valid current password' - end - end + it 'handles the invalid password attempt, and prompts the user to try again', :aggregate_failures do + expect(Gitlab::AppLogger).to receive(:info) + .with(message: 'Invalid current password when attempting to update user password', username: user.username, ip: user.current_sign_in_ip) + + subject + + user.reload - it 'does not change password with invalid old password' do - page.within '.update-password' do - fill_in 'user_current_password', with: 'invalid' - fill_passwords('password', 'confirmation') + expect(user.failed_attempts).to eq(1) + expect(user.valid_password?(new_password)).to eq(false) + expect(current_path).to eq(edit_profile_password_path) + + page.within '.flash-container' do + expect(page).to have_content('You must provide a valid current password') + end end - page.within '.flash-container' do - expect(page).to have_content 'You must provide a valid current password' + it 'locks the user account when user passes the maximum attempts threshold', :aggregate_failures do + user.update!(failed_attempts: User.maximum_attempts.pred) + + subject + + expect(current_path).to eq(new_user_session_path) + + page.within '.flash-container' do + expect(page).to have_content('Your account is locked.') + end end end - it 'changes user password' do - page.within '.update-password' do - fill_in "user_current_password", with: user.password - fill_passwords('22233344', '22233344') + context 'when current password is blank' do + let(:user_current_password) { nil } + + it_behaves_like 'user enters an incorrect current password' + end + + context 'when current password is incorrect' do + let(:user_current_password) {'invalid' } + + it_behaves_like 'user enters an incorrect current password' + end + + context 'when the password reset is successful' do + subject do + page.within '.update-password' do + fill_in "user_current_password", with: user.password + fill_passwords(new_password, new_password) + end end - expect(current_path).to eq new_user_session_path + it 'changes the password, logs the user out and prompts them to sign in again', :aggregate_failures do + expect { subject }.to change { user.reload.valid_password?(new_password) }.to(true) + expect(current_path).to eq new_user_session_path + + page.within '.flash-container' do + expect(page).to have_content('Password was successfully updated. Please sign in again.') + end + end end end 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 afd750d02eb..79c4057a8b9 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -807,6 +807,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state 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/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json index e3aeace6383..1072e63b20b 100644 --- a/spec/fixtures/lib/gitlab/import_export/complex/project.json +++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json @@ -7579,23 +7579,6 @@ } } ], - "triggers": [ - { - "id": 123, - "token": "cdbfasdf44a5958c83654733449e585", - "project_id": 5, - "owner_id": 1, - "created_at": "2017-01-16T15:25:28.637Z", - "updated_at": "2017-01-16T15:25:28.637Z" - }, - { - "id": 456, - "token": "33a66349b5ad01fc00174af87804e40", - "project_id": 5, - "created_at": "2017-01-16T15:25:29.637Z", - "updated_at": "2017-01-16T15:25:29.637Z" - } - ], "pipeline_schedules": [ { "id": 1, diff --git a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/triggers.ndjson b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/triggers.ndjson index 93619f4fb44..2b5bda687b8 100644 --- a/spec/fixtures/lib/gitlab/import_export/complex/tree/project/triggers.ndjson +++ b/spec/fixtures/lib/gitlab/import_export/complex/tree/project/triggers.ndjson @@ -1,2 +1,2 @@ {"id":123,"token":"cdbfasdf44a5958c83654733449e585","project_id":5,"owner_id":1,"created_at":"2017-01-16T15:25:28.637Z","updated_at":"2017-01-16T15:25:28.637Z"} -{"id":456,"token":"33a66349b5ad01fc00174af87804e40","project_id":5,"created_at":"2017-01-16T15:25:29.637Z","updated_at":"2017-01-16T15:25:29.637Z"} +{"id":456,"token":"33a66349b5ad01fc00174af87804e40","project_id":5,"created_at":"2017-01-16T15:25:29.637Z","updated_at":"2017-01-16T15:25:29.637Z"}
\ No newline at end of file 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/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 211ed064762..94ad7759110 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -574,6 +574,15 @@ describe('GfmAutoComplete', () => { }), ).toBe('<li><small>grp/proj#5</small> Some Issue</li>'); }); + + it('escapes title in the template as it is user input', () => { + expect( + GfmAutoComplete.Issues.templateFunction({ + id: 5, + title: '${search}<script>oh no $', // eslint-disable-line no-template-curly-in-string + }), + ).toBe('<li><small>5</small> ${search}<script>oh no $</li>'); + }); }); describe('GfmAutoComplete.Members', () => { @@ -608,16 +617,18 @@ describe('GfmAutoComplete', () => { ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>'); }); - it('should add escaped title if title is set', () => { + it('escapes title in the template as it is user input', () => { expect( GfmAutoComplete.Members.templateFunction({ avatarTag: 'IMG', username: 'my-group', - title: 'MyGroup+', + title: '${search}<script>oh no $', // eslint-disable-line no-template-curly-in-string icon: '<i class="icon"/>', availabilityStatus: '', }), - ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>'); + ).toBe( + '<li>IMG my-group <small>${search}<script>oh no $</small> <i class="icon"/></li>', + ); }); it('should add user availability status if availabilityStatus is set', () => { @@ -782,6 +793,15 @@ describe('GfmAutoComplete', () => { ${'/unlabel ~'} | ${assignedLabels} `('$input shows $output.length labels', expectLabels); }); + + it('escapes title in the template as it is user input', () => { + const color = '#123456'; + const title = '${search}<script>oh no $'; // eslint-disable-line no-template-curly-in-string + + expect(GfmAutoComplete.Labels.templateFunction(color, title)).toBe( + '<li><span class="dropdown-label-box" style="background: #123456"></span> ${search}<script>oh no $</li>', + ); + }); }); describe('emoji', () => { @@ -829,4 +849,15 @@ describe('GfmAutoComplete', () => { }); }); }); + + describe('milestones', () => { + it('escapes title in the template as it is user input', () => { + const expired = false; + const title = '${search}<script>oh no $'; // eslint-disable-line no-template-curly-in-string + + expect(GfmAutoComplete.Milestones.templateFunction(title, expired)).toBe( + '<li>${search}<script>oh no $</li>', + ); + }); + }); }); diff --git a/spec/frontend/users_select/index_spec.js b/spec/frontend/users_select/index_spec.js index 99caaf61c54..0d2aae78944 100644 --- a/spec/frontend/users_select/index_spec.js +++ b/spec/frontend/users_select/index_spec.js @@ -1,3 +1,5 @@ +import { escape } from 'lodash'; +import UsersSelect from '~/users_select/index'; import { createInputsModelExpectation, createUnassignedExpectation, @@ -91,5 +93,19 @@ describe('~/users_select/index', () => { expect(findDropdownItemsModel()).toEqual(expectation); }); }); + + describe('renderApprovalRules', () => { + const ruleNames = ['simple-name', '"\'<>&', '"><script>alert(1)<script>']; + + it.each(ruleNames)('escapes rule name correctly for %s', (name) => { + const escapedName = escape(name); + + expect( + UsersSelect.prototype.renderApprovalRules('reviewer', [{ name }]), + ).toMatchInterpolatedText( + `<div class="gl-display-flex gl-font-sm"> <span class="gl-text-truncate" title="${escapedName}">${escapedName}</span> </div>`, + ); + }); + }); }); }); diff --git a/spec/graphql/types/group_invitation_type_spec.rb b/spec/graphql/types/group_invitation_type_spec.rb index dab2d43fc90..9eedc2db81d 100644 --- a/spec/graphql/types/group_invitation_type_spec.rb +++ b/spec/graphql/types/group_invitation_type_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Types::GroupInvitationType do specify { expect(described_class.graphql_name).to eq('GroupInvitation') } - specify { expect(described_class).to require_graphql_authorizations(:read_group) } + specify { expect(described_class).to require_graphql_authorizations(:admin_group) } it 'has the expected fields' do expected_fields = %w[ diff --git a/spec/graphql/types/project_invitation_type_spec.rb b/spec/graphql/types/project_invitation_type_spec.rb index 148a763a5fa..5c0b03c2505 100644 --- a/spec/graphql/types/project_invitation_type_spec.rb +++ b/spec/graphql/types/project_invitation_type_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Types::ProjectInvitationType do specify { expect(described_class.graphql_name).to eq('ProjectInvitation') } - specify { expect(described_class).to require_graphql_authorizations(:read_project) } + specify { expect(described_class).to require_graphql_authorizations(:admin_project) } it 'has the expected fields' do expected_fields = %w[ 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 diff --git a/spec/lib/banzai/filter/spaced_link_filter_spec.rb b/spec/lib/banzai/filter/spaced_link_filter_spec.rb index 2c64657d69d..820ebeb6945 100644 --- a/spec/lib/banzai/filter/spaced_link_filter_spec.rb +++ b/spec/lib/banzai/filter/spaced_link_filter_spec.rb @@ -63,6 +63,16 @@ RSpec.describe Banzai::Filter::SpacedLinkFilter do end end + it 'does not process malicious input' do + Timeout.timeout(10) do + doc = filter('[ (](' * 60_000) + + found_links = doc.css('a') + + expect(found_links.size).to eq(0) + end + end + it 'converts multiple URLs' do link1 = '[first](slug one)' link2 = '[second](http://example.com/slug two)' diff --git a/spec/lib/gitlab/auth/request_authenticator_spec.rb b/spec/lib/gitlab/auth/request_authenticator_spec.rb index 28e93a8da52..2543eb3a5e9 100644 --- a/spec/lib/gitlab/auth/request_authenticator_spec.rb +++ b/spec/lib/gitlab/auth/request_authenticator_spec.rb @@ -81,32 +81,72 @@ RSpec.describe Gitlab::Auth::RequestAuthenticator do expect(subject.find_sessionless_user(:api)).to eq job_token_user end - it 'returns lfs_token user if no job_token user found' do - allow_any_instance_of(described_class) - .to receive(:find_user_from_lfs_token) - .and_return(lfs_token_user) - - expect(subject.find_sessionless_user(:api)).to eq lfs_token_user - end - - it 'returns basic_auth_access_token user if no lfs_token user found' do + it 'returns nil even if basic_auth_access_token is available' do allow_any_instance_of(described_class) .to receive(:find_user_from_personal_access_token) .and_return(basic_auth_access_token_user) - expect(subject.find_sessionless_user(:api)).to eq basic_auth_access_token_user + expect(subject.find_sessionless_user(:api)).to be_nil end - it 'returns basic_auth_access_password user if no basic_auth_access_token user found' do + it 'returns nil even if find_user_from_lfs_token is available' do allow_any_instance_of(described_class) - .to receive(:find_user_from_basic_auth_password) - .and_return(basic_auth_password_user) + .to receive(:find_user_from_lfs_token) + .and_return(lfs_token_user) - expect(subject.find_sessionless_user(:api)).to eq basic_auth_password_user + expect(subject.find_sessionless_user(:api)).to be_nil end it 'returns nil if no user found' do - expect(subject.find_sessionless_user(:api)).to be_blank + expect(subject.find_sessionless_user(:api)).to be_nil + end + + context 'in an API request' do + before do + env['SCRIPT_NAME'] = '/api/v4/projects' + end + + it 'returns basic_auth_access_token user if no job_token_user found' do + allow_any_instance_of(described_class) + .to receive(:find_user_from_personal_access_token) + .and_return(basic_auth_access_token_user) + + expect(subject.find_sessionless_user(:api)).to eq basic_auth_access_token_user + end + end + + context 'in a Git request' do + before do + env['SCRIPT_NAME'] = '/group/project.git/info/refs' + end + + it 'returns lfs_token user if no job_token user found' do + allow_any_instance_of(described_class) + .to receive(:find_user_from_lfs_token) + .and_return(lfs_token_user) + + expect(subject.find_sessionless_user(nil)).to eq lfs_token_user + end + + it 'returns basic_auth_access_token user if no lfs_token user found' do + allow_any_instance_of(described_class) + .to receive(:find_user_from_personal_access_token) + .and_return(basic_auth_access_token_user) + + expect(subject.find_sessionless_user(nil)).to eq basic_auth_access_token_user + end + + it 'returns basic_auth_access_password user if no basic_auth_access_token user found' do + allow_any_instance_of(described_class) + .to receive(:find_user_from_basic_auth_password) + .and_return(basic_auth_password_user) + + expect(subject.find_sessionless_user(nil)).to eq basic_auth_password_user + end + + it 'returns nil if no user found' do + expect(subject.find_sessionless_user(nil)).to be_blank + end end it 'rescue Gitlab::Auth::AuthenticationError exceptions' do diff --git a/spec/lib/gitlab/auth/two_factor_auth_verifier_spec.rb b/spec/lib/gitlab/auth/two_factor_auth_verifier_spec.rb index f906870195a..876c23a91bd 100644 --- a/spec/lib/gitlab/auth/two_factor_auth_verifier_spec.rb +++ b/spec/lib/gitlab/auth/two_factor_auth_verifier_spec.rb @@ -3,33 +3,50 @@ require 'spec_helper' RSpec.describe Gitlab::Auth::TwoFactorAuthVerifier do - let(:user) { create(:user) } + using RSpec::Parameterized::TableSyntax - subject { described_class.new(user) } + subject(:verifier) { described_class.new(user) } - describe '#two_factor_authentication_required?' do - describe 'when it is required on application level' do - it 'returns true' do - stub_application_setting require_two_factor_authentication: true + let(:user) { build_stubbed(:user, otp_grace_period_started_at: Time.zone.now) } - expect(subject.two_factor_authentication_required?).to be_truthy - end - end + describe '#two_factor_authentication_enforced?' do + subject { verifier.two_factor_authentication_enforced? } - describe 'when it is required on group level' do - it 'returns true' do - allow(user).to receive(:require_two_factor_authentication_from_group?).and_return(true) + where(:instance_level_enabled, :group_level_enabled, :grace_period_expired, :should_be_enforced) do + false | false | true | false + true | false | false | false + true | false | true | true + false | true | false | false + false | true | true | true + end - expect(subject.two_factor_authentication_required?).to be_truthy + with_them do + before do + stub_application_setting(require_two_factor_authentication: instance_level_enabled) + allow(user).to receive(:require_two_factor_authentication_from_group?).and_return(group_level_enabled) + stub_application_setting(two_factor_grace_period: grace_period_expired ? 0 : 1.month.in_hours) end + + it { is_expected.to eq(should_be_enforced) } end + end - describe 'when it is not required' do - it 'returns false when not required on group level' do - allow(user).to receive(:require_two_factor_authentication_from_group?).and_return(false) + describe '#two_factor_authentication_required?' do + subject { verifier.two_factor_authentication_required? } + + where(:instance_level_enabled, :group_level_enabled, :should_be_required) do + true | false | true + false | true | true + false | false | false + end - expect(subject.two_factor_authentication_required?).to be_falsey + with_them do + before do + stub_application_setting(require_two_factor_authentication: instance_level_enabled) + allow(user).to receive(:require_two_factor_authentication_from_group?).and_return(group_level_enabled) end + + it { is_expected.to eq(should_be_required) } end end @@ -85,25 +102,21 @@ RSpec.describe Gitlab::Auth::TwoFactorAuthVerifier do end describe '#two_factor_grace_period_expired?' do - before do - allow(user).to receive(:otp_grace_period_started_at).and_return(4.hours.ago) - end - it 'returns true if the grace period has expired' do - allow(subject).to receive(:two_factor_grace_period).and_return(2) + stub_application_setting two_factor_grace_period: 0 expect(subject.two_factor_grace_period_expired?).to be_truthy end it 'returns false if the grace period has not expired' do - allow(subject).to receive(:two_factor_grace_period).and_return(6) + stub_application_setting two_factor_grace_period: 1.month.in_hours expect(subject.two_factor_grace_period_expired?).to be_falsey end context 'when otp_grace_period_started_at is nil' do it 'returns false' do - allow(user).to receive(:otp_grace_period_started_at).and_return(nil) + user.otp_grace_period_started_at = nil expect(subject.two_factor_grace_period_expired?).to be_falsey end diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index cc592bb8f24..5ec6e23774a 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -386,7 +386,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do shared_examples 'with an invalid access token' do it 'fails for a non-member' do expect(gl_auth.find_for_git_client(project_bot_user.username, access_token.token, project: project, ip: 'ip')) - .to have_attributes(auth_failure ) + .to have_attributes(auth_failure) end context 'when project bot user is blocked' do @@ -396,7 +396,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do it 'fails for a blocked project bot' do expect(gl_auth.find_for_git_client(project_bot_user.username, access_token.token, project: project, ip: 'ip')) - .to have_attributes(auth_failure ) + .to have_attributes(auth_failure) end end end @@ -466,6 +466,41 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do .to have_attributes(auth_failure) end + context 'when 2fa is enabled globally' do + let_it_be(:user) do + create(:user, username: 'normal_user', password: 'my-secret', otp_grace_period_started_at: 1.day.ago) + end + + before do + stub_application_setting(require_two_factor_authentication: true) + end + + it 'fails if grace period expired' do + stub_application_setting(two_factor_grace_period: 0) + + expect { gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip') } + .to raise_error(Gitlab::Auth::MissingPersonalAccessTokenError) + end + + it 'goes through if grace period is not expired yet' do + stub_application_setting(two_factor_grace_period: 72) + + expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip')) + .to have_attributes(actor: user, project: nil, type: :gitlab_or_ldap, authentication_abilities: described_class.full_authentication_abilities) + end + end + + context 'when 2fa is enabled personally' do + let(:user) do + create(:user, :two_factor, username: 'normal_user', password: 'my-secret', otp_grace_period_started_at: 1.day.ago) + end + + it 'fails' do + expect { gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip') } + .to raise_error(Gitlab::Auth::MissingPersonalAccessTokenError) + end + end + it 'goes through lfs authentication' do user = create( :user, @@ -757,16 +792,16 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do describe 'find_with_user_password' do let!(:user) do create(:user, - username: username, - password: password, - password_confirmation: password) + username: username, + password: password, + password_confirmation: password) end let(:username) { 'John' } # username isn't lowercase, test this let(:password) { 'my-secret' } it "finds user by valid login/password" do - expect( gl_auth.find_with_user_password(username, password) ).to eql user + expect(gl_auth.find_with_user_password(username, password)).to eql user end it 'finds user by valid email/password with case-insensitive email' do @@ -779,12 +814,12 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do it "does not find user with invalid password" do password = 'wrong' - expect( gl_auth.find_with_user_password(username, password) ).not_to eql user + expect(gl_auth.find_with_user_password(username, password)).not_to eql user end it "does not find user with invalid login" do user = 'wrong' - expect( gl_auth.find_with_user_password(username, password) ).not_to eql user + expect(gl_auth.find_with_user_password(username, password)).not_to eql user end include_examples 'user login operation with unique ip limit' do @@ -796,13 +831,13 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do it 'finds the user in deactivated state' do user.deactivate! - expect( gl_auth.find_with_user_password(username, password) ).to eql user + expect(gl_auth.find_with_user_password(username, password)).to eql user end it "does not find user in blocked state" do user.block - expect( gl_auth.find_with_user_password(username, password) ).not_to eql user + expect(gl_auth.find_with_user_password(username, password)).not_to eql user end it 'does not find user in locked state' do @@ -814,13 +849,13 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do it "does not find user in ldap_blocked state" do user.ldap_block - expect( gl_auth.find_with_user_password(username, password) ).not_to eql user + expect(gl_auth.find_with_user_password(username, password)).not_to eql user end it 'does not find user in blocked_pending_approval state' do user.block_pending_approval - expect( gl_auth.find_with_user_password(username, password) ).not_to eql user + expect(gl_auth.find_with_user_password(username, password)).not_to eql user end context 'with increment_failed_attempts' do diff --git a/spec/lib/gitlab/fogbugz_import/importer_spec.rb b/spec/lib/gitlab/fogbugz_import/importer_spec.rb index eb0c4da6ce3..9b58b772d1a 100644 --- a/spec/lib/gitlab/fogbugz_import/importer_spec.rb +++ b/spec/lib/gitlab/fogbugz_import/importer_spec.rb @@ -4,23 +4,11 @@ require 'spec_helper' RSpec.describe Gitlab::FogbugzImport::Importer do let(:project) { create(:project_empty_repo) } - let(:importer) { described_class.new(project) } - let(:repo) do - instance_double(Gitlab::FogbugzImport::Repository, - safe_name: 'vim', - path: 'vim', - raw_data: '') - end - - let(:import_data) { { 'repo' => repo } } - let(:credentials) do - { - 'fb_session' => { - 'uri' => 'https://testing.fogbugz.com', - 'token' => 'token' - } - } - end + let(:fogbugz_project) { { 'ixProject' => project.id, 'sProject' => 'vim' } } + let(:import_data) { { 'repo' => fogbugz_project } } + let(:base_url) { 'https://testing.fogbugz.com' } + let(:token) { 'token' } + let(:credentials) { { 'fb_session' => { 'uri' => base_url, 'token' => token } } } let(:closed_bug) do { @@ -46,18 +34,22 @@ RSpec.describe Gitlab::FogbugzImport::Importer do let(:fogbugz_bugs) { [opened_bug, closed_bug] } + subject(:importer) { described_class.new(project) } + before do project.create_import_data(data: import_data, credentials: credentials) - allow_any_instance_of(::Fogbugz::Interface).to receive(:command).with(:listCategories).and_return([]) - allow_any_instance_of(Gitlab::FogbugzImport::Client).to receive(:cases).and_return(fogbugz_bugs) + + stub_fogbugz('listProjects', projects: { project: [fogbugz_project], count: 1 }) + stub_fogbugz('listCategories', categories: { category: [], count: 0 }) + stub_fogbugz('search', cases: { case: fogbugz_bugs, count: fogbugz_bugs.size }) end it 'imports bugs' do - expect { importer.execute }.to change { Issue.count }.by(2) + expect { subject.execute }.to change { Issue.count }.by(2) end it 'imports opened bugs' do - importer.execute + subject.execute issue = Issue.where(project_id: project.id).find_by_title(opened_bug[:sTitle]) @@ -65,10 +57,54 @@ RSpec.describe Gitlab::FogbugzImport::Importer do end it 'imports closed bugs' do - importer.execute + subject.execute issue = Issue.where(project_id: project.id).find_by_title(closed_bug[:sTitle]) expect(issue.state_id).to eq(Issue.available_states[:closed]) end + + context 'verify url' do + context 'when host is localhost' do + let(:base_url) { 'https://localhost:3000' } + + it 'does not allow localhost requests' do + expect { subject.execute } + .to raise_error( + ::Gitlab::HTTP::BlockedUrlError, + "URL 'https://localhost:3000/api.asp' is blocked: Requests to localhost are not allowed" + ) + end + end + + context 'when host is on local network' do + let(:base_url) { 'http://192.168.0.1' } + + it 'does not allow localhost requests' do + expect { subject.execute } + .to raise_error( + ::Gitlab::HTTP::BlockedUrlError, + "URL 'http://192.168.0.1/api.asp' is blocked: Requests to the local network are not allowed" + ) + end + end + + context 'when host is ftp protocol' do + let(:base_url) { 'ftp://testing' } + + it 'only accept http and https requests' do + expect { subject.execute } + .to raise_error( + HTTParty::UnsupportedURIScheme, + "'ftp://testing/api.asp' Must be HTTP, HTTPS or Generic" + ) + end + end + end + + def stub_fogbugz(command, response) + stub_request(:post, "#{base_url}/api.asp") + .with(body: hash_including({ 'cmd' => command, 'token' => token })) + .to_return(status: 200, body: response.to_xml(root: :response)) + end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index bf682e4e4c6..bf2e3c7f5f8 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -435,17 +435,19 @@ RSpec.describe Gitlab::GitAccess do it 'disallows users with expired password to pull' do project.add_maintainer(user) - user.update!(password_expires_at: 2.minutes.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.minutes.ago) expect { pull_access_check }.to raise_forbidden("Your password expired. Please access GitLab from a web browser to update your password.") end - it 'allows ldap users with expired password to pull' do - project.add_maintainer(user) - user.update!(password_expires_at: 2.minutes.ago) - allow(user).to receive(:ldap_user?).and_return(true) + context 'with an ldap user' do + let(:user) { create(:omniauth_user, provider: 'ldap', password_expires_at: 2.minutes.ago) } - expect { pull_access_check }.not_to raise_error + it 'allows ldap users with expired password to pull' do + project.add_maintainer(user) + + expect { pull_access_check }.not_to raise_error + end end context 'when the project repository does not exist' do @@ -987,24 +989,23 @@ RSpec.describe Gitlab::GitAccess do end it 'disallows users with expired password to push' do - user.update!(password_expires_at: 2.minutes.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.minutes.ago) expect { push_access_check }.to raise_forbidden("Your password expired. Please access GitLab from a web browser to update your password.") end - it 'allows ldap users with expired password to push' do - user.update!(password_expires_at: 2.minutes.ago) - allow(user).to receive(:ldap_user?).and_return(true) + context 'with an ldap user' do + let(:user) { create(:omniauth_user, provider: 'ldap', password_expires_at: 2.minutes.ago) } - expect { push_access_check }.not_to raise_error - end + it 'allows ldap users with expired password to push' do + expect { push_access_check }.not_to raise_error + end - it 'disallows blocked ldap users with expired password to push' do - user.block - user.update!(password_expires_at: 2.minutes.ago) - allow(user).to receive(:ldap_user?).and_return(true) + it 'disallows blocked ldap users with expired password to push' do + user.block - expect { push_access_check }.to raise_forbidden("Your account has been blocked.") + expect { push_access_check }.to raise_forbidden("Your account has been blocked.") + end end it 'cleans up the files' do diff --git a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb index 82f465c4f9e..518a9337826 100644 --- a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb @@ -445,8 +445,8 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer do expect(@project.merge_requests.size).to eq(9) end - it 'only restores valid triggers' do - expect(@project.triggers.size).to eq(1) + it 'does not restore triggers' do + expect(@project.triggers.size).to eq(0) end it 'has the correct number of pipelines and statuses' do diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index a9efa32f986..287be24d11f 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -401,15 +401,6 @@ Ci::Variable: - encrypted_value - encrypted_value_salt - encrypted_value_iv -Ci::Trigger: -- id -- token -- project_id -- created_at -- updated_at -- owner_id -- description -- ref Ci::PipelineSchedule: - id - description @@ -556,7 +547,6 @@ Project: - disable_overriding_approvers_per_merge_request - merge_requests_ff_only_enabled - issues_template -- repository_size_limit - sync_time - service_desk_enabled - last_repository_updated_at diff --git a/spec/lib/gitlab/legacy_github_import/client_spec.rb b/spec/lib/gitlab/legacy_github_import/client_spec.rb index 0929b90d1f4..83ba5858d81 100644 --- a/spec/lib/gitlab/legacy_github_import/client_spec.rb +++ b/spec/lib/gitlab/legacy_github_import/client_spec.rb @@ -86,6 +86,15 @@ RSpec.describe Gitlab::LegacyGithubImport::Client do it 'builds a endpoint with the given options' do expect(client.api.api_endpoint).to eq 'https://try.gitea.io/api/v3/' end + + context 'and hostname' do + subject(:client) { described_class.new(token, host: 'https://167.99.148.217/', api_version: 'v1', hostname: 'try.gitea.io') } + + it 'builds a endpoint with the given options' do + expect(client.api.connection_options.dig(:headers, :host)).to eq 'try.gitea.io' + expect(client.api.api_endpoint).to eq 'https://167.99.148.217/api/v1/' + end + end end end diff --git a/spec/lib/gitlab/lfs_token_spec.rb b/spec/lib/gitlab/lfs_token_spec.rb index a8472062f03..3bc0bd385a7 100644 --- a/spec/lib/gitlab/lfs_token_spec.rb +++ b/spec/lib/gitlab/lfs_token_spec.rb @@ -126,7 +126,7 @@ RSpec.describe Gitlab::LfsToken, :clean_gitlab_redis_shared_state do end context 'when the user password is expired' do - let(:actor) { create(:user, password_expires_at: 1.minute.ago, password_automatically_set: true) } + let(:actor) { create(:user, password_expires_at: 1.minute.ago) } it 'returns false' do expect(lfs_token.token_valid?(lfs_token.token)).to be false @@ -135,12 +135,12 @@ RSpec.describe Gitlab::LfsToken, :clean_gitlab_redis_shared_state do end context 'when the actor is an ldap user' do - before do - allow(actor).to receive(:ldap_user?).and_return(true) - end + let(:actor) { create(:omniauth_user, provider: 'ldap') } context 'when the user is blocked' do - let(:actor) { create(:user, :blocked) } + before do + actor.block! + end it 'returns false' do expect(lfs_token.token_valid?(lfs_token.token)).to be false @@ -148,7 +148,9 @@ RSpec.describe Gitlab::LfsToken, :clean_gitlab_redis_shared_state do end context 'when the user password is expired' do - let(:actor) { create(:user, password_expires_at: 1.minute.ago) } + before do + actor.update!(password_expires_at: 1.minute.ago) + end it 'returns true' do expect(lfs_token.token_valid?(lfs_token.token)).to be true diff --git a/spec/lib/gitlab/string_regex_marker_spec.rb b/spec/lib/gitlab/string_regex_marker_spec.rb index a02be83558c..0cbe44eacf4 100644 --- a/spec/lib/gitlab/string_regex_marker_spec.rb +++ b/spec/lib/gitlab/string_regex_marker_spec.rb @@ -23,9 +23,10 @@ RSpec.describe Gitlab::StringRegexMarker do context 'with multiple occurrences' do let(:raw) { %{a <b> <c> d} } let(:rich) { %{a <b> <c> d}.html_safe } + let(:regexp) { /<[a-z]>/ } subject do - described_class.new(raw, rich).mark(/<[a-z]>/) do |text, left:, right:, mode:| + described_class.new(raw, rich).mark(regexp) do |text, left:, right:, mode:| %{<strong>#{text}</strong>}.html_safe end end @@ -34,6 +35,15 @@ RSpec.describe Gitlab::StringRegexMarker do expect(subject).to eq(%{a <strong><b></strong> <strong><c></strong> d}) expect(subject).to be_html_safe end + + context 'with a Gitlab::UntrustedRegexp' do + let(:regexp) { Gitlab::UntrustedRegexp.new('<[a-z]>') } + + it 'marks the matches' do + expect(subject).to eq(%{a <strong><b></strong> <strong><c></strong> d}) + expect(subject).to be_html_safe + end + end end end end diff --git a/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb b/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb new file mode 100644 index 00000000000..0d0f6a3df67 --- /dev/null +++ b/spec/migrations/20210914095310_cleanup_orphan_project_access_tokens_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration!('cleanup_orphan_project_access_tokens') + +RSpec.describe CleanupOrphanProjectAccessTokens, :migration do + def create_user(**extra_options) + defaults = { state: 'active', projects_limit: 0, email: "#{extra_options[:username]}@example.com" } + + table(:users).create!(defaults.merge(extra_options)) + end + + def create_membership(**extra_options) + defaults = { access_level: 30, notification_level: 0, source_id: 1, source_type: 'Project' } + + table(:members).create!(defaults.merge(extra_options)) + end + + let!(:regular_user) { create_user(username: 'regular') } + let!(:orphan_bot) { create_user(username: 'orphaned_bot', user_type: 6) } + let!(:used_bot) do + create_user(username: 'used_bot', user_type: 6).tap do |bot| + create_membership(user_id: bot.id) + end + end + + it 'marks all bots without memberships as deactivated' do + expect do + migrate! + regular_user.reload + orphan_bot.reload + used_bot.reload + end.to change { + [regular_user.state, orphan_bot.state, used_bot.state] + }.from(%w[active active active]).to(%w[active deactivated active]) + end + + it 'schedules for deletion all bots without memberships' do + job_class = 'DeleteUserWorker'.safe_constantize + + if job_class + expect(job_class).to receive(:bulk_perform_async).with([[orphan_bot.id, orphan_bot.id, skip_authorization: true]]) + + migrate! + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 334f9b4ae30..ca4c38d4663 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -5407,43 +5407,11 @@ RSpec.describe User do end describe '#password_expired_if_applicable?' do - let(:user) { build(:user, password_expires_at: password_expires_at, password_automatically_set: set_automatically?) } + let(:user) { build(:user, password_expires_at: password_expires_at) } subject { user.password_expired_if_applicable? } - context 'when user is not ldap user' do - context 'when user has password set automatically' do - let(:set_automatically?) { true } - - context 'when password_expires_at is not set' do - let(:password_expires_at) {} - - it 'returns false' do - is_expected.to be_falsey - end - end - - context 'when password_expires_at is in the past' do - let(:password_expires_at) { 1.minute.ago } - - it 'returns true' do - is_expected.to be_truthy - end - end - - context 'when password_expires_at is in the future' do - let(:password_expires_at) { 1.minute.from_now } - - it 'returns false' do - is_expected.to be_falsey - end - end - end - end - - context 'when user has password not set automatically' do - let(:set_automatically?) { false } - + shared_examples 'password expired not applicable' do context 'when password_expires_at is not set' do let(:password_expires_at) {} @@ -5469,13 +5437,7 @@ RSpec.describe User do end end - context 'when user is ldap user' do - let(:user) { build(:user, password_expires_at: password_expires_at) } - - before do - allow(user).to receive(:ldap_user?).and_return(true) - end - + context 'with a regular user' do context 'when password_expires_at is not set' do let(:password_expires_at) {} @@ -5487,8 +5449,8 @@ RSpec.describe User do context 'when password_expires_at is in the past' do let(:password_expires_at) { 1.minute.ago } - it 'returns false' do - is_expected.to be_falsey + it 'returns true' do + is_expected.to be_truthy end end @@ -5501,32 +5463,26 @@ RSpec.describe User do end end - context 'when user is a project bot' do - let(:user) { build(:user, :project_bot, password_expires_at: password_expires_at) } - - context 'when password_expires_at is not set' do - let(:password_expires_at) {} - - it 'returns false' do - is_expected.to be_falsey - end + context 'when user is a bot' do + before do + allow(user).to receive(:bot?).and_return(true) end - context 'when password_expires_at is in the past' do - let(:password_expires_at) { 1.minute.ago } + it_behaves_like 'password expired not applicable' + end - it 'returns false' do - is_expected.to be_falsey - end - end + context 'when password_automatically_set is true' do + let(:user) { create(:omniauth_user, provider: 'ldap')} - context 'when password_expires_at is in the future' do - let(:password_expires_at) { 1.minute.from_now } + it_behaves_like 'password expired not applicable' + end - it 'returns false' do - is_expected.to be_falsey - end + context 'when allow_password_authentication is false' do + before do + allow(user).to receive(:allow_password_authentication?).and_return(false) end + + it_behaves_like 'password expired not applicable' end end diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index 122612df355..ca9a5b1853c 100644 --- a/spec/policies/global_policy_spec.rb +++ b/spec/policies/global_policy_spec.rb @@ -249,15 +249,13 @@ RSpec.describe GlobalPolicy do context 'user with expired password' do before do - current_user.update!(password_expires_at: 2.minutes.ago, password_automatically_set: true) + current_user.update!(password_expires_at: 2.minutes.ago) end it { is_expected.not_to be_allowed(:access_api) } context 'when user is using ldap' do - before do - allow(current_user).to receive(:ldap_user?).and_return(true) - end + let(:current_user) { create(:omniauth_user, provider: 'ldap', password_expires_at: 2.minutes.ago) } it { is_expected.to be_allowed(:access_api) } end @@ -445,15 +443,13 @@ RSpec.describe GlobalPolicy do context 'user with expired password' do before do - current_user.update!(password_expires_at: 2.minutes.ago, password_automatically_set: true) + current_user.update!(password_expires_at: 2.minutes.ago) end it { is_expected.not_to be_allowed(:access_git) } context 'when user is using ldap' do - before do - allow(current_user).to receive(:ldap_user?).and_return(true) - end + let(:current_user) { create(:omniauth_user, provider: 'ldap', password_expires_at: 2.minutes.ago) } it { is_expected.to be_allowed(:access_git) } end @@ -537,15 +533,13 @@ RSpec.describe GlobalPolicy do context 'user with expired password' do before do - current_user.update!(password_expires_at: 2.minutes.ago, password_automatically_set: true) + current_user.update!(password_expires_at: 2.minutes.ago) end it { is_expected.not_to be_allowed(:use_slash_commands) } context 'when user is using ldap' do - before do - allow(current_user).to receive(:ldap_user?).and_return(true) - end + let(:current_user) { create(:omniauth_user, provider: 'ldap', password_expires_at: 2.minutes.ago) } it { is_expected.to be_allowed(:use_slash_commands) } end diff --git a/spec/requests/api/import_bitbucket_server_spec.rb b/spec/requests/api/import_bitbucket_server_spec.rb index 2225f737f36..970416c7444 100644 --- a/spec/requests/api/import_bitbucket_server_spec.rb +++ b/spec/requests/api/import_bitbucket_server_spec.rb @@ -28,6 +28,20 @@ RSpec.describe API::ImportBitbucketServer do Grape::Endpoint.before_each nil end + it 'rejects requests when Bitbucket Server Importer is disabled' do + stub_application_setting(import_sources: nil) + + post api("/import/bitbucket_server", user), params: { + bitbucket_server_url: base_uri, + bitbucket_server_username: user, + personal_access_token: token, + bitbucket_server_project: project_key, + bitbucket_server_repo: repo_slug + } + + expect(response).to have_gitlab_http_status(:forbidden) + end + it 'returns 201 response when the project is imported successfully' do allow(Gitlab::BitbucketServerImport::ProjectCreator) .to receive(:new).with(project_key, repo_slug, anything, repo_slug, user.namespace, user, anything) diff --git a/spec/requests/api/invitations_spec.rb b/spec/requests/api/invitations_spec.rb index 76a4548df8a..b23ba0021e0 100644 --- a/spec/requests/api/invitations_spec.rb +++ b/spec/requests/api/invitations_spec.rb @@ -259,22 +259,32 @@ RSpec.describe API::Invitations do let(:route) { get invitations_url(source, stranger) } end - %i[maintainer developer access_requester stranger].each do |type| + context "when authenticated as a maintainer" do + it 'returns 200' do + get invitations_url(source, maintainer) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(0) + end + end + + %i[developer access_requester stranger].each do |type| context "when authenticated as a #{type}" do - it 'returns 200' do + it 'returns 403' do user = public_send(type) get invitations_url(source, user) - expect(response).to have_gitlab_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.size).to eq(0) + expect(response).to have_gitlab_http_status(:forbidden) end end end it 'avoids N+1 queries' do + invite_member_by_email(source, source_type, email, maintainer) + # Establish baseline get invitations_url(source, maintainer) @@ -282,7 +292,7 @@ RSpec.describe API::Invitations do get invitations_url(source, maintainer) end - invite_member_by_email(source, source_type, email, maintainer) + invite_member_by_email(source, source_type, email2, maintainer) expect do get invitations_url(source, maintainer) @@ -290,7 +300,7 @@ RSpec.describe API::Invitations do end it 'does not find confirmed members' do - get invitations_url(source, developer) + get invitations_url(source, maintainer) expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers @@ -300,10 +310,10 @@ RSpec.describe API::Invitations do end it 'finds all members with no query string specified' do - invite_member_by_email(source, source_type, email, developer) - invite_member_by_email(source, source_type, email2, developer) + invite_member_by_email(source, source_type, email, maintainer) + invite_member_by_email(source, source_type, email2, maintainer) - get invitations_url(source, developer), params: { query: '' } + get invitations_url(source, maintainer), params: { query: '' } expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers @@ -314,17 +324,17 @@ RSpec.describe API::Invitations do end it 'finds the invitation by invite_email with query string' do - invite_member_by_email(source, source_type, email, developer) - invite_member_by_email(source, source_type, email2, developer) + invite_member_by_email(source, source_type, email, maintainer) + invite_member_by_email(source, source_type, email2, maintainer) - get invitations_url(source, developer), params: { query: email } + get invitations_url(source, maintainer), params: { query: email } expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.count).to eq(1) expect(json_response.first['invite_email']).to eq(email) - expect(json_response.first['created_by_name']).to eq(developer.name) + expect(json_response.first['created_by_name']).to eq(maintainer.name) expect(json_response.first['user_name']).to eq(nil) end end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 80bccdfee0c..be8a6c7bdcf 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1149,6 +1149,16 @@ RSpec.describe API::Projects do expect(response).to have_gitlab_http_status(:bad_request) end + it 'disallows creating a project with an import_url when git import source is disabled' do + stub_application_setting(import_sources: nil) + + project_params = { import_url: 'http://example.com', path: 'path-project-Foo', name: 'Foo Project' } + expect { post api('/projects', user), params: project_params } + .not_to change { Project.count } + + expect(response).to have_gitlab_http_status(:forbidden) + end + it 'sets a project as public' do project = attributes_for(:project, visibility: 'public') diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 527e548ad19..ee1911b0a26 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -53,37 +53,6 @@ RSpec.describe API::Users do end end - describe 'GET /users/:id' do - context 'when unauthenticated' do - it 'does not contain the note of the user' do - get api("/users/#{user.id}") - - expect(json_response).not_to have_key('note') - end - end - - context 'when authenticated' do - context 'as an admin' do - it 'contains the note of the user' do - get api("/users/#{user.id}", admin) - - expect(json_response).to have_key('note') - expect(json_response['note']).to eq(user.note) - expect(json_response).to have_key('sign_in_count') - end - end - - context 'as a regular user' do - it 'does not contain the note of the user' do - get api("/users/#{user.id}", user) - - expect(json_response).not_to have_key('note') - expect(json_response).not_to have_key('sign_in_count') - end - end - end - end - describe "PUT /users/:id" do context 'when user is an admin' do it "updates note of the user" do @@ -527,6 +496,8 @@ RSpec.describe API::Users do end describe "GET /users/:id" do + let_it_be(:user2, reload: true) { create(:user, username: 'another_user') } + it "returns a user by id" do get api("/users/#{user.id}", user) @@ -564,6 +535,64 @@ RSpec.describe API::Users do expect(json_response.keys).not_to include 'trial' end + it 'returns a 404 if the target user is present but inaccessible' do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(user, :read_user, user2).and_return(false) + + get api("/users/#{user2.id}", user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns the `created_at` field for public users' do + get api("/users/#{user2.id}", user) + + expect(response).to match_response_schema('public_api/v4/user/basic') + expect(json_response.keys).to include('created_at') + end + + it 'does not return the `created_at` field for private users' do + get api("/users/#{private_user.id}", user) + + expect(response).to match_response_schema('public_api/v4/user/basic') + expect(json_response.keys).not_to include('created_at') + end + + it 'returns the `followers` field for public users' do + get api("/users/#{user2.id}", user) + + expect(response).to match_response_schema('public_api/v4/user/basic') + expect(json_response.keys).to include('followers') + end + + it 'does not return the `followers` field for private users' do + get api("/users/#{private_user.id}", user) + + expect(response).to match_response_schema('public_api/v4/user/basic') + expect(json_response.keys).not_to include('followers') + end + + it 'returns the `following` field for public users' do + get api("/users/#{user2.id}", user) + + expect(response).to match_response_schema('public_api/v4/user/basic') + expect(json_response.keys).to include('following') + end + + it 'does not return the `following` field for private users' do + get api("/users/#{private_user.id}", user) + + expect(response).to match_response_schema('public_api/v4/user/basic') + expect(json_response.keys).not_to include('following') + end + + it 'does not contain the note of the user' do + get api("/users/#{user.id}", user) + + expect(json_response).not_to have_key('note') + expect(json_response).not_to have_key('sign_in_count') + end + context 'when job title is present' do let(:job_title) { 'Fullstack Engineer' } @@ -580,6 +609,14 @@ RSpec.describe API::Users do end context 'when authenticated as admin' do + it 'contains the note of the user' do + get api("/users/#{user.id}", admin) + + expect(json_response).to have_key('note') + expect(json_response['note']).to eq(user.note) + expect(json_response).to have_key('sign_in_count') + end + it 'includes the `is_admin` field' do get api("/users/#{user.id}", admin) @@ -640,62 +677,10 @@ RSpec.describe API::Users do end context 'for an anonymous user' do - it "returns a user by id" do - get api("/users/#{user.id}") - - expect(response).to match_response_schema('public_api/v4/user/basic') - expect(json_response['username']).to eq(user.username) - end - - it "returns a 404 if the target user is present but inaccessible" do - allow(Ability).to receive(:allowed?).and_call_original - allow(Ability).to receive(:allowed?).with(nil, :read_user, user).and_return(false) - - get api("/users/#{user.id}") - - expect(response).to have_gitlab_http_status(:not_found) - end - - it "returns the `created_at` field for public users" do - get api("/users/#{user.id}") - - expect(response).to match_response_schema('public_api/v4/user/basic') - expect(json_response.keys).to include 'created_at' - end - - it "does not return the `created_at` field for private users" do - get api("/users/#{private_user.id}") - - expect(response).to match_response_schema('public_api/v4/user/basic') - expect(json_response.keys).not_to include 'created_at' - end - - it "returns the `followers` field for public users" do - get api("/users/#{user.id}") - - expect(response).to match_response_schema('public_api/v4/user/basic') - expect(json_response.keys).to include 'followers' - end - - it "does not return the `followers` field for private users" do - get api("/users/#{private_user.id}") - - expect(response).to match_response_schema('public_api/v4/user/basic') - expect(json_response.keys).not_to include 'followers' - end - - it "returns the `following` field for public users" do + it 'returns 403' do get api("/users/#{user.id}") - expect(response).to match_response_schema('public_api/v4/user/basic') - expect(json_response.keys).to include 'following' - end - - it "does not return the `following` field for private users" do - get api("/users/#{private_user.id}") - - expect(response).to match_response_schema('public_api/v4/user/basic') - expect(json_response.keys).not_to include 'following' + expect(response).to have_gitlab_http_status(:forbidden) end end @@ -788,6 +773,14 @@ RSpec.describe API::Users do describe 'GET /users/:id/followers' do let(:follower) { create(:user) } + context 'for an anonymous user' do + it 'returns 403' do + get api("/users/#{user.id}") + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + context 'user has followers' do it 'lists followers' do follower.follow(user) @@ -823,6 +816,14 @@ RSpec.describe API::Users do describe 'GET /users/:id/following' do let(:followee) { create(:user) } + context 'for an anonymous user' do + it 'returns 403' do + get api("/users/#{user.id}") + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + context 'user has followers' do it 'lists following user' do user.follow(followee) diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index a16f5abf608..d2528600477 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -61,7 +61,7 @@ RSpec.describe 'Git HTTP requests' do shared_examples 'operations are not allowed with expired password' do context "when password is expired" do it "responds to downloads with status 401 Unauthorized" do - user.update!(password_expires_at: 2.days.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.days.ago) download(path, user: user.username, password: user.password) do |response| expect(response).to have_gitlab_http_status(:unauthorized) @@ -69,7 +69,7 @@ RSpec.describe 'Git HTTP requests' do end it "responds to uploads with status 401 Unauthorized" do - user.update!(password_expires_at: 2.days.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.days.ago) upload(path, user: user.username, password: user.password) do |response| expect(response).to have_gitlab_http_status(:unauthorized) @@ -614,7 +614,7 @@ RSpec.describe 'Git HTTP requests' do context "when password is expired" do it "responds to downloads with status 401 unauthorized" do - user.update!(password_expires_at: 2.days.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.days.ago) download(path, **env) do |response| expect(response).to have_gitlab_http_status(:unauthorized) @@ -697,7 +697,7 @@ RSpec.describe 'Git HTTP requests' do context "when password is expired" do it "responds to uploads with status 401 unauthorized" do - user.update!(password_expires_at: 2.days.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.days.ago) write_access_token = create(:personal_access_token, user: user, scopes: [:write_repository]) @@ -950,7 +950,7 @@ RSpec.describe 'Git HTTP requests' do context 'when users password is expired' do it 'rejects pulls with 401 unauthorized' do - user.update!(password_expires_at: 2.days.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.days.ago) download(path, user: 'gitlab-ci-token', password: build.token) do |response| expect(response).to have_gitlab_http_status(:unauthorized) @@ -1245,7 +1245,7 @@ RSpec.describe 'Git HTTP requests' do context "when password is expired" do it "responds to downloads with status 401 unauthorized" do - user.update!(password_expires_at: 2.days.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.days.ago) download(path, **env) do |response| expect(response).to have_gitlab_http_status(:unauthorized) @@ -1328,7 +1328,7 @@ RSpec.describe 'Git HTTP requests' do context "when password is expired" do it "responds to uploads with status 401 unauthorized" do - user.update!(password_expires_at: 2.days.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.days.ago) write_access_token = create(:personal_access_token, user: user, scopes: [:write_repository]) @@ -1555,7 +1555,7 @@ RSpec.describe 'Git HTTP requests' do context 'when users password is expired' do it 'rejects pulls with 401 unauthorized' do - user.update!(password_expires_at: 2.days.ago, password_automatically_set: true) + user.update!(password_expires_at: 2.days.ago) download(path, user: 'gitlab-ci-token', password: build.token) do |response| expect(response).to have_gitlab_http_status(:unauthorized) diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index 02eb4262690..656ae744ac1 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -126,7 +126,7 @@ RSpec.describe 'Git LFS API and storage' do it_behaves_like 'LFS http 200 blob response' context 'when user password is expired' do - let_it_be(:user) { create(:user, password_expires_at: 1.minute.ago, password_automatically_set: true)} + let_it_be(:user) { create(:user, password_expires_at: 1.minute.ago)} it_behaves_like 'LFS http 401 response' end @@ -344,7 +344,7 @@ RSpec.describe 'Git LFS API and storage' do end context 'when user password is expired' do - let_it_be(:user) { create(:user, password_expires_at: 1.minute.ago, password_automatically_set: true)} + let_it_be(:user) { create(:user, password_expires_at: 1.minute.ago)} let(:role) { :reporter} @@ -958,7 +958,7 @@ RSpec.describe 'Git LFS API and storage' do it_behaves_like 'LFS http 200 workhorse response' context 'when user password is expired' do - let_it_be(:user) { create(:user, password_expires_at: 1.minute.ago, password_automatically_set: true) } + let_it_be(:user) { create(:user, password_expires_at: 1.minute.ago) } it_behaves_like 'LFS http 401 response' end diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb index 87ef6fa1a18..be942f6ae86 100644 --- a/spec/requests/rack_attack_global_spec.rb +++ b/spec/requests/rack_attack_global_spec.rb @@ -933,17 +933,28 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac end context 'authenticated with lfs token' do - it 'request is authenticated by token in basic auth' do - lfs_token = Gitlab::LfsToken.new(user) - encoded_login = ["#{user.username}:#{lfs_token.token}"].pack('m0') + let(:lfs_url) { '/namespace/repo.git/info/lfs/objects/batch' } + let(:lfs_token) { Gitlab::LfsToken.new(user) } + let(:encoded_login) { ["#{user.username}:#{lfs_token.token}"].pack('m0') } + let(:headers) { { 'AUTHORIZATION' => "Basic #{encoded_login}" } } + it 'request is authenticated by token in basic auth' do expect_authenticated_request - get url, headers: { 'AUTHORIZATION' => "Basic #{encoded_login}" } + get lfs_url, headers: headers + end + + it 'request is not authenticated with API URL' do + expect_unauthenticated_request + + get url, headers: headers end end context 'authenticated with regular login' do + let(:encoded_login) { ["#{user.username}:#{user.password}"].pack('m0') } + let(:headers) { { 'AUTHORIZATION' => "Basic #{encoded_login}" } } + it 'request is authenticated after login' do login_as(user) @@ -952,12 +963,30 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac get url end - it 'request is authenticated by credentials in basic auth' do - encoded_login = ["#{user.username}:#{user.password}"].pack('m0') + it 'request is not authenticated by credentials in basic auth' do + expect_unauthenticated_request - expect_authenticated_request + get url, headers: headers + end + + context 'with POST git-upload-pack' do + it 'request is authenticated by credentials in basic auth' do + expect(::Gitlab::Workhorse).to receive(:verify_api_request!) + + expect_authenticated_request - get url, headers: { 'AUTHORIZATION' => "Basic #{encoded_login}" } + post '/namespace/repo.git/git-upload-pack', headers: headers + end + end + + context 'with GET info/refs' do + it 'request is authenticated by credentials in basic auth' do + expect(::Gitlab::Workhorse).to receive(:verify_api_request!) + + expect_authenticated_request + + get '/namespace/repo.git/info/refs?service=git-upload-pack', headers: headers + end end end end diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index 4a76347ea45..27688d8c966 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -450,6 +450,16 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do end end + context 'when project has project bots' do + let!(:project_bot) { create(:user, :project_bot).tap { |user| project.add_maintainer(user) } } + + it 'deletes bot user as well' do + expect do + destroy_project(project, user) + end.to change { User.find_by(id: project_bot.id) }.to(nil) + end + end + context 'error while destroying', :sidekiq_inline do let!(:pipeline) { create(:ci_pipeline, project: project) } let!(:builds) { create_list(:ci_build, 2, :artifacts, pipeline: pipeline) } diff --git a/spec/support/shared_examples/controllers/uploads_actions_shared_examples.rb b/spec/support/shared_examples/controllers/uploads_actions_shared_examples.rb index a20c1d78912..62b35923bcd 100644 --- a/spec/support/shared_examples/controllers/uploads_actions_shared_examples.rb +++ b/spec/support/shared_examples/controllers/uploads_actions_shared_examples.rb @@ -323,6 +323,16 @@ RSpec.shared_examples 'handle uploads authorize' do end end + context 'when id is not passed as a param' do + let(:params) { super().without(:id) } + + it 'returns 404 status' do + post_authorize + + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'when a user can upload a file' do before do sign_in(user) diff --git a/workhorse/internal/artifacts/entry.go b/workhorse/internal/artifacts/entry.go index 91a0843f1ee..d5b3dfb672c 100644 --- a/workhorse/internal/artifacts/entry.go +++ b/workhorse/internal/artifacts/entry.go @@ -14,6 +14,7 @@ import ( "syscall" "gitlab.com/gitlab-org/labkit/log" + "gitlab.com/gitlab-org/labkit/mask" "gitlab.com/gitlab-org/gitlab/workhorse/internal/helper" "gitlab.com/gitlab-org/gitlab/workhorse/internal/senddata" @@ -35,7 +36,7 @@ func (e *entry) Inject(w http.ResponseWriter, r *http.Request, sendData string) log.WithContextFields(r.Context(), log.Fields{ "entry": params.Entry, - "archive": params.Archive, + "archive": mask.URL(params.Archive), "path": r.URL.Path, }).Print("SendEntry: sending") |