From 6a0628fc3d74c441aceae7b8db8b7051b4b4bb0d Mon Sep 17 00:00:00 2001 From: Miguel Rincon Date: Mon, 3 Nov 2025 12:09:16 +0100 Subject: [PATCH] Prevent usages of navigator.clipboard This change adds an eslint rule to warn contributors against using `navigator.clipboard`. `navigator.clipboard` may not work in non-secure environments, like a default GDK install, in most browsers. Instead, it suggests using `~/lib/utils/copy_to_clipboard.js` as an alternative. --- .../components/bubble_menus/code_block_bubble_menu.vue | 1 + .../components/bubble_menus/link_bubble_menu.vue | 1 + .../components/bubble_menus/reference_bubble_menu.vue | 1 + app/assets/javascripts/glql/components/common/facade.vue | 1 + app/assets/javascripts/glql/utils/copy_as_gfm.js | 1 + app/assets/javascripts/lib/utils/copy_to_clipboard.js | 3 +++ .../routes/candidates/show/candidate_detail.vue | 1 + .../experiments/index/components/mlflow_usage_modal.vue | 1 + .../ml/model_registry/apps/show_ml_model_version.vue | 1 + .../repository/components/blob_content_viewer.vue | 1 + .../repository/components/header_area/blob_controls.vue | 1 + .../input_copy_toggle_visibility.vue | 1 + .../vue_shared/components/modal_copy_button.vue | 5 +++++ .../work_items/components/notes/work_item_note.vue | 1 + .../work_items/components/work_item_actions.vue | 1 + .../work_item_development_branch_item.vue | 1 + .../work_item_development_mr_item.vue | 1 + .../frameworks_report/framework_info_drawer.vue | 2 ++ .../components/frameworks_report/frameworks_table.vue | 1 + .../compliance_violations/components/discussion_note.vue | 1 + .../get_started/components/command_line_modal.vue | 1 + .../shared/analytics_clipboard_input.vue | 1 + eslint.config.mjs | 9 ++++++++- 23 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue index efb66f07710b8e..8c745d7627d1b9 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue @@ -106,6 +106,7 @@ export default { }, copyCodeBlockText() { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.getCodeBlockText()); }, diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue index ba82407f700347..f7a4ac43904fa8 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue @@ -172,6 +172,7 @@ export default { ? this.linkCanonicalSrc : joinPaths(gon.gitlab_url, this.linkHref); + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(fullUrl); }, diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue index 2f0eb8a6bef7c5..f0f3c134a4d43c 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue @@ -136,6 +136,7 @@ export default { this.tiptapEditor.chain().focus().deleteSelection().run(); }, copyReferenceURL() { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.href); }, applyFormat(value) { diff --git a/app/assets/javascripts/glql/components/common/facade.vue b/app/assets/javascripts/glql/components/common/facade.vue index 6bc4831466137b..c9ed315f05c5f1 100644 --- a/app/assets/javascripts/glql/components/common/facade.vue +++ b/app/assets/javascripts/glql/components/common/facade.vue @@ -122,6 +122,7 @@ export default { }, copySource() { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.wrappedQuery); }, diff --git a/app/assets/javascripts/glql/utils/copy_as_gfm.js b/app/assets/javascripts/glql/utils/copy_as_gfm.js index 754d067857d101..3090e8b5b71d0d 100644 --- a/app/assets/javascripts/glql/utils/copy_as_gfm.js +++ b/app/assets/javascripts/glql/utils/copy_as_gfm.js @@ -20,5 +20,6 @@ export async function copyGLQLNodeAsGFM(el) { 'text/html': new Blob([html], { type: 'text/html' }), }); + // eslint-disable-next-line no-restricted-properties -- navigator.clipboard intentionally used here navigator.clipboard.write([clipboardItem]); } diff --git a/app/assets/javascripts/lib/utils/copy_to_clipboard.js b/app/assets/javascripts/lib/utils/copy_to_clipboard.js index d1789a350cc9a8..975f299fb1674d 100644 --- a/app/assets/javascripts/lib/utils/copy_to_clipboard.js +++ b/app/assets/javascripts/lib/utils/copy_to_clipboard.js @@ -9,7 +9,10 @@ */ export const copyToClipboard = (text, container = document.body) => { // First, try a simple clipboard.writeText (works on https and localhost) + + // eslint-disable-next-line no-restricted-properties -- navigator.clipboard intentionally used here if (navigator.clipboard && window.isSecureContext) { + // eslint-disable-next-line no-restricted-properties -- navigator.clipboard intentionally used here return navigator.clipboard.writeText(text); } diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_detail.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_detail.vue index 54808a17422bb3..a067a0eea18c26 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_detail.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_detail.vue @@ -97,6 +97,7 @@ export default { }, methods: { copyMlflowId() { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.info.eid); }, }, diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/mlflow_usage_modal.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/mlflow_usage_modal.vue index 42caae74571526..6dac7175e8c027 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/mlflow_usage_modal.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/mlflow_usage_modal.vue @@ -33,6 +33,7 @@ export default { }, methods: { copyInstructions() { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.instruction.cmd); }, }, diff --git a/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue b/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue index a32c8019f56716..6cb4e1e924245b 100644 --- a/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue +++ b/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue @@ -256,6 +256,7 @@ export default { } }, copyMlflowId() { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.candidate.info.eid); }, }, diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 69caadccc5b681..0e186a2404031c 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -379,6 +379,7 @@ export default { this.forkTarget = target; }, onCopy() { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.blobInfo.rawTextBlob); }, handleToggleBlame() { diff --git a/app/assets/javascripts/repository/components/header_area/blob_controls.vue b/app/assets/javascripts/repository/components/header_area/blob_controls.vue index 5a3abbc5872dbc..6f7c8f245d6546 100644 --- a/app/assets/javascripts/repository/components/header_area/blob_controls.vue +++ b/app/assets/javascripts/repository/components/header_area/blob_controls.vue @@ -243,6 +243,7 @@ export default { this.trackEvent(BLAME_BUTTON_CLICK); }, onCopy() { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.blobInfo.rawTextBlob); }, onShowForkSuggestion() { diff --git a/app/assets/javascripts/vue_shared/components/input_copy_toggle_visibility/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/input_copy_toggle_visibility/input_copy_toggle_visibility.vue index ffcb23ae391fd6..015e26c4b11ddf 100644 --- a/app/assets/javascripts/vue_shared/components/input_copy_toggle_visibility/input_copy_toggle_visibility.vue +++ b/app/assets/javascripts/vue_shared/components/input_copy_toggle_visibility/input_copy_toggle_visibility.vue @@ -148,6 +148,7 @@ export default { try { // user is trying to copy from the password input, set their clipboard for them + // eslint-disable-next-line no-restricted-properties await navigator.clipboard?.writeText(this.value); this.handleCopyButtonClick(); } catch (e) { diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index 93b13aaa4cac06..8573c60510dae7 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -5,6 +5,11 @@ import { uniqueId } from 'lodash'; import { __ } from '~/locale'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +/** + * Creates an instance of Clipboard that can works inside modals. + * + * Consider using `~/vue_shared/components/simple_copy_button.vue` instead. + */ export default { components: { GlButton, diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index 306e64e5434c1b..a384ba77ce4f41 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -332,6 +332,7 @@ export default { }, notifyCopyDone() { if (this.isModal) { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.noteUrl); } toast(__('Link copied to clipboard.')); diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 8923ac93d45748..de22855f53b2db 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -428,6 +428,7 @@ export default { methods: { copyToClipboard(text, message) { if (this.isModal) { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(text); } toast(message); diff --git a/app/assets/javascripts/work_items/components/work_item_development/work_item_development_branch_item.vue b/app/assets/javascripts/work_items/components/work_item_development/work_item_development_branch_item.vue index a361828e8d3a71..aee2d8b7b0033c 100644 --- a/app/assets/javascripts/work_items/components/work_item_development/work_item_development_branch_item.vue +++ b/app/assets/javascripts/work_items/components/work_item_development/work_item_development_branch_item.vue @@ -63,6 +63,7 @@ export default { methods: { copyToClipboard(text, message) { if (this.isModal) { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(text); } toast(message); diff --git a/app/assets/javascripts/work_items/components/work_item_development/work_item_development_mr_item.vue b/app/assets/javascripts/work_items/components/work_item_development/work_item_development_mr_item.vue index 8caa8e1057a440..711f9c23e04cce 100644 --- a/app/assets/javascripts/work_items/components/work_item_development/work_item_development_mr_item.vue +++ b/app/assets/javascripts/work_items/components/work_item_development/work_item_development_mr_item.vue @@ -113,6 +113,7 @@ export default { methods: { copyToClipboard(text, message) { if (this.isModal) { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(text); } toast(message); diff --git a/ee/app/assets/javascripts/compliance_dashboard/components/frameworks_report/framework_info_drawer.vue b/ee/app/assets/javascripts/compliance_dashboard/components/frameworks_report/framework_info_drawer.vue index acf66d86be51d9..c5b884c4bf82ea 100644 --- a/ee/app/assets/javascripts/compliance_dashboard/components/frameworks_report/framework_info_drawer.vue +++ b/ee/app/assets/javascripts/compliance_dashboard/components/frameworks_report/framework_info_drawer.vue @@ -202,10 +202,12 @@ export default { this.after = this.projects.pageInfo.endCursor; }, copyFrameworkIdToClipboard() { + // eslint-disable-next-line no-restricted-properties navigator?.clipboard?.writeText(this.normalisedFrameworkId); this.$toast.show(this.$options.i18n.copyFrameworkIdToastText); }, copyControlIdToClipboard(control) { + // eslint-disable-next-line no-restricted-properties navigator?.clipboard?.writeText(getIdFromGraphQLId(control.id)); this.$toast.show(this.$options.i18n.copyControlIdToastText); }, diff --git a/ee/app/assets/javascripts/compliance_dashboard/components/frameworks_report/frameworks_table.vue b/ee/app/assets/javascripts/compliance_dashboard/components/frameworks_report/frameworks_table.vue index f88dbc17f28999..b164b0a18d2725 100644 --- a/ee/app/assets/javascripts/compliance_dashboard/components/frameworks_report/frameworks_table.vue +++ b/ee/app/assets/javascripts/compliance_dashboard/components/frameworks_report/frameworks_table.vue @@ -124,6 +124,7 @@ export default { } }, copyFrameworkId(id) { + // eslint-disable-next-line no-restricted-properties navigator?.clipboard?.writeText(getIdFromGraphQLId(id)); this.$toast.show(this.$options.i18n.copyIdToastText); this.$refs[`framework-dropdown-${id}`].closeAndFocus(); diff --git a/ee/app/assets/javascripts/compliance_violations/components/discussion_note.vue b/ee/app/assets/javascripts/compliance_violations/components/discussion_note.vue index cdd47679fec833..820160fdfa4a81 100644 --- a/ee/app/assets/javascripts/compliance_violations/components/discussion_note.vue +++ b/ee/app/assets/javascripts/compliance_violations/components/discussion_note.vue @@ -90,6 +90,7 @@ export default { }, methods: { copyNoteLink() { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(this.fullNoteUrl); toast(__('Link copied to clipboard.')); this.$refs.dropdown.close(); diff --git a/ee/app/assets/javascripts/pages/projects/get_started/components/command_line_modal.vue b/ee/app/assets/javascripts/pages/projects/get_started/components/command_line_modal.vue index 26c60aeaca520c..b9702c74e8849e 100644 --- a/ee/app/assets/javascripts/pages/projects/get_started/components/command_line_modal.vue +++ b/ee/app/assets/javascripts/pages/projects/get_started/components/command_line_modal.vue @@ -101,6 +101,7 @@ export default { }, methods: { copyToClipboard(text) { + // eslint-disable-next-line no-restricted-properties navigator.clipboard.writeText(text); }, handleCopy(text, trackingLabel) { diff --git a/ee/app/assets/javascripts/product_analytics/shared/analytics_clipboard_input.vue b/ee/app/assets/javascripts/product_analytics/shared/analytics_clipboard_input.vue index ad08f746f527f8..16eed08b7d2129 100644 --- a/ee/app/assets/javascripts/product_analytics/shared/analytics_clipboard_input.vue +++ b/ee/app/assets/javascripts/product_analytics/shared/analytics_clipboard_input.vue @@ -30,6 +30,7 @@ export default { methods: { async copyValue() { try { + // eslint-disable-next-line no-restricted-properties await navigator.clipboard.writeText(this.value); this.tooltipText = this.$options.i18n.copied; } catch (error) { diff --git a/eslint.config.mjs b/eslint.config.mjs index 969e2cb09527dd..7e54a8985fbc91 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -296,7 +296,8 @@ export default [ }, { selector: "ImportSpecifier[imported.name='GlBreakpointInstance']", - message: 'GlBreakpointInstance only checks viewport breakpoints. You may want the breakpoints of a panel. Use PanelBreakpointInstance at ~/panel_breakpoint_instance instead (or add eslint-ignore here).', + message: + 'GlBreakpointInstance only checks viewport breakpoints. You may want the breakpoints of a panel. Use PanelBreakpointInstance at ~/panel_breakpoint_instance instead (or add eslint-ignore here).', }, { selector: 'Literal[value=/docs.gitlab.+\\u002Fee/]', @@ -358,6 +359,12 @@ export default [ message: 'Use `scrollTo` in `~/lib/utils/scroll_utils.js` to ensure scrolling inside your scrolling containers or panels.', }, + { + object: 'navigator', + property: 'clipboard', + message: + 'Use `copyToClipboard` in `~/lib/utils/copy_to_clipboard.js` to support copying in secure and non-secure environments.', + }, { object: 'vm', property: '$delete', -- GitLab