diff --git a/app/validators/qualified_domain_array_validator.rb b/app/validators/qualified_domain_array_validator.rb new file mode 100644 index 00000000000..986c146a9db --- /dev/null +++ b/app/validators/qualified_domain_array_validator.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# QualifiedDomainArrayValidator +# +# Custom validator for URL hosts/'qualified domains' (FQDNs, ex: gitlab.com, sub.example.com). +# This does not check if the domain actually exists. It only checks if it is a +# valid domain string. +# +# Example: +# +# class ApplicationSetting < ApplicationRecord +# validates :outbound_local_requests_whitelist, qualified_domain_array: true +# end +# +class QualifiedDomainArrayValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + validate_value_present(record, attribute, value) + validate_host_length(record, attribute, value) + validate_idna_encoding(record, attribute, value) + validate_sanitization(record, attribute, value) + end + + private + + def validate_value_present(record, attribute, value) + return unless value.blank? + + record.errors.add(attribute, _('entries cannot be blank')) + end + + def validate_host_length(record, attribute, value) + return unless value&.any? { |entry| entry.size > 255 } + + record.errors.add(attribute, _('entries cannot be larger than 255 characters')) + end + + def validate_idna_encoding(record, attribute, value) + return if value&.all?(&:ascii_only?) + + record.errors.add(attribute, _('unicode domains should use IDNA encoding')) + end + + def validate_sanitization(record, attribute, value) + sanitizer = Rails::Html::FullSanitizer.new + return unless value&.any? { |str| sanitizer.sanitize(str) != str } + + record.errors.add(attribute, _('entries cannot contain HTML tags')) + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bd26ca6714d..5cd9aa7e2de 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -12803,6 +12803,15 @@ msgstr "" msgid "encrypted: needs to be a :required, :optional or :migrating!" msgstr "" +msgid "entries cannot be blank" +msgstr "" + +msgid "entries cannot be larger than 255 characters" +msgstr "" + +msgid "entries cannot contain HTML tags" +msgstr "" + msgid "error" msgstr "" @@ -13308,6 +13317,9 @@ msgstr "" msgid "triggered" msgstr "" +msgid "unicode domains should use IDNA encoding" +msgstr "" + msgid "updated" msgstr "" diff --git a/spec/validators/qualified_domain_array_validator_spec.rb b/spec/validators/qualified_domain_array_validator_spec.rb new file mode 100644 index 00000000000..a96b00bfd1d --- /dev/null +++ b/spec/validators/qualified_domain_array_validator_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe QualifiedDomainArrayValidator do + class TestClass + include ActiveModel::Validations + + attr_accessor :domain_array + + def initialize(domain_array) + self.domain_array = domain_array + end + end + + let!(:record) do + TestClass.new(['gitlab.com']) + end + + subject { validator.validate(record) } + + shared_examples 'cannot be blank' do + it 'returns error when attribute is blank' do + record.domain_array = [] + + subject + + expect(record.errors).to be_present + expect(record.errors.first[1]).to eq 'entries cannot be blank' + end + end + + shared_examples 'can be nil' do + it 'allows when attribute is nil' do + record.domain_array = nil + + subject + + expect(record.errors).to be_empty + end + end + + describe 'validations' do + let(:validator) { described_class.new(attributes: [:domain_array]) } + + it_behaves_like 'cannot be blank' + + it 'returns error when attribute is nil' do + record.domain_array = nil + + subject + + expect(record.errors).to be_present + end + + it 'allows when domain is valid' do + subject + + expect(record.errors).to be_empty + end + + it 'returns error when domain contains unicode' do + record.domain_array = ['ğitlab.com'] + + subject + + expect(record.errors).to be_present + expect(record.errors.first[1]).to eq 'unicode domains should use IDNA encoding' + end + + it 'returns error when entry is larger than 255 chars' do + record.domain_array = ['a' * 256] + + subject + + expect(record.errors).to be_present + expect(record.errors.first[1]).to eq 'entries cannot be larger than 255 characters' + end + + it 'returns error when entry contains HTML tags' do + record.domain_array = ['gitlab.com

something

'] + + subject + + expect(record.errors).to be_present + expect(record.errors.first[1]).to eq 'entries cannot contain HTML tags' + end + end + + context 'when allow_nil is set to true' do + let(:validator) { described_class.new(attributes: [:domain_array], allow_nil: true) } + + it_behaves_like 'can be nil' + + it_behaves_like 'cannot be blank' + end + + context 'when allow_blank is set to true' do + let(:validator) { described_class.new(attributes: [:domain_array], allow_blank: true) } + + it_behaves_like 'can be nil' + + it 'allows when attribute is blank' do + record.domain_array = [] + + subject + + expect(record.errors).to be_empty + end + end +end