url_validator.rb 3.07 KB
Newer Older
1 2
# frozen_string_literal: true

Robert Speicher's avatar
Robert Speicher committed
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
# UrlValidator
#
# Custom validator for URLs.
#
# By default, only URLs for the HTTP(S) protocols will be considered valid.
# Provide a `:protocols` option to configure accepted protocols.
#
# Example:
#
#   class User < ActiveRecord::Base
#     validates :personal_url, url: true
#
#     validates :ftp_url, url: { protocols: %w(ftp) }
#
#     validates :git_url, url: { protocols: %w(http https ssh git) }
#   end
#
20 21 22
# This validator can also block urls pointing to localhost or the local network to
# protect against Server-side Request Forgery (SSRF), or check for the right port.
#
23 24 25 26 27 28
# The available options are:
# - protocols: Allowed protocols. Default: http and https
# - allow_localhost: Allow urls pointing to localhost. Default: true
# - allow_local_network: Allow urls pointing to private network addresses. Default: true
# - ports: Allowed ports. Default: all.
# - enforce_user: Validate user format. Default: false
29
# - enforce_sanitization: Validate that there are no html/css/js tags. Default: false
30
#
31 32 33 34 35 36
# Example:
#   class User < ActiveRecord::Base
#     validates :personal_url, url: { allow_localhost: false, allow_local_network: false}
#
#     validates :web_url, url: { ports: [80, 443] }
#   end
Robert Speicher's avatar
Robert Speicher committed
37
class UrlValidator < ActiveModel::EachValidator
38 39 40 41
  DEFAULT_PROTOCOLS = %w(http https).freeze

  attr_reader :record

Robert Speicher's avatar
Robert Speicher committed
42
  def validate_each(record, attribute, value)
43 44
    @record = record

45
    unless value.present?
46
      record.errors.add(attribute, 'must be a valid URL')
47
      return
Robert Speicher's avatar
Robert Speicher committed
48
    end
49

50 51
    value = strip_value!(record, attribute, value)

52 53 54
    Gitlab::UrlBlocker.validate!(value, blocker_args)
  rescue Gitlab::UrlBlocker::BlockedUrlError => e
    record.errors.add(attribute, "is blocked: #{e.message}")
Robert Speicher's avatar
Robert Speicher committed
55 56 57 58
  end

  private

59 60 61 62 63 64 65
  def strip_value!(record, attribute, value)
    new_value = value.strip
    return value if new_value == value

    record.public_send("#{attribute}=", new_value) # rubocop:disable GitlabSecurity/PublicSend
  end

Robert Speicher's avatar
Robert Speicher committed
66
  def default_options
67 68 69 70 71
    # By default the validator doesn't block any url based on the ip address
    {
      protocols: DEFAULT_PROTOCOLS,
      ports: [],
      allow_localhost: true,
72
      allow_local_network: true,
73
      ascii_only: false,
74 75
      enforce_user: false,
      enforce_sanitization: false
76
    }
Robert Speicher's avatar
Robert Speicher committed
77 78
  end

79 80 81 82 83 84 85
  def current_options
    options = self.options.map do |option, value|
      [option, value.is_a?(Proc) ? value.call(record) : value]
    end.to_h

    default_options.merge(options)
  end
86

87
  def blocker_args
88
    current_options.slice(*default_options.keys).tap do |args|
89 90 91 92 93
      if allow_setting_local_requests?
        args[:allow_localhost] = args[:allow_local_network] = true
      end
    end
  end
Robert Speicher's avatar
Robert Speicher committed
94

95
  def allow_setting_local_requests?
96 97 98 99 100 101
    # We cannot use Gitlab::CurrentSettings as ApplicationSetting itself
    # uses UrlValidator to validate urls. This ends up in a cycle
    # when Gitlab::CurrentSettings creates an ApplicationSetting which then
    # calls this validator.
    #
    # See https://gitlab.com/gitlab-org/gitlab-ee/issues/9833
102
    ApplicationSetting.current&.allow_local_requests_from_hooks_and_services?
Robert Speicher's avatar
Robert Speicher committed
103 104
  end
end