diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index f73a609a7c2d6e4facc632898d610456e7755ed7..01a5dfa7a6a4ae36f0d6027722f3de50c92a98c7 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -40,6 +40,7 @@ class GraphqlController < ApplicationController before_action :track_neovim_plugin_usage before_action :disable_query_limiting before_action :limit_query_size + before_action :enforce_language_server_restrictions before_action :disallow_mutations_for_get @@ -80,6 +81,12 @@ def execute end end + rescue_from Gitlab::Auth::RestrictedLanguageServerClientError do |exception| + log_exception(exception) + + render_error(exception.message, status: :unauthorized) + end + rescue_from Gitlab::Auth::DpopValidationError do |exception| log_exception(exception) @@ -147,6 +154,15 @@ def check_dpop! request: current_request).execute end + def enforce_language_server_restrictions + response = Gitlab::Auth::EditorExtensions::LanguageServerClientVerifier.new( + current_user: current_user, + request: current_request + ).execute + + raise Gitlab::Auth::RestrictedLanguageServerClientError, response.message if response.error? + end + def permitted_params @permitted_params ||= multiplex? ? permitted_multiplex_params : permitted_standalone_query_params end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 0a2e2a1e77dd4acd1d53cf2d1e49352f80d52183..9828ffb0e7deac5e55ccbd2acb5dfbcac7df1704 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -616,6 +616,8 @@ def visible_attributes :global_search_issues_enabled, :global_search_merge_requests_enabled, :global_search_block_anonymous_searches_enabled, + :enable_language_server_restrictions, + :minimum_language_server_version, :vscode_extension_marketplace, :vscode_extension_marketplace_enabled, :reindexing_minimum_index_size, diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 0c82f5b07de3e5aa6e6694801dd3de9064eb1237..10346a2c1e8163aaf8c65cac1a4700ceb2fa8aae 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -980,6 +980,13 @@ def self.kroki_formats_attributes jsonb_accessor :vscode_extension_marketplace, vscode_extension_marketplace_enabled: [:boolean, { default: false, store_key: :enabled }] + jsonb_accessor :editor_extensions, + enable_language_server_restrictions: [:boolean, { default: false }], + minimum_language_server_version: [:string, { default: '0.1.0' }] + + validates :editor_extensions, + json_schema: { filename: 'application_setting_editor_extensions', detail_errors: true } + before_validation :ensure_uuid! before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed? before_validation :normalize_default_branch_name diff --git a/app/validators/json_schemas/application_setting_editor_extensions.json b/app/validators/json_schemas/application_setting_editor_extensions.json new file mode 100644 index 0000000000000000000000000000000000000000..a017f11614d15de6b3d9c9870bc1d75b91a7d6ed --- /dev/null +++ b/app/validators/json_schemas/application_setting_editor_extensions.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Instance-wide Editor Extension settings", + "type": "object", + "additionalProperties": false, + "properties": { + "enable_language_server_restrictions": { + "type": "boolean", + "default": false, + "description": "Enables enforcing language server restrictions" + }, + "minimum_language_server_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "maxLength": 64, + "description": "Minimum language server version to accept requests from" + } + } +} diff --git a/app/views/admin/application_settings/_editor_extensions.html.haml b/app/views/admin/application_settings/_editor_extensions.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..9026d4876142d52b08e8d768c02e01318a24d872 --- /dev/null +++ b/app/views/admin/application_settings/_editor_extensions.html.haml @@ -0,0 +1,21 @@ += render ::Layouts::SettingsBlockComponent.new(_('Editor Extensions'), + id: 'js-editor-extensions-settings', + testid: 'admin-editor-extensions-settings', + expanded: expanded_by_default?) do |c| + - c.with_description do + = _('Configure instance-wide Editor Extensions settings') + - c.with_body do + = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-editor-extensions-settings'), html: { class: 'fieldset-form', id: 'editor-extensions-settings' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :minimum_language_server_version, _('Minimum GitLab Language Server client version'), class: 'label-bold' + = f.text_field :minimum_language_server_version, placeholder: '0.1.0', class: 'form-control gl-form-input' + .form-text.gl-text-subtle + = _('Minimum client version to enforce for editor extensions using the GitLab Language Server.') + .form-group + = f.gitlab_ui_checkbox_component :enable_language_server_restrictions, _('Language Server restrictions enabled') + .form-text.gl-text-subtle + = _('Whether to enforce minimum language server version.') + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index e480b9879b046eb27233e24178a6358ce7af3719..d6e0c9425d9d62c415108a98f28fe109909855fa 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -112,3 +112,4 @@ = render 'admin/application_settings/analytics' = render_if_exists 'admin/application_settings/amazon_q' = render 'admin/application_settings/extension_marketplace' += render 'admin/application_settings/editor_extensions' diff --git a/config/application_setting_columns/editor_extensions.yml b/config/application_setting_columns/editor_extensions.yml new file mode 100644 index 0000000000000000000000000000000000000000..73412599d364bed57178a2ffb8e4fd1d41e6da14 --- /dev/null +++ b/config/application_setting_columns/editor_extensions.yml @@ -0,0 +1,12 @@ +--- +api_type: +attr: editor_extensions +clusterwide: true +column: editor_extensions +db_type: jsonb +default: "'{}'::jsonb" +description: Editor Extensions restrictions and settings +encrypted: false +gitlab_com_different_than_default: false +jihu: false +not_null: true diff --git a/config/feature_flags/beta/enforce_language_server_version.yml b/config/feature_flags/beta/enforce_language_server_version.yml new file mode 100644 index 0000000000000000000000000000000000000000..9b9aa70e332adf5e7e0e36572b05404e1a91b5d9 --- /dev/null +++ b/config/feature_flags/beta/enforce_language_server_version.yml @@ -0,0 +1,10 @@ +--- +name: enforce_language_server_version +description: Enforce a minimum version for GitLab Language Server clients +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/541744 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/193642 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/541743 +milestone: '18.1' +group: group::editor extensions +type: beta +default_enabled: false diff --git a/db/migrate/20250604171923_add_editor_extensions_to_application_settings.rb b/db/migrate/20250604171923_add_editor_extensions_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..f4506c3458f29348ee24b9f09dd8811e918e61f8 --- /dev/null +++ b/db/migrate/20250604171923_add_editor_extensions_to_application_settings.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class AddEditorExtensionsToApplicationSettings < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + + milestone '18.1' + + CONSTRAINT_NAME = 'check_application_settings_editor_extensions_is_hash' + + def up + with_lock_retries do + add_column :application_settings, :editor_extensions, :jsonb, default: {}, null: false, if_not_exists: true + end + + add_check_constraint( + :application_settings, + "(jsonb_typeof(editor_extensions) = 'object')", + CONSTRAINT_NAME + ) + end + + def down + remove_check_constraint :application_settings, CONSTRAINT_NAME + + with_lock_retries do + remove_column :application_settings, :editor_extensions, if_exists: true + end + end +end diff --git a/db/schema_migrations/20250604171923 b/db/schema_migrations/20250604171923 new file mode 100644 index 0000000000000000000000000000000000000000..b261ef27562693d4f902de90def37861d50b2c0b --- /dev/null +++ b/db/schema_migrations/20250604171923 @@ -0,0 +1 @@ +68558ac5f9b87ee06fd8129b626c64b0814b617c41b81dc214bb537519e7acf9 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 113773e8cb8ae0ff48ba31ce4504693a1b041d9c..8686133f74db3a8dffb7db99ea2d0ef15e3bb226 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9232,6 +9232,7 @@ CREATE TABLE application_settings ( web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, lock_web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, tmp_asset_proxy_secret_key jsonb, + editor_extensions jsonb DEFAULT '{}'::jsonb NOT NULL, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_dep_proxy_ttl_policies_worker_capacity_positive CHECK ((dependency_proxy_ttl_group_policy_worker_capacity >= 0)), CONSTRAINT app_settings_ext_pipeline_validation_service_url_text_limit CHECK ((char_length(external_pipeline_validation_service_url) <= 255)), @@ -9287,6 +9288,7 @@ CREATE TABLE application_settings ( CONSTRAINT check_application_settings_database_reindexing_is_hash CHECK ((jsonb_typeof(database_reindexing) = 'object'::text)), CONSTRAINT check_application_settings_duo_chat_is_hash CHECK ((jsonb_typeof(duo_chat) = 'object'::text)), CONSTRAINT check_application_settings_duo_workflow_is_hash CHECK ((jsonb_typeof(duo_workflow) = 'object'::text)), + CONSTRAINT check_application_settings_editor_extensions_is_hash CHECK ((jsonb_typeof(editor_extensions) = 'object'::text)), CONSTRAINT check_application_settings_elasticsearch_is_hash CHECK ((jsonb_typeof(elasticsearch) = 'object'::text)), CONSTRAINT check_application_settings_group_settings_is_hash CHECK ((jsonb_typeof(group_settings) = 'object'::text)), CONSTRAINT check_application_settings_importers_is_hash CHECK ((jsonb_typeof(importers) = 'object'::text)), diff --git a/doc/administration/settings/editor_extensions.md b/doc/administration/settings/editor_extensions.md new file mode 100644 index 0000000000000000000000000000000000000000..bf747af458a26649a07f17d51cf6ca63ad7493db --- /dev/null +++ b/doc/administration/settings/editor_extensions.md @@ -0,0 +1,75 @@ +--- +stage: Create +group: Editor Extensions +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments +description: Configure GitLab Editor Extensions including Visual Studio Code, JetBrains IDEs, Visual Studio, Eclipse and Neovim. +title: Configure Editor Extensions +--- + +{{< details >}} + +- Tier: Free, Premium, Ultimate +- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated + +{{< /details >}} + +Configure Editor Extensions settings for your GitLab instance in the Admin area. + +You can enforce the following restrictions on Editor Extensions: + +- Enforce a minimum language server version. + +## Enforce a minimum language server version + +{{< history >}} + +- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/541744) in GitLab 18.1 [with a flag](../feature_flags.md) named `enforce_language_server_version`. Disabled by default. + +{{< /history >}} + +{{< alert type="flag" >}} + +On GitLab Self-Managed, by default this feature is not available. To make it available, an administrator can [enable the feature flag](../feature_flags.md) named `enforce_language_server_version`. +On GitLab.com, this feature is available but can be configured by GitLab.com administrators only. +On GitLab Dedicated, this feature is available. + +{{< /alert >}} + +By default, any GitLab Language Server version can connect to your GitLab instance when +Personal Access Tokens are enabled. You can configure a minimum language server version and +block requests from clients on older versions. Existing clients will receive an API error + +Prerequisites: + +- You must be an administrator. + + ```ruby + # For a specific user + Feature.enable(:enforce_language_server_version, User.find(1)) + + # For this GitLab instance + Feature.enable(:enforce_language_server_version) + ``` + +To enforce a minimum GitLab Language Server version: + +1. On the left sidebar, at the bottom, select **Admin**. +1. On the left sidebar, select **Settings > General**. +1. Expand **Editor Extensions**. +1. Check **Language Server restrictions enabled**. +1. Under **Minimum GitLab Language Server client version**, enter a valid GitLab Language Server version. + +To allow any GitLab Language Server clients: + +1. On the left sidebar, at the bottom, select **Admin**. +1. On the left sidebar, select **Settings > General**. +1. Expand **Editor Extensions**. +1. Uncheck **Language Server restrictions enabled**. +1. Under **Minimum GitLab Language Server client version**, enter a valid GitLab Language Server version. + +{{< alert type="note" >}} + +Allowing all requests is **not recommended** because it can cause incompatibility if your GitLab version is ahead of your Editor Extensions. +Updating your Editor Extensions is **recommended** to receive the latest feature improvements, bug fixes, and security fixes. + +{{< /alert >}} diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index 832ade5ff25e00788f8fdda6d66d25d93a2ea798..a05e4bef1e75129cc64ac37f4fa50b6a2d94e9a1 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -64,6 +64,7 @@ def find_current_user! forbidden!(api_access_denied_message(user)) end + check_language_server_client!(user) check_dpop!(user) user @@ -137,6 +138,17 @@ def check_dpop!(user) group_id: params[:id]) end + def check_language_server_client!(user) + return unless api_request? && user.is_a?(User) + + response = Gitlab::Auth::EditorExtensions::LanguageServerClientVerifier.new( + current_user: user, + request: current_request + ).execute + + raise Gitlab::Auth::RestrictedLanguageServerClientError, response.message if response.error? + end + def user_allowed_or_deploy_token?(user) Gitlab::UserAccess.new(user).allowed? || user.is_a?(DeployToken) end @@ -171,6 +183,7 @@ def install_error_responders(base) Gitlab::Auth::RevokedError, Gitlab::Auth::ImpersonationDisabled, Gitlab::Auth::InsufficientScopeError, + Gitlab::Auth::RestrictedLanguageServerClientError, Gitlab::Auth::DpopValidationError] base.__send__(:rescue_from, *error_classes, oauth2_bearer_token_error_handler) # rubocop:disable GitlabSecurity/PublicSend @@ -215,6 +228,11 @@ def oauth2_bearer_token_error_handler Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new( :dpop_error, e) + + when Gitlab::Auth::RestrictedLanguageServerClientError + Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new( + :restricted_language_server_client_error, + e) end status, headers, body = response.finish diff --git a/lib/api/settings.rb b/lib/api/settings.rb index 631cd56277c469e067484ff77e35a5e7cc0fe0cf..674c55b627a92317df9814723fe560260803c98c 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -252,6 +252,8 @@ def filter_attributes_using_license(attrs) optional :preset, type: String, desc: "The preset configuration of URL's for the VS Code Extension Marketplace" optional :custom_values, type: Hash, desc: "VS Code Extension Marketplace URL's when preset is 'custom'" end + optional :enable_language_server_restrictions, type: Boolean, desc: 'Enables enforcing language server restrictions' + optional :minimum_language_server_version, type: String, desc: 'The minimum language server version to accept requests from' Gitlab::SSHPublicKey.supported_types.each do |type| optional :"#{type}_key_restriction", diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 376c7bf7f573a4cb0c5d3b9b1d4ce99102a23fb3..5098f3b5f2621071cfcb96bc5416ec33b485d8c5 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -16,6 +16,12 @@ def initialize(msg) end end + class RestrictedLanguageServerClientError < AuthenticationError + def initialize(msg) + super("Language server client error: #{msg}") + end + end + class InsufficientScopeError < AuthenticationError attr_reader :scopes diff --git a/lib/gitlab/auth/editor_extensions/language_server_client.rb b/lib/gitlab/auth/editor_extensions/language_server_client.rb new file mode 100644 index 0000000000000000000000000000000000000000..fd7158d5088a5748b43ecdfb0dd4e0a1e8941232 --- /dev/null +++ b/lib/gitlab/auth/editor_extensions/language_server_client.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module EditorExtensions + class LanguageServerClient + USER_AGENT_PATTERN = /gitlab-language-server|code-completions-language-server-experiment/ + VERSION_PATTERN = ::Gitlab::Regex.semver_regex + + def initialize(client_version:, user_agent:) + @client_version = client_version + @user_agent = user_agent + end + + def lsp_client? + # Either condition is sufficient to identify an LSP client: + # - client_version matching semantic versioning pattern, or + # - user_agent containing known LSP client identifiers + client_version&.match(VERSION_PATTERN) || user_agent&.match(USER_AGENT_PATTERN) + end + + def version + return Gem::Version.new(client_version) if client_version&.match(VERSION_PATTERN) + + # For older clients it is likely LSP version information is absent. + Gem::Version.new('0.1.0') + end + + private + + attr_reader :client_version, :user_agent + end + end + end +end diff --git a/lib/gitlab/auth/editor_extensions/language_server_client_verifier.rb b/lib/gitlab/auth/editor_extensions/language_server_client_verifier.rb new file mode 100644 index 0000000000000000000000000000000000000000..bc44c1db112525061a6c68864c045d6f84e8f970 --- /dev/null +++ b/lib/gitlab/auth/editor_extensions/language_server_client_verifier.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module EditorExtensions + class LanguageServerClientVerifier + def initialize(current_user:, request:) + @current_user = current_user + @request = request + end + + def execute + return ServiceResponse.success unless client.lsp_client? && enforce_language_server_version? + + return ServiceResponse.success if client.version >= minimum_version + + ServiceResponse.error( + message: 'Requests from Editor Extension clients are restricted', + payload: { client_version: client.version }, + reason: :instance_requires_newer_client + ) + end + + private + + attr_reader :current_user, :request + + def client + Gitlab::Auth::EditorExtensions::LanguageServerClient.new( + client_version: request.headers['HTTP_X_GITLAB_LANGUAGE_SERVER_VERSION'], + user_agent: request.headers['HTTP_USER_AGENT'] + ) + end + + def enforce_language_server_version? + return false unless Gitlab::CurrentSettings.gitlab_dedicated_instance? || + Feature.enabled?(:enforce_language_server_version, current_user) + + Gitlab::CurrentSettings.enable_language_server_restrictions + end + + def minimum_version + Gem::Version.new(Gitlab::CurrentSettings.minimum_language_server_version) + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 718f277e8c8dc754a179dbb0b48eecc75cf6cbd2..2b36d3970af1646c2cdc5763ab3406756871c368 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16947,6 +16947,9 @@ msgstr "" msgid "Configure import sources and settings related to import and export features." msgstr "" +msgid "Configure instance-wide Editor Extensions settings" +msgstr "" + msgid "Configure it later" msgstr "" @@ -23913,6 +23916,9 @@ msgstr "" msgid "Editing this file is not supported" msgstr "" +msgid "Editor Extensions" +msgstr "" + msgid "Editor toolbar" msgstr "" @@ -35868,6 +35874,9 @@ msgstr "" msgid "Language" msgstr "" +msgid "Language Server restrictions enabled" +msgstr "" + msgid "Language type" msgstr "" @@ -39041,9 +39050,15 @@ msgstr "" msgid "Minimal Access" msgstr "" +msgid "Minimum GitLab Language Server client version" +msgstr "" + msgid "Minimum capacity to be available before we schedule more mirrors preemptively." msgstr "" +msgid "Minimum client version to enforce for editor extensions using the GitLab Language Server." +msgstr "" + msgid "Minimum required approvals" msgstr "" @@ -69205,6 +69220,9 @@ msgstr[1] "" msgid "When:" msgstr "" +msgid "Whether to enforce minimum language server version." +msgstr "" + msgid "Which API requests are affected?" msgstr "" diff --git a/spec/lib/gitlab/auth/editor_extensions/language_server_client_spec.rb b/spec/lib/gitlab/auth/editor_extensions/language_server_client_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..23b2e24cdc2287819436b0bf30cf83cae88bd765 --- /dev/null +++ b/spec/lib/gitlab/auth/editor_extensions/language_server_client_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Auth::EditorExtensions::LanguageServerClient, feature_category: :editor_extensions do + describe '#lsp_client?' do + subject { described_class.new(client_version: client_version, user_agent: user_agent) } + + context 'with no client version' do + let(:client_version) { nil } + + context 'with no user agent' do + let(:user_agent) { nil } + + it { is_expected.not_to be_lsp_client } + it { is_expected.to have_attributes(version: eq(Gem::Version.new('0.1.0'))) } + end + + context 'with an outdated user agent' do + let(:user_agent) do + 'code-completions-language-server-experiment (gl-visual-studio-extension:1.0.0.0; arch:X64;)' + end + + it { is_expected.to be_lsp_client.and have_attributes(version: eq(Gem::Version.new('0.1.0'))) } + end + + context 'with an unrecognized user agent' do + let(:user_agent) { 'unknown-agent 1.0.0' } + + it { is_expected.not_to be_lsp_client } + it { is_expected.to have_attributes(version: eq(Gem::Version.new('0.1.0'))) } + end + end + + context 'with invalid client version and an outdated user agent' do + let(:client_version) { 'a.b.c' } + let(:user_agent) { 'code-completions-language-server-experiment (gl-visual-studio-extension:1.0.0.0; arch:X64;)' } + + it { is_expected.to be_lsp_client.and have_attributes(version: eq(Gem::Version.new('0.1.0'))) } + end + + context 'with valid client version and a recognized user agent' do + let(:client_version) { '1.0.0' } + let(:user_agent) { 'gitlab-language-server 1.0.0' } + + it { is_expected.to be_lsp_client.and have_attributes(version: eq(Gem::Version.new('1.0.0'))) } + end + + context 'with valid client version and no user agent' do + let(:client_version) { '1.0.0' } + let(:user_agent) { nil } + + it { is_expected.to be_lsp_client.and have_attributes(version: eq(Gem::Version.new('1.0.0'))) } + end + end +end diff --git a/spec/lib/gitlab/auth/editor_extensions/language_server_client_verifier_spec.rb b/spec/lib/gitlab/auth/editor_extensions/language_server_client_verifier_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b9d26ae3dacf3eda12c21a1c29058124449d21b1 --- /dev/null +++ b/spec/lib/gitlab/auth/editor_extensions/language_server_client_verifier_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Auth::EditorExtensions::LanguageServerClientVerifier, feature_category: :editor_extensions do + let_it_be(:user, freeze: true) { create(:user) } + + let(:request) do + instance_double(ActionDispatch::Request, { + headers: ActionDispatch::Http::Headers.from_hash(headers) + }) + end + + describe '#execute' do + subject { described_class.new(current_user: user, request: request).execute } + + shared_examples 'client verification was successful' do |params| + let(:headers) { params.fetch(:headers) } + + it { expect(subject).to be_success } + end + + shared_examples 'client verification was unsuccessful' do |params| + let(:headers) { params.fetch(:headers) } + + it { expect(subject).to be_error.and have_attributes(reason: params[:reason]) } + end + + shared_examples 'allowed clients were successful' do + context 'with a different client' do + it_behaves_like 'client verification was successful', headers: { + 'HTTP_USER_AGENT' => 'my-cool-app 1.2.3' + } + end + + context 'with a matching language server client' do + it_behaves_like 'client verification was successful', headers: { + 'HTTP_USER_AGENT' => 'gitlab-language-server 2.0.0', + 'HTTP_X_GITLAB_LANGUAGE_SERVER_VERSION' => '2.0.0' + } + end + + context 'with an updated language server client' do + it_behaves_like 'client verification was successful', headers: { + 'HTTP_USER_AGENT' => 'gitlab-language-server 999.99.9', + 'HTTP_X_GITLAB_LANGUAGE_SERVER_VERSION' => '999.99.9' + } + end + end + + shared_examples 'restricted clients were unsuccessful' do + context 'with an obsolete language server client' do + it_behaves_like 'client verification was unsuccessful', + reason: :instance_requires_newer_client, + headers: { + 'HTTP_USER_AGENT' => 'code-completions-language-server-experiment (gl-visual-studio-extension:1.0.0.0)' + } + end + + context 'with an outdated language server client' do + it_behaves_like 'client verification was unsuccessful', + reason: :instance_requires_newer_client, + headers: { + 'HTTP_USER_AGENT' => 'gitlab-language-server 1.2.3', + 'HTTP_X_GITLAB_LANGUAGE_SERVER_VERSION' => '1.2.3' + } + end + end + + shared_examples 'the enable_language_server_restrictions application setting is disabled' do + include_examples 'allowed clients were successful' + + context 'with an obsolete language server client' do + it_behaves_like 'client verification was successful', headers: { + 'HTTP_USER_AGENT' => 'code-completions-language-server-experiment (gl-visual-studio-extension:1.0.0.0)' + } + end + + context 'with an outdated language server client' do + it_behaves_like 'client verification was successful', headers: { + 'HTTP_USER_AGENT' => 'gitlab-language-server 1.2.3', + 'HTTP_X_GITLAB_LANGUAGE_SERVER_VERSION' => '1.2.3' + } + end + end + + shared_context 'with language server restrictions disabled' do + before do + allow(Gitlab::CurrentSettings.current_application_settings).to receive_messages( + enable_language_server_restrictions: false, + minimum_language_server_version: '2.0.0') + end + end + + shared_context 'with language server restrictions enabled' do + before do + allow(Gitlab::CurrentSettings.current_application_settings).to receive_messages( + enable_language_server_restrictions: true, + minimum_language_server_version: '2.0.0') + end + end + + context 'with the enforce_language_server_version feature flag disabled' do + before do + stub_feature_flags(enforce_language_server_version: false) + allow(Gitlab::CurrentSettings.current_application_settings).to receive(:gitlab_dedicated_instance?) + .and_return(false) + end + + context 'with the enable_language_server_restrictions application setting disabled' do + include_context 'with language server restrictions disabled' + it_behaves_like 'the enable_language_server_restrictions application setting is disabled' + end + + context 'with the enable_language_server_restrictions application setting enabled' do + include_context 'with language server restrictions enabled' + it_behaves_like 'the enable_language_server_restrictions application setting is disabled' + end + end + + context 'with the enforce_language_server_version feature flag enabled' do + before do + stub_feature_flags(enforce_language_server_version: true) + allow(Gitlab::CurrentSettings.current_application_settings).to receive(:gitlab_dedicated_instance?) + .and_return(false) + end + + context 'with the enable_language_server_restrictions application setting disabled' do + include_context 'with language server restrictions disabled' + it_behaves_like 'the enable_language_server_restrictions application setting is disabled' + end + + context 'with the enable_language_server_restrictions application setting enabled' do + include_context 'with language server restrictions enabled' + it_behaves_like 'allowed clients were successful' + it_behaves_like 'restricted clients were unsuccessful' + end + end + + context 'on a dedicated instance' do + before do + stub_feature_flags(enforce_language_server_version: false) + allow(Gitlab::CurrentSettings.current_application_settings).to receive(:gitlab_dedicated_instance?) + .and_return(true) + end + + context 'with the enable_language_server_restrictions application setting disabled' do + include_context 'with language server restrictions disabled' + it_behaves_like 'the enable_language_server_restrictions application setting is disabled' + end + + context 'with the enable_language_server_restrictions application setting enabled' do + include_context 'with language server restrictions enabled' + it_behaves_like 'allowed clients were successful' + it_behaves_like 'restricted clients were unsuccessful' + end + end + end +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 7481999a153b2ddba23b66dc2b81f4a3481cb8c1..e1311431c4a7b071b0ecacdd6039ccd4525afb7b 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -103,6 +103,7 @@ ecdsa_sk_key_restriction: 0, ed25519_key_restriction: 0, ed25519_sk_key_restriction: 0, + enable_language_server_restrictions: false, eks_integration_enabled: false, email_confirmation_setting: 'off', email_restrictions_enabled: false, @@ -175,6 +176,7 @@ max_yaml_depth: 100, max_yaml_size_bytes: 2.megabytes, members_delete_limit: 60, + minimum_language_server_version: '0.1.0', minimum_password_length: ApplicationSettingImplementation::DEFAULT_MINIMUM_PASSWORD_LENGTH, mirror_available: true, notes_create_limit: 300, @@ -2150,6 +2152,54 @@ def expect_invalid end end + describe '#editor_extensions' do + it 'sets the correct default values' do + expect(setting.enable_language_server_restrictions).to be(false) + expect(setting.minimum_language_server_version).to eq('0.1.0') + end + + context 'when provided different invalid values' do + using RSpec::Parameterized::TableSyntax + + where(:enable_language_server_restrictions, :minimum_language_server_version) do + false | nil + true | 'invalid semantic version' + true | '' + end + + with_them do + let(:value) do + { + enable_language_server_restrictions: enable_language_server_restrictions, + minimum_language_server_version: minimum_language_server_version + } + end + + it { is_expected.not_to allow_value(value).for(:editor_extensions) } + end + end + + context 'when provided different valid values' do + using RSpec::Parameterized::TableSyntax + + where(:enable_language_server_restrictions, :minimum_language_server_version) do + false | '0.1.0' + true | '8.0.0' + end + + with_them do + let(:value) do + { + enable_language_server_restrictions: enable_language_server_restrictions, + minimum_language_server_version: minimum_language_server_version + } + end + + it { is_expected.to allow_value(value).for(:editor_extensions) } + end + end + end + describe '#vscode_extension_marketplace' do let(:invalid_custom) { { enabled: false, preset: "custom", custom_values: {} } } let(:invalid_custom_urls) do diff --git a/spec/requests/api/api_spec.rb b/spec/requests/api/api_spec.rb index cd891f20a9fde85c3a0f88fdcecbf7c5f63d7d54..9d829da176403d9d28fbeb53f4bc2bfd9369c53d 100644 --- a/spec/requests/api/api_spec.rb +++ b/spec/requests/api/api_spec.rb @@ -657,4 +657,133 @@ it_behaves_like 'not audited request' end end + + describe 'Language Server client restrictions', feature_category: :editor_extensions do + let_it_be(:user) { create(:user) } + let_it_be(:oauth_access_token) { create(:oauth_access_token, user: user, scopes: [:api]) } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user, scopes: [:api]) } + # It does not matter which endpoint is used because language server restrictions + # should apply to every request. `/user` is used as an example + # to represent any API endpoint. + let(:request_path) { '/version' } + + shared_examples 'an allowed client' do + it 'returns 200 when using an OAuth token' do + get(api(request_path, oauth_access_token: oauth_access_token), headers: headers) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns 200 when using a Personal Access Token' do + get(api(request_path, personal_access_token: personal_access_token), headers: headers) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + shared_examples 'an unallowed client' do + it 'returns 401 when using an OAuth token' do + get(api(request_path, oauth_access_token: oauth_access_token), headers: headers) + + expect(json_response["error_description"]).to a_string_including( + "Requests from Editor Extension clients are restricted") + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'returns 401 when using a Personal Access Token' do + get(api(request_path, personal_access_token: personal_access_token), headers: headers) + + expect(json_response["error_description"]).to a_string_including( + "Requests from Editor Extension clients are restricted") + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'with allowed clients' do + using RSpec::Parameterized::TableSyntax + + where(:is_dedicated, :enforce_language_server_version, :enable_language_server_restrictions, :client_version, + :user_agent) do + false | false | false | '0.1.0' | 'gitlab-language-server 0.1.0' + false | false | true | '0.1.0' | 'gitlab-language-server 0.1.0' + false | false | false | nil | 'code-completions-language-server-experiment (gitlab.vim: 1.0.0)' + false | false | false | nil | 'gitlab-language-server 1.0.0' + false | false | false | nil | 'unknown-app 1.0.0' + false | false | false | nil | nil + false | true | true | '1.0.0' | 'gitlab-language-server 1.0.0' + false | true | true | '2.0.0' | 'gitlab-language-server 2.0.0' + true | false | true | '1.0.0' | 'gitlab-language-server 1.0.0' + true | false | true | '2.0.0' | 'gitlab-language-server 2.0.0' + end + + with_them do + before do + stub_feature_flags(enforce_language_server_version: enforce_language_server_version) + + allow(Gitlab::CurrentSettings.current_application_settings).to receive_messages( + enable_language_server_restrictions: enable_language_server_restrictions, + gitlab_dedicated_instance?: is_dedicated, + minimum_language_server_version: '1.0.0') + end + + let(:headers) do + { + 'User-Agent' => user_agent, + 'X-GitLab-Language-Server-Version' => client_version + } + end + + it_behaves_like 'an allowed client' + end + end + + shared_examples 'unallowed clients' do + using RSpec::Parameterized::TableSyntax + + where(:client_version, :user_agent) do + '0.1.0' | 'code-completions-language-server-experiment (gl-visual-studio-extension:1.0.0.0; arch:X64;)' + '0.1.0' | 'gitlab-language-server 1.0.0' + '0.1.0' | nil + nil | 'code-completions-language-server-experiment (gl-visual-studio-extension:1.0.0.0; arch:X64;)' + nil | 'gitlab-language-server 0.1.0' + end + + with_them do + let(:headers) do + { + 'User-Agent' => user_agent, + 'X-GitLab-Language-Server-Version' => client_version + } + end + + it_behaves_like 'an unallowed client' + end + end + + context 'with unallowed clients on dedicated' do + before do + stub_feature_flags(enforce_language_server_version: false) + + allow(Gitlab::CurrentSettings.current_application_settings).to receive_messages( + enable_language_server_restrictions: true, + gitlab_dedicated_instance?: true, + minimum_language_server_version: '1.0.0') + end + + it_behaves_like 'unallowed clients' + end + + context 'with unallowed clients outside of dedicated' do + before do + stub_feature_flags(enforce_language_server_version: true) + + allow(Gitlab::CurrentSettings.current_application_settings).to receive_messages( + enable_language_server_restrictions: true, + gitlab_dedicated_instance?: false, + minimum_language_server_version: '1.0.0') + end + + it_behaves_like 'unallowed clients' + end + end end diff --git a/spec/requests/api/graphql/editor_extensions_spec.rb b/spec/requests/api/graphql/editor_extensions_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..faee63e307e3e2f83bca9cf17fec3eb0c869889b --- /dev/null +++ b/spec/requests/api/graphql/editor_extensions_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Editor Extensions GraphQL integration', :clean_gitlab_redis_cache, feature_category: :editor_extensions do + include GraphqlHelpers + + let_it_be(:organization) { create(:organization) } + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user, organizations: [organization], developer_of: project) } + let(:query) { graphql_query_for('project', { fullPath: project.full_path }, 'id') } + + describe 'language server client restrictions' do + context 'with allowed clients' do + using RSpec::Parameterized::TableSyntax + + where(:is_dedicated, :enforce_language_server_version, :enable_language_server_restrictions, :client_version, + :user_agent) do + false | false | false | '0.1.0' | 'gitlab-language-server 0.1.0' + false | false | false | nil | 'code-completions-language-server-experiment (gitlab.vim: 1.0.0)' + false | false | false | nil | 'gitlab-language-server 1.0.0' + false | false | false | nil | 'unknown-app 1.0.0' + false | false | false | nil | nil + false | false | true | '0.1.0' | 'gitlab-language-server 0.1.0' + false | true | true | '1.0.0' | 'gitlab-language-server 1.0.0' + false | true | true | '2.0.0' | 'gitlab-language-server 2.0.0' + true | false | false | '0.1.0' | 'gitlab-language-server 0.1.0' + true | false | false | nil | 'code-completions-language-server-experiment (gitlab.vim: 1.0.0)' + true | false | false | nil | 'gitlab-language-server 1.0.0' + true | false | false | nil | 'unknown-app 1.0.0' + true | false | false | nil | nil + end + + with_them do + before do + stub_feature_flags(enforce_language_server_version: enforce_language_server_version) + + allow(Gitlab::CurrentSettings.current_application_settings).to receive_messages( + enable_language_server_restrictions: enable_language_server_restrictions, + gitlab_dedicated_instance?: is_dedicated, + minimum_language_server_version: '1.0.0') + end + + it 'is suppported' do + post_graphql(query, current_user: user, headers: { + 'User-Agent' => user_agent, + 'X-GitLab-Language-Server-Version' => client_version + }) + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_data['project']['id']).to eq(project.to_gid.to_s) + end + end + end + + shared_examples 'unallowed clients' do + using RSpec::Parameterized::TableSyntax + + where(:client_version, :user_agent) do + '0.1.0' | 'code-completions-language-server-experiment (gl-visual-studio-extension:1.0.0.0; arch:X64;)' + '0.1.0' | 'gitlab-language-server 1.0.0' + '0.1.0' | nil + nil | 'code-completions-language-server-experiment (gl-visual-studio-extension:1.0.0.0; arch:X64;)' + nil | 'gitlab-language-server 0.1.0' + end + + with_them do + it 'is supported' do + post_graphql(query, current_user: user, headers: { + 'User-Agent' => user_agent, + 'X-GitLab-Language-Server-Version' => client_version + }) + + expect(response).to have_gitlab_http_status(:unauthorized) + expect(graphql_errors).to contain_exactly( + hash_including('message' => a_string_including('Requests from Editor Extension clients are restricted')) + ) + end + end + end + + context 'with unallowed clients on dedicated' do + before do + stub_feature_flags(enforce_language_server_version: false) + + allow(Gitlab::CurrentSettings.current_application_settings).to receive_messages( + enable_language_server_restrictions: true, + gitlab_dedicated_instance?: true, + minimum_language_server_version: '1.0.0') + end + + it_behaves_like 'unallowed clients' + end + + context 'with unallowed clients outside of dedicated' do + before do + stub_feature_flags(enforce_language_server_version: true) + + allow(Gitlab::CurrentSettings.current_application_settings).to receive_messages( + enable_language_server_restrictions: true, + gitlab_dedicated_instance?: false, + minimum_language_server_version: '1.0.0') + end + + it_behaves_like 'unallowed clients' + end + end +end