kczx-user-management 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CUTOVER-RUNBOOK.md +73 -0
- package/MERGED-DEPLOYMENT.md +100 -0
- package/README.md +158 -0
- package/client/bundle.js +1799 -0
- package/client/index.js +1785 -0
- package/client/package.json +3 -0
- package/cordis.patch.yml +13 -0
- package/docs/FAQ.md +95 -0
- package/docs/demo-totp-dark.gif +0 -0
- package/docs/demo-totp-light.gif +0 -0
- package/docs/demo.gif +0 -0
- package/package.json +80 -0
- package/scripts/build-client.mjs +42 -0
- package/scripts/ensure-deps.mjs +50 -0
- package/scripts/import-from-dsh-passwords.mjs +111 -0
- package/src/certs.js +89 -0
- package/src/gate.js +162 -0
- package/src/gateway-core.js +769 -0
- package/src/index.js +1358 -0
- package/src/login-page.js +184 -0
- package/src/permissions.js +477 -0
- package/src/proxy.js +474 -0
- package/src/store.js +1072 -0
- package/src/totp.js +136 -0
- package/src/trusted-identity.js +143 -0
- package/src/vendor/qrcode-generator.js +2289 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Standalone login/register page served at `/login`.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately independent of the host SPA (which the gate blocks until
|
|
7
|
+
* authenticated): one self-contained HTML string, no external assets, dark/
|
|
8
|
+
* light adaptive. Talks to /user-management/api like everything else.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function esc(value) {
|
|
12
|
+
return String(value).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PAGE_STYLES = `
|
|
16
|
+
:root { color-scheme: light dark; }
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
body {
|
|
19
|
+
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
20
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
21
|
+
background: #f5f6f8; color: #1f2329;
|
|
22
|
+
}
|
|
23
|
+
@media (prefers-color-scheme: dark) {
|
|
24
|
+
body { background: #16181c; color: #e8eaed; }
|
|
25
|
+
}
|
|
26
|
+
.card {
|
|
27
|
+
width: 360px; max-width: calc(100vw - 32px); padding: 32px 28px 24px;
|
|
28
|
+
border-radius: 14px; background: #ffffff; border: 1px solid rgba(0,0,0,.08);
|
|
29
|
+
box-shadow: 0 8px 30px rgba(0,0,0,.08);
|
|
30
|
+
}
|
|
31
|
+
@media (prefers-color-scheme: dark) {
|
|
32
|
+
.card { background: #22252b; border-color: rgba(255,255,255,.08); box-shadow: 0 8px 30px rgba(0,0,0,.4); }
|
|
33
|
+
}
|
|
34
|
+
h1 { font-size: 18px; margin: 0 0 4px; text-align: center; }
|
|
35
|
+
.sub { font-size: 12px; opacity: .6; text-align: center; margin: 0 0 20px; }
|
|
36
|
+
.tabs { display: flex; gap: 4px; background: rgba(127,127,127,.12); border-radius: 8px; padding: 3px; margin-bottom: 18px; }
|
|
37
|
+
.tabs button {
|
|
38
|
+
flex: 1; border: 0; background: transparent; color: inherit; font-size: 13px;
|
|
39
|
+
padding: 7px 0; border-radius: 6px; cursor: pointer;
|
|
40
|
+
}
|
|
41
|
+
.tabs button.active { background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
|
|
42
|
+
@media (prefers-color-scheme: dark) { .tabs button.active { background: #3a3f47; } }
|
|
43
|
+
label { display: block; font-size: 12px; opacity: .75; margin: 12px 0 4px; }
|
|
44
|
+
label .opt { opacity: .8; font-weight: 400; }
|
|
45
|
+
input {
|
|
46
|
+
width: 100%; padding: 9px 11px; font-size: 14px; border-radius: 8px;
|
|
47
|
+
border: 1px solid rgba(127,127,127,.35); background: transparent; color: inherit; outline: none;
|
|
48
|
+
}
|
|
49
|
+
input:focus { border-color: #4c6ef5; }
|
|
50
|
+
.remember { display: flex; align-items: center; gap: 6px; font-size: 12px; opacity: .85; margin: 12px 0 0; cursor: pointer; user-select: none; }
|
|
51
|
+
.remember input { width: auto; margin: 0; cursor: pointer; }
|
|
52
|
+
.err { min-height: 18px; font-size: 12px; color: #e03131; margin: 10px 0 2px; white-space: pre-wrap; }
|
|
53
|
+
.reg-note { font-size: 12px; color: #2b8a3e; margin: 10px 0 2px; white-space: pre-wrap; }
|
|
54
|
+
.submit {
|
|
55
|
+
width: 100%; margin-top: 10px; padding: 10px 0; font-size: 14px; border: 0; border-radius: 8px;
|
|
56
|
+
background: #4c6ef5; color: #fff; cursor: pointer;
|
|
57
|
+
}
|
|
58
|
+
.submit:disabled { opacity: .55; cursor: default; }
|
|
59
|
+
.hint { font-size: 12px; color: #f08c00; margin-top: 12px; text-align: center; }
|
|
60
|
+
.foot { margin-top: 16px; font-size: 11px; opacity: .45; text-align: center; }
|
|
61
|
+
`
|
|
62
|
+
|
|
63
|
+
const PAGE_SCRIPT = `
|
|
64
|
+
(function () {
|
|
65
|
+
var hasUsers = __HAS_USERS__;
|
|
66
|
+
var loginForm = document.getElementById('login-form');
|
|
67
|
+
var registerForm = document.getElementById('register-form');
|
|
68
|
+
var otpInput = document.getElementById('login-otp');
|
|
69
|
+
var remember = document.getElementById('login-remember');
|
|
70
|
+
function show(tab) {
|
|
71
|
+
loginForm.style.display = tab === 'login' ? '' : 'none';
|
|
72
|
+
registerForm.style.display = tab === 'register' ? '' : 'none';
|
|
73
|
+
document.getElementById('tab-login').classList.toggle('active', tab === 'login');
|
|
74
|
+
document.getElementById('tab-register').classList.toggle('active', tab === 'register');
|
|
75
|
+
loginForm.querySelector('.err').textContent = '';
|
|
76
|
+
registerForm.querySelector('.err').textContent = '';
|
|
77
|
+
document.getElementById('reg-note').textContent = '';
|
|
78
|
+
}
|
|
79
|
+
document.getElementById('tab-login').addEventListener('click', function () { show('login') });
|
|
80
|
+
document.getElementById('tab-register').addEventListener('click', function () { show('register') });
|
|
81
|
+
function submit(form, path, body) {
|
|
82
|
+
var errEl = form.querySelector('.err');
|
|
83
|
+
var button = form.querySelector('.submit');
|
|
84
|
+
button.disabled = true;
|
|
85
|
+
errEl.textContent = '';
|
|
86
|
+
fetch(path, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'content-type': 'application/json' },
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
}).then(function (res) {
|
|
91
|
+
return res.json().catch(function () { return {} }).then(function (data) { return { ok: res.ok, data: data } });
|
|
92
|
+
}).then(function (result) {
|
|
93
|
+
if (result.ok) {
|
|
94
|
+
if (form === registerForm && result.data && result.data.pending) {
|
|
95
|
+
// approval mode: registered as disabled — no session, stay and inform
|
|
96
|
+
document.getElementById('reg-note').textContent = '注册成功!账号需管理员审核启用后方可登录,请稍后再来。';
|
|
97
|
+
button.disabled = false;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (form === loginForm) {
|
|
101
|
+
try {
|
|
102
|
+
if (remember.checked) localStorage.setItem('um-last-username', body.username || '');
|
|
103
|
+
else localStorage.removeItem('um-last-username');
|
|
104
|
+
} catch (e) {}
|
|
105
|
+
}
|
|
106
|
+
location.href = '/';
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
errEl.textContent = (result.data && result.data.error) || '请求失败';
|
|
110
|
+
button.disabled = false;
|
|
111
|
+
}).catch(function () {
|
|
112
|
+
errEl.textContent = '网络错误';
|
|
113
|
+
button.disabled = false;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
loginForm.addEventListener('submit', function (e) {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
submit(loginForm, '/user-management/api/login', {
|
|
119
|
+
username: document.getElementById('login-username').value.trim(),
|
|
120
|
+
password: document.getElementById('login-password').value,
|
|
121
|
+
otp: otpInput.value.replace(/\\s+/g, '') || undefined,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
registerForm.addEventListener('submit', function (e) {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
var errEl = registerForm.querySelector('.err');
|
|
127
|
+
var password = document.getElementById('reg-password').value;
|
|
128
|
+
if (password !== document.getElementById('reg-password2').value) {
|
|
129
|
+
errEl.textContent = '两次输入的密码不一致';
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
submit(registerForm, '/user-management/api/register', {
|
|
133
|
+
username: document.getElementById('reg-username').value.trim(),
|
|
134
|
+
password: password,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
var remembered = localStorage.getItem('um-last-username');
|
|
139
|
+
if (remembered) document.getElementById('login-username').value = remembered;
|
|
140
|
+
} catch (e) {}
|
|
141
|
+
if (!hasUsers) {
|
|
142
|
+
document.getElementById('first-hint').style.display = '';
|
|
143
|
+
show('register');
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
`
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render the login page. `hasUsers=false` (fresh system) flips the default
|
|
150
|
+
* tab to register and reveals the first-user-becomes-admin hint.
|
|
151
|
+
*/
|
|
152
|
+
function renderLoginPage({ hasUsers, title = 'DSH 控制台' } = {}) {
|
|
153
|
+
return '<!doctype html>\n<html lang="zh-CN">\n<head>\n<meta charset="utf-8">\n' +
|
|
154
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
|
|
155
|
+
`<title>${esc(title)} · 登录</title>\n` +
|
|
156
|
+
`<style>${PAGE_STYLES}</style>\n</head>\n<body>\n` +
|
|
157
|
+
'<div class="card">\n' +
|
|
158
|
+
`<h1>${esc(title)}</h1>\n<p class="sub">登录后继续访问</p>\n` +
|
|
159
|
+
'<div class="tabs">' +
|
|
160
|
+
'<button type="button" id="tab-login" class="active">登录</button>' +
|
|
161
|
+
'<button type="button" id="tab-register">注册</button></div>\n' +
|
|
162
|
+
'<form id="login-form">\n' +
|
|
163
|
+
'<label for="login-username">用户名</label>\n<input id="login-username" autocomplete="username" required>\n' +
|
|
164
|
+
'<label for="login-password">密码</label>\n<input id="login-password" type="password" autocomplete="current-password" required>\n' +
|
|
165
|
+
'<label for="login-otp">两步验证码 <span class="opt">(未开启请留空)</span></label>\n' +
|
|
166
|
+
'<input id="login-otp" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="已开启两步验证?输入 6 位动态码">\n' +
|
|
167
|
+
'<label class="remember"><input type="checkbox" id="login-remember" checked> 记住用户名</label>\n' +
|
|
168
|
+
'<div class="err"></div>\n' +
|
|
169
|
+
'<button class="submit" type="submit">登录</button>\n</form>\n' +
|
|
170
|
+
'<form id="register-form" style="display:none">\n' +
|
|
171
|
+
'<label for="reg-username">用户名</label>\n<input id="reg-username" autocomplete="username" required>\n' +
|
|
172
|
+
'<label for="reg-password">密码(至少 6 位)</label>\n<input id="reg-password" type="password" autocomplete="new-password" minlength="6" required>\n' +
|
|
173
|
+
'<label for="reg-password2">确认密码</label>\n<input id="reg-password2" type="password" autocomplete="new-password" required>\n' +
|
|
174
|
+
'<div class="err"></div>\n' +
|
|
175
|
+
'<div class="reg-note" id="reg-note"></div>\n' +
|
|
176
|
+
'<button class="submit" type="submit">注册并登录</button>\n' +
|
|
177
|
+
`<div class="hint" id="first-hint" style="display:none">当前还没有任何账号,首个注册的账号将成为管理员</div>\n</form>\n` +
|
|
178
|
+
'<div class="foot">dsh 插件 · user-management</div>\n' +
|
|
179
|
+
'</div>\n' +
|
|
180
|
+
`<script>${PAGE_SCRIPT.replace('__HAS_USERS__', hasUsers ? 'true' : 'false')}</script>\n` +
|
|
181
|
+
'</body>\n</html>\n'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { renderLoginPage }
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workspace-scope and sandbox policy — the pure half of the account model
|
|
5
|
+
* ported from dsh-passwords.
|
|
6
|
+
*
|
|
7
|
+
* Everything here is a pure function over values: no store, no request, no
|
|
8
|
+
* cordis. The enforcement layer (gateway response filtering, request clamping)
|
|
9
|
+
* calls these; keeping them separate is what makes the policy verifiable
|
|
10
|
+
* without a live gateway.
|
|
11
|
+
*
|
|
12
|
+
* Semantics are carried over verbatim, because the store and the settings UI
|
|
13
|
+
* already speak them:
|
|
14
|
+
* - an EMPTY allow-list means \"every workspace\" (the unrestricted default);
|
|
15
|
+
* - DENY_ALL_WORKSPACES is how an admin says \"no workspace at all\", which an
|
|
16
|
+
* empty list cannot express;
|
|
17
|
+
* - a path is allowed when it equals an entry or sits underneath it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
|
|
22
|
+
/** Sentinel entry meaning \"deny every workspace\" (distinct from \"[] = all\"). */
|
|
23
|
+
const DENY_ALL_WORKSPACES = '__deny__'
|
|
24
|
+
|
|
25
|
+
/** Sandbox modes from narrowest to widest; the rank decides what is a
|
|
26
|
+
* downgrade and what would be an escalation. */
|
|
27
|
+
const SANDBOX_RANK = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
|
|
28
|
+
|
|
29
|
+
/** Recursion cap for the JSON walkers: a deeply nested upstream payload must
|
|
30
|
+
* not turn into a stack-overflow DoS. */
|
|
31
|
+
const MAX_JSON_DEPTH = 8
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Canonical path form for comparisons: backslashes to slashes, posix
|
|
35
|
+
* normalization (so `..` and trailing slashes cannot smuggle a path past the
|
|
36
|
+
* allow-list), and case folding for Windows-style paths.
|
|
37
|
+
*/
|
|
38
|
+
function normalizePath(p) {
|
|
39
|
+
let n = String(p === undefined || p === null ? '' : p).replace(/\\/g, '/')
|
|
40
|
+
n = path.posix.normalize(n)
|
|
41
|
+
// Windows-style paths fold to lower case in FULL, not just the drive letter:
|
|
42
|
+
// the filesystem is case-insensitive, so a case-sensitive compare would
|
|
43
|
+
// reject a legitimate 'D:\\Work' allow-list entry for a workspace reported
|
|
44
|
+
// as 'd:/work'. POSIX paths keep their case sensitivity.
|
|
45
|
+
// (dsh-passwords lower-cased only the drive letter — it targets Linux hosts;
|
|
46
|
+
// this is a deliberate divergence for Windows deployments.)
|
|
47
|
+
if (/^[a-zA-Z]:/.test(n)) n = n.toLowerCase()
|
|
48
|
+
// posix.normalize KEEPS one trailing separator ('/srv/a/' survives as
|
|
49
|
+
// written), which would break the separator-boundary prefix test below: an
|
|
50
|
+
// admin pasting 'D:\\work\\' would then silently match nothing. Fold it
|
|
51
|
+
// away — the root ('/') is kept, and a bare 'c:' still matches the drive.
|
|
52
|
+
if (n.length > 1 && n.endsWith('/')) n = n.replace(/\/+$/, '')
|
|
53
|
+
return n
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether a list actually restricts anything (empty = unrestricted). */
|
|
57
|
+
function isWorkspaceRestricted(allowedFolders) {
|
|
58
|
+
return Array.isArray(allowedFolders) && allowedFolders.length > 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether `target` is inside the allow-list (or the list is unrestricted). */
|
|
62
|
+
function folderAllowed(target, allowedFolders) {
|
|
63
|
+
const list = Array.isArray(allowedFolders) ? allowedFolders : []
|
|
64
|
+
if (list.length === 0) return true
|
|
65
|
+
if (list.includes(DENY_ALL_WORKSPACES)) return false
|
|
66
|
+
const p = normalizePath(target)
|
|
67
|
+
return list.some((entry) => {
|
|
68
|
+
const base = normalizePath(entry)
|
|
69
|
+
// normalize('') is '.': an empty entry and '/' both mean \"the whole disk\"
|
|
70
|
+
if (base === '.' || base === '/') return true
|
|
71
|
+
// Prefix match must respect a separator boundary, or '/srv/ab' would pass
|
|
72
|
+
// an allow-list entry of '/srv/a'.
|
|
73
|
+
return p === base || p.startsWith(base + '/')
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Drop JSON entries whose path field falls outside the allow-list.
|
|
79
|
+
* Only array items carrying that field are judged; everything else is copied
|
|
80
|
+
* through recursively, so unrelated payload shapes survive untouched.
|
|
81
|
+
*/
|
|
82
|
+
function filterByPathFieldWithPredicate(value, field, allowed, depth = 0) {
|
|
83
|
+
// Past the cap the path field can no longer be checked reliably: drop the
|
|
84
|
+
// subtree instead of passing it through (fail closed).
|
|
85
|
+
if (depth > MAX_JSON_DEPTH) return null
|
|
86
|
+
if (value === null || value === undefined) return value === undefined ? undefined : value
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
const out = []
|
|
89
|
+
for (const item of value) {
|
|
90
|
+
if (item !== null && typeof item === 'object' && typeof item[field] === 'string' && item[field] !== '' && !allowed(item[field])) continue
|
|
91
|
+
out.push(filterByPathFieldWithPredicate(item, field, allowed, depth + 1))
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === 'object') {
|
|
96
|
+
const out = {}
|
|
97
|
+
for (const [key, nested] of Object.entries(value)) out[key] = filterByPathFieldWithPredicate(nested, field, allowed, depth + 1)
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
return value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** filterByPathField with the allow-list as the predicate. */
|
|
104
|
+
function filterByPathField(value, allowedFolders, field, depth = 0) {
|
|
105
|
+
return filterByPathFieldWithPredicate(value, field, (candidate) => folderAllowed(candidate, allowedFolders), depth)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rank a sandbox preset. An unknown value ranks as the MOST permissive, so an
|
|
110
|
+
* unrecognized preset can never be mistaken for a downgrade and silently let
|
|
111
|
+
* through (dsh-passwords' rule, kept because the reasoning holds).
|
|
112
|
+
* Own-property check, not a bare lookup: 'constructor' and friends must not
|
|
113
|
+
* resolve to anything from the prototype chain.
|
|
114
|
+
*/
|
|
115
|
+
function sandboxPresetRank(preset) {
|
|
116
|
+
return Object.prototype.hasOwnProperty.call(SANDBOX_RANK, preset) ? SANDBOX_RANK[preset] : 2
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Request-body fields that may carry the target path, in priority order. */
|
|
120
|
+
const PATH_FIELDS = [
|
|
121
|
+
'cwd',
|
|
122
|
+
'path',
|
|
123
|
+
'directory',
|
|
124
|
+
'dir',
|
|
125
|
+
'folder',
|
|
126
|
+
'workspace',
|
|
127
|
+
'root',
|
|
128
|
+
'workspacePath',
|
|
129
|
+
'absolutePath',
|
|
130
|
+
'target',
|
|
131
|
+
'targetPath',
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
/** Depth cap for the body walkers (same reasoning as the JSON filter). */
|
|
135
|
+
const MAX_BODY_DEPTH = 6
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* First path-looking string in a request body, walking the typert envelope
|
|
139
|
+
* ({ type, rpcId, method, payload }).
|
|
140
|
+
*
|
|
141
|
+
* `args` is skipped on purpose: it is a pseudo-field dsh does not consume, so a
|
|
142
|
+
* body of { payload: { args: { cwd: '/root/11' } } } actually creates a session
|
|
143
|
+
* in the DEFAULT workspace. Trusting args.cwd would authorize a path the host
|
|
144
|
+
* never uses — a fail-open hole rather than a strictness bug.
|
|
145
|
+
*
|
|
146
|
+
* Ported verbatim from dsh-passwords; the reasoning above is why.
|
|
147
|
+
*/
|
|
148
|
+
function extractPathFromBody(value, depth = 0) {
|
|
149
|
+
if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return null
|
|
150
|
+
for (const field of PATH_FIELDS) {
|
|
151
|
+
const candidate = value[field]
|
|
152
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate
|
|
153
|
+
}
|
|
154
|
+
for (const key of Object.keys(value)) {
|
|
155
|
+
if (key === 'args') continue
|
|
156
|
+
const nested = extractPathFromBody(value[key], depth + 1)
|
|
157
|
+
if (nested !== null) return nested
|
|
158
|
+
}
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Same walk, looking for a workspaceId (session.create may send one instead of cwd). */
|
|
163
|
+
function extractWorkspaceId(value, depth = 0) {
|
|
164
|
+
if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return null
|
|
165
|
+
if (typeof value.workspaceId === 'string' && value.workspaceId.length > 0) return value.workspaceId
|
|
166
|
+
for (const key of Object.keys(value)) {
|
|
167
|
+
if (key === 'args') continue
|
|
168
|
+
const nested = extractWorkspaceId(value[key], depth + 1)
|
|
169
|
+
if (nested !== null) return nested
|
|
170
|
+
}
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Harvest workspaceId → path pairs out of a workspace-list response, so a later
|
|
176
|
+
* session.create that carries only a workspaceId can still be checked against
|
|
177
|
+
* the allow-list. Both `workspaceId` and a top-level `id` are accepted: dsh
|
|
178
|
+
* reports the former, and a miss here means a fail-closed 403 on a legitimate
|
|
179
|
+
* request.
|
|
180
|
+
*/
|
|
181
|
+
function collectIdPathPairs(value, out = new Map(), depth = 0) {
|
|
182
|
+
if (depth > MAX_JSON_DEPTH || value === null) return out
|
|
183
|
+
if (Array.isArray(value)) {
|
|
184
|
+
for (const item of value) collectIdPathPairs(item, out, depth + 1)
|
|
185
|
+
} else if (typeof value === 'object') {
|
|
186
|
+
if (typeof value.path === 'string') {
|
|
187
|
+
const id = typeof value.workspaceId === 'string' ? value.workspaceId : typeof value.id === 'string' ? value.id : null
|
|
188
|
+
if (id !== null) out.set(id, value.path)
|
|
189
|
+
}
|
|
190
|
+
for (const nested of Object.values(value)) collectIdPathPairs(nested, out, depth + 1)
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** First string value stored under `field` anywhere in the body (depth-capped). */
|
|
196
|
+
function findStringField(value, field, depth = 0) {
|
|
197
|
+
if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return null
|
|
198
|
+
const candidate = value[field]
|
|
199
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate
|
|
200
|
+
for (const key of Object.keys(value)) {
|
|
201
|
+
const nested = findStringField(value[key], field, depth + 1)
|
|
202
|
+
if (nested !== null) return nested
|
|
203
|
+
}
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The preset named by a `/permission` slash command, or null.
|
|
209
|
+
* e.g. "/permission workspace-write" → "workspace-write".
|
|
210
|
+
*/
|
|
211
|
+
function permissionPresetFromCommand(line) {
|
|
212
|
+
const match = /^\/permission\s+([A-Za-z0-9_-]+)/.exec(String(line || '').trim())
|
|
213
|
+
return match ? match[1] : null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The preset written by a settings.mutate body.
|
|
218
|
+
*
|
|
219
|
+
* The field lives inside an `ops[].path` ARRAY (path: [..., 'defaultPreset'])
|
|
220
|
+
* rather than under an object key, so the generic field walker cannot find it —
|
|
221
|
+
* hence this dedicated shape match.
|
|
222
|
+
*/
|
|
223
|
+
function presetFromSettingsMutate(value, depth = 0) {
|
|
224
|
+
if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return null
|
|
225
|
+
if (Array.isArray(value.path) && value.path.includes('defaultPreset')) {
|
|
226
|
+
if (typeof value.value === 'string' && value.value.length > 0) return value.value
|
|
227
|
+
}
|
|
228
|
+
for (const key of Object.keys(value)) {
|
|
229
|
+
const nested = presetFromSettingsMutate(value[key], depth + 1)
|
|
230
|
+
if (nested !== null) return nested
|
|
231
|
+
}
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Force every approval outcome in a response body to 'rejected'.
|
|
237
|
+
*
|
|
238
|
+
* A restricted account's agent must not be able to escalate its own sandbox by
|
|
239
|
+
* asking for permission: the user may click "allow", but the answer forwarded
|
|
240
|
+
* upstream is a denial. Only objects carrying BOTH a string approvalId and a
|
|
241
|
+
* string outcome are touched, so ask_user_question answers (which use `answer`)
|
|
242
|
+
* pass through untouched.
|
|
243
|
+
*
|
|
244
|
+
* @returns whether anything actually changed (the caller only rewrites the body
|
|
245
|
+
* when it did).
|
|
246
|
+
*/
|
|
247
|
+
function forceRejectApproval(value, depth = 0) {
|
|
248
|
+
if (depth > MAX_BODY_DEPTH || value === null || typeof value !== 'object') return false
|
|
249
|
+
let changed = false
|
|
250
|
+
if (typeof value.approvalId === 'string' && typeof value.outcome === 'string' && value.outcome !== 'rejected') {
|
|
251
|
+
value.outcome = 'rejected'
|
|
252
|
+
changed = true
|
|
253
|
+
}
|
|
254
|
+
for (const key of Object.keys(value)) {
|
|
255
|
+
const nested = value[key]
|
|
256
|
+
if (nested !== null && typeof nested === 'object' && forceRejectApproval(nested, depth + 1)) changed = true
|
|
257
|
+
}
|
|
258
|
+
return changed
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Invisible Unicode: zero-width joiners/spaces, bidi controls, word joiners,
|
|
263
|
+
* BOM, soft hyphen, Mongolian vowel separator, combining grapheme joiner,
|
|
264
|
+
* Arabic letter mark, Hangul fillers.
|
|
265
|
+
*
|
|
266
|
+
* These characters mean nothing to a human reader but are perfectly visible
|
|
267
|
+
* instructions to a model, which makes them a prompt-injection carrier: a file
|
|
268
|
+
* the agent reads can hide text that only comes back to the model when the
|
|
269
|
+
* session history is replayed. Stripping them loses no meaning.
|
|
270
|
+
*/
|
|
271
|
+
const HIDDEN_UNICODE_RE = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff\u00ad\u180e\u034f\u061c\u115f\u1160]/g
|
|
272
|
+
|
|
273
|
+
/** Strip invisible Unicode from one string. */
|
|
274
|
+
function sanitizeHiddenUnicode(content) {
|
|
275
|
+
return String(content).replace(HIDDEN_UNICODE_RE, '')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The same, over a whole JSON payload: every string is cleaned, structure and
|
|
280
|
+
* other types are preserved. Past the depth cap the subtree is returned as-is
|
|
281
|
+
* (this is a defense in depth, not an authorization check — refusing the
|
|
282
|
+
* response to the user would be worse than passing an unsanitized deep node).
|
|
283
|
+
*/
|
|
284
|
+
function sanitizeHiddenUnicodeJson(value, depth = 0) {
|
|
285
|
+
if (depth > MAX_JSON_DEPTH) return value
|
|
286
|
+
if (typeof value === 'string') return sanitizeHiddenUnicode(value)
|
|
287
|
+
if (value === null || typeof value !== 'object') return value
|
|
288
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeHiddenUnicodeJson(item, depth + 1))
|
|
289
|
+
const out = {}
|
|
290
|
+
for (const [key, nested] of Object.entries(value)) out[key] = sanitizeHiddenUnicodeJson(nested, depth + 1)
|
|
291
|
+
return out
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Clamp a sandbox mode wider than the account's allowance back down to it.
|
|
296
|
+
*
|
|
297
|
+
* Why this exists: a session shared by the owning account can carry
|
|
298
|
+
* permission/preset or sandbox/mode events recorded while it ran with a wider
|
|
299
|
+
* sandbox. Replaying that history into a restricted account would hand it the
|
|
300
|
+
* wider mode without a single action from anyone — an escalation with no
|
|
301
|
+
* approval step to intercept.
|
|
302
|
+
*
|
|
303
|
+
* Rewrites in place: `permission/preset` events, `sandbox/mode` events, and
|
|
304
|
+
* the projected `currentValue` the client displays as the active preset.
|
|
305
|
+
*
|
|
306
|
+
* @returns whether anything changed.
|
|
307
|
+
*/
|
|
308
|
+
function clampSessionHistorySandbox(value, allowedMode, depth = 0) {
|
|
309
|
+
if (depth > MAX_JSON_DEPTH || value === null || typeof value !== 'object') return false
|
|
310
|
+
if (typeof allowedMode !== 'string' || !Object.prototype.hasOwnProperty.call(SANDBOX_RANK, allowedMode)) return false
|
|
311
|
+
const allowedRank = SANDBOX_RANK[allowedMode]
|
|
312
|
+
let changed = false
|
|
313
|
+
|
|
314
|
+
const widerThan = (mode) => Object.prototype.hasOwnProperty.call(SANDBOX_RANK, mode) && SANDBOX_RANK[mode] > allowedRank
|
|
315
|
+
|
|
316
|
+
if (value.type === 'permission/preset' && value.data && typeof value.data === 'object') {
|
|
317
|
+
if (typeof value.data.preset === 'string' && widerThan(value.data.preset)) {
|
|
318
|
+
value.data.preset = allowedMode
|
|
319
|
+
changed = true
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (value.type === 'sandbox/mode' && value.data && typeof value.data === 'object') {
|
|
323
|
+
if (typeof value.data.mode === 'string' && widerThan(value.data.mode)) {
|
|
324
|
+
value.data.mode = allowedMode
|
|
325
|
+
changed = true
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (typeof value.currentValue === 'string' && widerThan(value.currentValue)) {
|
|
329
|
+
value.currentValue = allowedMode
|
|
330
|
+
changed = true
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const key of Object.keys(value)) {
|
|
334
|
+
const nested = value[key]
|
|
335
|
+
if (nested !== null && typeof nested === 'object' && clampSessionHistorySandbox(nested, allowedMode, depth + 1)) {
|
|
336
|
+
changed = true
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return changed
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Workspace creation endpoints (gated by allowWorkspaceCreate). */
|
|
343
|
+
function isWorkspaceCreate(pathname) {
|
|
344
|
+
return /^\/api\/workspace[.\/](add|create)([.\/]|$)/.test(String(pathname))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Workspace delete/rename endpoints (ownership applies as well as the switch). */
|
|
348
|
+
function isWorkspaceDeleteOrRename(pathname) {
|
|
349
|
+
return /^\/api\/workspace[.\/](remove|delete|rename|update)([.\/]|$)/.test(String(pathname))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Old and new path of a rename/delete request, or null when either is missing.
|
|
354
|
+
*
|
|
355
|
+
* Callers must stay fail-closed on null: a rename whose target cannot be read is
|
|
356
|
+
* a rename that cannot be authorized.
|
|
357
|
+
*/
|
|
358
|
+
function extractWorkspaceRenamePaths(value, depth = 0) {
|
|
359
|
+
const oldKeys = new Set(['oldPath', 'previousPath', 'sourcePath', 'fromPath'])
|
|
360
|
+
const newKeys = new Set(['newPath', 'targetPath', 'destinationPath', 'toPath'])
|
|
361
|
+
let oldPath = null
|
|
362
|
+
let newPath = null
|
|
363
|
+
const visit = (current, level) => {
|
|
364
|
+
if (level > MAX_BODY_DEPTH || current === null || typeof current !== 'object') return
|
|
365
|
+
if (oldPath !== null && newPath !== null) return
|
|
366
|
+
if (Array.isArray(current)) {
|
|
367
|
+
for (const item of current) visit(item, level + 1)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
for (const [key, nested] of Object.entries(current)) {
|
|
371
|
+
if (oldPath === null && oldKeys.has(key) && typeof nested === 'string' && nested !== '') oldPath = nested
|
|
372
|
+
else if (newPath === null && newKeys.has(key) && typeof nested === 'string' && nested !== '') newPath = nested
|
|
373
|
+
else visit(nested, level + 1)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
visit(value, depth)
|
|
377
|
+
return oldPath !== null && newPath !== null ? { oldPath, newPath } : null
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** aionui-panel file-tree endpoints that WRITE (they mirror the upload switch). */
|
|
381
|
+
const AIONUI_WRITE_PATHS = new Set([
|
|
382
|
+
'/aionui-panel/write',
|
|
383
|
+
'/aionui-panel/delete',
|
|
384
|
+
'/aionui-panel/git-stage',
|
|
385
|
+
'/aionui-panel/git-unstage',
|
|
386
|
+
'/aionui-panel/git-discard',
|
|
387
|
+
])
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Endpoints that put a file INTO the host: the uploads plugin, the file-path
|
|
391
|
+
* "import into workspace" bridge, the SSH upload, and the aionui-panel writers.
|
|
392
|
+
*
|
|
393
|
+
* Ported from dsh-passwords (its isUploadRequest plus the aionui writers, which
|
|
394
|
+
* it gates through the same switch).
|
|
395
|
+
*/
|
|
396
|
+
function isUploadRequest(method, pathname) {
|
|
397
|
+
const m = String(method || '').toUpperCase()
|
|
398
|
+
if (m !== 'POST' && m !== 'PUT' && m !== 'DELETE') return false
|
|
399
|
+
if (AIONUI_WRITE_PATHS.has(pathname)) return true
|
|
400
|
+
if (m !== 'POST' && m !== 'PUT') return false
|
|
401
|
+
return (
|
|
402
|
+
pathname === '/api/dsh-uploads' ||
|
|
403
|
+
pathname.startsWith('/api/dsh-uploads/') ||
|
|
404
|
+
pathname === '/api/filePathBridge/importFile' ||
|
|
405
|
+
pathname === '/api/dsh-ssh/upload'
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Endpoints that take data OUT of the host: the git tool RPCs, the git-graph
|
|
411
|
+
* and aionui git panels, plus the other "hand me the server's contents" doors
|
|
412
|
+
* (session export zip, SSH download/list, uploads download).
|
|
413
|
+
*
|
|
414
|
+
* Deliberately a prefix match on git RPCs only — /api/session.fetch and friends
|
|
415
|
+
* are ordinary endpoints and must not be caught here.
|
|
416
|
+
*/
|
|
417
|
+
function isGitRequest(pathname) {
|
|
418
|
+
return (
|
|
419
|
+
/^\/api\/git[-.\/]/i.test(pathname) ||
|
|
420
|
+
/^\/aionui-panel\/git[-.]/.test(pathname) ||
|
|
421
|
+
/^\/api\/session[.\/]export/.test(pathname) ||
|
|
422
|
+
/^\/api\/dsh-ssh[.\/](download|ls)/.test(pathname) ||
|
|
423
|
+
/^\/api\/dsh-uploads[.\/]download/.test(pathname)
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Third-party "ops surfaces" that only the owning account may see: the SSH host
|
|
429
|
+
* inventory/tunnels (they carry connection details and key material), the skin
|
|
430
|
+
* centre and modlens panels (outside the permission model entirely), and the
|
|
431
|
+
* shared upload store's LIST/DELETE routes (enumerating or deleting other
|
|
432
|
+
* people's files). The uploads POST path stays governed by allowUpload and its
|
|
433
|
+
* download by allowGit, as in dsh-passwords.
|
|
434
|
+
*/
|
|
435
|
+
function isAdminOnlyPluginEndpoint(method, pathname) {
|
|
436
|
+
const m = String(method || 'GET').toUpperCase()
|
|
437
|
+
return (
|
|
438
|
+
pathname === '/api/dsh-ssh' ||
|
|
439
|
+
pathname.startsWith('/api/dsh-ssh/') ||
|
|
440
|
+
pathname === '/api/skin-center' ||
|
|
441
|
+
pathname.startsWith('/api/skin-center/') ||
|
|
442
|
+
pathname === '/modlens' ||
|
|
443
|
+
pathname.startsWith('/modlens/') ||
|
|
444
|
+
(pathname === '/api/dsh-uploads' && (m === 'GET' || m === 'DELETE'))
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export {
|
|
449
|
+
isWorkspaceCreate,
|
|
450
|
+
isWorkspaceDeleteOrRename,
|
|
451
|
+
extractWorkspaceRenamePaths,
|
|
452
|
+
HIDDEN_UNICODE_RE,
|
|
453
|
+
sanitizeHiddenUnicode,
|
|
454
|
+
sanitizeHiddenUnicodeJson,
|
|
455
|
+
clampSessionHistorySandbox,
|
|
456
|
+
AIONUI_WRITE_PATHS,
|
|
457
|
+
isUploadRequest,
|
|
458
|
+
isGitRequest,
|
|
459
|
+
isAdminOnlyPluginEndpoint,
|
|
460
|
+
findStringField,
|
|
461
|
+
permissionPresetFromCommand,
|
|
462
|
+
presetFromSettingsMutate,
|
|
463
|
+
forceRejectApproval,
|
|
464
|
+
PATH_FIELDS,
|
|
465
|
+
extractPathFromBody,
|
|
466
|
+
extractWorkspaceId,
|
|
467
|
+
collectIdPathPairs,
|
|
468
|
+
DENY_ALL_WORKSPACES,
|
|
469
|
+
SANDBOX_RANK,
|
|
470
|
+
MAX_JSON_DEPTH,
|
|
471
|
+
normalizePath,
|
|
472
|
+
isWorkspaceRestricted,
|
|
473
|
+
folderAllowed,
|
|
474
|
+
filterByPathField,
|
|
475
|
+
filterByPathFieldWithPredicate,
|
|
476
|
+
sandboxPresetRank,
|
|
477
|
+
}
|