From 57a03b551b4c1442a17368b0a99a6173fcec387e Mon Sep 17 00:00:00 2001
From: Javiera Tapia <jtapia@gitlab.com>
Date: Mon, 10 Mar 2025 16:35:30 -0300
Subject: [PATCH] approval_rules name is unbounded

Merge branch 'security-519348-approval-rules-name-bounded' into 'master'

See merge request gitlab-org/security/gitlab!4812

Changelog: security
---
 doc/api/merge_request_approvals.md            | 12 +++++-----
 .../approvals/components/rules/rule_form.vue  | 13 +++++++++++
 .../assets/javascripts/approvals/constants.js |  1 +
 ee/app/models/concerns/approval_rule_like.rb  |  2 ++
 .../components/rules/rule_form_spec.js        | 23 +++++++++++++++++++
 .../concerns/approval_rule_like_spec.rb       | 19 +++++++++++++++
 locale/gitlab.pot                             |  3 +++
 7 files changed, 67 insertions(+), 6 deletions(-)

diff --git a/doc/api/merge_request_approvals.md b/doc/api/merge_request_approvals.md
index ed3cb74534ace..a0ae7fbd0352a 100644
--- a/doc/api/merge_request_approvals.md
+++ b/doc/api/merge_request_approvals.md
@@ -505,7 +505,7 @@ Supported attributes:
 |-------------------------------------|-------------------|----------|-------------|
 | `id`                                | integer or string | Yes      | The ID or [URL-encoded path of a project](rest/_index.md#namespaced-paths). |
 | `approvals_required`                | integer           | Yes      | The number of required approvals for this rule. |
-| `name`                              | string            | Yes      | The name of the approval rule. |
+| `name`                              | string            | Yes      | The name of the approval rule. Limited to 1024 characters. |
 | `applies_to_all_protected_branches` | boolean           | No       | Whether to apply the rule to all protected branches. If set to `true`, ignores the value of `protected_branch_ids`. Default is `false`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/335316) in GitLab 15.3. |
 | `group_ids`                         | Array             | No       | The IDs of groups as approvers. |
 | `protected_branch_ids`              | Array             | No       | The IDs of protected branches to scope the rule by. To identify the ID, [use the API](protected_branches.md#list-protected-branches). |
@@ -649,7 +649,7 @@ Supported attributes:
 | `id`                                | integer or string | Yes      | The ID or [URL-encoded path of a project](rest/_index.md#namespaced-paths). |
 | `approvals_required`                | integer           | Yes      | The number of required approvals for this rule. |
 | `approval_rule_id`                  | integer           | Yes      | The ID of a approval rule. |
-| `name`                              | string            | Yes      | The name of the approval rule. |
+| `name`                              | string            | Yes      | The name of the approval rule. Limited to 1024 characters. |
 | `applies_to_all_protected_branches` | boolean           | No       | Whether to apply the rule to all protected branches. If set to `true`, it ignores the value of `protected_branch_ids`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/335316) in GitLab 15.3. |
 | `group_ids`                         | Array             | No       | The IDs of groups as approvers. |
 | `protected_branch_ids`              | Array             | No       | The IDs of protected branches to scope the rule by. To identify the ID, [use the API](protected_branches.md#list-protected-branches). |
@@ -1101,7 +1101,7 @@ Supported attributes:
 | `id`                       | integer or string | Yes | The ID or [URL-encoded path of a project](rest/_index.md#namespaced-paths) |
 | `approvals_required`       | integer           | Yes | The number of required approvals for this rule.                              |
 | `merge_request_iid`        | integer           | Yes | The IID of the merge request.                                                |
-| `name`                     | string            | Yes | The name of the approval rule.                                               |
+| `name`                     | string            | Yes | The name of the approval rule. Limited to 1024 characters.                                               |
 | `approval_project_rule_id` | integer           | No | The ID of a project's approval rule.                                     |
 | `group_ids`                | Array             | No | The IDs of groups as approvers.                                              |
 | `user_ids`                 | Array             | No | The IDs of users as approvers. If you provide both `user_ids` and `usernames`, it adds both lists of users. |
@@ -1193,7 +1193,7 @@ Supported attributes:
 | `merge_request_iid`    | integer           | Yes      | The IID of a merge request. |
 | `approvals_required`   | integer           | No       | The number of required approvals for this rule. |
 | `group_ids`            | Array             | No       | The IDs of groups as approvers. |
-| `name`                 | string            | No       | The name of the approval rule. |
+| `name`                 | string            | No       | The name of the approval rule. Limited to 1024 characters. |
 | `remove_hidden_groups` | boolean           | No       | Whether to remove hidden groups. |
 | `user_ids`             | Array             | No       | The IDs of users as approvers. If you provide both `user_ids` and `usernames`, it adds both lists of users. |
 | `usernames`            | string array      | No       | The usernames of approvers for this rule (same as `user_ids` but requires a list of usernames). If you provide both `user_ids` and `usernames`, it adds both lists of users. |
@@ -1389,7 +1389,7 @@ Supported attributes:
 |----------------------|-------------------|----------|-------------|
 | `id`                 | integer or string | Yes      | The ID or [URL-encoded path of a group](rest/_index.md#namespaced-paths). |
 | `approvals_required` | integer           | Yes      | The number of required approvals for this rule. |
-| `name`               | string            | Yes      | The name of the approval rule. |
+| `name`               | string            | Yes      | The name of the approval rule. Limited to 1024 characters. |
 | `group_ids`          | array             | No       | The IDs of groups as approvers. |
 | `rule_type`          | string            | No       | The rule type. `any_approver` is a pre-configured default rule with `approvals_required` at `0`. Other rules are `regular` (used for regular [merge request approval rules](../user/project/merge_requests/approvals/rules.md)) and `report_approver`. Don't use this field to build approval rules from the API. The `report_approver` field is used when GitLab creates an approval rule from configured and enabled [merge request approval policies](../user/application_security/policies/merge_request_approval_policies.md). |
 | `user_ids`           | array             | No       | The IDs of users as approvers. |
@@ -1469,7 +1469,7 @@ Supported attributes:
 | `id`                 | integer or string | Yes      | The ID or [URL-encoded path of a group](rest/_index.md#namespaced-paths). |
 | `approvals_required` | string            | No       | The number of required approvals for this rule. |
 | `group_ids`          | integer           | No       | The IDs of users as approvers. |
-| `name`               | string            | No       | The name of the approval rule. |
+| `name`               | string            | No       | The name of the approval rule. Limited to 1024 characters. |
 | `rule_type`          | array             | No       | The rule type. `any_approver` is a pre-configured default rule with `approvals_required` at `0`. Other rules are `regular` (used for regular [merge request approval rules](../user/project/merge_requests/approvals/rules.md)) and `report_approver`. Don't use this field to build approval rules from the API. The `report_approver` field is used when GitLab creates an approval rule from configured and enabled [merge request approval policies](../user/application_security/policies/merge_request_approval_policies.md). |
 | `user_ids`           | array             | No       | The IDs of groups as approvers. |
 
diff --git a/ee/app/assets/javascripts/approvals/components/rules/rule_form.vue b/ee/app/assets/javascripts/approvals/components/rules/rule_form.vue
index 2c4b2de2fdb71..5e199c0a740a4 100644
--- a/ee/app/assets/javascripts/approvals/components/rules/rule_form.vue
+++ b/ee/app/assets/javascripts/approvals/components/rules/rule_form.vue
@@ -109,6 +109,19 @@ export default {
         if (!this.name) {
           return APPROVAL_DIALOG_I18N.validations.ruleNameMissing;
         }
+
+        const lengthError = this.serverValidationErrors.find((error) =>
+          /name is too long \(maximum is \d+ characters\)/.test(error),
+        );
+
+        if (lengthError) {
+          const match = lengthError.match(/name is too long \(maximum is (\d+) characters\)/);
+          const maxLength = match[1];
+
+          return sprintf(APPROVAL_DIALOG_I18N.validations.ruleNameTooLong, {
+            number: maxLength,
+          });
+        }
       }
 
       return '';
diff --git a/ee/app/assets/javascripts/approvals/constants.js b/ee/app/assets/javascripts/approvals/constants.js
index 643d3a94e55d3..c41dbc997a399 100644
--- a/ee/app/assets/javascripts/approvals/constants.js
+++ b/ee/app/assets/javascripts/approvals/constants.js
@@ -96,6 +96,7 @@ export const APPROVAL_DIALOG_I18N = {
     branchesRequired: __('Please select a valid target branch'),
     ruleNameTaken: __('Rule name is already taken.'),
     ruleNameMissing: __('Please provide a name'),
+    ruleNameTooLong: __('Please enter a name with less than %{number} characters.'),
   },
 };
 
diff --git a/ee/app/models/concerns/approval_rule_like.rb b/ee/app/models/concerns/approval_rule_like.rb
index 36e7e9942747e..3dd8b77e671fd 100644
--- a/ee/app/models/concerns/approval_rule_like.rb
+++ b/ee/app/models/concerns/approval_rule_like.rb
@@ -13,6 +13,7 @@ module ApprovalRuleLike
   NEWLY_DETECTED = 'newly_detected'
   NEW_NEEDS_TRIAGE = 'new_needs_triage'
   NEW_DISMISSED = 'new_dismissed'
+  NAME_LENGTH_LIMIT = 1024
 
   NEWLY_DETECTED_STATUSES = [NEW_NEEDS_TRIAGE, NEW_DISMISSED].freeze
   DEFAULT_VULNERABILITY_STATUSES = [NEW_NEEDS_TRIAGE, NEW_DISMISSED].freeze
@@ -46,6 +47,7 @@ module ApprovalRuleLike
     }
 
     validates :name, presence: true
+    validates :name, length: { maximum: NAME_LENGTH_LIMIT }, if: -> { new_record? || name_changed? }
     validates :approvals_required, numericality: { less_than_or_equal_to: APPROVALS_REQUIRED_MAX, greater_than_or_equal_to: 0 }
     validates :report_type, presence: true, if: :report_approver?
 
diff --git a/ee/spec/frontend/approvals/components/rules/rule_form_spec.js b/ee/spec/frontend/approvals/components/rules/rule_form_spec.js
index a4eea26c3f5ff..c1c85565b879a 100644
--- a/ee/spec/frontend/approvals/components/rules/rule_form_spec.js
+++ b/ee/spec/frontend/approvals/components/rules/rule_form_spec.js
@@ -41,6 +41,16 @@ const nameTakenError = {
   },
 };
 
+const nameTooLongError = {
+  response: {
+    data: {
+      message: {
+        name: ['is too long (maximum is 1024 characters)'],
+      },
+    },
+  },
+};
+
 Vue.use(Vuex);
 
 describe('EE Approvals RuleForm', () => {
@@ -389,6 +399,19 @@ describe('EE Approvals RuleForm', () => {
           expect(nameGroup.props('state')).toBe(false);
           expect(nameGroup.props('invalidFeedback')).toBe('Rule name is already taken.');
         });
+
+        it('when submitted with a name too long, shows the "ruleNameTooLong" validation', async () => {
+          actions.postRule.mockRejectedValueOnce(nameTooLongError);
+
+          await findForm().trigger('submit');
+          await waitForPromises();
+
+          const nameGroup = findNameValidation();
+          expect(nameGroup.props('state')).toBe(false);
+          expect(nameGroup.props('invalidFeedback')).toBe(
+            'Please enter a name with less than 1024 characters.',
+          );
+        });
       });
 
       it('adds selected approvers on selection', async () => {
diff --git a/ee/spec/models/concerns/approval_rule_like_spec.rb b/ee/spec/models/concerns/approval_rule_like_spec.rb
index 05c69036e7bca..4ef1f0c74a718 100644
--- a/ee/spec/models/concerns/approval_rule_like_spec.rb
+++ b/ee/spec/models/concerns/approval_rule_like_spec.rb
@@ -371,6 +371,25 @@
           end
         end
       end
+
+      context 'name attribute' do
+        it { is_expected.to validate_length_of(:name).is_at_most(described_class::NAME_LENGTH_LIMIT) }
+
+        context 'when name is above the length limit' do
+          it 'does not cause a validation error when the name is not changed' do
+            # Modify the name in the database directly to bypass validations
+            subject.class.where(id: subject.id).update_all(
+              name: 'x' * (described_class::NAME_LENGTH_LIMIT + 10)
+            )
+
+            subject.reload
+            expect(subject.name.length).to be > described_class::NAME_LENGTH_LIMIT
+
+            subject.update!(approvals_required: described_class::APPROVALS_REQUIRED_MAX)
+            expect(subject).to be_valid
+          end
+        end
+      end
     end
   end
 
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 8501347d9b707..18cee3afc1f96 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -43609,6 +43609,9 @@ msgstr ""
 msgid "Please enter a name for the custom emoji."
 msgstr ""
 
+msgid "Please enter a name with less than %{number} characters."
+msgstr ""
+
 msgid "Please enter a non-negative number"
 msgstr ""
 
-- 
GitLab