summaryrefslogtreecommitdiff
path: root/app/validators/dynamic_path_validator.rb
blob: d992b0c3725ac20cd2f1a821489ccd85375ef345 (plain)
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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# DynamicPathValidator
#
# Custom validator for GitLab path values.
# These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project`
#
# Values are checked for formatting and exclusion from a list of reserved path
# names.
class DynamicPathValidator < ActiveModel::EachValidator
  # All routes that appear on the top level must be listed here.
  # This will make sure that groups cannot be created with these names
  # as these routes would be masked by the paths already in place.
  #
  # Example:
  #   /api/api-project
  #
  #  the path `api` shouldn't be allowed because it would be masked by `api/*`
  #
  TOP_LEVEL_ROUTES = %w[
    -
    .well-known
    abuse_reports
    admin
    all
    api
    assets
    autocomplete
    ci
    dashboard
    explore
    files
    groups
    health_check
    help
    hooks
    import
    invites
    issues
    jwt
    koding
    member
    merge_requests
    new
    notes
    notification_settings
    oauth
    profile
    projects
    public
    repository
    robots.txt
    s
    search
    sent_notifications
    services
    snippets
    teams
    u
    unicorn_test
    unsubscribes
    uploads
    users
  ].freeze

  # This list should contain all words following `/*namespace_id/:project_id` in
  # routes that contain a second wildcard.
  #
  # Example:
  #   /*namespace_id/:project_id/badges/*ref/build
  #
  # If `badges` was allowed as a project/group name, we would not be able to access the
  # `badges` route for those projects:
  #
  # Consider a namespace with path `foo/bar` and a project called `badges`.
  # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg`
  #
  # When accessing this path the route would be matched to the `badges` path
  # with the following params:
  #   - namespace_id: `foo`
  #   - project_id: `bar`
  #   - ref: `badges/master`
  #
  # Failing to find the project, this would result in a 404.
  #
  # By rejecting `badges` the router can _count_ on the fact that `badges` will
  # be preceded by the `namespace/project`.
  WILDCARD_ROUTES = %w[
    badges
    blame
    blob
    builds
    commits
    create
    create_dir
    edit
    environments/folders
    files
    find_file
    gitlab-lfs/objects
    info/lfs/objects
    new
    preview
    raw
    refs
    tree
    update
    wikis
  ].freeze

  # These are all the paths that follow `/groups/*id/ or `/groups/*group_id`
  # We need to reject these because we have a `/groups/*id` page that is the same
  # as the `/*id`.
  #
  # If we would allow a subgroup to be created with the name `activity` then
  # this group would not be accessible through `/groups/parent/activity` since
  # this would map to the activity-page of it's parent.
  GROUP_ROUTES = %w[
    activity
    analytics
    audit_events
    avatar
    edit
    group_members
    hooks
    issues
    labels
    ldap
    ldap_group_links
    merge_requests
    milestones
    notification_setting
    pipeline_quota
    projects
    subgroups
  ].freeze

  CHILD_ROUTES = (WILDCARD_ROUTES | GROUP_ROUTES).freeze

  def self.without_reserved_wildcard_paths_regex
    @without_reserved_wildcard_paths_regex ||= regex_excluding_child_paths(WILDCARD_ROUTES)
  end

  def self.without_reserved_child_paths_regex
    @without_reserved_child_paths_regex ||= regex_excluding_child_paths(CHILD_ROUTES)
  end

  # This is used to validate a full path.
  # It doesn't match paths
  #   - Starting with one of the top level words
  #   - Containing one of the child level words in the middle of a path
  def self.regex_excluding_child_paths(child_routes)
    reserved_top_level_words = Regexp.union(TOP_LEVEL_ROUTES)
    not_starting_in_reserved_word = %r{\A/?(?!(#{reserved_top_level_words})(/|\z))}

    reserved_child_level_words = Regexp.union(child_routes)
    not_containing_reserved_child = %r{(?!\S+/(#{reserved_child_level_words})(/|\z))}

    %r{#{not_starting_in_reserved_word}
       #{not_containing_reserved_child}
       #{Gitlab::Regex.full_namespace_regex}}x
  end

  def self.valid?(path)
    path =~ Gitlab::Regex.full_namespace_regex && !full_path_reserved?(path)
  end

  def self.full_path_reserved?(path)
    path = path.to_s.downcase
    _project_part, namespace_parts = path.reverse.split('/', 2).map(&:reverse)

    wildcard_reserved?(path) || child_reserved?(namespace_parts)
  end

  def self.child_reserved?(path)
    return false unless path

    path !~ without_reserved_child_paths_regex
  end

  def self.wildcard_reserved?(path)
    return false unless path

    path !~ without_reserved_wildcard_paths_regex
  end

  delegate :full_path_reserved?,
           :child_reserved?,
           to: :class

  def path_reserved_for_record?(record, value)
    full_path = record.respond_to?(:full_path) ? record.full_path : value

    # For group paths the entire path cannot contain a reserved child word
    # The path doesn't contain the last `_project_part` so we need to validate
    # if the entire path.
    # Example:
    #   A *group* with full path `parent/activity` is reserved.
    #   A *project* with full path `parent/activity` is allowed.
    if record.is_a? Group
      child_reserved?(full_path)
    else
      full_path_reserved?(full_path)
    end
  end

  def validate_each(record, attribute, value)
    unless value =~ Gitlab::Regex.namespace_regex
      record.errors.add(attribute, Gitlab::Regex.namespace_regex_message)
      return
    end

    if path_reserved_for_record?(record, value)
      record.errors.add(attribute, "#{value} is a reserved name")
    end
  end
end