From 3ed2efb3d4c7a1abfb079ba361e04bc9d7849747 Mon Sep 17 00:00:00 2001 From: David Fernandez <dfernandez@gitlab.com> Date: Tue, 16 Jun 2020 04:31:50 +0000 Subject: [PATCH] Validate regex before using `CleanupContainerRepositoryWorker` Three guards have been added: 1. The service used by the worker will not raise an error upon receiving an invalid regex but it will return an error response 2. The expiration container policy will not schedule the next run for the given container expiration policy if it is valid. In addition, the given container expiration policy will be disabled 3. Added a new UntrustedRegexp validator for Grape APIs. This one has been used in `API::ProjectContainerRepositories` to prevent enqueuing a job with invalid regex --- app/models/container_expiration_policy.rb | 4 ++ .../container_expiration_policy_service.rb | 7 +++ .../cleanup_tags_service.rb | 12 ++++++ .../container_expiration_policy_worker.rb | 2 + ...n-policy-when-invalid-regex-is-present.yml | 5 +++ config/initializers/grape_validators.rb | 1 + doc/api/container_registry.md | 6 +-- lib/api/project_container_repositories.rb | 6 +-- .../validators/untrusted_regexp.rb | 19 ++++++++ .../validators/untrusted_regexp_spec.rb | 28 ++++++++++++ .../container_expiration_policy_spec.rb | 10 +++++ .../project_container_repositories_spec.rb | 34 +++++++++++++++ ...ontainer_expiration_policy_service_spec.rb | 15 +++++++ .../cleanup_tags_service_spec.rb | 43 ++++++++++++++++++- ...container_expiration_policy_worker_spec.rb | 17 ++++++++ 15 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 changelogs/unreleased/216088-disable-container-expiration-policy-when-invalid-regex-is-present.yml create mode 100644 lib/api/validations/validators/untrusted_regexp.rb create mode 100644 spec/lib/api/validations/validators/untrusted_regexp_spec.rb diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index fb1e0ce255e0d..b1dd720d9082f 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -52,4 +52,8 @@ def self.older_than_options def set_next_run_at self.next_run_at = Time.zone.now + ChronicDuration.parse(cadence).seconds end + + def disable! + update_attribute(:enabled, false) + end end diff --git a/app/services/container_expiration_policy_service.rb b/app/services/container_expiration_policy_service.rb index 82274fd866889..80f3229832323 100644 --- a/app/services/container_expiration_policy_service.rb +++ b/app/services/container_expiration_policy_service.rb @@ -1,7 +1,14 @@ # frozen_string_literal: true class ContainerExpirationPolicyService < BaseService + InvalidPolicyError = Class.new(StandardError) + def execute(container_expiration_policy) + unless container_expiration_policy.valid? + container_expiration_policy.disable! + raise InvalidPolicyError + end + container_expiration_policy.schedule_next_run! container_expiration_policy.container_repositories.find_each do |container_repository| diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index b53a9c1561e4d..c5809c11ea9a0 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -6,6 +6,7 @@ class CleanupTagsService < BaseService def execute(container_repository) return error('feature disabled') unless can_use? return error('access denied') unless can_destroy? + return error('invalid regex') unless valid_regex? tags = container_repository.tags tags = without_latest(tags) @@ -76,6 +77,17 @@ def can_destroy? def can_use? Feature.enabled?(:container_registry_cleanup, project, default_enabled: true) end + + def valid_regex? + %w(name_regex_delete name_regex name_regex_keep).each do |param_name| + regex = params[param_name] + Gitlab::UntrustedRegexp.new(regex) unless regex.blank? + end + true + rescue RegexpError => e + Gitlab::ErrorTracking.log_exception(e, project_id: project.id) + false + end end end end diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb index e1544be5aed13..96590e165ae13 100644 --- a/app/workers/container_expiration_policy_worker.rb +++ b/app/workers/container_expiration_policy_worker.rb @@ -12,6 +12,8 @@ def perform user: container_expiration_policy.project.owner) do |project:, user:| ContainerExpirationPolicyService.new(project, user) .execute(container_expiration_policy) + rescue ContainerExpirationPolicyService::InvalidPolicyError => e + Gitlab::ErrorTracking.log_exception(e, container_expiration_policy_id: container_expiration_policy.id) end end end diff --git a/changelogs/unreleased/216088-disable-container-expiration-policy-when-invalid-regex-is-present.yml b/changelogs/unreleased/216088-disable-container-expiration-policy-when-invalid-regex-is-present.yml new file mode 100644 index 0000000000000..0a8df78d0c71b --- /dev/null +++ b/changelogs/unreleased/216088-disable-container-expiration-policy-when-invalid-regex-is-present.yml @@ -0,0 +1,5 @@ +--- +title: Validate regex before sending them to CleanupContainerRepositoryWorker +merge_request: 34282 +author: +type: added diff --git a/config/initializers/grape_validators.rb b/config/initializers/grape_validators.rb index 9d2b6dc9bd121..22f2c9ecf9247 100644 --- a/config/initializers/grape_validators.rb +++ b/config/initializers/grape_validators.rb @@ -7,3 +7,4 @@ Grape::Validations.register_validator(:integer_none_any, ::API::Validations::Validators::IntegerNoneAny) Grape::Validations.register_validator(:array_none_any, ::API::Validations::Validators::ArrayNoneAny) Grape::Validations.register_validator(:check_assignees_count, ::API::Validations::Validators::CheckAssigneesCount) +Grape::Validations.register_validator(:untrusted_regexp, ::API::Validations::Validators::UntrustedRegexp) diff --git a/doc/api/container_registry.md b/doc/api/container_registry.md index dbfda65a131cb..d4a4fc1a73302 100644 --- a/doc/api/container_registry.md +++ b/doc/api/container_registry.md @@ -240,9 +240,9 @@ DELETE /projects/:id/registry/repositories/:repository_id/tags | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. | | `repository_id` | integer | yes | The ID of registry repository. | -| `name_regex` | string | no | The [re2](https://github.com/google/re2/wiki/Syntax) regex of the name to delete. To delete all tags specify `.*`. **Note:** `name_regex` is deprecated in favor of `name_regex_delete`.| -| `name_regex_delete` | string | yes | The [re2](https://github.com/google/re2/wiki/Syntax) regex of the name to delete. To delete all tags specify `.*`.| -| `name_regex_keep` | string | no | The [re2](https://github.com/google/re2/wiki/Syntax) regex of the name to keep. This value will override any matches from `name_regex_delete`. Note: setting to `.*` will result in a no-op. | +| `name_regex` | string | no | The [re2](https://github.com/google/re2/wiki/Syntax) regex of the name to delete. To delete all tags specify `.*`. **Note:** `name_regex` is deprecated in favor of `name_regex_delete`. This field is validated. | +| `name_regex_delete` | string | yes | The [re2](https://github.com/google/re2/wiki/Syntax) regex of the name to delete. To delete all tags specify `.*`. This field is validated. | +| `name_regex_keep` | string | no | The [re2](https://github.com/google/re2/wiki/Syntax) regex of the name to keep. This value will override any matches from `name_regex_delete`. This field is validated. Note: setting to `.*` will result in a no-op. | | `keep_n` | integer | no | The amount of latest tags of given name to keep. | | `older_than` | string | no | Tags to delete that are older than the given time, written in human readable form `1h`, `1d`, `1month`. | diff --git a/lib/api/project_container_repositories.rb b/lib/api/project_container_repositories.rb index f378b87d4b649..2a0099018d94b 100644 --- a/lib/api/project_container_repositories.rb +++ b/lib/api/project_container_repositories.rb @@ -70,11 +70,11 @@ class ProjectContainerRepositories < Grape::API end params do requires :repository_id, type: Integer, desc: 'The ID of the repository' - optional :name_regex_delete, type: String, desc: 'The tag name regexp to delete, specify .* to delete all' - optional :name_regex, type: String, desc: 'The tag name regexp to delete, specify .* to delete all' + optional :name_regex_delete, type: String, untrusted_regexp: true, desc: 'The tag name regexp to delete, specify .* to delete all' + optional :name_regex, type: String, untrusted_regexp: true, desc: 'The tag name regexp to delete, specify .* to delete all' # require either name_regex (deprecated) or name_regex_delete, it is ok to have both at_least_one_of :name_regex, :name_regex_delete - optional :name_regex_keep, type: String, desc: 'The tag name regexp to retain' + optional :name_regex_keep, type: String, untrusted_regexp: true, desc: 'The tag name regexp to retain' optional :keep_n, type: Integer, desc: 'Keep n of latest tags with matching name' optional :older_than, type: String, desc: 'Delete older than: 1h, 1d, 1month' end diff --git a/lib/api/validations/validators/untrusted_regexp.rb b/lib/api/validations/validators/untrusted_regexp.rb new file mode 100644 index 0000000000000..ec623684e67e8 --- /dev/null +++ b/lib/api/validations/validators/untrusted_regexp.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module API + module Validations + module Validators + class UntrustedRegexp < Grape::Validations::Base + def validate_param!(attr_name, params) + value = params[attr_name] + return unless value + + Gitlab::UntrustedRegexp.new(value) + rescue RegexpError => e + message = "is an invalid regexp: #{e.message}" + raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)], message: message + end + end + end + end +end diff --git a/spec/lib/api/validations/validators/untrusted_regexp_spec.rb b/spec/lib/api/validations/validators/untrusted_regexp_spec.rb new file mode 100644 index 0000000000000..491bf94fd7930 --- /dev/null +++ b/spec/lib/api/validations/validators/untrusted_regexp_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Validations::Validators::UntrustedRegexp do + include ApiValidatorsHelpers + + subject do + described_class.new(['test'], {}, false, scope.new) + end + + context 'valid regex' do + it 'does not raise a validation error' do + expect_no_validation_error('test' => 'test') + expect_no_validation_error('test' => '.*') + expect_no_validation_error('test' => Gitlab::Regex.environment_name_regex_chars) + end + end + + context 'invalid regex' do + it 'raises a validation error' do + expect_validation_error('test' => '[') + expect_validation_error('test' => '*foobar') + expect_validation_error('test' => '?foobar') + expect_validation_error('test' => '\A[^/%\s]+(..\z') + end + end +end diff --git a/spec/models/container_expiration_policy_spec.rb b/spec/models/container_expiration_policy_spec.rb index 7d9c763889747..588685b04bffd 100644 --- a/spec/models/container_expiration_policy_spec.rb +++ b/spec/models/container_expiration_policy_spec.rb @@ -103,4 +103,14 @@ end end end + + describe '#disable!' do + let_it_be(:container_expiration_policy) { create(:container_expiration_policy) } + + subject { container_expiration_policy.disable! } + + it 'disables the container expiration policy' do + expect { subject }.to change { container_expiration_policy.reload.enabled }.from(true).to(false) + end + end end diff --git a/spec/requests/api/project_container_repositories_spec.rb b/spec/requests/api/project_container_repositories_spec.rb index 91905635c3fdd..471fc99117b3d 100644 --- a/spec/requests/api/project_container_repositories_spec.rb +++ b/spec/requests/api/project_container_repositories_spec.rb @@ -223,6 +223,40 @@ expect(response).to have_gitlab_http_status(:accepted) end end + + context 'with invalid regex' do + let(:invalid_regex) { '*v10.' } + let(:lease_key) { "container_repository:cleanup_tags:#{root_repository.id}" } + + RSpec.shared_examples 'rejecting the invalid regex' do |param_name| + it 'does not enqueue a job' do + expect(CleanupContainerRepositoryWorker).not_to receive(:perform_async) + + subject + end + + it_behaves_like 'returning response status', :bad_request + + it 'returns an error message' do + subject + + expect(json_response['error']).to include("#{param_name} is an invalid regexp") + end + end + + before do + stub_last_activity_update + stub_exclusive_lease(lease_key, timeout: 1.hour) + end + + %i[name_regex_delete name_regex name_regex_keep].each do |param_name| + context "for #{param_name}" do + let(:params) { { param_name => invalid_regex } } + + it_behaves_like 'rejecting the invalid regex', param_name + end + end + end end end diff --git a/spec/services/container_expiration_policy_service_spec.rb b/spec/services/container_expiration_policy_service_spec.rb index b2f2b2e1236e5..97715b990efe8 100644 --- a/spec/services/container_expiration_policy_service_spec.rb +++ b/spec/services/container_expiration_policy_service_spec.rb @@ -27,5 +27,20 @@ expect(container_expiration_policy.next_run_at).to be > Time.zone.now end + + context 'with an invalid container expiration policy' do + before do + allow(container_expiration_policy).to receive(:valid?).and_return(false) + end + + it 'disables it' do + expect(container_expiration_policy).not_to receive(:schedule_next_run!) + expect(CleanupContainerRepositoryWorker).not_to receive(:perform_async) + + expect { subject } + .to change { container_expiration_policy.reload.enabled }.from(true).to(false) + .and raise_error(ContainerExpirationPolicyService::InvalidPolicyError) + end + end end end diff --git a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb index 01f09f208fd6f..11ea7d5167352 100644 --- a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb +++ b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb @@ -4,7 +4,7 @@ describe Projects::ContainerRepository::CleanupTagsService do let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, :private) } + let_it_be(:project, reload: true) { create(:project, :private) } let_it_be(:repository) { create(:container_repository, :root, project: project) } let(:service) { described_class.new(project, user, params) } @@ -72,6 +72,47 @@ end end + context 'with invalid regular expressions' do + RSpec.shared_examples 'handling an invalid regex' do + it 'keeps all tags' do + expect(Projects::ContainerRepository::DeleteTagsService) + .not_to receive(:new) + subject + end + + it 'returns an error' do + response = subject + + expect(response[:status]).to eq(:error) + expect(response[:message]).to eq('invalid regex') + end + + it 'calls error tracking service' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).and_call_original + + subject + end + end + + context 'when name_regex_delete is invalid' do + let(:params) { { 'name_regex_delete' => '*test*' } } + + it_behaves_like 'handling an invalid regex' + end + + context 'when name_regex is invalid' do + let(:params) { { 'name_regex' => '*test*' } } + + it_behaves_like 'handling an invalid regex' + end + + context 'when name_regex_keep is invalid' do + let(:params) { { 'name_regex_keep' => '*test*' } } + + it_behaves_like 'handling an invalid regex' + end + end + context 'when delete regex matching specific tags is used' do let(:params) do { 'name_regex_delete' => 'C|D' } diff --git a/spec/workers/container_expiration_policy_worker_spec.rb b/spec/workers/container_expiration_policy_worker_spec.rb index 48ab161463375..b15a28dcdca51 100644 --- a/spec/workers/container_expiration_policy_worker_spec.rb +++ b/spec/workers/container_expiration_policy_worker_spec.rb @@ -53,5 +53,22 @@ subject end end + + context 'an invalid policy' do + let_it_be(:container_expiration_policy) { create(:container_expiration_policy, :runnable) } + let_it_be(:user) {container_expiration_policy.project.owner } + + before do + container_expiration_policy.update_column(:name_regex, '*production') + end + + it 'runs the policy and tracks an error' do + expect(ContainerExpirationPolicyService) + .to receive(:new).with(container_expiration_policy.project, user).and_call_original + expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(ContainerExpirationPolicyService::InvalidPolicyError), container_expiration_policy_id: container_expiration_policy.id) + + expect { subject }.to change { container_expiration_policy.reload.enabled }.from(true).to(false) + end + end end end -- GitLab