From ee68bd9771f671ce7c258a8f5441125f1a9c2d53 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Tue, 6 Feb 2018 13:25:46 +0000 Subject: [PATCH 1/2] Add DNS verification to Pages custom domains --- .../projects/pages_domains_controller.rb | 18 +- app/helpers/application_settings_helper.rb | 1 + app/mailers/emails/pages_domains.rb | 43 +++ app/mailers/notify.rb | 1 + app/models/pages_domain.rb | 65 ++++- app/services/notification_service.rb | 32 +++ .../update_pages_configuration_service.rb | 10 +- app/services/verify_pages_domain_service.rb | 106 +++++++ .../application_settings/_form.html.haml | 11 + .../pages_domain_disabled_email.html.haml | 15 + .../pages_domain_disabled_email.text.haml | 13 + .../pages_domain_enabled_email.html.haml | 11 + .../pages_domain_enabled_email.text.haml | 9 + ...domain_verification_failed_email.html.haml | 17 ++ ...domain_verification_failed_email.text.haml | 14 + ...ain_verification_succeeded_email.html.haml | 13 + ...ain_verification_succeeded_email.text.haml | 10 + app/views/projects/pages/_list.html.haml | 13 +- .../projects/pages_domains/show.html.haml | 25 +- app/workers/all_queues.yml | 2 + .../pages_domain_verification_cron_worker.rb | 10 + .../pages_domain_verification_worker.rb | 11 + ...7-pages-custom-domain-dns-verification.yml | 5 + config/gitlab.yml.example | 4 + config/initializers/1_settings.rb | 4 + config/routes/project.rb | 6 +- config/sidekiq_queues.yml | 1 + ...216120000_add_pages_domain_verification.rb | 8 + ...0010_add_pages_domain_verified_at_index.rb | 15 + ...llow_domain_verification_to_be_disabled.rb | 7 + ...16120030_add_pages_domain_enabled_until.rb | 7 + ...40_add_pages_domain_enabled_until_index.rb | 17 ++ ...pages_domains_verification_grace_period.rb | 26 ++ ...020_fill_pages_domain_verification_code.rb | 41 +++ ...030_enqueue_verify_pages_domain_workers.rb | 16 ++ db/schema.rb | 9 +- doc/administration/pages/index.md | 12 + .../pages/getting_started_part_three.md | 41 ++- .../project/pages/img/verify_your_domain.png | Bin 0 -> 30163 bytes lib/api/entities.rb | 8 + .../projects/pages_domains_controller_spec.rb | 41 ++- spec/factories/pages_domains.rb | 21 +- spec/features/projects/pages_spec.rb | 2 - .../public_api/v4/pages_domain/basic.json | 5 +- .../public_api/v4/pages_domain/detail.json | 5 +- spec/mailers/emails/pages_domains_spec.rb | 71 +++++ ...nqueue_verify_pages_domain_workers_spec.rb | 23 ++ spec/models/pages_domain_spec.rb | 144 +++++++++- spec/services/notification_service_spec.rb | 72 +++++ .../verify_pages_domain_service_spec.rb | 270 ++++++++++++++++++ ...es_domain_verification_cron_worker_spec.rb | 21 ++ .../pages_domain_verification_worker_spec.rb | 27 ++ 52 files changed, 1357 insertions(+), 22 deletions(-) create mode 100644 app/mailers/emails/pages_domains.rb create mode 100644 app/services/verify_pages_domain_service.rb create mode 100644 app/views/notify/pages_domain_disabled_email.html.haml create mode 100644 app/views/notify/pages_domain_disabled_email.text.haml create mode 100644 app/views/notify/pages_domain_enabled_email.html.haml create mode 100644 app/views/notify/pages_domain_enabled_email.text.haml create mode 100644 app/views/notify/pages_domain_verification_failed_email.html.haml create mode 100644 app/views/notify/pages_domain_verification_failed_email.text.haml create mode 100644 app/views/notify/pages_domain_verification_succeeded_email.html.haml create mode 100644 app/views/notify/pages_domain_verification_succeeded_email.text.haml create mode 100644 app/workers/pages_domain_verification_cron_worker.rb create mode 100644 app/workers/pages_domain_verification_worker.rb create mode 100644 changelogs/unreleased/29497-pages-custom-domain-dns-verification.yml create mode 100644 db/migrate/20180216120000_add_pages_domain_verification.rb create mode 100644 db/migrate/20180216120010_add_pages_domain_verified_at_index.rb create mode 100644 db/migrate/20180216120020_allow_domain_verification_to_be_disabled.rb create mode 100644 db/migrate/20180216120030_add_pages_domain_enabled_until.rb create mode 100644 db/migrate/20180216120040_add_pages_domain_enabled_until_index.rb create mode 100644 db/migrate/20180216120050_pages_domains_verification_grace_period.rb create mode 100644 db/post_migrate/20180216121020_fill_pages_domain_verification_code.rb create mode 100644 db/post_migrate/20180216121030_enqueue_verify_pages_domain_workers.rb create mode 100644 doc/user/project/pages/img/verify_your_domain.png create mode 100644 spec/mailers/emails/pages_domains_spec.rb create mode 100644 spec/migrations/enqueue_verify_pages_domain_workers_spec.rb create mode 100644 spec/services/verify_pages_domain_service_spec.rb create mode 100644 spec/workers/pages_domain_verification_cron_worker_spec.rb create mode 100644 spec/workers/pages_domain_verification_worker_spec.rb diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index 15e77d854dc..b71f1e5fef4 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController before_action :require_pages_enabled! before_action :authorize_update_pages!, except: [:show] - before_action :domain, only: [:show, :destroy] + before_action :domain, only: [:show, :destroy, :verify] def show end @@ -12,11 +12,23 @@ class Projects::PagesDomainsController < Projects::ApplicationController @domain = @project.pages_domains.new end + def verify + result = VerifyPagesDomainService.new(@domain).execute + + if result[:status] == :success + flash[:notice] = 'Successfully verified domain ownership' + else + flash[:alert] = 'Failed to verify domain ownership' + end + + redirect_to project_pages_domain_path(@project, @domain) + end + def create @domain = @project.pages_domains.create(pages_domain_params) if @domain.valid? - redirect_to project_pages_path(@project) + redirect_to project_pages_domain_path(@project, @domain) else render 'new' end @@ -46,6 +58,6 @@ class Projects::PagesDomainsController < Projects::ApplicationController end def domain - @domain ||= @project.pages_domains.find_by(domain: params[:id].to_s) + @domain ||= @project.pages_domains.find_by!(domain: params[:id].to_s) end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index e293b3ef329..ab68ecad2ba 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -199,6 +199,7 @@ module ApplicationSettingsHelper :metrics_port, :metrics_sample_interval, :metrics_timeout, + :pages_domain_verification_enabled, :password_authentication_enabled_for_web, :password_authentication_enabled_for_git, :performance_bar_allowed_group_id, diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb new file mode 100644 index 00000000000..0027dfdc36b --- /dev/null +++ b/app/mailers/emails/pages_domains.rb @@ -0,0 +1,43 @@ +module Emails + module PagesDomains + def pages_domain_enabled_email(domain, recipient) + @domain = domain + @project = domain.project + + mail( + to: recipient.notification_email, + subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled") + ) + end + + def pages_domain_disabled_email(domain, recipient) + @domain = domain + @project = domain.project + + mail( + to: recipient.notification_email, + subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled") + ) + end + + def pages_domain_verification_succeeded_email(domain, recipient) + @domain = domain + @project = domain.project + + mail( + to: recipient.notification_email, + subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'") + ) + end + + def pages_domain_verification_failed_email(domain, recipient) + @domain = domain + @project = domain.project + + mail( + to: recipient.notification_email, + subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'") + ) + end + end +end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index eade0fe278f..45d4fb451d8 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -5,6 +5,7 @@ class Notify < BaseMailer include Emails::Issues include Emails::MergeRequests include Emails::Notes + include Emails::PagesDomains include Emails::Projects include Emails::Profile include Emails::Pipelines diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index d8bf54e0c40..588bd50ed77 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -1,10 +1,14 @@ class PagesDomain < ActiveRecord::Base + VERIFICATION_KEY = 'gitlab-pages-verification-code'.freeze + VERIFICATION_THRESHOLD = 3.days.freeze + belongs_to :project validates :domain, hostname: { allow_numeric_hostname: true } validates :domain, uniqueness: { case_sensitive: false } validates :certificate, certificate: true, allow_nil: true, allow_blank: true validates :key, certificate_key: true, allow_nil: true, allow_blank: true + validates :verification_code, presence: true, allow_blank: false validate :validate_pages_domain validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? } @@ -16,10 +20,32 @@ class PagesDomain < ActiveRecord::Base key: Gitlab::Application.secrets.db_key_base, algorithm: 'aes-256-cbc' + after_initialize :set_verification_code after_create :update_daemon - after_save :update_daemon + after_update :update_daemon, if: :pages_config_changed? after_destroy :update_daemon + scope :enabled, -> { where('enabled_until >= ?', Time.now ) } + scope :needs_verification, -> do + verified_at = arel_table[:verified_at] + enabled_until = arel_table[:enabled_until] + threshold = Time.now + VERIFICATION_THRESHOLD + + where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold)))) + end + + def verified? + !!verified_at + end + + def unverified? + !verified? + end + + def enabled? + !Gitlab::CurrentSettings.pages_domain_verification_enabled? || enabled_until.present? + end + def to_param domain end @@ -84,12 +110,49 @@ class PagesDomain < ActiveRecord::Base @certificate_text ||= x509.try(:to_text) end + # Verification codes may be TXT records for domain or verification_domain, to + # support the use of CNAME records on domain. + def verification_domain + return unless domain.present? + + "_#{VERIFICATION_KEY}.#{domain}" + end + + def keyed_verification_code + return unless verification_code.present? + + "#{VERIFICATION_KEY}=#{verification_code}" + end + private + def set_verification_code + return if self.verification_code.present? + + self.verification_code = SecureRandom.hex(16) + end + def update_daemon ::Projects::UpdatePagesConfigurationService.new(project).execute end + def pages_config_changed? + project_id_changed? || + domain_changed? || + certificate_changed? || + key_changed? || + became_enabled? || + became_disabled? + end + + def became_enabled? + enabled_until.present? && !enabled_until_was.present? + end + + def became_disabled? + !enabled_until.present? && enabled_until_was.present? + end + def validate_matching_key unless has_matching_key? self.errors.add(:key, "doesn't match the certificate") diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 56e941d90ff..e07ecda27b5 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -339,6 +339,30 @@ class NotificationService end end + def pages_domain_verification_succeeded(domain) + recipients_for_pages_domain(domain).each do |user| + mailer.pages_domain_verification_succeeded_email(domain, user).deliver_later + end + end + + def pages_domain_verification_failed(domain) + recipients_for_pages_domain(domain).each do |user| + mailer.pages_domain_verification_failed_email(domain, user).deliver_later + end + end + + def pages_domain_enabled(domain) + recipients_for_pages_domain(domain).each do |user| + mailer.pages_domain_enabled_email(domain, user).deliver_later + end + end + + def pages_domain_disabled(domain) + recipients_for_pages_domain(domain).each do |user| + mailer.pages_domain_disabled_email(domain, user).deliver_later + end + end + protected def new_resource_email(target, method) @@ -433,6 +457,14 @@ class NotificationService private + def recipients_for_pages_domain(domain) + project = domain.project + + return [] unless project + + notifiable_users(project.team.masters, :watch, target: project) + end + def notifiable?(*args) NotificationRecipientService.notifiable?(*args) end diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index cacb74b1205..52ff64cc938 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -23,7 +23,7 @@ module Projects end def pages_domains_config - project.pages_domains.map do |domain| + enabled_pages_domains.map do |domain| { domain: domain.domain, certificate: domain.certificate, @@ -32,6 +32,14 @@ module Projects end end + def enabled_pages_domains + if Gitlab::CurrentSettings.pages_domain_verification_enabled? + project.pages_domains.enabled + else + project.pages_domains + end + end + def reload_daemon # GitLab Pages daemon constantly watches for modification time of `pages.path` # It reloads configuration when `pages.path` is modified diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb new file mode 100644 index 00000000000..40fc42f2690 --- /dev/null +++ b/app/services/verify_pages_domain_service.rb @@ -0,0 +1,106 @@ +require 'resolv' + +class VerifyPagesDomainService < BaseService + # The maximum number of seconds to be spent on each DNS lookup + RESOLVER_TIMEOUT_SECONDS = 15 + + # How long verification lasts for + VERIFICATION_PERIOD = 7.days + + attr_reader :domain + + def initialize(domain) + @domain = domain + end + + def execute + return error("No verification code set for #{domain.domain}") unless domain.verification_code.present? + + if !verification_enabled? || dns_record_present? + verify_domain! + elsif expired? + disable_domain! + else + unverify_domain! + end + end + + private + + def verify_domain! + was_disabled = !domain.enabled? + was_unverified = domain.unverified? + + # Prevent any pre-existing grace period from being truncated + reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max + + domain.update!(verified_at: Time.now, enabled_until: reverify) + + if was_disabled + notify(:enabled) + elsif was_unverified + notify(:verification_succeeded) + end + + success + end + + def unverify_domain! + if domain.verified? + domain.update!(verified_at: nil) + notify(:verification_failed) + end + + error("Couldn't verify #{domain.domain}") + end + + def disable_domain! + domain.update!(verified_at: nil, enabled_until: nil) + + notify(:disabled) + + error("Couldn't verify #{domain.domain}. It is now disabled.") + end + + # A domain is only expired until `disable!` has been called + def expired? + domain.enabled_until && domain.enabled_until < Time.now + end + + def dns_record_present? + Resolv::DNS.open do |resolver| + resolver.timeouts = RESOLVER_TIMEOUT_SECONDS + + check(domain.domain, resolver) || check(domain.verification_domain, resolver) + end + end + + def check(domain_name, resolver) + records = parse(txt_records(domain_name, resolver)) + + records.any? do |record| + record == domain.keyed_verification_code || record == domain.verification_code + end + rescue => err + log_error("Failed to check TXT records on #{domain_name} for #{domain.domain}: #{err}") + false + end + + def txt_records(domain_name, resolver) + resolver.getresources(domain_name, Resolv::DNS::Resource::IN::TXT) + end + + def parse(records) + records.flat_map(&:strings).flat_map(&:split) + end + + def verification_enabled? + Gitlab::CurrentSettings.pages_domain_verification_enabled? + end + + def notify(type) + return unless verification_enabled? + + notification_service.public_send("pages_domain_#{type}", domain) # rubocop:disable GitlabSecurity/PublicSend + end +end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 60f12030f98..20527d31870 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -237,6 +237,17 @@ .col-sm-10 = f.number_field :max_pages_size, class: 'form-control' .help-block 0 for unlimited + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :pages_domain_verification_enabled do + = f.check_box :pages_domain_verification_enabled + Require users to prove ownership of custom domains + .help-block + Domain verification is an essential security measure for public GitLab + sites. Users are required to demonstrate they control a domain before + it is enabled + = link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') %fieldset %legend Continuous Integration and Deployment diff --git a/app/views/notify/pages_domain_disabled_email.html.haml b/app/views/notify/pages_domain_disabled_email.html.haml new file mode 100644 index 00000000000..34ce4238a12 --- /dev/null +++ b/app/views/notify/pages_domain_disabled_email.html.haml @@ -0,0 +1,15 @@ +%p + Following a verification check, your GitLab Pages custom domain has been + %strong disabled. + This means that your content is no longer visible at #{link_to @domain.url, @domain.url} +%p + Project: #{link_to @project.human_name, project_url(@project)} +%p + Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)} +%p + If this domain has been disabled in error, please follow + = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + to verify and re-enable your domain. +%p + If you no longer wish to use this domain with GitLab Pages, please remove it + from your GitLab project and delete any related DNS records. diff --git a/app/views/notify/pages_domain_disabled_email.text.haml b/app/views/notify/pages_domain_disabled_email.text.haml new file mode 100644 index 00000000000..4e81b054b1f --- /dev/null +++ b/app/views/notify/pages_domain_disabled_email.text.haml @@ -0,0 +1,13 @@ +Following a verification check, your GitLab Pages custom domain has been +**disabled**. This means that your content is no longer visible at #{@domain.url} + +Project: #{@project.human_name} (#{project_url(@project)}) +Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)}) + +If this domain has been disabled in error, please follow these instructions +to verify and re-enable your domain: + += help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + +If you no longer wish to use this domain with GitLab Pages, please remove it +from your GitLab project and delete any related DNS records. diff --git a/app/views/notify/pages_domain_enabled_email.html.haml b/app/views/notify/pages_domain_enabled_email.html.haml new file mode 100644 index 00000000000..db09e503f65 --- /dev/null +++ b/app/views/notify/pages_domain_enabled_email.html.haml @@ -0,0 +1,11 @@ +%p + Following a verification check, your GitLab Pages custom domain has been + enabled. You should now be able to view your content at #{link_to @domain.url, @domain.url} +%p + Project: #{link_to @project.human_name, project_url(@project)} +%p + Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)} +%p + Please visit + = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + for more information about custom domain verification. diff --git a/app/views/notify/pages_domain_enabled_email.text.haml b/app/views/notify/pages_domain_enabled_email.text.haml new file mode 100644 index 00000000000..1ed1dbb8315 --- /dev/null +++ b/app/views/notify/pages_domain_enabled_email.text.haml @@ -0,0 +1,9 @@ +Following a verification check, your GitLab Pages custom domain has been +enabled. You should now be able to view your content at #{@domain.url} + +Project: #{@project.human_name} (#{project_url(@project)}) +Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)}) + +Please visit += help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') +for more information about custom domain verification. diff --git a/app/views/notify/pages_domain_verification_failed_email.html.haml b/app/views/notify/pages_domain_verification_failed_email.html.haml new file mode 100644 index 00000000000..0bb0eb09fd5 --- /dev/null +++ b/app/views/notify/pages_domain_verification_failed_email.html.haml @@ -0,0 +1,17 @@ +%p + Verification has failed for one of your GitLab Pages custom domains! +%p + Project: #{link_to @project.human_name, project_url(@project)} +%p + Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)} +%p + Unless you take action, it will be disabled on + %strong= @domain.enabled_until.strftime('%F %T.') + Until then, you can view your content at #{link_to @domain.url, @domain.url} +%p + Please visit + = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + for more information about custom domain verification. +%p + If you no longer wish to use this domain with GitLab Pages, please remove it + from your GitLab project and delete any related DNS records. diff --git a/app/views/notify/pages_domain_verification_failed_email.text.haml b/app/views/notify/pages_domain_verification_failed_email.text.haml new file mode 100644 index 00000000000..c14e0e0c24d --- /dev/null +++ b/app/views/notify/pages_domain_verification_failed_email.text.haml @@ -0,0 +1,14 @@ +Verification has failed for one of your GitLab Pages custom domains! + +Project: #{@project.human_name} (#{project_url(@project)}) +Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)}) + +Unless you take action, it will be disabled on *#{@domain.enabled_until.strftime('%F %T')}*. +Until then, you can view your content at #{@domain.url} + +Please visit += help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') +for more information about custom domain verification. + +If you no longer wish to use this domain with GitLab Pages, please remove it +from your GitLab project and delete any related DNS records. diff --git a/app/views/notify/pages_domain_verification_succeeded_email.html.haml b/app/views/notify/pages_domain_verification_succeeded_email.html.haml new file mode 100644 index 00000000000..2ead3187b10 --- /dev/null +++ b/app/views/notify/pages_domain_verification_succeeded_email.html.haml @@ -0,0 +1,13 @@ +%p + One of your GitLab Pages custom domains has been successfully verified! +%p + Project: #{link_to @project.human_name, project_url(@project)} +%p + Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)} +%p + This is a notification. No action is required on your part. You can view your + content at #{link_to @domain.url, @domain.url} +%p + Please visit + = link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + for more information about custom domain verification. diff --git a/app/views/notify/pages_domain_verification_succeeded_email.text.haml b/app/views/notify/pages_domain_verification_succeeded_email.text.haml new file mode 100644 index 00000000000..e7cdbdee420 --- /dev/null +++ b/app/views/notify/pages_domain_verification_succeeded_email.text.haml @@ -0,0 +1,10 @@ +One of your GitLab Pages custom domains has been successfully verified! + +Project: #{@project.human_name} (#{project_url(@project)}) +Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)}) + +No action is required on your part. You can view your content at #{@domain.url} + +Please visit += help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') +for more information about custom domain verification. diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index a85cda407af..75df92b05a7 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -3,15 +3,26 @@ .panel-heading Domains (#{@domains.count}) %ul.well-list + - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? - @domains.each do |domain| %li .pull-right = link_to 'Details', project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped" = link_to 'Remove', project_pages_domain_path(@project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" .clearfix - %span= link_to domain.domain, domain.url + - if verification_enabled + - tooltip, status = domain.unverified? ? ['Unverified', 'failed'] : ['Verified', 'success'] + = link_to domain.url, title: tooltip, class: 'has-tooltip' do + = sprite_icon("status_#{status}", size: 16, css_class: "has-tooltip ci-status-icon ci-status-icon-#{status}") + = domain.domain + - else + = link_to domain.domain, domain.url %p - if domain.subject %span.label.label-gray Certificate: #{domain.subject} - if domain.expired? %span.label.label-danger Expired + - if verification_enabled && domain.unverified? + %li.warning-row + #{domain.domain} is not verified. To learn how to verify ownership, visit your + = link_to 'domain details', project_pages_domain_path(@project, domain) diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index 876cac0dacb..72e9203bdb0 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -1,4 +1,10 @@ - page_title "#{@domain.domain}", 'Pages Domains' +- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? +- if verification_enabled && @domain.unverified? + %p.alert.alert-warning + %strong + This domain is not verified. You will need to verify ownership before + access is enabled. %h3.page-title Pages Domain @@ -15,9 +21,26 @@ DNS %td %p - To access the domain create a new DNS record: + To access this domain create a new DNS record: %pre #{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}. + - if verification_enabled + %tr + %td + Verification status + %td + %p + - help_link = help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + To #{link_to 'verify ownership', help_link} of your domain, create + this DNS record: + %pre + #{@domain.verification_domain} TXT #{@domain.keyed_verification_code} + %p + - if @domain.verified? + #{@domain.domain} has been successfully verified. + - else + = button_to 'Verify ownership', verify_project_pages_domain_path(@project, @domain), class: 'btn btn-save btn-sm' + %tr %td Certificate diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index f2c20114534..28a5e5da037 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -3,6 +3,7 @@ - cronjob:expire_build_artifacts - cronjob:gitlab_usage_ping - cronjob:import_export_project_cleanup +- cronjob:pages_domain_verification_cron - cronjob:pipeline_schedule - cronjob:prune_old_events - cronjob:remove_expired_group_links @@ -82,6 +83,7 @@ - new_merge_request - new_note - pages +- pages_domain_verification - post_receive - process_commit - project_cache diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb new file mode 100644 index 00000000000..a3ff4bd2101 --- /dev/null +++ b/app/workers/pages_domain_verification_cron_worker.rb @@ -0,0 +1,10 @@ +class PagesDomainVerificationCronWorker + include ApplicationWorker + include CronjobQueue + + def perform + PagesDomain.needs_verification.find_each do |domain| + PagesDomainVerificationWorker.perform_async(domain.id) + end + end +end diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb new file mode 100644 index 00000000000..2e93489113c --- /dev/null +++ b/app/workers/pages_domain_verification_worker.rb @@ -0,0 +1,11 @@ +class PagesDomainVerificationWorker + include ApplicationWorker + + def perform(domain_id) + domain = PagesDomain.find_by(id: domain_id) + + return unless domain + + VerifyPagesDomainService.new(domain).execute + end +end diff --git a/changelogs/unreleased/29497-pages-custom-domain-dns-verification.yml b/changelogs/unreleased/29497-pages-custom-domain-dns-verification.yml new file mode 100644 index 00000000000..f958f3f1272 --- /dev/null +++ b/changelogs/unreleased/29497-pages-custom-domain-dns-verification.yml @@ -0,0 +1,5 @@ +--- +title: Add verification for GitLab Pages custom domains +merge_request: +author: +type: security diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index bbc2bcfb0cc..bd696a7f2c5 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -214,6 +214,10 @@ production: &base repository_archive_cache_worker: cron: "0 * * * *" + # Verify custom GitLab Pages domains + pages_domain_verification_cron_worker: + cron: "*/15 * * * *" + registry: # enabled: true # host: registry.example.com diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 17a8801f7bc..ea0dee7af53 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -427,6 +427,10 @@ Settings.cron_jobs['stuck_merge_jobs_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_merge_jobs_worker']['cron'] ||= '0 */2 * * *' Settings.cron_jobs['stuck_merge_jobs_worker']['job_class'] = 'StuckMergeJobsWorker' +Settings.cron_jobs['pages_domain_verification_cron_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['pages_domain_verification_cron_worker']['cron'] ||= '*/15 * * * *' +Settings.cron_jobs['pages_domain_verification_cron_worker']['job_class'] = 'PagesDomainVerificationCronWorker' + # # GitLab Shell # diff --git a/config/routes/project.rb b/config/routes/project.rb index 1912808f9c0..37f2d490030 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -55,7 +55,11 @@ constraints(ProjectUrlConstrainer.new) do end resource :pages, only: [:show, :destroy] do - resources :domains, only: [:show, :new, :create, :destroy], controller: 'pages_domains', constraints: { id: %r{[^/]+} } + resources :domains, only: [:show, :new, :create, :destroy], controller: 'pages_domains', constraints: { id: %r{[^/]+} } do + member do + post :verify + end + end end resources :snippets, concerns: :awardable, constraints: { id: /\d+/ } do diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 31a38f2b508..f037e3d1221 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -67,3 +67,4 @@ - [gcp_cluster, 1] - [project_migrate_hashed_storage, 1] - [storage_migrator, 1] + - [pages_domain_verification, 1] diff --git a/db/migrate/20180216120000_add_pages_domain_verification.rb b/db/migrate/20180216120000_add_pages_domain_verification.rb new file mode 100644 index 00000000000..8b7cae92285 --- /dev/null +++ b/db/migrate/20180216120000_add_pages_domain_verification.rb @@ -0,0 +1,8 @@ +class AddPagesDomainVerification < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :pages_domains, :verified_at, :datetime_with_timezone + add_column :pages_domains, :verification_code, :string + end +end diff --git a/db/migrate/20180216120010_add_pages_domain_verified_at_index.rb b/db/migrate/20180216120010_add_pages_domain_verified_at_index.rb new file mode 100644 index 00000000000..825dfb52dce --- /dev/null +++ b/db/migrate/20180216120010_add_pages_domain_verified_at_index.rb @@ -0,0 +1,15 @@ +class AddPagesDomainVerifiedAtIndex < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :pages_domains, :verified_at + end + + def down + remove_concurrent_index :pages_domains, :verified_at + end +end diff --git a/db/migrate/20180216120020_allow_domain_verification_to_be_disabled.rb b/db/migrate/20180216120020_allow_domain_verification_to_be_disabled.rb new file mode 100644 index 00000000000..06d458028b3 --- /dev/null +++ b/db/migrate/20180216120020_allow_domain_verification_to_be_disabled.rb @@ -0,0 +1,7 @@ +class AllowDomainVerificationToBeDisabled < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :application_settings, :pages_domain_verification_enabled, :boolean, default: true, null: false + end +end diff --git a/db/migrate/20180216120030_add_pages_domain_enabled_until.rb b/db/migrate/20180216120030_add_pages_domain_enabled_until.rb new file mode 100644 index 00000000000..b40653044dd --- /dev/null +++ b/db/migrate/20180216120030_add_pages_domain_enabled_until.rb @@ -0,0 +1,7 @@ +class AddPagesDomainEnabledUntil < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :pages_domains, :enabled_until, :datetime_with_timezone + end +end diff --git a/db/migrate/20180216120040_add_pages_domain_enabled_until_index.rb b/db/migrate/20180216120040_add_pages_domain_enabled_until_index.rb new file mode 100644 index 00000000000..00f6e4979da --- /dev/null +++ b/db/migrate/20180216120040_add_pages_domain_enabled_until_index.rb @@ -0,0 +1,17 @@ +class AddPagesDomainEnabledUntilIndex < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :pages_domains, [:project_id, :enabled_until] + add_concurrent_index :pages_domains, [:verified_at, :enabled_until] + end + + def down + remove_concurrent_index :pages_domains, [:verified_at, :enabled_until] + remove_concurrent_index :pages_domains, [:project_id, :enabled_until] + end +end diff --git a/db/migrate/20180216120050_pages_domains_verification_grace_period.rb b/db/migrate/20180216120050_pages_domains_verification_grace_period.rb new file mode 100644 index 00000000000..d7f8634b536 --- /dev/null +++ b/db/migrate/20180216120050_pages_domains_verification_grace_period.rb @@ -0,0 +1,26 @@ +class PagesDomainsVerificationGracePeriod < ActiveRecord::Migration + DOWNTIME = false + + class PagesDomain < ActiveRecord::Base + include EachBatch + end + + # Allow this migration to resume if it fails partway through + disable_ddl_transaction! + + def up + now = Time.now + grace = now + 30.days + + PagesDomain.each_batch do |relation| + relation.update_all(verified_at: now, enabled_until: grace) + + # Sleep 2 minutes between batches to not overload the DB with dead tuples + sleep(2.minutes) unless relation.reorder(:id).last == PagesDomain.reorder(:id).last + end + end + + def down + # no-op + end +end diff --git a/db/post_migrate/20180216121020_fill_pages_domain_verification_code.rb b/db/post_migrate/20180216121020_fill_pages_domain_verification_code.rb new file mode 100644 index 00000000000..d423673d2a5 --- /dev/null +++ b/db/post_migrate/20180216121020_fill_pages_domain_verification_code.rb @@ -0,0 +1,41 @@ +class FillPagesDomainVerificationCode < ActiveRecord::Migration + DOWNTIME = false + + class PagesDomain < ActiveRecord::Base + include EachBatch + end + + # Allow this migration to resume if it fails partway through + disable_ddl_transaction! + + def up + PagesDomain.where(verification_code: [nil, '']).each_batch do |relation| + connection.execute(set_codes_sql(relation)) + + # Sleep 2 minutes between batches to not overload the DB with dead tuples + sleep(2.minutes) unless relation.reorder(:id).last == PagesDomain.reorder(:id).last + end + + change_column_null(:pages_domains, :verification_code, false) + end + + def down + change_column_null(:pages_domains, :verification_code, true) + end + + private + + def set_codes_sql(relation) + ids = relation.pluck(:id) + whens = ids.map { |id| "WHEN #{id} THEN '#{SecureRandom.hex(16)}'" } + + <<~SQL + UPDATE pages_domains + SET verification_code = + CASE id + #{whens.join("\n")} + END + WHERE id IN(#{ids.join(',')}) + SQL + end +end diff --git a/db/post_migrate/20180216121030_enqueue_verify_pages_domain_workers.rb b/db/post_migrate/20180216121030_enqueue_verify_pages_domain_workers.rb new file mode 100644 index 00000000000..bf9bf4e660f --- /dev/null +++ b/db/post_migrate/20180216121030_enqueue_verify_pages_domain_workers.rb @@ -0,0 +1,16 @@ +class EnqueueVerifyPagesDomainWorkers < ActiveRecord::Migration + class PagesDomain < ActiveRecord::Base + include EachBatch + end + + def up + PagesDomain.each_batch do |relation| + ids = relation.pluck(:id).map { |id| [id] } + PagesDomainVerificationWorker.bulk_perform_async(ids) + end + end + + def down + # no-op + end +end diff --git a/db/schema.rb b/db/schema.rb index 409d1ac7644..5bb461169f1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180213131630) do +ActiveRecord::Schema.define(version: 20180216121030) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -156,6 +156,7 @@ ActiveRecord::Schema.define(version: 20180213131630) do t.integer "gitaly_timeout_fast", default: 10, null: false t.boolean "authorized_keys_enabled", default: true, null: false t.string "auto_devops_domain" + t.boolean "pages_domain_verification_enabled", default: true, null: false end create_table "audit_events", force: :cascade do |t| @@ -1313,10 +1314,16 @@ ActiveRecord::Schema.define(version: 20180213131630) do t.string "encrypted_key_iv" t.string "encrypted_key_salt" t.string "domain" + t.datetime_with_timezone "verified_at" + t.string "verification_code", null: false + t.datetime_with_timezone "enabled_until" end add_index "pages_domains", ["domain"], name: "index_pages_domains_on_domain", unique: true, using: :btree + add_index "pages_domains", ["project_id", "enabled_until"], name: "index_pages_domains_on_project_id_and_enabled_until", using: :btree add_index "pages_domains", ["project_id"], name: "index_pages_domains_on_project_id", using: :btree + add_index "pages_domains", ["verified_at", "enabled_until"], name: "index_pages_domains_on_verified_at_and_enabled_until", using: :btree + add_index "pages_domains", ["verified_at"], name: "index_pages_domains_on_verified_at", using: :btree create_table "personal_access_tokens", force: :cascade do |t| t.integer "user_id", null: false diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index edb3e4c961e..00c631fdaae 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -226,6 +226,18 @@ world. Custom domains and TLS are supported. 1. [Reconfigure GitLab][reconfigure] +### Custom domain verification + +To prevent malicious users from hijacking domains that don't belong to them, +GitLab supports [custom domain verification](../../user/project/pages/getting_started_part_three.md#dns-txt-record). +When adding a custom domain, users will be required to prove they own it by +adding a GitLab-controlled verification code to the DNS records for that domain. + +If your userbase is private or otherwise trusted, you can disable the +verification requirement. Navigate to `Admin area ➔ Settings` and uncheck +**Require users to prove ownership of custom domains** in the Pages section. +This setting is enabled by default. + ## Change storage path Follow the steps below to change the default path where GitLab Pages' contents diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md index b6cf68a02a2..430fe3af1f8 100644 --- a/doc/user/project/pages/getting_started_part_three.md +++ b/doc/user/project/pages/getting_started_part_three.md @@ -62,7 +62,7 @@ for the most popular hosting services: - [Microsoft](https://msdn.microsoft.com/en-us/library/bb727018.aspx) If your hosting service is not listed above, you can just try to -search the web for "how to add dns record on ". +search the web for `how to add dns record on `. ### DNS A record @@ -95,12 +95,32 @@ without any `/project-name`. ![DNS CNAME record pointing to GitLab.com project](img/dns_cname_record_example.png) -### TL;DR +#### DNS TXT record + +Unless your GitLab administrator has [disabled custom domain verification](../../../administration/pages/index.md#custom-domain-verification), +you'll have to prove that you own the domain by creating a `TXT` record +containing a verification code. The code will be displayed after you +[add your custom domain to GitLab Pages settings](#add-your-custom-domain-to-gitlab-pages-settings). + +If using a [DNS A record](#dns-a-record), you can place the TXT record directly +under the domain. If using a [DNS CNAME record](#dns-cname-record), the two record types won't +co-exist, so you need to place the TXT record in a special subdomain of its own. + +#### TL;DR + +If the domain has multiple uses (e.g., you host email on it as well): | From | DNS Record | To | | ---- | ---------- | -- | | domain.com | A | 52.167.214.135 | -| subdomain.domain.com | CNAME | namespace.gitlab.io | +| domain.com | TXT | gitlab-pages-verification-code=00112233445566778899aabbccddeeff | + +If the domain is dedicated to GitLab Pages use and no other services run on it: + +| From | DNS Record | To | +| ---- | ---------- | -- | +| subdomain.domain.com | CNAME | gitlab.io | +| _gitlab-pages-verification-code.subdomain.domain.com | TXT | gitlab-pages-verification-code=00112233445566778899aabbccddeeff | > **Notes**: > @@ -121,6 +141,17 @@ your site will be accessible only via HTTP: ![Add new domain](img/add_certificate_to_pages.png) +Once you have added a new domain, you will need to **verify your ownership** +(unless the GitLab administrator has disabled this feature). A verification code +will be shown to you; add it as a [DNS TXT record](#dns-txt-record), then press +the "Verify ownership" button to activate your new domain: + +![Verify your domain](img/verify_your_domain.png) + +Once your domain has been verified, leave the verification record in place - +your domain will be periodically reverified, and may be disabled if the record +is removed. + You can add more than one alias (custom domains and subdomains) to the same project. An alias can be understood as having many doors leading to the same room. @@ -128,8 +159,8 @@ All the aliases you've set to your site will be listed on **Setting > Pages**. From that page, you can view, add, and remove them. Note that [DNS propagation may take some time (up to 24h)](http://www.inmotionhosting.com/support/domain-names/dns-nameserver-changes/domain-names-dns-changes), -although it's usually a matter of minutes to complete. Until it does, visit attempts -to your domain will respond with a 404. +although it's usually a matter of minutes to complete. Until it does, verification +will fail and attempts to visit your domain will respond with a 404. Read through the [general documentation on GitLab Pages](introduction.md#add-a-custom-domain-to-your-pages-website) to learn more about adding custom domains to GitLab Pages sites. diff --git a/doc/user/project/pages/img/verify_your_domain.png b/doc/user/project/pages/img/verify_your_domain.png new file mode 100644 index 0000000000000000000000000000000000000000..89c69cac9a5e1e7a55bac6733939043e1d04f282 GIT binary patch literal 30163 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

3EP+0Nj;`duf0v7| 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 z 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 literal 0 HcmV?d00001 diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 45c737c6c29..167878ba600 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1154,6 +1154,10 @@ module API expose :domain expose :url expose :project_id + expose :verified?, as: :verified + expose :verification_code, as: :verification_code + expose :enabled_until + expose :certificate, as: :certificate_expiration, if: ->(pages_domain, _) { pages_domain.certificate? }, @@ -1165,6 +1169,10 @@ module API class PagesDomain < Grape::Entity expose :domain expose :url + expose :verified?, as: :verified + expose :verification_code, as: :verification_code + expose :enabled_until + expose :certificate, if: ->(pages_domain, _) { pages_domain.certificate? }, using: PagesDomainCertificate do |pages_domain| diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb index e9e7d357d9c..2192fd5cae2 100644 --- a/spec/controllers/projects/pages_domains_controller_spec.rb +++ b/spec/controllers/projects/pages_domains_controller_spec.rb @@ -46,7 +46,46 @@ describe Projects::PagesDomainsController do post(:create, request_params.merge(pages_domain: pages_domain_params)) end.to change { PagesDomain.count }.by(1) - expect(response).to redirect_to(project_pages_path(project)) + created_domain = PagesDomain.reorder(:id).last + + expect(created_domain).to be_present + expect(response).to redirect_to(project_pages_domain_path(project, created_domain)) + end + end + + describe 'POST verify' do + let(:params) { request_params.merge(id: pages_domain.domain) } + + def stub_service + service = double(:service) + + expect(VerifyPagesDomainService).to receive(:new) { service } + + service + end + + it 'handles verification success' do + expect(stub_service).to receive(:execute).and_return(status: :success) + + post :verify, params + + expect(response).to redirect_to project_pages_domain_path(project, pages_domain) + expect(flash[:notice]).to eq('Successfully verified domain ownership') + end + + it 'handles verification failure' do + expect(stub_service).to receive(:execute).and_return(status: :failed) + + post :verify, params + + expect(response).to redirect_to project_pages_domain_path(project, pages_domain) + expect(flash[:alert]).to eq('Failed to verify domain ownership') + end + + it 'returns a 404 response for an unknown domain' do + post :verify, request_params.merge(id: 'unknown-domain') + + expect(response).to have_gitlab_http_status(404) end end diff --git a/spec/factories/pages_domains.rb b/spec/factories/pages_domains.rb index 61b04708da2..35b44e1c52e 100644 --- a/spec/factories/pages_domains.rb +++ b/spec/factories/pages_domains.rb @@ -1,6 +1,25 @@ FactoryBot.define do factory :pages_domain, class: 'PagesDomain' do - domain 'my.domain.com' + sequence(:domain) { |n| "my#{n}.domain.com" } + verified_at { Time.now } + enabled_until { 1.week.from_now } + + trait :disabled do + verified_at nil + enabled_until nil + end + + trait :unverified do + verified_at nil + end + + trait :reverify do + enabled_until { 1.hour.from_now } + end + + trait :expired do + enabled_until { 1.hour.ago } + end trait :with_certificate do certificate '-----BEGIN CERTIFICATE----- diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index 3f1ef0b2a47..a96f2c186a4 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -60,7 +60,6 @@ feature 'Pages' do fill_in 'Domain', with: 'my.test.domain.com' click_button 'Create New Domain' - expect(page).to have_content('Domains (1)') expect(page).to have_content('my.test.domain.com') end end @@ -159,7 +158,6 @@ feature 'Pages' do fill_in 'Key (PEM)', with: certificate_key click_button 'Create New Domain' - expect(page).to have_content('Domains (1)') expect(page).to have_content('my.test.domain.com') end end diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json index e8c17298b43..ed8ed9085c0 100644 --- a/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json +++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/basic.json @@ -4,6 +4,9 @@ "domain": { "type": "string" }, "url": { "type": "uri" }, "project_id": { "type": "integer" }, + "verified": { "type": "boolean" }, + "verification_code": { "type": ["string", "null"] }, + "enabled_until": { "type": ["date", "null"] }, "certificate_expiration": { "type": "object", "properties": { @@ -14,6 +17,6 @@ "additionalProperties": false } }, - "required": ["domain", "url", "project_id"], + "required": ["domain", "url", "project_id", "verified", "verification_code", "enabled_until"], "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json index 08db8d47050..b57d544f896 100644 --- a/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json +++ b/spec/fixtures/api/schemas/public_api/v4/pages_domain/detail.json @@ -3,6 +3,9 @@ "properties": { "domain": { "type": "string" }, "url": { "type": "uri" }, + "verified": { "type": "boolean" }, + "verification_code": { "type": ["string", "null"] }, + "enabled_until": { "type": ["date", "null"] }, "certificate": { "type": "object", "properties": { @@ -15,6 +18,6 @@ "additionalProperties": false } }, - "required": ["domain", "url"], + "required": ["domain", "url", "verified", "verification_code", "enabled_until"], "additionalProperties": false } diff --git a/spec/mailers/emails/pages_domains_spec.rb b/spec/mailers/emails/pages_domains_spec.rb new file mode 100644 index 00000000000..fe428ea657d --- /dev/null +++ b/spec/mailers/emails/pages_domains_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' +require 'email_spec' + +describe Emails::PagesDomains do + include EmailSpec::Matchers + include_context 'gitlab email notification' + + set(:project) { create(:project) } + set(:domain) { create(:pages_domain, project: project) } + set(:user) { project.owner } + + shared_examples 'a pages domain email' do + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like 'a user cannot unsubscribe through footer link' + + it 'has the expected content' do + aggregate_failures do + is_expected.to have_subject(email_subject) + is_expected.to have_body_text(project.human_name) + is_expected.to have_body_text(domain.domain) + is_expected.to have_body_text domain.url + is_expected.to have_body_text project_pages_domain_url(project, domain) + is_expected.to have_body_text help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + end + end + end + + describe '#pages_domain_enabled_email' do + let(:email_subject) { "#{project.path} | GitLab Pages domain '#{domain.domain}' has been enabled" } + + subject { Notify.pages_domain_enabled_email(domain, user) } + + it_behaves_like 'a pages domain email' + + it { is_expected.to have_body_text 'has been enabled' } + end + + describe '#pages_domain_disabled_email' do + let(:email_subject) { "#{project.path} | GitLab Pages domain '#{domain.domain}' has been disabled" } + + subject { Notify.pages_domain_disabled_email(domain, user) } + + it_behaves_like 'a pages domain email' + + it { is_expected.to have_body_text 'has been disabled' } + end + + describe '#pages_domain_verification_succeeded_email' do + let(:email_subject) { "#{project.path} | Verification succeeded for GitLab Pages domain '#{domain.domain}'" } + + subject { Notify.pages_domain_verification_succeeded_email(domain, user) } + + it_behaves_like 'a pages domain email' + + it { is_expected.to have_body_text 'successfully verified' } + end + + describe '#pages_domain_verification_failed_email' do + let(:email_subject) { "#{project.path} | ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'" } + + subject { Notify.pages_domain_verification_failed_email(domain, user) } + + it_behaves_like 'a pages domain email' + + it 'says verification has failed and when the domain is enabled until' do + is_expected.to have_body_text 'Verification has failed' + is_expected.to have_body_text domain.enabled_until.strftime('%F %T') + end + end +end diff --git a/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb b/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb new file mode 100644 index 00000000000..afcaefa0591 --- /dev/null +++ b/spec/migrations/enqueue_verify_pages_domain_workers_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20180216121030_enqueue_verify_pages_domain_workers') + +describe EnqueueVerifyPagesDomainWorkers, :sidekiq, :migration do + around do |example| + Sidekiq::Testing.fake! do + example.run + end + end + + describe '#up' do + it 'enqueues a verification worker for every domain' do + domains = 1.upto(3).map { |i| PagesDomain.create!(domain: "my#{i}.domain.com") } + + expect { migrate! }.to change(PagesDomainVerificationWorker.jobs, :size).by(3) + + enqueued_ids = PagesDomainVerificationWorker.jobs.map { |job| job['args'] } + expected_ids = domains.map { |domain| [domain.id] } + + expect(enqueued_ids).to match_array(expected_ids) + end + end +end diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index 9d12f96c642..95713d8b85b 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -1,6 +1,10 @@ require 'spec_helper' describe PagesDomain do + using RSpec::Parameterized::TableSyntax + + subject(:pages_domain) { described_class.new } + describe 'associations' do it { is_expected.to belong_to(:project) } end @@ -64,19 +68,51 @@ describe PagesDomain do end end + describe 'validations' do + it { is_expected.to validate_presence_of(:verification_code) } + end + + describe '#verification_code' do + subject { pages_domain.verification_code } + + it 'is set automatically with 128 bits of SecureRandom data' do + expect(SecureRandom).to receive(:hex).with(16) { 'verification code' } + + is_expected.to eq('verification code') + end + end + + describe '#keyed_verification_code' do + subject { pages_domain.keyed_verification_code } + + it { is_expected.to eq("gitlab-pages-verification-code=#{pages_domain.verification_code}") } + end + + describe '#verification_domain' do + subject { pages_domain.verification_domain } + + it { is_expected.to be_nil } + + it 'is a well-known subdomain if the domain is present' do + pages_domain.domain = 'example.com' + + is_expected.to eq('_gitlab-pages-verification-code.example.com') + end + end + describe '#url' do subject { domain.url } context 'without the certificate' do let(:domain) { build(:pages_domain, certificate: '') } - it { is_expected.to eq('http://my.domain.com') } + it { is_expected.to eq("http://#{domain.domain}") } end context 'with a certificate' do let(:domain) { build(:pages_domain, :with_certificate) } - it { is_expected.to eq('https://my.domain.com') } + it { is_expected.to eq("https://#{domain.domain}") } end end @@ -154,4 +190,108 @@ describe PagesDomain do # We test only existence of output, since the output is long it { is_expected.not_to be_empty } end + + describe '#update_daemon' do + it 'runs when the domain is created' do + domain = build(:pages_domain) + + expect(domain).to receive(:update_daemon) + + domain.save! + end + + it 'runs when the domain is destroyed' do + domain = create(:pages_domain) + + expect(domain).to receive(:update_daemon) + + domain.destroy! + end + + it 'delegates to Projects::UpdatePagesConfigurationService' do + service = instance_double('Projects::UpdatePagesConfigurationService') + expect(Projects::UpdatePagesConfigurationService).to receive(:new) { service } + expect(service).to receive(:execute) + + create(:pages_domain) + end + + context 'configuration updates when attributes change' do + set(:project1) { create(:project) } + set(:project2) { create(:project) } + set(:domain) { create(:pages_domain) } + + where(:attribute, :old_value, :new_value, :update_expected) do + now = Time.now + future = now + 1.day + + :project | nil | :project1 | true + :project | :project1 | :project1 | false + :project | :project1 | :project2 | true + :project | :project1 | nil | true + + # domain can't be set to nil + :domain | 'a.com' | 'a.com' | false + :domain | 'a.com' | 'b.com' | true + + # verification_code can't be set to nil + :verification_code | 'foo' | 'foo' | false + :verification_code | 'foo' | 'bar' | false + + :verified_at | nil | now | false + :verified_at | now | now | false + :verified_at | now | future | false + :verified_at | now | nil | false + + :enabled_until | nil | now | true + :enabled_until | now | now | false + :enabled_until | now | future | false + :enabled_until | now | nil | true + end + + with_them do + it 'runs if a relevant attribute has changed' do + a = old_value.is_a?(Symbol) ? send(old_value) : old_value + b = new_value.is_a?(Symbol) ? send(new_value) : new_value + + domain.update!(attribute => a) + + if update_expected + expect(domain).to receive(:update_daemon) + else + expect(domain).not_to receive(:update_daemon) + end + + domain.update!(attribute => b) + end + end + + context 'TLS configuration' do + set(:domain_with_tls) { create(:pages_domain, :with_key, :with_certificate) } + + let(:cert1) { domain_with_tls.certificate } + let(:cert2) { cert1 + ' ' } + let(:key1) { domain_with_tls.key } + let(:key2) { key1 + ' ' } + + it 'updates when added' do + expect(domain).to receive(:update_daemon) + + domain.update!(key: key1, certificate: cert1) + end + + it 'updates when changed' do + expect(domain_with_tls).to receive(:update_daemon) + + domain_with_tls.update!(key: key2, certificate: cert2) + end + + it 'updates when removed' do + expect(domain_with_tls).to receive(:update_daemon) + + domain_with_tls.update!(key: nil, certificate: nil) + end + end + end + end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 836ffb7cea0..62fdf870090 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -1678,6 +1678,78 @@ describe NotificationService, :mailer do end end + describe 'Pages domains' do + set(:project) { create(:project) } + set(:domain) { create(:pages_domain, project: project) } + set(:u_blocked) { create(:user, :blocked) } + set(:u_silence) { create_user_with_notification(:disabled, 'silent', project) } + set(:u_owner) { project.owner } + set(:u_master1) { create(:user) } + set(:u_master2) { create(:user) } + set(:u_developer) { create(:user) } + + before do + project.add_master(u_blocked) + project.add_master(u_silence) + project.add_master(u_master1) + project.add_master(u_master2) + project.add_developer(u_developer) + + reset_delivered_emails! + end + + %i[ + pages_domain_enabled + pages_domain_disabled + pages_domain_verification_succeeded + pages_domain_verification_failed + ].each do |sym| + describe "##{sym}" do + subject(:notify!) { notification.send(sym, domain) } + + it 'emails current watching masters' do + expect(Notify).to receive(:"#{sym}_email").at_least(:once).and_call_original + + notify! + + should_only_email(u_master1, u_master2, u_owner) + end + + it 'emails nobody if the project is missing' do + domain.project = nil + + notify! + + should_not_email_anyone + end + end + end + + describe '#pages_domain_verification_failed' do + it 'emails current watching masters' do + notification.pages_domain_verification_failed(domain) + + should_only_email(u_master1, u_master2, u_owner) + end + end + + describe '#pages_domain_enabled' do + it 'emails current watching masters' do + notification.pages_domain_enabled(domain) + + should_only_email(u_master1, u_master2, u_owner) + end + end + + describe '#pages_domain_disabled' do + it 'emails current watching masters' do + notification.pages_domain_disabled(domain) + + should_only_email(u_master1, u_master2, u_owner) + end + end + end + def build_team(project) @u_watcher = create_global_setting_for(create(:user), :watch) @u_participating = create_global_setting_for(create(:user), :participating) diff --git a/spec/services/verify_pages_domain_service_spec.rb b/spec/services/verify_pages_domain_service_spec.rb new file mode 100644 index 00000000000..576db1dde2d --- /dev/null +++ b/spec/services/verify_pages_domain_service_spec.rb @@ -0,0 +1,270 @@ +require 'spec_helper' + +describe VerifyPagesDomainService do + using RSpec::Parameterized::TableSyntax + include EmailHelpers + + let(:error_status) { { status: :error, message: "Couldn't verify #{domain.domain}" } } + + subject(:service) { described_class.new(domain) } + + describe '#execute' do + context 'verification code recognition (verified domain)' do + where(:domain_sym, :code_sym) do + :domain | :verification_code + :domain | :keyed_verification_code + + :verification_domain | :verification_code + :verification_domain | :keyed_verification_code + end + + with_them do + set(:domain) { create(:pages_domain) } + + let(:domain_name) { domain.send(domain_sym) } + let(:verification_code) { domain.send(code_sym) } + + it 'verifies and enables the domain' do + stub_resolver(domain_name => ['something else', verification_code]) + + expect(service.execute).to eq(status: :success) + expect(domain).to be_verified + expect(domain).to be_enabled + end + + it 'verifies and enables when the code is contained partway through a TXT record' do + stub_resolver(domain_name => "something #{verification_code} else") + + expect(service.execute).to eq(status: :success) + expect(domain).to be_verified + expect(domain).to be_enabled + end + + it 'does not verify when the code is not present' do + stub_resolver(domain_name => 'something else') + + expect(service.execute).to eq(error_status) + + expect(domain).not_to be_verified + expect(domain).to be_enabled + end + end + + context 'verified domain' do + set(:domain) { create(:pages_domain) } + + it 'unverifies (but does not disable) when the right code is not present' do + stub_resolver(domain.domain => 'something else') + + expect(service.execute).to eq(error_status) + expect(domain).not_to be_verified + expect(domain).to be_enabled + end + + it 'unverifies (but does not disable) when no records are present' do + stub_resolver + + expect(service.execute).to eq(error_status) + expect(domain).not_to be_verified + expect(domain).to be_enabled + end + end + + context 'expired domain' do + set(:domain) { create(:pages_domain, :expired) } + + it 'verifies and enables when the right code is present' do + stub_resolver(domain.domain => domain.keyed_verification_code) + + expect(service.execute).to eq(status: :success) + + expect(domain).to be_verified + expect(domain).to be_enabled + end + + it 'disables when the right code is not present' do + error_status[:message] += '. It is now disabled.' + + stub_resolver + + expect(service.execute).to eq(error_status) + + expect(domain).not_to be_verified + expect(domain).not_to be_enabled + end + end + end + + context 'timeout behaviour' do + let(:domain) { create(:pages_domain) } + + it 'sets a timeout on the DNS query' do + expect(stub_resolver).to receive(:timeouts=).with(described_class::RESOLVER_TIMEOUT_SECONDS) + + service.execute + end + end + + context 'email notifications' do + let(:notification_service) { instance_double('NotificationService') } + + where(:factory, :verification_succeeds, :expected_notification) do + nil | true | nil + nil | false | :verification_failed + :reverify | true | nil + :reverify | false | :verification_failed + :unverified | true | :verification_succeeded + :unverified | false | nil + :expired | true | nil + :expired | false | :disabled + :disabled | true | :enabled + :disabled | false | nil + end + + with_them do + let(:domain) { create(:pages_domain, *[factory].compact) } + + before do + allow(service).to receive(:notification_service) { notification_service } + + if verification_succeeds + stub_resolver(domain.domain => domain.verification_code) + else + stub_resolver + end + end + + it 'sends a notification if appropriate' do + if expected_notification + expect(notification_service).to receive(:"pages_domain_#{expected_notification}").with(domain) + end + + service.execute + end + end + + context 'pages verification disabled' do + let(:domain) { create(:pages_domain, :disabled) } + + before do + stub_application_setting(pages_domain_verification_enabled: false) + allow(service).to receive(:notification_service) { notification_service } + end + + it 'skips email notifications' do + expect(notification_service).not_to receive(:pages_domain_enabled) + + service.execute + end + end + end + + context 'pages configuration updates' do + context 'enabling a disabled domain' do + let(:domain) { create(:pages_domain, :disabled) } + + it 'schedules an update' do + stub_resolver(domain.domain => domain.verification_code) + + expect(domain).to receive(:update_daemon) + + service.execute + end + end + + context 'verifying an enabled domain' do + let(:domain) { create(:pages_domain) } + + it 'schedules an update' do + stub_resolver(domain.domain => domain.verification_code) + + expect(domain).not_to receive(:update_daemon) + + service.execute + end + end + + context 'disabling an expired domain' do + let(:domain) { create(:pages_domain, :expired) } + + it 'schedules an update' do + stub_resolver + + expect(domain).to receive(:update_daemon) + + service.execute + end + end + + context 'failing to verify a disabled domain' do + let(:domain) { create(:pages_domain, :disabled) } + + it 'does not schedule an update' do + stub_resolver + + expect(domain).not_to receive(:update_daemon) + + service.execute + end + end + end + + context 'no verification code' do + let(:domain) { create(:pages_domain) } + + it 'returns an error' do + domain.verification_code = '' + + disallow_resolver! + + expect(service.execute).to eq(status: :error, message: "No verification code set for #{domain.domain}") + end + end + + context 'pages domain verification is disabled' do + let(:domain) { create(:pages_domain, :disabled) } + + before do + stub_application_setting(pages_domain_verification_enabled: false) + end + + it 'extends domain validity by unconditionally reverifying' do + disallow_resolver! + + service.execute + + expect(domain).to be_verified + expect(domain).to be_enabled + end + + it 'does not shorten any grace period' do + grace = Time.now + 1.year + domain.update!(enabled_until: grace) + disallow_resolver! + + service.execute + + expect(domain.enabled_until).to be_like_time(grace) + end + end + end + + def disallow_resolver! + expect(Resolv::DNS).not_to receive(:open) + end + + def stub_resolver(stubbed_lookups = {}) + resolver = instance_double('Resolv::DNS') + allow(resolver).to receive(:timeouts=) + + expect(Resolv::DNS).to receive(:open).and_yield(resolver) + + allow(resolver).to receive(:getresources) { [] } + stubbed_lookups.each do |domain, records| + records = Array(records).map { |txt| Resolv::DNS::Resource::IN::TXT.new(txt) } + allow(resolver).to receive(:getresources).with(domain, Resolv::DNS::Resource::IN::TXT) { records } + end + + resolver + end +end diff --git a/spec/workers/pages_domain_verification_cron_worker_spec.rb b/spec/workers/pages_domain_verification_cron_worker_spec.rb new file mode 100644 index 00000000000..8f780428c82 --- /dev/null +++ b/spec/workers/pages_domain_verification_cron_worker_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe PagesDomainVerificationCronWorker do + subject(:worker) { described_class.new } + + describe '#perform' do + it 'enqueues a PagesDomainVerificationWorker for domains needing verification' do + verified = create(:pages_domain) + reverify = create(:pages_domain, :reverify) + disabled = create(:pages_domain, :disabled) + + [reverify, disabled].each do |domain| + expect(PagesDomainVerificationWorker).to receive(:perform_async).with(domain.id) + end + + expect(PagesDomainVerificationWorker).not_to receive(:perform_async).with(verified.id) + + worker.perform + end + end +end diff --git a/spec/workers/pages_domain_verification_worker_spec.rb b/spec/workers/pages_domain_verification_worker_spec.rb new file mode 100644 index 00000000000..372fc95ab4a --- /dev/null +++ b/spec/workers/pages_domain_verification_worker_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe PagesDomainVerificationWorker do + subject(:worker) { described_class.new } + + let(:domain) { create(:pages_domain) } + + describe '#perform' do + it 'does nothing for a non-existent domain' do + domain.destroy + + expect(VerifyPagesDomainService).not_to receive(:new) + + expect { worker.perform(domain.id) }.not_to raise_error + end + + it 'delegates to VerifyPagesDomainService' do + service = double(:service) + expected_domain = satisfy { |obj| obj == domain } + + expect(VerifyPagesDomainService).to receive(:new).with(expected_domain) { service } + expect(service).to receive(:execute) + + worker.perform(domain.id) + end + end +end From d600a6ef7117a7c113cbdf5394e04d8938810b9b Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Mon, 19 Feb 2018 17:18:41 +0000 Subject: [PATCH 2/2] Log pages domain verification changes to application.log --- app/services/verify_pages_domain_service.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb index 40fc42f2690..86166047302 100644 --- a/app/services/verify_pages_domain_service.rb +++ b/app/services/verify_pages_domain_service.rb @@ -101,6 +101,7 @@ class VerifyPagesDomainService < BaseService def notify(type) return unless verification_enabled? + Gitlab::AppLogger.info("Pages domain '#{domain.domain}' changed state to '#{type}'") notification_service.public_send("pages_domain_#{type}", domain) # rubocop:disable GitlabSecurity/PublicSend end end