1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
|
# frozen_string_literal: true
# AddressableUrlValidator
#
# Custom validator for URLs. This is a stricter version of UrlValidator - it also checks
# for using the right protocol, but it actually parses the URL checking for any syntax errors.
# The regex is also different from `URI` as we use `Addressable::URI` here.
#
# By default, only URLs for the HTTP(S) schemes will be considered valid.
# Provide a `:schemes` option to configure accepted schemes.
#
# Example:
#
# class User < ActiveRecord::Base
# validates :personal_url, addressable_url: true
#
# validates :ftp_url, addressable_url: { schemes: %w(ftp) }
#
# validates :git_url, addressable_url: { schemes: %w(http https ssh git) }
# end
#
# 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.
#
# Configuration options:
# * <tt>message</tt> - A custom error message, used when the URL is blank. (default is: "must be a valid URL").
# * <tt>blocked_message</tt> - A custom error message, used when the URL is blocked. Default: +'is blocked: %{exception_message}'+.
# * <tt>schemes</tt> - Array of URI schemes. Default: +['http', 'https']+
# * <tt>allow_localhost</tt> - Allow urls pointing to +localhost+. Default: +true+
# * <tt>allow_local_network</tt> - Allow urls pointing to private network addresses. Default: +true+
# * <tt>allow_blank</tt> - Allow urls to be +blank+. Default: +false+
# * <tt>allow_nil</tt> - Allow urls to be +nil+. Default: +false+
# * <tt>ports</tt> - Allowed ports. Default: +all+.
# * <tt>deny_all_requests_except_allowed</tt> - Deny all requests. Default: Respects the instance app setting.
# Note: Regardless of whether enforced during validation, an HTTP request that uses the URI may still be blocked.
# * <tt>enforce_user</tt> - Validate user format. Default: +false+
# * <tt>enforce_sanitization</tt> - Validate that there are no html/css/js tags. Default: +false+
#
# Example:
# class User < ActiveRecord::Base
# validates :personal_url, addressable_url: { allow_localhost: false, allow_local_network: false}
#
# validates :web_url, addressable_url: { ports: [80, 443] }
# end
class AddressableUrlValidator < ActiveModel::EachValidator
attr_reader :record
DENY_ALL_REQUESTS_EXCEPT_ALLOWED_DEFAULT = proc { deny_all_requests_except_allowed? }.freeze
# By default, we avoid checking the dns rebinding protection
# when saving/updating a record. Sometimes, the url
# is not resolvable at that point, and some automated
# tasks that uses that url won't work.
# See https://gitlab.com/gitlab-org/gitlab-foss/issues/66723
BLOCKER_VALIDATE_OPTIONS = {
schemes: %w[http https],
ports: [],
allow_localhost: true,
allow_local_network: true,
ascii_only: false,
deny_all_requests_except_allowed: DENY_ALL_REQUESTS_EXCEPT_ALLOWED_DEFAULT,
enforce_user: false,
enforce_sanitization: false,
dns_rebind_protection: false,
outbound_local_requests_allowlist: []
}.freeze
DEFAULT_OPTIONS = BLOCKER_VALIDATE_OPTIONS.merge({
message: 'must be a valid URL',
blocked_message: 'is blocked: %{exception_message}'
}).freeze
def initialize(options)
options.reverse_merge!(DEFAULT_OPTIONS)
super(options)
end
def validate_each(record, attribute, value)
@record = record
unless value.present?
record.errors.add(attribute, options.fetch(:message))
return
end
value = strip_value!(record, attribute, value)
Gitlab::HTTP_V2::UrlBlocker.validate!(value, **blocker_args)
rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e
record.errors.add(attribute, options.fetch(:blocked_message) % { exception_message: e.message })
end
private
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
def current_options
options.transform_values do |value|
value.is_a?(Proc) ? value.call(record) : value
end
end
def blocker_args
current_options.slice(*BLOCKER_VALIDATE_OPTIONS.keys).tap do |args|
if self.class.allow_setting_local_requests?
args[:allow_localhost] = args[:allow_local_network] = true
end
args[:outbound_local_requests_allowlist] = self.class.outbound_local_requests_allowlist
end
end
def self.allow_setting_local_requests?
# 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/issues/9833
ApplicationSetting.current&.allow_local_requests_from_web_hooks_and_services?
end
def self.deny_all_requests_except_allowed?
ApplicationSetting.current&.deny_all_requests_except_allowed?
end
def self.outbound_local_requests_allowlist
ApplicationSetting.current&.outbound_local_requests_whitelist || [] # rubocop:disable Naming/InclusiveLanguage -- existing setting
end
end
|