From 658381b28fc8bd796393c5cff1d20d554c0100c5 Mon Sep 17 00:00:00 2001 From: Enrique Alcantara Date: Wed, 15 Oct 2025 15:23:52 +0200 Subject: [PATCH 1/3] Web IDE extension host domain application setting Add an application setting that allows modifying the Web IDE extension host domain. Use the application setting value to set up CSP rules in the Web IDE and set up the external domain to isolate 3rd-party extensions and web views Changelog: added --- .../javascripts/ide/init_gitlab_web_ide.js | 9 +++- .../get_web_ide_workbench_config.js | 53 +++++++++++++++---- .../admin/application_settings_controller.rb | 8 +++ app/controllers/concerns/web_ide_csp.rb | 2 +- app/helpers/application_settings_helper.rb | 1 + app/helpers/ide_helper.rb | 4 +- app/models/application_setting.rb | 6 ++- .../application_setting_implementation.rb | 1 + ..._setting_vscode_extension_marketplace.json | 11 ++++ .../application_settings/_web_ide.html.haml | 31 +++++++++++ .../application_settings/general.html.haml | 1 + config/routes/admin.rb | 1 + config/webpack.constants.js | 5 +- lib/web_ide/extension_marketplace.rb | 16 ++++++ locale/gitlab.pot | 24 +++++++++ .../application_settings_controller_spec.rb | 27 ++++++++++ spec/features/admin/admin_settings_spec.rb | 25 +++++++++ spec/frontend/ide/init_gitlab_web_ide_spec.js | 13 +++++ .../get_web_ide_workbench_config_spec.js | 41 ++++++++++---- spec/helpers/ide_helper_spec.rb | 8 ++- .../lib/web_ide/extension_marketplace_spec.rb | 48 +++++++++++++++++ spec/models/application_setting_spec.rb | 35 +++++++++++- spec/requests/api/settings_spec.rb | 3 +- spec/requests/ide_controller_spec.rb | 3 +- .../_extension_marketplace.html.haml_spec.rb | 2 +- 25 files changed, 349 insertions(+), 29 deletions(-) create mode 100644 app/views/admin/application_settings/_web_ide.html.haml diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 7cef7ff9ea6e7b..d4d8a16c735c03 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -1,5 +1,5 @@ import { start } from '@gitlab/web-ide'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; import Tracking from '~/tracking'; import { getLineRangeFromHash } from '~/lib/utils/url_utility'; @@ -34,9 +34,14 @@ export const initGitlabWebIDE = async (el) => { extensionMarketplaceSettings: extensionMarketplaceSettingsJSON, settingsContextHash, signOutPath, + extensionHostDomain, + extensionHostDomainChanged, } = el.dataset; - const webIdeWorkbenchConfig = await getWebIDEWorkbenchConfig(); + const webIdeWorkbenchConfig = await getWebIDEWorkbenchConfig({ + extensionHostDomain, + extensionHostDomainChanged: parseBoolean(extensionHostDomainChanged), + }); const container = setupIdeContainer(el); const editorFont = editorFontJSON ? convertObjectPropsToCamelCase(JSON.parse(editorFontJSON), { deep: true }) diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js index c5b8cc92d703de..28dd44bb73e375 100644 --- a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js @@ -1,12 +1,30 @@ import * as packageJSON from '@gitlab/web-ide/package.json'; import { pingWorkbench } from '@gitlab/web-ide'; import { sha256 } from '~/lib/utils/text_utility'; +import { joinPaths } from '~/lib/utils/url_utility'; import { getGitLabUrl } from './get_gitlab_url'; -const buildExtensionHostUrl = () => { - const workbenchVersion = packageJSON.version; +/** + * Builds the URL path that points to the Web IDE extension host + * assets. If the extension host domain changed in application settings, + * the Web IDE assumes that the new domain points to the Gitlab instance + * itself therefore the base path starts with the Gitlab instance root assets + * path "assets/webpack". + * @param {*} extensionHostDomainChanged + * @returns + */ +const buildBaseAssetsPath = (extensionHostDomainChanged) => { + const assetsRoot = extensionHostDomainChanged ? '/assets/webpack' : '/'; + + return joinPaths(assetsRoot, `gitlab-web-ide-vscode-workbench-${packageJSON.version}`); +}; - return `https://{{uuid}}.cdn.web-ide.gitlab-static.net/gitlab-web-ide-vscode-workbench-${workbenchVersion}/vscode`; +const buildExtensionHostUrl = ({ extensionHostDomain, extensionHostDomainChanged }) => { + const extensionHostUrl = new URL( + `https://{{uuid}}.${extensionHostDomain}${joinPaths(buildBaseAssetsPath(extensionHostDomainChanged), 'vscode')}`, + ); + + return extensionHostUrl.href; }; const rejectHTTPEmbedderOrigin = () => { @@ -22,12 +40,15 @@ const rejectHTTPEmbedderOrigin = () => { * to ensure that the URL is unique for each user. * @returns {string} */ -export const buildWorkbenchUrl = async () => { +export const buildWorkbenchUrl = async ({ extensionHostDomain, extensionHostDomainChanged }) => { const digest = await sha256(`${window.location.origin}-${window.gon.current_username}`); const digestShort = digest.slice(0, 30); - const workbenchVersion = packageJSON.version; + const workbenchBasePath = buildBaseAssetsPath(extensionHostDomainChanged); + const workbenchUrl = new URL( + `https://workbench-${digestShort}.${extensionHostDomain}${workbenchBasePath}`, + ); - return `https://workbench-${digestShort}.cdn.web-ide.gitlab-static.net/gitlab-web-ide-vscode-workbench-${workbenchVersion}`; + return workbenchUrl.href; }; /** @@ -38,13 +59,27 @@ export const buildWorkbenchUrl = async () => { * - extensionsHostBaseUrl URL pointing to the origin and the base path where the Web IDE's extensions host assets are hosted. * - crossOriginExtensionHost Boolean specifying whether the extensions host will use cross-origin isolation. */ -export const getWebIDEWorkbenchConfig = async () => { - const extensionsHostBaseUrl = buildExtensionHostUrl(); +export const getWebIDEWorkbenchConfig = async ({ + extensionHostDomain, + extensionHostDomainChanged = false, +} = {}) => { + if (typeof extensionHostDomain !== 'string' || !extensionHostDomain) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Provide a valid extension host domain'); + } + + const extensionsHostBaseUrl = buildExtensionHostUrl({ + extensionHostDomain, + extensionHostDomainChanged, + }); try { rejectHTTPEmbedderOrigin(); - const workbenchBaseUrl = await buildWorkbenchUrl(); + const workbenchBaseUrl = await buildWorkbenchUrl({ + extensionHostDomain, + extensionHostDomainChanged, + }); await pingWorkbench({ el: document.body, diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index d3d625ff8bd722..3ff466b6ea3310 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -38,6 +38,7 @@ class ApplicationSettingsController < Admin::ApplicationController feature_category :observability, [:reset_error_tracking_access_token] feature_category :global_search, [:search] feature_category :environment_management, [:usage_quotas] + feature_category :editor_extensions, [:reset_vscode_extension_marketplace_extension_host_domain] VALID_SETTING_PANELS = %w[general repository ci_cd reporting metrics_and_profiling @@ -104,6 +105,13 @@ def reset_error_tracking_access_token notice: _('New error tracking access token has been generated!') end + def reset_vscode_extension_marketplace_extension_host_domain + ::WebIde::ExtensionMarketplace.reset_extension_host_domain! + + redirect_to general_admin_application_settings_path(anchor: 'js-web-ide-settings'), + notice: _('The Web IDE extension host domain was restored to its default value.') + end + def clear_repository_check_states RepositoryCheck::ClearWorker.perform_async # rubocop:disable CodeReuse/Worker diff --git a/app/controllers/concerns/web_ide_csp.rb b/app/controllers/concerns/web_ide_csp.rb index 0d67393e3bd574..072eebbddb515a 100644 --- a/app/controllers/concerns/web_ide_csp.rb +++ b/app/controllers/concerns/web_ide_csp.rb @@ -23,7 +23,7 @@ def include_web_ide_csp default_src = Array(request.content_security_policy.directives['default-src'] || []) request.content_security_policy.directives['frame-src'] ||= default_src - request.content_security_policy.directives['frame-src'].concat([webpack_url, 'https://*.web-ide.gitlab-static.net/', + request.content_security_policy.directives['frame-src'].concat([webpack_url, "https://*.#{WebIde::ExtensionMarketplace.extension_host_domain}/", ide_oauth_redirect_url, oauth_authorization_url]) request.content_security_policy.directives['worker-src'] ||= default_src diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index fe89034fb16e53..bb3b0a94d944b6 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -636,6 +636,7 @@ def visible_attributes :minimum_language_server_version, :vscode_extension_marketplace, :vscode_extension_marketplace_enabled, + :vscode_extension_marketplace_extension_host_domain, :reindexing_minimum_index_size, :reindexing_minimum_relative_bloat_size, :anonymous_searches_allowed, diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 476bc24a35b9b2..4875237362cc65 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -83,7 +83,9 @@ def extend_ide_data(project:) 'csp-nonce' => content_security_policy_nonce, 'editor-font' => ide_fonts.to_json, 'extension-marketplace-settings' => extension_marketplace_settings.to_json, - 'settings-context-hash' => settings_context_hash + 'settings-context-hash' => settings_context_hash, + 'extension-host-domain' => WebIde::ExtensionMarketplace.extension_host_domain, + 'extension-host-domain-changed' => WebIde::ExtensionMarketplace.extension_host_domain_changed? }.merge(ide_code_suggestions_data).merge(ide_oauth_data) end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index ba82b76bd5c6e4..9316182d1b1b22 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -1025,7 +1025,11 @@ def self.kroki_formats_attributes json_schema: { filename: "application_setting_vscode_extension_marketplace", detail_errors: true } jsonb_accessor :vscode_extension_marketplace, - vscode_extension_marketplace_enabled: [:boolean, { default: false, store_key: :enabled }] + vscode_extension_marketplace_enabled: [:boolean, { default: false, store_key: :enabled }], + vscode_extension_marketplace_extension_host_domain: [ + :string, + { default: ::WebIde::ExtensionMarketplace::DEFAULT_EXTENSION_HOST_DOMAIN, store_key: :extension_host_domain } + ] jsonb_accessor :editor_extensions, enable_language_server_restrictions: [:boolean, { default: false }], diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index d2dd3fc3eec7e2..66f4654af62a5b 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -351,6 +351,7 @@ def defaults # rubocop:disable Metrics/AbcSize top_level_group_creation_enabled: true, ropc_without_client_credentials: true, vscode_extension_marketplace_enabled: false, + vscode_extension_marketplace_extension_host_domain: ::WebIde::ExtensionMarketplace::DEFAULT_EXTENSION_HOST_DOMAIN, reindexing_minimum_index_size: 1.gigabyte, reindexing_minimum_relative_bloat_size: 0.2, git_push_pipeline_limit: 4, diff --git a/app/validators/json_schemas/application_setting_vscode_extension_marketplace.json b/app/validators/json_schemas/application_setting_vscode_extension_marketplace.json index 54c0c0ae07cb6e..2e34937e7153fe 100644 --- a/app/validators/json_schemas/application_setting_vscode_extension_marketplace.json +++ b/app/validators/json_schemas/application_setting_vscode_extension_marketplace.json @@ -9,6 +9,17 @@ "default": false, "description": "Should the VSCode Extension Marketplace be enabled for Web IDE and Workspaces" }, + "extension_host_domain": { + "type": "string", + "pattern": "^(?=.{1,253}$)(?!-)(?:[a-zA-Z0-9-]{1,63}(? { el.dataset.userPreferencesPath = TEST_USER_PREFERENCES_PATH; el.dataset.mergeRequest = TEST_MR_ID; el.dataset.filePath = TEST_FILE_PATH; + el.dataset.extensionHostDomain = 'web-ide.example.net'; + el.dataset.extensionHostDomainChanged = true; el.dataset.editorFont = JSON.stringify({ fallback_font_family: 'monospace', font_faces: [ @@ -180,6 +182,17 @@ describe('ide/init_gitlab_web_ide', () => { }); }); + it('provides extensionHostDomain and extensionHostDomainChanged external parameters to workbench URL builder', () => { + const rootEl = document.getElementById(ROOT_ELEMENT_ID); + + expect(rootEl.dataset.extensionHostDomain).toBe('web-ide.example.net'); + expect(rootEl.dataset.extensionHostDomainChanged).toBe('true'); + expect(getWebIDEWorkbenchConfig).toHaveBeenCalledWith({ + extensionHostDomain: 'web-ide.example.net', + extensionHostDomainChanged: true, + }); + }); + describe('when web-ide is ready', () => { beforeEach(() => { start.mockResolvedValue({ ready: Promise.resolve() }); diff --git a/spec/frontend/ide/lib/gitlab_web_ide/get_web_ide_workbench_config_spec.js b/spec/frontend/ide/lib/gitlab_web_ide/get_web_ide_workbench_config_spec.js index 26e9b818c76b36..d4c760c31813d4 100644 --- a/spec/frontend/ide/lib/gitlab_web_ide/get_web_ide_workbench_config_spec.js +++ b/spec/frontend/ide/lib/gitlab_web_ide/get_web_ide_workbench_config_spec.js @@ -14,6 +14,10 @@ jest.mock('~/ide/lib/gitlab_web_ide/get_gitlab_url'); describe('~/ide/lib/gitlab_web_ide/get_base_config', () => { const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/gitlab-web-ide/public/path'; const GITLAB_URL = 'https://gitlab.example.com'; + const DEFAULT_PARAMETERS = { + extensionHostDomain: 'web-ide-example.net', + extensionHostDomainChanged: false, + }; useMockLocationHelper(); stubCrypto(); @@ -33,7 +37,7 @@ describe('~/ide/lib/gitlab_web_ide/get_base_config', () => { beforeEach(async () => { window.location.protocol = 'http:'; - config = await getWebIDEWorkbenchConfig(); + config = await getWebIDEWorkbenchConfig(DEFAULT_PARAMETERS); }); it('does not call pingWorkbench', () => { @@ -49,25 +53,44 @@ describe('~/ide/lib/gitlab_web_ide/get_base_config', () => { }); }); + describe('when the extensionHostDomain changed', () => { + let config; + + beforeEach(async () => { + config = await getWebIDEWorkbenchConfig({ + ...DEFAULT_PARAMETERS, + extensionHostDomainChanged: true, + }); + }); + + it('appends /assets/webpack to the URL paths', () => { + expect(config).toEqual({ + crossOriginExtensionHost: true, + workbenchBaseUrl: `https://workbench-82f9aaae2ef4f6ffb993ca55c2a2eb.${DEFAULT_PARAMETERS.extensionHostDomain}/assets/webpack/gitlab-web-ide-vscode-workbench-${packageJSON.version}`, + extensionsHostBaseUrl: `https://{{uuid}}.${DEFAULT_PARAMETERS.extensionHostDomain}/assets/webpack/gitlab-web-ide-vscode-workbench-${packageJSON.version}/vscode`, + }); + }); + }); + describe('when pingWorkbench is successful', () => { beforeEach(() => { pingWorkbench.mockResolvedValueOnce(); }); it('returns workbench configuration based on cdn.web-ide.gitlab-static.net', async () => { - const config = await getWebIDEWorkbenchConfig(); + const config = await getWebIDEWorkbenchConfig(DEFAULT_PARAMETERS); expect(pingWorkbench).toHaveBeenCalledWith({ el: document.body, config: { - workbenchBaseUrl: `https://workbench-82f9aaae2ef4f6ffb993ca55c2a2eb.cdn.web-ide.gitlab-static.net/gitlab-web-ide-vscode-workbench-${packageJSON.version}`, + workbenchBaseUrl: `https://workbench-82f9aaae2ef4f6ffb993ca55c2a2eb.${DEFAULT_PARAMETERS.extensionHostDomain}/gitlab-web-ide-vscode-workbench-${packageJSON.version}`, gitlabUrl: 'https://gitlab.example.com', }, }); expect(config).toEqual({ crossOriginExtensionHost: true, - workbenchBaseUrl: `https://workbench-82f9aaae2ef4f6ffb993ca55c2a2eb.cdn.web-ide.gitlab-static.net/gitlab-web-ide-vscode-workbench-${packageJSON.version}`, - extensionsHostBaseUrl: `https://{{uuid}}.cdn.web-ide.gitlab-static.net/gitlab-web-ide-vscode-workbench-${packageJSON.version}/vscode`, + workbenchBaseUrl: `https://workbench-82f9aaae2ef4f6ffb993ca55c2a2eb.${DEFAULT_PARAMETERS.extensionHostDomain}/gitlab-web-ide-vscode-workbench-${packageJSON.version}`, + extensionsHostBaseUrl: `https://{{uuid}}.${DEFAULT_PARAMETERS.extensionHostDomain}/gitlab-web-ide-vscode-workbench-${packageJSON.version}/vscode`, }); }); }); @@ -78,12 +101,12 @@ describe('~/ide/lib/gitlab_web_ide/get_base_config', () => { }); it('return workbenchConfiguration based on gitlabUrl', async () => { - const result = await getWebIDEWorkbenchConfig(); + const result = await getWebIDEWorkbenchConfig(DEFAULT_PARAMETERS); expect(pingWorkbench).toHaveBeenCalledWith({ el: document.body, config: { - workbenchBaseUrl: `https://workbench-82f9aaae2ef4f6ffb993ca55c2a2eb.cdn.web-ide.gitlab-static.net/gitlab-web-ide-vscode-workbench-${packageJSON.version}`, + workbenchBaseUrl: `https://workbench-82f9aaae2ef4f6ffb993ca55c2a2eb.${DEFAULT_PARAMETERS.extensionHostDomain}/gitlab-web-ide-vscode-workbench-${packageJSON.version}`, gitlabUrl: 'https://gitlab.example.com', }, }); @@ -110,8 +133,8 @@ describe('~/ide/lib/gitlab_web_ide/get_base_config', () => { window.location.origin = origin; window.gon.current_username = currentUsername; - expect(await buildWorkbenchUrl()).toBe( - `https://workbench-${result}.cdn.web-ide.gitlab-static.net/gitlab-web-ide-vscode-workbench-${packageJSON.version}`, + expect(await buildWorkbenchUrl(DEFAULT_PARAMETERS)).toBe( + `https://workbench-${result}.${DEFAULT_PARAMETERS.extensionHostDomain}/gitlab-web-ide-vscode-workbench-${packageJSON.version}`, ); }, ); diff --git a/spec/helpers/ide_helper_spec.rb b/spec/helpers/ide_helper_spec.rb index b1401a3f81b39f..b0458ee46c08e1 100644 --- a/spec/helpers/ide_helper_spec.rb +++ b/spec/helpers/ide_helper_spec.rb @@ -105,12 +105,18 @@ it 'includes extension marketplace settings and settings context hash' do expect(WebIde::ExtensionMarketplace).to receive(:webide_extension_marketplace_settings) .with(user: user).and_return(settings) + expect(WebIde::ExtensionMarketplace).to receive(:extension_host_domain) + .and_return('web-ide.net') + expect(WebIde::ExtensionMarketplace).to receive(:extension_host_domain_changed?) + .and_return(true) actual = helper.ide_data(project: nil, fork_info: fork_info, params: params) expect(actual).to include({ 'extension-marketplace-settings' => settings.to_json, - 'settings-context-hash' => expected_settings_hash + 'settings-context-hash' => expected_settings_hash, + 'extension-host-domain' => 'web-ide.net', + 'extension-host-domain-changed' => true }) end end diff --git a/spec/lib/web_ide/extension_marketplace_spec.rb b/spec/lib/web_ide/extension_marketplace_spec.rb index e7ca71cad80dd8..d1d12d5992de1a 100644 --- a/spec/lib/web_ide/extension_marketplace_spec.rb +++ b/spec/lib/web_ide/extension_marketplace_spec.rb @@ -97,4 +97,52 @@ it { is_expected.to match(expectation) } end end + + describe '#extension_host_domain' do + subject(:extension_host_domain) { described_class.extension_host_domain } + + context 'when vscode_extension_marketplace_extension_host_domain is set to default' do + before do + Gitlab::CurrentSettings.update!( + vscode_extension_marketplace_extension_host_domain: 'cdn.web-ide.gitlab-static.net' + ) + end + + it { is_expected.to eq('cdn.web-ide.gitlab-static.net') } + end + + context 'when vscode_extension_marketplace_extension_host_domain is set to custom domain' do + before do + Gitlab::CurrentSettings.update!( + vscode_extension_marketplace_extension_host_domain: 'custom-cdn.example.com' + ) + end + + it { is_expected.to eq('custom-cdn.example.com') } + end + end + + describe '#extension_host_domain_changed?' do + subject(:extension_host_domain_changed) { described_class.extension_host_domain_changed? } + + context 'when extension_host_domain is set to default value' do + before do + Gitlab::CurrentSettings.update!( + vscode_extension_marketplace_extension_host_domain: 'cdn.web-ide.gitlab-static.net' + ) + end + + it { is_expected.to be(false) } + end + + context 'when extension_host_domain is set to custom domain' do + before do + Gitlab::CurrentSettings.update!( + vscode_extension_marketplace_extension_host_domain: 'custom-cdn.example.com' + ) + end + + it { is_expected.to be(true) } + end + end end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 9b9c6b443856f8..45b8854f893a76 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -299,8 +299,13 @@ users_get_by_id_limit: 300, users_get_by_id_limit_allowlist: [], valid_runner_registrars: ApplicationSettingImplementation::VALID_RUNNER_REGISTRAR_TYPES, - vscode_extension_marketplace: { 'enabled' => false }, + vscode_extension_marketplace: { + 'enabled' => false, + 'extension_host_domain' => ::WebIde::ExtensionMarketplace::DEFAULT_EXTENSION_HOST_DOMAIN + }, vscode_extension_marketplace_enabled?: false, + vscode_extension_marketplace_extension_host_domain: + ::WebIde::ExtensionMarketplace::DEFAULT_EXTENSION_HOST_DOMAIN, whats_new_variant: 'all_tiers', # changed from 0 to "all_tiers" due to enum conversion wiki_asciidoc_allow_uri_includes: false, wiki_page_max_content_bytes: 5.megabytes @@ -2473,6 +2478,34 @@ def expect_invalid end end + describe '#vscode_extension_marketplace_extension_host_domain' do + context 'with valid domain' do + it { is_expected.to allow_value({ extension_host_domain: 'foo.net' }).for(:vscode_extension_marketplace) } + it { is_expected.to allow_value({ extension_host_domain: 'cdn.foo.net' }).for(:vscode_extension_marketplace) } + end + + context "with invalid domain" do + using RSpec::Parameterized::TableSyntax + + where(:domain) do + %w[ + foo + invalid domain + http://foo.com + example..com + -example.com + example.com- + .example.com + example.com. + ] + end + + with_them do + it { is_expected.not_to allow_value({ extension_host_domain: domain }).for(:vscode_extension_marketplace) } + end + end + end + describe '#static_objects_external_storage_auth_token=', :aggregate_failures do subject(:set_auth_token) { setting.static_objects_external_storage_auth_token = token } diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index ee5f7637b8e47a..57971d29256be5 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -1244,7 +1244,8 @@ expect(response).to have_gitlab_http_status(:ok) expect(json_response['vscode_extension_marketplace_enabled']).to eq(true) - expect(json_response['vscode_extension_marketplace']).to eq({ "enabled" => true }) + expect(json_response['vscode_extension_marketplace']) + .to eq({ "enabled" => true, "extension_host_domain" => "cdn.web-ide.gitlab-static.net" }) end end end diff --git a/spec/requests/ide_controller_spec.rb b/spec/requests/ide_controller_spec.rb index 5571d4f5debdd0..b29e00c1878b8e 100644 --- a/spec/requests/ide_controller_spec.rb +++ b/spec/requests/ide_controller_spec.rb @@ -194,7 +194,8 @@ it 'updates the content security policy with the correct frame sources' do subject - expect(find_csp_directive('frame-src')).to include("http://www.example.com/assets/webpack/", "https://*.web-ide.gitlab-static.net/", + expect(find_csp_directive('frame-src')).to include("http://www.example.com/assets/webpack/", + "https://*.cdn.web-ide.gitlab-static.net/", ide_oauth_redirect_url, oauth_authorization_url) expect(find_csp_directive('worker-src')).to include("http://www.example.com/assets/webpack/") end diff --git a/spec/views/admin/application_settings/_extension_marketplace.html.haml_spec.rb b/spec/views/admin/application_settings/_extension_marketplace.html.haml_spec.rb index 08615494a08559..222f9b111254f1 100644 --- a/spec/views/admin/application_settings/_extension_marketplace.html.haml_spec.rb +++ b/spec/views/admin/application_settings/_extension_marketplace.html.haml_spec.rb @@ -38,7 +38,7 @@ expected_json = { presets: expected_presets, - initialSettings: { enabled: false } + initialSettings: { enabled: false, extension_host_domain: "cdn.web-ide.gitlab-static.net" } }.to_json expect(vue_app).not_to be_nil -- GitLab From b72e30e006c6eec1d40579896b358133695cd4e4 Mon Sep 17 00:00:00 2001 From: Enrique Alcantara Date: Fri, 17 Oct 2025 10:53:41 +0200 Subject: [PATCH 2/3] Code review feedback - Fix boolean view parameter - Make buildWorkbenchUrl function more readable - Display non existing host domain error to users - Use findRootElement helper --- .../javascripts/ide/init_gitlab_web_ide.js | 44 +++++++++---------- .../get_web_ide_workbench_config.js | 15 ++++--- app/helpers/ide_helper.rb | 2 +- locale/gitlab.pot | 3 ++ spec/frontend/ide/init_gitlab_web_ide_spec.js | 2 +- spec/helpers/ide_helper_spec.rb | 2 +- 6 files changed, 38 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index d4d8a16c735c03..83d6a8dc86179c 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -38,31 +38,31 @@ export const initGitlabWebIDE = async (el) => { extensionHostDomainChanged, } = el.dataset; - const webIdeWorkbenchConfig = await getWebIDEWorkbenchConfig({ - extensionHostDomain, - extensionHostDomainChanged: parseBoolean(extensionHostDomainChanged), - }); - const container = setupIdeContainer(el); - const editorFont = editorFontJSON - ? convertObjectPropsToCamelCase(JSON.parse(editorFontJSON), { deep: true }) - : null; - const forkInfo = forkInfoJSON ? JSON.parse(forkInfoJSON) : null; - const extensionMarketplaceSettings = extensionMarketplaceSettingsJSON - ? convertObjectPropsToCamelCase(JSON.parse(extensionMarketplaceSettingsJSON), { deep: true }) - : undefined; + try { + const webIdeWorkbenchConfig = await getWebIDEWorkbenchConfig({ + extensionHostDomain, + extensionHostDomainChanged: parseBoolean(extensionHostDomainChanged), + }); + const container = setupIdeContainer(el); + const editorFont = editorFontJSON + ? convertObjectPropsToCamelCase(JSON.parse(editorFontJSON), { deep: true }) + : null; + const forkInfo = forkInfoJSON ? JSON.parse(forkInfoJSON) : null; + const extensionMarketplaceSettings = extensionMarketplaceSettingsJSON + ? convertObjectPropsToCamelCase(JSON.parse(extensionMarketplaceSettingsJSON), { deep: true }) + : undefined; - const oauthConfig = getOAuthConfig(el.dataset); - const httpHeaders = oauthConfig - ? undefined - : // Use same headers as defined in axios_utils (not needed in oauth) - { - [csrf.headerKey]: csrf.token, - 'X-Requested-With': 'XMLHttpRequest', - }; + const oauthConfig = getOAuthConfig(el.dataset); + const httpHeaders = oauthConfig + ? undefined + : // Use same headers as defined in axios_utils (not needed in oauth) + { + [csrf.headerKey]: csrf.token, + 'X-Requested-With': 'XMLHttpRequest', + }; - const lineRange = getLineRangeFromHash(); + const lineRange = getLineRangeFromHash(); - try { // See ClientOnlyConfig https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L17 const { ready } = await start(container.element, { ...getBaseConfig(), diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js index 28dd44bb73e375..95d23e235b35bf 100644 --- a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js @@ -1,5 +1,6 @@ import * as packageJSON from '@gitlab/web-ide/package.json'; import { pingWorkbench } from '@gitlab/web-ide'; +import { s__ } from '~/locale'; import { sha256 } from '~/lib/utils/text_utility'; import { joinPaths } from '~/lib/utils/url_utility'; import { getGitLabUrl } from './get_gitlab_url'; @@ -20,9 +21,10 @@ const buildBaseAssetsPath = (extensionHostDomainChanged) => { }; const buildExtensionHostUrl = ({ extensionHostDomain, extensionHostDomainChanged }) => { - const extensionHostUrl = new URL( - `https://{{uuid}}.${extensionHostDomain}${joinPaths(buildBaseAssetsPath(extensionHostDomainChanged), 'vscode')}`, - ); + const baseAssetsPath = buildBaseAssetsPath(extensionHostDomainChanged); + const fullAssetsPath = joinPaths(baseAssetsPath, 'vscode'); + + const extensionHostUrl = new URL(`https://{{uuid}}.${extensionHostDomain}${fullAssetsPath}`); return extensionHostUrl.href; }; @@ -64,8 +66,11 @@ export const getWebIDEWorkbenchConfig = async ({ extensionHostDomainChanged = false, } = {}) => { if (typeof extensionHostDomain !== 'string' || !extensionHostDomain) { - // eslint-disable-next-line @gitlab/require-i18n-strings - throw new Error('Provide a valid extension host domain'); + throw new Error( + s__( + 'WebIDE|The Web IDE does not have a valid extension host domain and it could not be initialized.', + ), + ); } const extensionsHostBaseUrl = buildExtensionHostUrl({ diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 4875237362cc65..d97943be77f9d9 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -85,7 +85,7 @@ def extend_ide_data(project:) 'extension-marketplace-settings' => extension_marketplace_settings.to_json, 'settings-context-hash' => settings_context_hash, 'extension-host-domain' => WebIde::ExtensionMarketplace.extension_host_domain, - 'extension-host-domain-changed' => WebIde::ExtensionMarketplace.extension_host_domain_changed? + 'extension-host-domain-changed' => WebIde::ExtensionMarketplace.extension_host_domain_changed?.to_s }.merge(ide_code_suggestions_data).merge(ide_oauth_data) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 66a2c8cdef7bda..e79d9f0f6aa6d7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -73715,6 +73715,9 @@ msgstr "" msgid "WebIDE|Quickly and easily edit multiple files in your project. Press . to open" msgstr "" +msgid "WebIDE|The Web IDE does not have a valid extension host domain and it could not be initialized." +msgstr "" + msgid "WebIdeOAuthCallback|Close tab" msgstr "" diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index 5556a993e5d00f..7a208d372eeae6 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -183,7 +183,7 @@ describe('ide/init_gitlab_web_ide', () => { }); it('provides extensionHostDomain and extensionHostDomainChanged external parameters to workbench URL builder', () => { - const rootEl = document.getElementById(ROOT_ELEMENT_ID); + const rootEl = findRootElement(); expect(rootEl.dataset.extensionHostDomain).toBe('web-ide.example.net'); expect(rootEl.dataset.extensionHostDomainChanged).toBe('true'); diff --git a/spec/helpers/ide_helper_spec.rb b/spec/helpers/ide_helper_spec.rb index b0458ee46c08e1..ddbcb2e5722370 100644 --- a/spec/helpers/ide_helper_spec.rb +++ b/spec/helpers/ide_helper_spec.rb @@ -116,7 +116,7 @@ 'extension-marketplace-settings' => settings.to_json, 'settings-context-hash' => expected_settings_hash, 'extension-host-domain' => 'web-ide.net', - 'extension-host-domain-changed' => true + 'extension-host-domain-changed' => "true" }) end end -- GitLab From bebd310ad036cd95b85d57942b21e68fead49936 Mon Sep 17 00:00:00 2001 From: Enrique Alcantara Date: Mon, 20 Oct 2025 09:42:29 +0200 Subject: [PATCH 3/3] Code review feedback --- .../get_web_ide_workbench_config.js | 45 +++++++++++++++---- .../application_settings/_web_ide.html.haml | 1 - 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js index 95d23e235b35bf..a55534a9e1ed03 100644 --- a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_web_ide_workbench_config.js @@ -11,7 +11,8 @@ import { getGitLabUrl } from './get_gitlab_url'; * the Web IDE assumes that the new domain points to the Gitlab instance * itself therefore the base path starts with the Gitlab instance root assets * path "assets/webpack". - * @param {*} extensionHostDomainChanged + * @param {Boolean} extensionHostDomainChanged Whether the base extension host domain + * is not the default value built into the GitLab instance. * @returns */ const buildBaseAssetsPath = (extensionHostDomainChanged) => { @@ -20,11 +21,26 @@ const buildBaseAssetsPath = (extensionHostDomainChanged) => { return joinPaths(assetsRoot, `gitlab-web-ide-vscode-workbench-${packageJSON.version}`); }; +/** + * Builds the URL that points to the VSCode extension host service. If instance admin + * provides a custom the extension host domain, this function prepends `/assets/webpack` + * to the URL path because it assumes the custom extension host domains points to the + * GitLab instance. + * + * VSCode expects that the extension host domain is a wildcard therefore we insert a placeholder + * {{uuid}} at the beginning of the domain. + * + * @param {String} options.extensionHostDomain Base extension host domain coming from + * application settings + * @param {Boolean} options.extensionHostDomainChanged Whether the base extension host domain + * is not the default value built into the GitLab instance. + * @returns + */ const buildExtensionHostUrl = ({ extensionHostDomain, extensionHostDomainChanged }) => { const baseAssetsPath = buildBaseAssetsPath(extensionHostDomainChanged); const fullAssetsPath = joinPaths(baseAssetsPath, 'vscode'); - const extensionHostUrl = new URL(`https://{{uuid}}.${extensionHostDomain}${fullAssetsPath}`); + const extensionHostUrl = new URL(fullAssetsPath, `https://{{uuid}}.${extensionHostDomain}`); return extensionHostUrl.href; }; @@ -36,18 +52,26 @@ const rejectHTTPEmbedderOrigin = () => { }; /** - * Generates the workbench URL for Web IDE + * Builds the URL that points to the VSCode workbench assets. If instance admin + * provides a custom the extension host domain, this function prepends `/assets/webpack` + * to the URL path because it assumes the custom extension host domains points to the + * GitLab instance. + * + * The client generates a workbench subdomain based on the GitLab instance domain and the + * current username. + * + * @param {String} options.extensionHostDomain Base extension host domain coming from + * application settings + * @param {Boolean} options.extensionHostDomainChanged Whether the base extension host domain + * is not the default value built into the GitLab instance. * - * Uses the current user's username and the origin to generate a digest - * to ensure that the URL is unique for each user. - * @returns {string} */ export const buildWorkbenchUrl = async ({ extensionHostDomain, extensionHostDomainChanged }) => { const digest = await sha256(`${window.location.origin}-${window.gon.current_username}`); const digestShort = digest.slice(0, 30); - const workbenchBasePath = buildBaseAssetsPath(extensionHostDomainChanged); const workbenchUrl = new URL( - `https://workbench-${digestShort}.${extensionHostDomain}${workbenchBasePath}`, + buildBaseAssetsPath(extensionHostDomainChanged), + `https://workbench-${digestShort}.${extensionHostDomain}`, ); return workbenchUrl.href; @@ -56,6 +80,11 @@ export const buildWorkbenchUrl = async ({ extensionHostDomain, extensionHostDoma /** * Retrieves configuration for Web IDE workbench. * + * @param {String} options.extensionHostDomain Base extension host domain coming from + * application settings + * @param {Boolean} options.extensionHostDomainChanged Whether the base extension host domain + * is not the default value built into the GitLab instance. + * * @returns An object containing the following properties * - workbenchBaseUrl URL pointing to the origin and base path where the Web IDE's workbench assets are hosted. * - extensionsHostBaseUrl URL pointing to the origin and the base path where the Web IDE's extensions host assets are hosted. diff --git a/app/views/admin/application_settings/_web_ide.html.haml b/app/views/admin/application_settings/_web_ide.html.haml index 97e7e281043e1c..ab1e70aae325ce 100644 --- a/app/views/admin/application_settings/_web_ide.html.haml +++ b/app/views/admin/application_settings/_web_ide.html.haml @@ -1,6 +1,5 @@ = render ::Layouts::SettingsBlockComponent.new(_('Web IDE'), id: 'js-web-ide-settings', - testid: 'web-ide-settings', expanded: expanded_by_default?) do |c| - c.with_description do = s_('AdminSettings|Configure the Web IDE extension host domain.') -- GitLab