Harden auth/vault path sanitization and clean WeKnora docs

This commit is contained in:
Agent Board
2026-07-28 17:04:27 +08:00
parent 2deaf59f7b
commit 26ff1a9c9a
190 changed files with 13454 additions and 4987 deletions
@@ -79,49 +79,77 @@
return data && data.result != null ? data.result : data;
}
function maskLabel(state) {
if (!state || state.state === 'absent') return '(无)';
if (state.state === 'masked') return '••••••••';
if (state.state === 'revealed') return state.value || '';
return '••••••••';
}
function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Reusable helper for rendering a single secret row (password/apikey/token).
* Used heavily by account secrets, nested secrets, and the second-secret bug fix.
* Reduces duplication in render blocks.
* Display label for a secret field state. Always HTML-escaped so callers can
* safely inject into innerHTML (revealed values may contain user-controlled text).
*/
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts = {}) {
var key = opts.secretId || '';
function maskLabel(secretState) {
if (!secretState || secretState.state === 'absent') return '(无)';
if (secretState.state === 'masked') return '••••••••';
if (secretState.state === 'revealed') return escapeHtml(secretState.value || '');
return '••••••••';
}
/**
* Optional reusable secret-row helper. Prefer bindWorkbench's secretRow for
* production paths; this stays fail-safe if called with uiState in opts.
* @param {object} [opts.uiState] workbench state with revealed / _revealedValues
*/
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts) {
opts = opts || {};
var uiState = opts.uiState || { revealed: {}, _revealedValues: {} };
var revealed = uiState.revealed || {};
var revealedValues = uiState._revealedValues || {};
var key = opts.secretId || secretId || field || '';
var has = secretState && secretState.state === 'masked';
var canReveal = has || (secretState && secretState.state === 'revealed');
if (!canReveal && !showEmpty) return '';
var shown = state.revealed[key]
? state._revealedValues && state._revealedValues[key]
? escapeHtml(state._revealedValues[key])
: maskLabel(secretState)
var isOpen = !!revealed[key];
var shown = isOpen
? (revealedValues[key] != null
? escapeHtml(revealedValues[key])
: maskLabel(secretState))
: maskLabel(secretState);
var revealBtn = state.revealed[key] ? '隐藏' : '显示';
var copyBtn = '<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' + escapeHtml(key) + '">复制</button>';
var revealBtn = isOpen ? '隐藏' : '显示';
var copyBtn =
'<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' +
escapeHtml(key) +
'">复制</button>';
var actions =
'<button type="button" data-vault-reveal="" data-vault-secret-id="' +
escapeHtml(secretId || '') +
'" data-testid="vault-reveal-' +
escapeHtml(key) +
'">' +
revealBtn +
'</button>' +
copyBtn;
var actions = state.revealed[key]
? '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn
: '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn;
return '<div class="mnote-vault-field-row" data-vault-secret-row="' + escapeHtml(key) + '">'
+ '<div class="mnote-vault-secret-value" data-testid="vault-secret-' + escapeHtml(key) + '">' + shown + '</div>'
+ '<div class="mnote-vault-secret-actions">' + actions + '</div>'
+ '</div>';
return (
'<div class="mnote-vault-field-row" data-vault-secret-row="' +
escapeHtml(key) +
'">' +
'<div class="mnote-vault-secret-value" data-testid="vault-secret-' +
escapeHtml(key) +
'">' +
shown +
'</div>' +
'<div class="mnote-vault-secret-actions">' +
actions +
'</div>' +
'</div>'
);
}
function bindWorkbench(root) {
@@ -531,6 +559,10 @@
badge.classList.remove('is-ai');
}
}
var repairBtn = qs('[data-vault-repair-ai-folders]', root);
if (repairBtn) {
repairBtn.hidden = !state.isAiVault;
}
root.setAttribute('data-vault-role', state.isAiVault ? 'ai' : 'user');
}
@@ -540,9 +572,9 @@
statusEl.setAttribute('data-type', type || 'info');
}
function confirmDiscard() {
async function confirmDiscard() {
if (!state.dirty) return true;
return window.confirm('有未保存的修改,确定放弃?');
return await window.mnote.confirm('有未保存的修改,确定放弃?');
}
function filteredItems() {
@@ -840,6 +872,53 @@
return out;
}
/**
* URLs for the edit form: keep empty draft rows (so "+ 网址" can add a blank
* line). normalizeUrlsFromItem strips empties and would make add-url look dead.
*/
function urlsForFormEditor(item) {
if (item && Array.isArray(item.urls) && item.urls.length) {
return item.urls.map(function (u) {
return u == null ? '' : String(u);
});
}
var urls = normalizeUrlsFromItem(item);
if (!urls.length) urls = [''];
return urls;
}
/** Make a safe absolute href for external open (http/https only). */
function externalUrlHref(raw) {
var t = String(raw || '').trim();
if (!t) return '';
if (/^https?:\/\//i.test(t)) return t;
if (/^\/\//.test(t)) return 'https:' + t;
// Bare host/path: assume https (common for vault entries).
if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}([/:].*)?$/i.test(t)) {
return 'https://' + t;
}
return '';
}
/** View-mode URL as hyperlink look; open only via Ctrl/Cmd+click (see click handler). */
function urlLinkHtml(raw) {
var text = String(raw || '').trim();
if (!text) return '<span class="mnote-vault-muted">—</span>';
var href = externalUrlHref(text);
if (!href) {
return '<span class="mnote-vault-field-value">' + escapeHtml(text) + '</span>';
}
return (
'<a class="mnote-vault-url-link" href="' +
escapeHtml(href) +
'" data-vault-external-url="' +
escapeHtml(href) +
'" title="Ctrl/Cmd + 点击打开">' +
escapeHtml(text) +
'</a>'
);
}
function emptyForm(defaults) {
defaults = defaults || {};
var urls = normalizeUrlsFromItem(defaults);
@@ -1381,20 +1460,14 @@
if (urls.length) {
urlsHtml = urls
.map(function (u, idx) {
var label = idx === 0 ? '主网址' : '备用 ' + idx;
var link =
'<a href="' +
escapeHtml(u) +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(u) +
'</a>';
var label = idx === 0 ? (urls.length > 1 ? '主网址' : '网址') : '备用 ' + idx;
return (
'<div class="mnote-vault-field-row">' +
'<label>' +
escapeHtml(label) +
'</label>' +
'<div class="mnote-vault-field-value">' +
link +
urlLinkHtml(u) +
'</div></div>'
);
})
@@ -1412,9 +1485,7 @@
' 个</span></div>' +
urlsHtml +
'</div>'
: urls.length === 1
? fieldRow('网址', urls[0], showEmpty)
: urlsHtml) +
: urlsHtml) +
'<div class="mnote-vault-section">' +
'<div class="mnote-vault-section-head"><span>登录账号</span>' +
(accounts.length > 1
@@ -1722,8 +1793,8 @@
var tagsStr = (item.tags || []).join(', ');
// 编辑时用 usernameTemplate/emailTemplate,避免把已展开明文写回。
var accounts = normalizeAccountsFromItem(item, true);
var urls = normalizeUrlsFromItem(item);
if (!urls.length) urls = [''];
// Prefer draft rows (may include empty slots after "+ 网址"); do not strip blanks.
var urls = urlsForFormEditor(item);
state._formUrls = urls.slice();
/**
* Rebuild draft slots from item projection.
@@ -2325,10 +2396,43 @@
} else {
renderDetail();
}
setStatus(
(state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条',
'success'
var repair = result && result.folderRepair;
var statusMsg =
(state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条';
if (repair && Number(repair.updated) > 0) {
statusMsg += ' · 已修复分组 ' + repair.updated + ' 条';
}
setStatus(statusMsg, 'success');
} catch (err) {
setStatus(err.message || String(err), 'error');
toast(err.message || String(err), 'error');
}
}
async function repairAiFolders() {
if (!state.isAiVault) return;
// 你已清掉其它人的分享时,可指定默认分享者;留空则自动从 from-* / sharedFrom 推断
var hint = window.prompt(
'修复 AI 分组为「用户名/原分组」。\n' +
'若库内只剩一人分享,可留空自动识别;否则填写分享者用户名(如 liaibo):',
'liaibo'
);
if (hint === null) return;
setStatus('正在修复 AI 分组…', 'info');
try {
var body = {};
var actor = String(hint || '').trim();
if (actor) body.defaultSourceActor = actor;
var payload = await apiJson('POST', '/api/vault/ai/repair-folders', state.rootUri, body);
var result = (payload && payload.result) || payload || {};
var n = Number(result.updated || 0);
toast(
n > 0
? '已修复 ' + n + ' 条 → 用户名/原分组(如 liaibo/系统)'
: '无需修复(分组已是 用户名/原分组)',
n > 0 ? 'success' : 'info'
);
await loadList();
} catch (err) {
setStatus(err.message || String(err), 'error');
toast(err.message || String(err), 'error');
@@ -2893,10 +2997,32 @@
}
}
root.addEventListener('click', function (ev) {
root.addEventListener('click', async function (ev) {
var t = ev.target;
if (!(t instanceof Element)) return;
// View-mode URL: look like a link, open only with Ctrl/Cmd+click.
var urlLink = t.closest('a.mnote-vault-url-link, a[data-vault-external-url]');
if (urlLink && root.contains(urlLink)) {
var href =
urlLink.getAttribute('data-vault-external-url') ||
urlLink.getAttribute('href') ||
'';
if (!(ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
return;
}
ev.preventDefault();
if (href) {
try {
window.open(href, '_blank', 'noopener,noreferrer');
} catch (_e) {
/* ignore popup block */
}
}
return;
}
var toggle = t.closest('[data-vault-toggle-folder]');
if (toggle && root.contains(toggle)) {
var fpath = toggle.getAttribute('data-vault-toggle-folder') || '';
@@ -3298,8 +3424,7 @@
var shareTitle =
(state.item && state.item.title) || state.selectedId;
var alreadyShared = itemIsSharedToAi(state.item);
if (
!window.confirm(
if (!(await window.mnote.confirm(
alreadyShared
? '将「' +
shareTitle +
@@ -3310,7 +3435,7 @@
'」复制到 AI 密码本(默认账号 mnote-e2e 的 my-space vault)?\n' +
'会新建一条带 ai-shared 标签的副本,并在本条目标记「已共享」;原条目保留。\n' +
'密文 [Key] 模板原样复制,并自动同步缺失密文 key。'
)
))
) {
return;
}
@@ -3343,7 +3468,7 @@
return loadList();
});
})
.catch(function (err) {
.catch(async function (err) {
toast(err.message || String(err), 'error');
setStatus(err.message || String(err), 'error');
});
@@ -3367,8 +3492,7 @@
state.item.sharedToAi &&
state.item.sharedToAi.targetActorId) ||
'mnote-e2e';
if (
!window.confirm(
if (!(await window.mnote.confirm(
'撤销「' +
unshareTitle +
'」对 AI 密码本(' +
@@ -3376,7 +3500,7 @@
')的共享?\n' +
'将清除本条「已共享」标记,并把 AI 密码本中的副本移入回收站。\n' +
'原条目与密文簿 key 保留;AI 侧密文 key 不删除(可能被其它条目引用)。'
)
))
) {
return;
}
@@ -3414,6 +3538,10 @@
return;
}
if (t.closest('[data-vault-repair-ai-folders]')) {
repairAiFolders();
return;
}
if (t.closest('[data-vault-cipher-book]')) {
if (!confirmDiscard()) return;
state.mode = 'cipher';
@@ -3472,7 +3600,7 @@
delete state.cipherRevealed[ek];
return loadCipherBook();
})
.catch(function (err) {
.catch(async function (err) {
toast(err.message || String(err), 'error');
});
return;
@@ -3480,7 +3608,7 @@
var cipherDel = t.closest('[data-vault-cipher-delete]');
if (cipherDel) {
var dk = cipherDel.getAttribute('data-vault-cipher-delete');
if (!window.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。')) return;
if (!(await window.mnote.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。'))) return;
apiJson(
'DELETE',
'/api/vault/cipher-book/' + encodeURIComponent(dk),
@@ -3570,27 +3698,6 @@
return;
}
var clearNestedSec = t.closest('[data-vault-clear-sec-value]');
if (clearNestedSec) {
var csid = clearNestedSec.getAttribute('data-vault-clear-sec-value');
var cAcc = clearNestedSec.getAttribute('data-acc-id');
var formClearSec = qs('[data-vault-form]', detailEl);
if (formClearSec) snapshotFormSlots(formClearSec);
(state._formAccounts || []).forEach(function (acc) {
if (cAcc && acc.id !== cAcc) return;
(acc.secrets || []).forEach(function (sec) {
if (sec.id !== csid) return;
sec.valueClear = true;
sec.valueText = '';
sec.valueState = { state: 'absent' };
});
});
state.dirty = true;
reRenderFormKeepingSlots(state.mode === 'create');
toast('密钥将在保存时清空', 'info');
return;
}
var clearBtn = t.closest('[data-vault-clear-secret]');
if (clearBtn) {
var cf = clearBtn.getAttribute('data-vault-clear-secret');
@@ -3606,7 +3713,7 @@
}
if (t.closest('[data-vault-delete]')) {
if (!window.confirm('确定将条目移入密码箱回收站?')) return;
if (!(await window.mnote.confirm('确定将条目移入密码箱回收站?'))) return;
apiJson(
'DELETE',
'/api/vault/items/' + encodeURIComponent(state.selectedId),
@@ -3637,13 +3744,13 @@
if (tabDeleted) tabDeleted.classList.remove('is-active');
return loadList();
})
.catch(function (err) {
.catch(async function (err) {
toast(err.message || String(err), 'error');
});
return;
}
if (t.closest('[data-vault-purge]')) {
if (!window.confirm('永久删除后无法恢复,确定?')) return;
if (!(await window.mnote.confirm('永久删除后无法恢复,确定?'))) return;
apiJson(
'POST',
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/purge',