From 90ad8e84678ffd33a2ddb6096534343dc728065d Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 14 Jul 2026 22:53:37 +0800 Subject: [PATCH] fix: protect secrets values and deletion --- plugins/secrets/frontend/src/index.js | 106 +++++++++++++++++++++++--- plugins/secrets/locales/en.json | 14 +++- plugins/secrets/locales/ru.json | 14 +++- plugins/secrets/plugin.json | 2 +- scripts/smoke-secrets-plugin.js | 36 ++++++++- 5 files changed, 151 insertions(+), 21 deletions(-) diff --git a/plugins/secrets/frontend/src/index.js b/plugins/secrets/frontend/src/index.js index 18c3aea..f8fb862 100644 --- a/plugins/secrets/frontend/src/index.js +++ b/plugins/secrets/frontend/src/index.js @@ -60,6 +60,11 @@ '.secrets-table th{width:9rem;color:var(--vt-color-text-muted,#7f8aa3);font-weight:500;background:var(--vt-color-surface-muted,#111629)}', '.secrets-table td{color:var(--vt-color-text-primary,#f4f7fb);overflow-wrap:anywhere}', '.secrets-table tr:last-child th,.secrets-table tr:last-child td{border-bottom:0}', + '.secrets-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6)}', + '.secrets-modal{width:24rem;max-width:90vw;display:grid;gap:.8rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42))}', + '.secrets-modal-title{margin:0;font-size:.96rem}', + '.secrets-modal-message{margin:0;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}', + '.secrets-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}', '@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row,.secrets-filters{grid-template-columns:1fr}}' ].join('\n'); @@ -97,10 +102,10 @@ || (node && (node.rootPath || node.name || node.id))); } - function scopeLabel(record) { + function scopeLabel(record, translate) { var scope = record && record.scope || {}; - if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Deal'; - return 'Global'; + if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || translate('ui.deal', null, 'Deal'); + return translate('ui.global', null, 'Global'); } function selectedIDFromProps(props) { @@ -111,16 +116,16 @@ return decodeURIComponent(path.replace(/^\/+/, '')); } - function groupRecords(records) { + function groupRecords(records, translate) { var groups = {}; records.forEach(function (record) { - var label = scopeLabel(record); + var label = scopeLabel(record, translate); groups[label] = groups[label] || []; groups[label].push(record); }); return Object.keys(groups).sort(function (a, b) { - if (a === 'Global') return -1; - if (b === 'Global') return 1; + if (a === translate('ui.global', null, 'Global')) return -1; + if (b === translate('ui.global', null, 'Global')) return 1; return a.localeCompare(b); }).map(function (label) { groups[label].sort(function (a, b) { @@ -152,6 +157,8 @@ var searchQuery = ''; var selectedRecord = null; var selectedValue = ''; + var isValueVisible = false; + var deleteModal = null; var initialized = false; var unlocked = false; var statusText = ''; @@ -190,6 +197,7 @@ read: 'Could not open this secret. Please try again.', save: 'Could not save the secret. Please try again.', delete: 'Could not delete the secret. Please try again.', + copyValue: 'Could not copy the secret value. Please try again.', copyLink: 'Could not copy the secret link. Please try again.', unlock: 'Could not unlock secrets. Check the master password and try again.' }[action] || 'Could not complete this action. Please try again.'); @@ -358,7 +366,7 @@ children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')])); return children; } - groupRecords(visibleRecords).forEach(function (group) { + groupRecords(visibleRecords, tr).forEach(function (group) { children.push(el('div', { className: 'secrets-group' }, [group.label])); children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) { var active = selectedRecord && selectedRecord.id === record.id; @@ -382,14 +390,16 @@ el('h2', {}, [tr('ui.select', null, 'Select a secret')]), el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText]) ]); + var valueIsAvailable = !!selectedValue; + var valueDisplay = valueIsAvailable && isValueVisible ? selectedValue : '••••••••••••'; return el('div', { className: 'secrets-card' }, [ el('h2', {}, [selectedRecord.title || selectedRecord.id]), el('table', { className: 'secrets-table' }, [ el('tbody', {}, [ - fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord)), + fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord, tr)), fieldRow('ID', selectedRecord.id), fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''), - fieldRow(tr('ui.password', null, 'Password'), selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'), + fieldRow(tr('ui.password', null, 'Password'), valueDisplay, isValueVisible && valueIsAvailable ? 'secrets-secret-value' : 'secrets-hidden-value'), fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '') ]) ]), @@ -400,6 +410,24 @@ 'data-secret-copy-link': selectedRecord.id, onClick: function () { copySecretLink(selectedRecord.id); } }, [tr('ui.copyLink', null, 'Copy secret link')]), + el('button', { + className: 'secrets-btn', + type: 'button', + disabled: !valueIsAvailable, + 'data-secret-copy-value': selectedRecord.id, + onClick: function () { copySecretValue(selectedValue); } + }, [tr('ui.copyValue', null, 'Copy value')]), + el('button', { + className: 'secrets-btn', + type: 'button', + disabled: !valueIsAvailable, + 'data-secret-toggle-value': selectedRecord.id, + onClick: function () { + if (!selectedValue) return; + isValueVisible = !isValueVisible; + render(); + } + }, [isValueVisible ? tr('ui.hideValue', null, 'Hide value') : tr('ui.showValue', null, 'Show value')]), el('button', { className: 'secrets-btn', type: 'button', @@ -547,6 +575,7 @@ selectedID = id; selectedRecord = records.find(function (record) { return record.id === id; }) || null; selectedValue = ''; + isValueVisible = false; render(); if (!id) return Promise.resolve(); return api.secrets.read(id).then(function (record) { @@ -576,17 +605,71 @@ } function deleteSecret(id) { - if (!id || !window.confirm(tr('ui.deleteConfirm', null, 'Delete this secret?'))) return; + if (!id) return; + removeDeleteModal(); + var title = selectedRecord && selectedRecord.id === id ? selectedRecord.title || selectedRecord.id : id; + var overlay = el('div', { + className: 'secrets-modal-overlay', + 'data-secret-delete-modal': '', + role: 'presentation' + }); + var cancel = el('button', { + className: 'secrets-btn', + type: 'button', + 'data-secret-delete-cancel': '', + onClick: removeDeleteModal + }, [tr('ui.cancel', null, 'Cancel')]); + var confirm = el('button', { + className: 'secrets-btn danger', + type: 'button', + 'data-secret-delete-confirm': '', + onClick: function () { + removeDeleteModal(); + confirmDeleteSecret(id); + } + }, [tr('ui.delete', null, 'Delete')]); + overlay.appendChild(el('div', { + className: 'secrets-modal', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': tr('ui.deleteSecretTitle', null, 'Delete secret') + }, [ + el('h2', { className: 'secrets-modal-title' }, [tr('ui.deleteSecretTitle', null, 'Delete secret')]), + el('p', { className: 'secrets-modal-message' }, [tr('ui.deleteConfirm', { title: title }, 'Delete "' + title + '"?')]), + el('div', { className: 'secrets-modal-actions' }, [cancel, confirm]) + ])); + document.body.appendChild(overlay); + deleteModal = overlay; + if (cancel.focus) cancel.focus(); + } + + function removeDeleteModal() { + if (deleteModal && typeof deleteModal.remove === 'function') deleteModal.remove(); + else if (deleteModal && deleteModal.parentNode) deleteModal.parentNode.removeChild(deleteModal); + deleteModal = null; + } + + function confirmDeleteSecret(id) { api.secrets.delete(id).then(function () { selectedID = ''; selectedRecord = null; selectedValue = ''; + isValueVisible = false; return loadRecords(); }).catch(function (err) { setUserFacingError('delete', err); }); } + function copySecretValue(value) { + if (!value) return; + writeClipboard(api, value).then(function () { + setStatus(tr('ui.valueCopied', null, 'Secret value copied'), false); + }).catch(function (err) { + setUserFacingError('copyValue', err); + }); + } + function copySecretLink(id) { api.secrets.copyLink(id).then(function (link) { return writeClipboard(api, link).then(function () { @@ -614,6 +697,7 @@ containerEl.__secretsCleanup = function () { disposed = true; + removeDeleteModal(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); }; }, diff --git a/plugins/secrets/locales/en.json b/plugins/secrets/locales/en.json index ca2be0b..35f95b7 100644 --- a/plugins/secrets/locales/en.json +++ b/plugins/secrets/locales/en.json @@ -1,6 +1,6 @@ { "manifest.name": "Secrets", - "manifest.description": "Encrypted global and workspace-scoped secret manager.", + "manifest.description": "Encrypted global and Deal-scoped secret manager.", "contributions.views.verstak.secrets.view.title": "Secrets", "contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets", "contributions.openProviders.verstak.secrets.secret.title": "Secrets", @@ -28,11 +28,14 @@ "ui.copyLink": "Copy secret link", "ui.edit": "Edit", "ui.delete": "Delete", + "ui.copyValue": "Copy value", + "ui.showValue": "Show value", + "ui.hideValue": "Hide value", "ui.fieldTitle": "Title", "ui.optionalUsername": "optional username", "ui.secretValue": "Secret value", "ui.global": "Global", - "ui.workspace": "Workspace", + "ui.workspace": "Deal", "ui.editSecret": "Edit secret", "ui.newSecret": "New secret", "ui.scope": "Scope", @@ -46,11 +49,14 @@ "ui.readError": "Could not open this secret. Please try again.", "ui.saveError": "Could not save the secret. Please try again.", "ui.deleteError": "Could not delete the secret. Please try again.", + "ui.copyValueError": "Could not copy the secret value. Please try again.", "ui.copyLinkError": "Could not copy the secret link. Please try again.", "ui.unlockError": "Could not unlock secrets. Check the master password and try again.", "ui.value": "Value", "ui.save": "Save", "ui.cancel": "Cancel", - "ui.deleteConfirm": "Delete this secret?", - "ui.linkCopied": "Secret link copied" + "ui.deleteSecretTitle": "Delete secret", + "ui.deleteConfirm": "Delete \"{title}\"? This cannot be undone.", + "ui.linkCopied": "Secret link copied", + "ui.valueCopied": "Secret value copied" } diff --git a/plugins/secrets/locales/ru.json b/plugins/secrets/locales/ru.json index 54513e9..a4e0985 100644 --- a/plugins/secrets/locales/ru.json +++ b/plugins/secrets/locales/ru.json @@ -1,6 +1,6 @@ { "manifest.name": "Секреты", - "manifest.description": "Зашифрованное хранилище общих секретов и секретов рабочих пространств.", + "manifest.description": "Зашифрованное хранилище общих секретов и секретов Дел.", "contributions.views.verstak.secrets.view.title": "Секреты", "contributions.sidebarItems.verstak.secrets.sidebar.title": "Секреты", "contributions.openProviders.verstak.secrets.secret.title": "Секреты", @@ -28,11 +28,14 @@ "ui.copyLink": "Копировать ссылку на секрет", "ui.edit": "Изменить", "ui.delete": "Удалить", + "ui.copyValue": "Копировать значение", + "ui.showValue": "Показать значение", + "ui.hideValue": "Скрыть значение", "ui.fieldTitle": "Название", "ui.optionalUsername": "необязательное имя пользователя", "ui.secretValue": "Значение секрета", "ui.global": "Общий", - "ui.workspace": "Рабочее пространство", + "ui.workspace": "Дело", "ui.editSecret": "Изменить секрет", "ui.newSecret": "Новый секрет", "ui.scope": "Область", @@ -46,11 +49,14 @@ "ui.readError": "Не удалось открыть секрет. Повторите попытку.", "ui.saveError": "Не удалось сохранить секрет. Повторите попытку.", "ui.deleteError": "Не удалось удалить секрет. Повторите попытку.", + "ui.copyValueError": "Не удалось скопировать значение секрета. Повторите попытку.", "ui.copyLinkError": "Не удалось скопировать ссылку на секрет. Повторите попытку.", "ui.unlockError": "Не удалось разблокировать секреты. Проверьте мастер-пароль и повторите попытку.", "ui.value": "Значение", "ui.save": "Сохранить", "ui.cancel": "Отмена", - "ui.deleteConfirm": "Удалить этот секрет?", - "ui.linkCopied": "Ссылка на секрет скопирована" + "ui.deleteSecretTitle": "Удалить секрет", + "ui.deleteConfirm": "Удалить «{title}»? Это действие нельзя отменить.", + "ui.linkCopied": "Ссылка на секрет скопирована", + "ui.valueCopied": "Значение секрета скопировано" } diff --git a/plugins/secrets/plugin.json b/plugins/secrets/plugin.json index 5470867..e029067 100644 --- a/plugins/secrets/plugin.json +++ b/plugins/secrets/plugin.json @@ -4,7 +4,7 @@ "name": "Secrets", "version": "0.1.0", "apiVersion": "0.1.0", - "description": "Encrypted global and workspace-scoped secret manager.", + "description": "Encrypted global and Deal-scoped secret manager.", "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "source": "official", "icon": "key-round", diff --git a/scripts/smoke-secrets-plugin.js b/scripts/smoke-secrets-plugin.js index 48c24fc..cc552ee 100755 --- a/scripts/smoke-secrets-plugin.js +++ b/scripts/smoke-secrets-plugin.js @@ -28,6 +28,16 @@ class FakeNode { return node; } + removeChild(node) { + this.children = this.children.filter((child) => child !== node); + node.parentNode = null; + return node; + } + + remove() { + if (this.parentNode) this.parentNode.removeChild(this); + } + setAttribute(name, value) { this.attributes[name] = String(value); } @@ -235,7 +245,17 @@ async function flush() { if (!container.textContent.includes('Group')) throw new Error('secret field table missing Group row'); if (!container.textContent.includes('Username')) throw new Error('secret field table missing Username row'); if (!container.textContent.includes('Password')) throw new Error('secret field table missing Password row'); - if (!container.textContent.includes('secret-value')) throw new Error('secret value was not shown in the field table'); + if (container.textContent.includes('secret-value')) throw new Error('secret value must stay hidden until the user explicitly reveals it'); + const copyValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-value') === 'client-a.db'); + if (!copyValueButton) throw new Error('copy value button missing'); + copyValueButton.click(); + await flush(); + if (!copied.includes('secret-value')) throw new Error('secret value was not copied'); + const showValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-toggle-value') === 'client-a.db'); + if (!showValueButton) throw new Error('show value button missing'); + showValueButton.click(); + await flush(); + if (!container.textContent.includes('secret-value')) throw new Error('secret value was not revealed after explicit request'); const copyButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-link') === 'client-a.db'); if (!copyButton) throw new Error('copy link button missing'); @@ -261,6 +281,20 @@ async function flush() { if (!deleteButton) throw new Error('delete button missing'); deleteButton.click(); await flush(); + if (deleted.includes('client-a.db')) throw new Error('secret delete must wait for confirmation'); + const deleteConfirmModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-modal') === ''); + if (!deleteConfirmModal) throw new Error('secret delete confirmation modal missing'); + const deleteCancelButton = walk(deleteConfirmModal, (node) => node.getAttribute && node.getAttribute('data-secret-delete-cancel') === ''); + if (!deleteCancelButton) throw new Error('secret delete cancel button missing'); + deleteCancelButton.click(); + await flush(); + if (deleted.includes('client-a.db')) throw new Error('secret delete ran after cancelling the confirmation'); + deleteButton.click(); + await flush(); + const deleteConfirmButton = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-confirm') === ''); + if (!deleteConfirmButton) throw new Error('secret delete confirm button missing'); + deleteConfirmButton.click(); + await flush(); if (!deleted.includes('client-a.db')) throw new Error('secret delete was not called'); records.push({ id: 'client-a.global-view', title: 'Client A Global View', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' });