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,769 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The user-management gateway server: one listener that terminates TLS
|
|
5
|
+
* (SNI-selected per-site certificates, self-signed where none are supplied),
|
|
6
|
+
* enforces a Host allow-list, runs the user-management store-backed auth gate,
|
|
7
|
+
* serves /login + /user-management/api/* locally, and reverse-proxies
|
|
8
|
+
* everything else — WebSocket upgrades included — to the loopback dsh
|
|
9
|
+
* webserver.
|
|
10
|
+
*
|
|
11
|
+
* Two listener modes share that whole pipeline:
|
|
12
|
+
* - default (node:https) — the standalone deployment, where this gateway IS
|
|
13
|
+
* the login door;
|
|
14
|
+
* - `plaintext: true` (node:http, loopback only) — the merged deployment,
|
|
15
|
+
* where dsh-passwords is the only door and this listener is the ledger
|
|
16
|
+
* observer it proxies through.
|
|
17
|
+
*
|
|
18
|
+
* Adapted from dsh-gateway (clarknu/dsh-gateway) lib/gateway-core.js. The
|
|
19
|
+
* dsh-gateway flat-users HMAC auth is replaced by user-management's store:
|
|
20
|
+
* the routing defers to a `decider` (see src/gate.js createDecider) for the
|
|
21
|
+
* allow/redirect/401/403 decision, and to `handleApi` for the local API.
|
|
22
|
+
*
|
|
23
|
+
* Cordis-free by design: the plugin wrapper (src/index.js) owns config
|
|
24
|
+
* resolution + lifecycle + hot-reload + self-heal; this module is testable
|
|
25
|
+
* standalone.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createServer as createHttpServer } from 'node:http'
|
|
29
|
+
import { createServer as createHttpsServer } from 'node:https'
|
|
30
|
+
import { createSecureContext } from 'node:tls'
|
|
31
|
+
import { X509Certificate } from 'node:crypto'
|
|
32
|
+
import { createProxy } from './proxy.js'
|
|
33
|
+
import {
|
|
34
|
+
clampSessionHistorySandbox,
|
|
35
|
+
collectIdPathPairs,
|
|
36
|
+
extractWorkspaceRenamePaths,
|
|
37
|
+
isWorkspaceCreate,
|
|
38
|
+
isWorkspaceDeleteOrRename,
|
|
39
|
+
normalizePath,
|
|
40
|
+
sanitizeHiddenUnicodeJson,
|
|
41
|
+
extractPathFromBody,
|
|
42
|
+
extractWorkspaceId,
|
|
43
|
+
filterByPathField,
|
|
44
|
+
findStringField,
|
|
45
|
+
folderAllowed,
|
|
46
|
+
forceRejectApproval,
|
|
47
|
+
isAdminOnlyPluginEndpoint,
|
|
48
|
+
isGitRequest,
|
|
49
|
+
isUploadRequest,
|
|
50
|
+
isWorkspaceRestricted,
|
|
51
|
+
filterByPathFieldWithPredicate,
|
|
52
|
+
permissionPresetFromCommand,
|
|
53
|
+
presetFromSettingsMutate,
|
|
54
|
+
sandboxPresetRank,
|
|
55
|
+
} from './permissions.js'
|
|
56
|
+
import { loadOrCreateSiteCert } from './certs.js'
|
|
57
|
+
import {
|
|
58
|
+
SESSION_COOKIE,
|
|
59
|
+
LOGIN_PAGE_PATH,
|
|
60
|
+
API_PREFIX,
|
|
61
|
+
parseCookies,
|
|
62
|
+
isAuditableRequest,
|
|
63
|
+
sendUnauthorized,
|
|
64
|
+
sendForbidden,
|
|
65
|
+
} from './gate.js'
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a gateway from resolved options. Returns { start, stop, port }.
|
|
69
|
+
*
|
|
70
|
+
* options: {
|
|
71
|
+
* listenHost, port, upstream, sites, certsDir, title,
|
|
72
|
+
* decider, // async (req) => { action, session?, path? }
|
|
73
|
+
* handleApi, // async (req, res, deps)
|
|
74
|
+
* renderLoginPage, // ({ hasUsers, title }) => html
|
|
75
|
+
* deps, // { store, clientIp } for handleApi
|
|
76
|
+
* clearedCookie, // () => string (for /logout)
|
|
77
|
+
* auditHooks, // { onApiRequest(req, session, path, status), onWsOpen(req, session, path) }
|
|
78
|
+
* log(msg), warn(msg), onError(err)
|
|
79
|
+
* }
|
|
80
|
+
*/
|
|
81
|
+
function createGateway(options) {
|
|
82
|
+
// Plaintext mode: the merged deployment's observer listener. See the option
|
|
83
|
+
// doc in index.js — loopback only, no certificate, reached solely by the
|
|
84
|
+
// co-located dsh-passwords gateway (which tunnels WS as raw TCP and so
|
|
85
|
+
// cannot speak TLS on this hop).
|
|
86
|
+
const plaintext = options.plaintext === true
|
|
87
|
+
const log = typeof options.log === 'function' ? options.log : () => {}
|
|
88
|
+
const warn = typeof options.warn === 'function' ? options.warn : log
|
|
89
|
+
const proxy = createProxy(options.upstream, options.getAuthenticatedUrl, { rewriteResponse: rewritePlanFor })
|
|
90
|
+
|
|
91
|
+
/** dsh's workspace-list RPC — the one response this layer rewrites. */
|
|
92
|
+
const WORKSPACE_LIST_RE = /^\/api\/workspace[.\/]list$/
|
|
93
|
+
/** dsh's session-creating RPC — the one request this layer inspects. */
|
|
94
|
+
const SESSION_CREATE_RE = /^\/api\/session[.\/]create([.\/]|$)/
|
|
95
|
+
/** The paths through which a sandbox preset can be widened. */
|
|
96
|
+
const SANDBOX_SETTINGS_RE = /^\/api\/settings[.\/]/
|
|
97
|
+
const SANDBOX_COMMAND_RE = /^\/api\/commands[.\/]execute$/
|
|
98
|
+
const SANDBOX_RESPOND_RE = /^\/api\/respond$/
|
|
99
|
+
/** Session history replay — clamped and sanitized for every account. */
|
|
100
|
+
const SESSION_HISTORY_RE = /^\/api\/session[.\/]history$/
|
|
101
|
+
/** Hard cap on an inspected request body; over it the request is refused. */
|
|
102
|
+
const MAX_GATE_BODY = 64 * 1024
|
|
103
|
+
/** workspaceId → path, harvested from workspace-list responses (see below). */
|
|
104
|
+
const workspacePathById = new Map()
|
|
105
|
+
|
|
106
|
+
function requestPath(req) {
|
|
107
|
+
try { return new URL(req.url || '/', 'http://dsh.local').pathname } catch { return '' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Ask the host half for an account's permissions. Kept behind deps so this
|
|
112
|
+
* module stays cordis-free and testable: no store, no policy, just a lookup.
|
|
113
|
+
*/
|
|
114
|
+
/** Owner map (path → username) as the host half sees it. */
|
|
115
|
+
function ownerMap() {
|
|
116
|
+
const read = options.deps && options.deps.workspaceOwners
|
|
117
|
+
if (typeof read !== 'function') return {}
|
|
118
|
+
try { return read() || {} } catch { return {} }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sessionUsername(session) {
|
|
122
|
+
return session && session.user && typeof session.user.username === 'string' ? session.user.username : ''
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sessionIsAdmin(session) {
|
|
126
|
+
return !!(session && session.user && session.user.role === 'admin')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function applyWorkspaceChange(method, path) {
|
|
130
|
+
const change = options.deps && options.deps[method]
|
|
131
|
+
if (typeof change !== 'function') return
|
|
132
|
+
try {
|
|
133
|
+
const result = change.apply(null, path)
|
|
134
|
+
if (result && typeof result.catch === 'function') result.catch(() => {})
|
|
135
|
+
} catch { /* ownership bookkeeping must never break a request */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function permissionsFor(session) {
|
|
139
|
+
const lookup = options.deps && options.deps.permissionsFor
|
|
140
|
+
if (typeof lookup !== 'function' || !session) return null
|
|
141
|
+
try { return lookup(session) } catch { return null }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Decide whether a response must be rewritten before the client sees it.
|
|
146
|
+
*
|
|
147
|
+
* Today only the workspace list qualifies: an account with a folder allow-list
|
|
148
|
+
* must not receive entries outside it. The unrestricted case (empty list, or
|
|
149
|
+
* no account match) returns null and streams through untouched, so the common
|
|
150
|
+
* path pays nothing.
|
|
151
|
+
*/
|
|
152
|
+
function rewritePlanFor(req) {
|
|
153
|
+
if ((req.method || '').toUpperCase() !== 'POST') return null
|
|
154
|
+
const path = requestPath(req)
|
|
155
|
+
const perms = permissionsFor(req.umSession)
|
|
156
|
+
|
|
157
|
+
if (WORKSPACE_LIST_RE.test(path)) {
|
|
158
|
+
const session = req.umSession
|
|
159
|
+
const folderScoped = perms !== null && perms !== undefined && isWorkspaceRestricted(perms.allowedFolders)
|
|
160
|
+
const ownerScoped = !sessionIsAdmin(session)
|
|
161
|
+
// An administrator with an unrestricted list has nothing to hide; anyone
|
|
162
|
+
// else either has a folder allow-list, or must not see other people's
|
|
163
|
+
// workspaces, or both.
|
|
164
|
+
if (!folderScoped && !ownerScoped) return null
|
|
165
|
+
const mine = sessionUsername(session)
|
|
166
|
+
const isAdmin = sessionIsAdmin(session)
|
|
167
|
+
return {
|
|
168
|
+
transform: (parsed) => {
|
|
169
|
+
// Harvest BEFORE filtering: the cache must also hold workspaces this
|
|
170
|
+
// account cannot see, so a create aimed at one resolves to a path and
|
|
171
|
+
// is then correctly refused (rather than looking unidentifiable).
|
|
172
|
+
collectIdPathPairs(parsed, workspacePathById)
|
|
173
|
+
const owners = ownerMap()
|
|
174
|
+
const visible = (candidate) => {
|
|
175
|
+
if (folderScoped && !folderAllowed(candidate, perms.allowedFolders)) return false
|
|
176
|
+
if (isAdmin) return true
|
|
177
|
+
const owner = owners[normalizePath(candidate)]
|
|
178
|
+
return owner === undefined || owner === mine
|
|
179
|
+
}
|
|
180
|
+
const filtered = filterByPathFieldWithPredicate(parsed, 'path', visible)
|
|
181
|
+
return sanitizeHiddenUnicodeJson(filtered)
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (SESSION_HISTORY_RE.test(path)) {
|
|
187
|
+
const allowedMode = perms && typeof perms.sandboxMode === 'string' ? perms.sandboxMode : null
|
|
188
|
+
return {
|
|
189
|
+
transform: (parsed) => {
|
|
190
|
+
// Clamp first (it rewrites values in place), then sanitize: a hidden
|
|
191
|
+
// character smuggled into a preset name must not survive the rewrite.
|
|
192
|
+
if (allowedMode !== null) clampSessionHistorySandbox(parsed, allowedMode)
|
|
193
|
+
return sanitizeHiddenUnicodeJson(parsed)
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const sites = (options.sites || [{ hosts: ['localhost'] }]).map((site) => ({
|
|
202
|
+
hosts: (site.hosts || []).map((h) => String(h).toLowerCase()),
|
|
203
|
+
cert: site.cert || '',
|
|
204
|
+
key: site.key || '',
|
|
205
|
+
}))
|
|
206
|
+
if (sites.length === 0) sites.push({ hosts: [], cert: '', key: '' })
|
|
207
|
+
|
|
208
|
+
const allowList = new Set(sites.flatMap((s) => s.hosts))
|
|
209
|
+
if (plaintext) {
|
|
210
|
+
// The signing gateway dials this listener as http://127.0.0.1:<port>, and
|
|
211
|
+
// a loopback literal is exactly what the auto site list omits (it skips
|
|
212
|
+
// internal addresses on purpose). Without these the observer would answer
|
|
213
|
+
// 421 to its only legitimate caller.
|
|
214
|
+
for (const host of ['127.0.0.1', 'localhost', '::1']) allowList.add(host)
|
|
215
|
+
}
|
|
216
|
+
const allowAll = allowList.size === 0
|
|
217
|
+
if (allowAll) {
|
|
218
|
+
warn('user-management: no hosts configured — accepting every Host header (set sites[].hosts to restrict)')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Plaintext carries no certificate at all: no TLS context is built, and
|
|
222
|
+
// certMaterial below stays null so the cert-download endpoints answer 404
|
|
223
|
+
// and the client's certificate card hides itself (the single door owns TLS).
|
|
224
|
+
const contexts = plaintext ? [] : sites.map((site) => {
|
|
225
|
+
const { cert, key } = loadOrCreateSiteCert(site, options.certsDir, log)
|
|
226
|
+
return {
|
|
227
|
+
hosts: site.hosts,
|
|
228
|
+
context: createSecureContext({ cert, key }),
|
|
229
|
+
certPem: cert,
|
|
230
|
+
keyPem: key,
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
const defaultSite = contexts[0] ?? null
|
|
234
|
+
|
|
235
|
+
// Certificate material for the download endpoints. The certificate is
|
|
236
|
+
// public by nature (broadcast in every TLS handshake) — serving it
|
|
237
|
+
// unauthenticated is the whole point: a first-time visitor fetches it,
|
|
238
|
+
// imports it into their trust store, and the self-signed warning goes
|
|
239
|
+
// away for good. Computed once per gateway instance.
|
|
240
|
+
let certMaterial = null
|
|
241
|
+
try {
|
|
242
|
+
if (defaultSite === null) throw new Error('plaintext listener: no certificate material')
|
|
243
|
+
const x509 = new X509Certificate(defaultSite.certPem)
|
|
244
|
+
certMaterial = {
|
|
245
|
+
pem: defaultSite.certPem,
|
|
246
|
+
der: x509.raw,
|
|
247
|
+
fingerprint: x509.fingerprint256,
|
|
248
|
+
notBefore: x509.validFrom,
|
|
249
|
+
notAfter: x509.validTo,
|
|
250
|
+
subject: x509.subject,
|
|
251
|
+
}
|
|
252
|
+
} catch { /* malformed cert — the endpoints answer 404 */ }
|
|
253
|
+
const allHosts = [...new Set(sites.flatMap((s) => s.hosts))]
|
|
254
|
+
|
|
255
|
+
/** Host matching: exact, bare wildcard, or *.example.com wildcard. */
|
|
256
|
+
function hostMatches(pattern, host) {
|
|
257
|
+
if (pattern === host || pattern === '*') return true
|
|
258
|
+
if (pattern.startsWith('*.')) {
|
|
259
|
+
const suffix = pattern.slice(1)
|
|
260
|
+
return host.endsWith(suffix) && host.length > suffix.length
|
|
261
|
+
}
|
|
262
|
+
return false
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Strip port and brackets from a Host header value; lowercase. */
|
|
266
|
+
function normalizeHost(header) {
|
|
267
|
+
if (!header) return ''
|
|
268
|
+
let host = String(header).trim().toLowerCase()
|
|
269
|
+
if (host.startsWith('[')) {
|
|
270
|
+
const end = host.indexOf(']')
|
|
271
|
+
return end === -1 ? host : host.slice(1, end)
|
|
272
|
+
}
|
|
273
|
+
const colon = host.lastIndexOf(':')
|
|
274
|
+
return colon === -1 ? host : host.slice(0, colon)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function selectContext(servername) {
|
|
278
|
+
if (defaultSite === null) return null // plaintext listener: never consulted
|
|
279
|
+
const name = (servername || '').toLowerCase()
|
|
280
|
+
for (const entry of contexts) {
|
|
281
|
+
if (entry.hosts.some((h) => hostMatches(h, name))) return entry.context
|
|
282
|
+
}
|
|
283
|
+
return defaultSite.context
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function hostAllowed(host) {
|
|
287
|
+
if (allowAll) return true
|
|
288
|
+
for (const pattern of allowList) {
|
|
289
|
+
if (hostMatches(pattern, host)) return true
|
|
290
|
+
}
|
|
291
|
+
return false
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const send = (res, status, headers, body) => {
|
|
295
|
+
res.writeHead(status, headers)
|
|
296
|
+
res.end(body)
|
|
297
|
+
}
|
|
298
|
+
const redirect = (res, location, extraHeaders) =>
|
|
299
|
+
send(res, 302, Object.assign({ Location: location, 'cache-control': 'no-store' }, extraHeaders || {}), '')
|
|
300
|
+
|
|
301
|
+
/** Buffer a request body with a hard cap; null means "could not read it". */
|
|
302
|
+
function readRequestBody(req, limit) {
|
|
303
|
+
return new Promise((resolve) => {
|
|
304
|
+
const chunks = []
|
|
305
|
+
let size = 0
|
|
306
|
+
let done = false
|
|
307
|
+
const finish = (value) => {
|
|
308
|
+
if (done) return
|
|
309
|
+
done = true
|
|
310
|
+
resolve(value)
|
|
311
|
+
}
|
|
312
|
+
req.on('data', (chunk) => {
|
|
313
|
+
size += chunk.length
|
|
314
|
+
if (size > limit) {
|
|
315
|
+
finish(null)
|
|
316
|
+
req.destroy()
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
chunks.push(chunk)
|
|
320
|
+
})
|
|
321
|
+
req.on('end', () => finish(Buffer.concat(chunks)))
|
|
322
|
+
req.on('error', () => finish(null))
|
|
323
|
+
req.on('aborted', () => finish(null))
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Path-level gates — no body needed, because the decision is purely
|
|
329
|
+
* (method, path).
|
|
330
|
+
*
|
|
331
|
+
* Two rules, in this order:
|
|
332
|
+
* 1. The third-party ops surfaces are admin-only for EVERYONE (they sit
|
|
333
|
+
* outside the permission model, so no account setting can open them);
|
|
334
|
+
* 2. otherwise the owning account (role 'admin') is unrestricted — the
|
|
335
|
+
* upload/git switches are for the accounts it hands out, exactly how
|
|
336
|
+
* dsh-passwords scopes them to sub-users.
|
|
337
|
+
*
|
|
338
|
+
* @returns {string|null} a refusal reason, or null to proceed.
|
|
339
|
+
*/
|
|
340
|
+
function pathGateFor(req) {
|
|
341
|
+
const session = req.umSession
|
|
342
|
+
const perms = permissionsFor(session)
|
|
343
|
+
if (perms === null || perms === undefined) return null
|
|
344
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
345
|
+
const path = requestPath(req)
|
|
346
|
+
const isAdmin = !!(session && session.user && session.user.role === 'admin')
|
|
347
|
+
|
|
348
|
+
if (isAdminOnlyPluginEndpoint(method, path)) {
|
|
349
|
+
return isAdmin ? null : 'this endpoint is available to the owning account only'
|
|
350
|
+
}
|
|
351
|
+
if (isAdmin) return null
|
|
352
|
+
if (perms.allowUpload !== true && isUploadRequest(method, path)) {
|
|
353
|
+
return 'uploads are disabled for this account'
|
|
354
|
+
}
|
|
355
|
+
if (perms.allowGit !== true && isGitRequest(path)) {
|
|
356
|
+
return 'git and download operations are disabled for this account'
|
|
357
|
+
}
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Ask the host half whether this account has spent its allowance.
|
|
363
|
+
*
|
|
364
|
+
* The host owns the counters (it owns the store); the gateway only asks, so
|
|
365
|
+
* this module stays free of persistence and policy. A refusal is a plain 403
|
|
366
|
+
* with the reason, and a broken counter never blocks traffic — an exception
|
|
367
|
+
* reads as "allowed", because a quota bug must not lock everyone out.
|
|
368
|
+
*/
|
|
369
|
+
function quotaGateFor(req) {
|
|
370
|
+
const refuse = options.deps && options.deps.quotaRefusal
|
|
371
|
+
if (typeof refuse !== 'function') return null
|
|
372
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
373
|
+
if (method === 'OPTIONS' || method === 'HEAD') return null
|
|
374
|
+
try {
|
|
375
|
+
return refuse(req.umSession, requestPath(req))
|
|
376
|
+
} catch {
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Which inspection, if any, this request needs — and whose rules apply.
|
|
383
|
+
*
|
|
384
|
+
* `kind` is the inspection to run: 'folder' guards where a session may be
|
|
385
|
+
* created, 'settings'/'command' guard against widening the sandbox, 'respond'
|
|
386
|
+
* forces escalation approvals to a denial.
|
|
387
|
+
*/
|
|
388
|
+
function gateFor(req) {
|
|
389
|
+
const method = (req.method || '').toUpperCase()
|
|
390
|
+
if (method !== 'POST' && method !== 'PUT') return null
|
|
391
|
+
const perms = permissionsFor(req.umSession)
|
|
392
|
+
if (perms === null || perms === undefined) return null
|
|
393
|
+
const path = requestPath(req)
|
|
394
|
+
const folderScope = isWorkspaceRestricted(perms.allowedFolders)
|
|
395
|
+
const sandboxScope = typeof perms.sandboxMode === 'string' && perms.sandboxMode !== ''
|
|
396
|
+
if (folderScope && SESSION_CREATE_RE.test(path)) return { kind: 'folder', perms }
|
|
397
|
+
// Workspace management is judged for every non-admin: the ownership map and
|
|
398
|
+
// the creation switch apply even to an account with no folder allow-list.
|
|
399
|
+
if (!sessionIsAdmin(req.umSession) && (isWorkspaceCreate(path) || isWorkspaceDeleteOrRename(path))) {
|
|
400
|
+
return { kind: 'workspace', perms, path }
|
|
401
|
+
}
|
|
402
|
+
if (sandboxScope && SANDBOX_SETTINGS_RE.test(path)) return { kind: 'settings', perms }
|
|
403
|
+
if (sandboxScope && SANDBOX_COMMAND_RE.test(path)) return { kind: 'command', perms }
|
|
404
|
+
if (sandboxScope && SANDBOX_RESPOND_RE.test(path)) return { kind: 'respond', perms }
|
|
405
|
+
return null
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Folder gate for session-creating requests.
|
|
410
|
+
*
|
|
411
|
+
* A restricted account may only start a session inside a folder it is allowed
|
|
412
|
+
* to touch. Finding that folder means reading the body, so the body is
|
|
413
|
+
* buffered and replayed upstream (proxy.sendRequestBody).
|
|
414
|
+
*
|
|
415
|
+
* @returns {string|null} null when the request may proceed, else a short
|
|
416
|
+
* reason it must be refused. Anything unverifiable is refused:
|
|
417
|
+
* an unreadable body is an unknown target, not a permission.
|
|
418
|
+
*/
|
|
419
|
+
async function inspectGatedRequest(req, gate) {
|
|
420
|
+
const raw = await readRequestBody(req, MAX_GATE_BODY)
|
|
421
|
+
if (raw === null) return 'request body unreadable'
|
|
422
|
+
let parsed = null
|
|
423
|
+
if (raw.length > 0) {
|
|
424
|
+
try {
|
|
425
|
+
parsed = JSON.parse(raw.toString('utf8'))
|
|
426
|
+
} catch {
|
|
427
|
+
return 'request body unparseable'
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (gate.kind === 'folder') {
|
|
432
|
+
let target = extractPathFromBody(parsed)
|
|
433
|
+
if (target === null) {
|
|
434
|
+
const id = extractWorkspaceId(parsed)
|
|
435
|
+
if (id !== null) target = workspacePathById.get(id) ?? null
|
|
436
|
+
if (target === null) return 'target workspace not identifiable'
|
|
437
|
+
}
|
|
438
|
+
if (!folderAllowed(target, gate.perms.allowedFolders)) return 'folder outside the allowed workspace scope'
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (gate.kind === 'workspace') {
|
|
442
|
+
if (gate.perms.allowWorkspaceCreate !== true) {
|
|
443
|
+
return 'workspace creation and management are disabled for this account'
|
|
444
|
+
}
|
|
445
|
+
// A rename carries oldPath/newPath — keys the generic path walker does not
|
|
446
|
+
// know — so the dedicated extractor goes first. Ordering it the other way
|
|
447
|
+
// round (as dsh-passwords did) makes every rename look unidentifiable and
|
|
448
|
+
// fails it closed.
|
|
449
|
+
const isRename = /(?:rename|update)(?:[.\/]|$)/.test(gate.path)
|
|
450
|
+
const renamePaths = isRename ? extractWorkspaceRenamePaths(parsed) : null
|
|
451
|
+
let target = renamePaths !== null ? renamePaths.oldPath : extractPathFromBody(parsed)
|
|
452
|
+
if (target === null) {
|
|
453
|
+
const id = extractWorkspaceId(parsed)
|
|
454
|
+
if (id !== null) target = workspacePathById.get(id) ?? null
|
|
455
|
+
if (target === null) return 'target workspace not identifiable'
|
|
456
|
+
}
|
|
457
|
+
if (isWorkspaceRestricted(gate.perms.allowedFolders) && !folderAllowed(target, gate.perms.allowedFolders)) {
|
|
458
|
+
return 'folder outside the allowed workspace scope'
|
|
459
|
+
}
|
|
460
|
+
const owners = ownerMap()
|
|
461
|
+
const me = sessionUsername(req.umSession)
|
|
462
|
+
const existing = owners[normalizePath(target)] === undefined ? null : owners[normalizePath(target)]
|
|
463
|
+
// Somebody else's workspace is not yours to touch, whatever the switches say.
|
|
464
|
+
if (existing !== null && existing !== me) return 'this workspace belongs to another account'
|
|
465
|
+
|
|
466
|
+
if (isWorkspaceCreate(gate.path)) {
|
|
467
|
+
if (existing === null) applyWorkspaceChange('claimWorkspace', [target, me])
|
|
468
|
+
} else {
|
|
469
|
+
// A rename whose two paths cannot both be read is not authorizable, so it
|
|
470
|
+
// is refused rather than guessed at.
|
|
471
|
+
if (isRename && renamePaths === null) return 'rename target not identifiable'
|
|
472
|
+
if (isRename && renamePaths !== null) {
|
|
473
|
+
if (isWorkspaceRestricted(gate.perms.allowedFolders) && !folderAllowed(renamePaths.newPath, gate.perms.allowedFolders)) {
|
|
474
|
+
return 'folder outside the allowed workspace scope'
|
|
475
|
+
}
|
|
476
|
+
applyWorkspaceChange('renameWorkspace', [target, renamePaths.newPath])
|
|
477
|
+
} else if (existing !== null) {
|
|
478
|
+
// The folder is going away: stop claiming it, or the path would stay
|
|
479
|
+
// hidden from everyone but an administrator forever.
|
|
480
|
+
applyWorkspaceChange('releaseWorkspace', [target])
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (gate.kind === 'settings') {
|
|
486
|
+
const preset = presetFromSettingsMutate(parsed)
|
|
487
|
+
const assigned = sandboxPresetRank(gate.perms.sandboxMode)
|
|
488
|
+
if (preset !== null && sandboxPresetRank(preset) > assigned) {
|
|
489
|
+
return `sandbox preset "${preset}" is wider than the account's allowance`
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (gate.kind === 'command') {
|
|
494
|
+
const line = findStringField(parsed, 'line')
|
|
495
|
+
const preset = line === null ? null : permissionPresetFromCommand(line)
|
|
496
|
+
if (preset !== null && sandboxPresetRank(preset) > sandboxPresetRank(gate.perms.sandboxMode)) {
|
|
497
|
+
return `sandbox preset "${preset}" is wider than the account's allowance`
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (gate.kind === 'respond') {
|
|
502
|
+
// The body is rewritten rather than refused: the answer still has to reach
|
|
503
|
+
// the host, it just cannot be an escalation granted to a restricted agent.
|
|
504
|
+
if (parsed !== null && typeof parsed === 'object' && forceRejectApproval(parsed)) {
|
|
505
|
+
const rewritten = Buffer.from(JSON.stringify(parsed), 'utf8')
|
|
506
|
+
req.umReplayBody = rewritten
|
|
507
|
+
req.headers['content-length'] = String(rewritten.length)
|
|
508
|
+
return null
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The upstream still has to receive the body: hand it back for replay.
|
|
513
|
+
req.umReplayBody = raw
|
|
514
|
+
req.headers['content-length'] = String(raw.length)
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Wire the operation-audit hook to fire when the response completes. */
|
|
519
|
+
function wireAudit(req, res, session, path) {
|
|
520
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
521
|
+
if (typeof options.auditHooks.onApiRequest === 'function' && isAuditableRequest(method, req.headers && req.headers.accept, path)) {
|
|
522
|
+
res.on('finish', () => {
|
|
523
|
+
try { options.auditHooks.onApiRequest(req, session, path, res.statusCode) } catch { /* audit must never break the gate */ }
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function handleRequest(req, res) {
|
|
529
|
+
const host = normalizeHost(req.headers.host)
|
|
530
|
+
if (!hostAllowed(host)) {
|
|
531
|
+
return send(res, 421, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }, 'user-management: unknown host')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const url = new URL(req.url || '/', `https://${req.headers.host || 'localhost'}`)
|
|
535
|
+
const path = url.pathname
|
|
536
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
537
|
+
|
|
538
|
+
// The decider runs for every request: it checks IP bans first (403,
|
|
539
|
+
// login page included), resolves the session, and returns the gate
|
|
540
|
+
// decision. /login + /logout stay public but are still subject to bans.
|
|
541
|
+
let decision
|
|
542
|
+
try {
|
|
543
|
+
decision = await options.decider(req)
|
|
544
|
+
} catch {
|
|
545
|
+
return sendUnauthorized(res)
|
|
546
|
+
}
|
|
547
|
+
if (decision.action === 'forbidden') return sendForbidden(res)
|
|
548
|
+
// Stashed for the response-rewrite hook: the proxy runs after routing and
|
|
549
|
+
// has no other way to learn whose permissions apply.
|
|
550
|
+
req.umSession = decision.session || null
|
|
551
|
+
|
|
552
|
+
// /user-management/api/cert[?format=der] + /cert-info — public material,
|
|
553
|
+
// served before the auth gate so a first-time (still untrusted) visitor
|
|
554
|
+
// can download the cert and bootstrap trust. IP bans still apply (the
|
|
555
|
+
// decider above ran already).
|
|
556
|
+
if (certMaterial && method === 'GET' && path === `${API_PREFIX}/cert`) {
|
|
557
|
+
const wantDer = url.searchParams.get('format') === 'der'
|
|
558
|
+
return send(res, 200, {
|
|
559
|
+
'content-type': wantDer ? 'application/x-x509-ca-cert' : 'application/x-pem-file',
|
|
560
|
+
'content-disposition': `attachment; filename="user-management-gateway.${wantDer ? 'cer' : 'crt'}"`,
|
|
561
|
+
'cache-control': 'no-store',
|
|
562
|
+
}, wantDer ? certMaterial.der : certMaterial.pem)
|
|
563
|
+
}
|
|
564
|
+
if (certMaterial && method === 'GET' && path === `${API_PREFIX}/cert-info`) {
|
|
565
|
+
return send(res, 200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, JSON.stringify({
|
|
566
|
+
fingerprint: certMaterial.fingerprint,
|
|
567
|
+
notBefore: certMaterial.notBefore,
|
|
568
|
+
notAfter: certMaterial.notAfter,
|
|
569
|
+
subject: certMaterial.subject,
|
|
570
|
+
hosts: allHosts,
|
|
571
|
+
}))
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// /login — standalone login/register page (register tab is default on a
|
|
575
|
+
// fresh system; first registrant becomes admin). Authed users bounce to /.
|
|
576
|
+
if (path === LOGIN_PAGE_PATH) {
|
|
577
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
578
|
+
return send(res, 405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET, HEAD' }, 'method not allowed')
|
|
579
|
+
}
|
|
580
|
+
if (decision.session) return redirect(res, '/')
|
|
581
|
+
const html = options.renderLoginPage({
|
|
582
|
+
hasUsers: options.deps.store.listUsers().length > 0,
|
|
583
|
+
title: options.title || 'DSH 控制台',
|
|
584
|
+
})
|
|
585
|
+
return send(res, 200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, html)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// /logout — clear the cookie + drop the server session, back to /login.
|
|
589
|
+
if (path === '/logout') {
|
|
590
|
+
const cookies = parseCookies(req.headers && req.headers.cookie)
|
|
591
|
+
const token = cookies[SESSION_COOKIE]
|
|
592
|
+
if (token) {
|
|
593
|
+
try { await options.deps.store.dropSession(token) } catch { /* best effort */ }
|
|
594
|
+
}
|
|
595
|
+
return redirect(res, LOGIN_PAGE_PATH, { 'Set-Cookie': options.clearedCookie() })
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// /user-management/api/* — handled locally by the existing API (login,
|
|
599
|
+
// register, session, self-service, admin user-mgmt, audit, bans). The
|
|
600
|
+
// decider already gated non-public sub-paths (401 anonymous); handleApi
|
|
601
|
+
// re-checks authority too.
|
|
602
|
+
if (path === API_PREFIX || path.startsWith(`${API_PREFIX}/`)) {
|
|
603
|
+
if (decision.action !== 'allow') {
|
|
604
|
+
if (decision.action === 'redirect') return redirect(res, decision.location || LOGIN_PAGE_PATH)
|
|
605
|
+
return sendUnauthorized(res)
|
|
606
|
+
}
|
|
607
|
+
wireAudit(req, res, decision.session, path)
|
|
608
|
+
try {
|
|
609
|
+
await options.handleApi(req, res, options.deps)
|
|
610
|
+
} catch (error) {
|
|
611
|
+
// The host half maps its own error types to status codes (a malformed
|
|
612
|
+
// request body is a 400, not a server fault); anything it does not name
|
|
613
|
+
// stays a 500.
|
|
614
|
+
const toStatus = options.deps && options.deps.statusForError
|
|
615
|
+
let status = 500
|
|
616
|
+
if (typeof toStatus === 'function') {
|
|
617
|
+
try {
|
|
618
|
+
const mapped = toStatus(error)
|
|
619
|
+
if (typeof mapped === 'number' && mapped >= 400 && mapped <= 599) status = mapped
|
|
620
|
+
} catch { /* fall back to 500 */ }
|
|
621
|
+
}
|
|
622
|
+
if (!res.headersSent) send(res, status, { 'content-type': 'application/json; charset=utf-8' }, JSON.stringify({ error: String((error && error.message) || error) }))
|
|
623
|
+
else res.end()
|
|
624
|
+
}
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// /plugins/* — dsh web SPA client-plugin bundles. The SPA's client-modules
|
|
629
|
+
// loader fetches these WITHOUT the session cookie (crossorigin), so the
|
|
630
|
+
// auth gate (401 anonymous) breaks plugin loading ("Failed to load
|
|
631
|
+
// plugins"). They are static SPA assets (no secrets) — proxy publicly to
|
|
632
|
+
// the loopback dsh web. IP bans already enforced above (the decider ran).
|
|
633
|
+
if (path.startsWith('/plugins/')) {
|
|
634
|
+
return proxy.handleRequest(req, res)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// /manifest.webmanifest + /favicon.svg — PWA metadata referenced by the
|
|
638
|
+
// SPA's index.html <link rel="manifest">. Chrome fetches the manifest
|
|
639
|
+
// WITHOUT credentials (the link carries no crossorigin="use-credentials"),
|
|
640
|
+
// so the auth gate 401s it even for logged-in users ("Manifest fetch from
|
|
641
|
+
// ... failed, code 401"). Static, no secrets — proxy publicly. IP bans
|
|
642
|
+
// already enforced above (the decider ran).
|
|
643
|
+
if (path === '/manifest.webmanifest' || path === '/favicon.svg') {
|
|
644
|
+
return proxy.handleRequest(req, res)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Everything else (the SPA, dsh /api, static) is proxied to the loopback
|
|
648
|
+
// dsh webserver — but only past the auth gate.
|
|
649
|
+
if (decision.action === 'allow') {
|
|
650
|
+
const quotaRefusal = quotaGateFor(req)
|
|
651
|
+
if (quotaRefusal !== null) {
|
|
652
|
+
return send(res, 403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
653
|
+
JSON.stringify({ error: 'usage allowance exhausted', reason: quotaRefusal }))
|
|
654
|
+
}
|
|
655
|
+
const pathRefusal = pathGateFor(req)
|
|
656
|
+
if (pathRefusal !== null) {
|
|
657
|
+
return send(res, 403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
658
|
+
JSON.stringify({ error: 'request not allowed for this account', reason: pathRefusal }))
|
|
659
|
+
}
|
|
660
|
+
const gate = gateFor(req)
|
|
661
|
+
if (gate !== null) {
|
|
662
|
+
const reason = await inspectGatedRequest(req, gate)
|
|
663
|
+
if (reason !== null) {
|
|
664
|
+
return send(res, 403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
665
|
+
JSON.stringify({ error: 'request not allowed for this account', reason }))
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
wireAudit(req, res, decision.session, path)
|
|
669
|
+
return proxy.handleRequest(req, res)
|
|
670
|
+
}
|
|
671
|
+
if (decision.action === 'redirect') return redirect(res, decision.location || LOGIN_PAGE_PATH)
|
|
672
|
+
return sendUnauthorized(res)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function handleUpgrade(req, socket, head) {
|
|
676
|
+
const host = normalizeHost(req.headers.host)
|
|
677
|
+
if (!hostAllowed(host)) {
|
|
678
|
+
socket.end('HTTP/1.1 421 Misdirected Request\r\nConnection: close\r\n\r\n')
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
Promise.resolve()
|
|
682
|
+
.then(() => options.decider(req))
|
|
683
|
+
.then((decision) => {
|
|
684
|
+
if (decision.action === 'allow') {
|
|
685
|
+
if (typeof options.auditHooks.onWsOpen === 'function') {
|
|
686
|
+
try { options.auditHooks.onWsOpen(req, decision.session, decision.path) } catch { /* audit must never break the gate */ }
|
|
687
|
+
}
|
|
688
|
+
return proxy.handleUpgrade(req, socket, head)
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
|
692
|
+
} catch { /* socket may already be gone */ }
|
|
693
|
+
socket.destroy()
|
|
694
|
+
})
|
|
695
|
+
.catch(() => {
|
|
696
|
+
try { socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') } catch {}
|
|
697
|
+
socket.destroy()
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
let server = null
|
|
702
|
+
let boundPort = null
|
|
703
|
+
|
|
704
|
+
async function start() {
|
|
705
|
+
// The default site's cert/key are passed directly: a server created
|
|
706
|
+
// with only a SecureContext (no cert/key) sends handshake_failure to
|
|
707
|
+
// clients that omit SNI — which is every browser connecting to an IP
|
|
708
|
+
// literal (https://192.168.x.x). cert/key keeps the no-SNI default
|
|
709
|
+
// context alive; SNICallback then selects per-host contexts.
|
|
710
|
+
const onRequest = (req, res) => {
|
|
711
|
+
handleRequest(req, res).catch(() => {
|
|
712
|
+
if (!res.headersSent) sendUnauthorized(res)
|
|
713
|
+
})
|
|
714
|
+
}
|
|
715
|
+
server = plaintext
|
|
716
|
+
? createHttpServer(onRequest)
|
|
717
|
+
: createHttpsServer(
|
|
718
|
+
{
|
|
719
|
+
cert: defaultSite.certPem,
|
|
720
|
+
key: defaultSite.keyPem,
|
|
721
|
+
SNICallback: (servername, callback) => callback(null, selectContext(servername)),
|
|
722
|
+
},
|
|
723
|
+
onRequest,
|
|
724
|
+
)
|
|
725
|
+
server.on('upgrade', handleUpgrade)
|
|
726
|
+
// TLS-only event: an HTTP listener can never emit it.
|
|
727
|
+
if (!plaintext) server.on('tlsClientError', (_err, tlsSocket) => tlsSocket && tlsSocket.destroy())
|
|
728
|
+
await new Promise((resolve, reject) => {
|
|
729
|
+
const onListening = () => { server.removeListener('error', onError); resolve() }
|
|
730
|
+
const onError = (err) => { server.removeListener('listening', onListening); reject(err) }
|
|
731
|
+
server.once('listening', onListening)
|
|
732
|
+
server.once('error', onError)
|
|
733
|
+
server.listen({ host: options.listenHost || '0.0.0.0', port: options.port != null ? options.port : 19843 })
|
|
734
|
+
})
|
|
735
|
+
boundPort = server.address().port
|
|
736
|
+
server.on('error', (err) => {
|
|
737
|
+
warn(`user-management: listener error — ${err.code || err.message}`)
|
|
738
|
+
if (server !== null && typeof options.onError === 'function') options.onError(err)
|
|
739
|
+
})
|
|
740
|
+
log(
|
|
741
|
+
`user-management: https://${options.listenHost === '0.0.0.0' ? '0.0.0.0' : options.listenHost}:${boundPort} ` +
|
|
742
|
+
`-> ${options.upstream} (hosts: ${allowAll ? '*' : [...allowList].join(',')})`,
|
|
743
|
+
)
|
|
744
|
+
return boundPort
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function stop() {
|
|
748
|
+
try { proxy.close() } catch { /* agent teardown must never take the listener down */ }
|
|
749
|
+
const old = server
|
|
750
|
+
server = null
|
|
751
|
+
boundPort = null
|
|
752
|
+
if (old) {
|
|
753
|
+
try { old.close() } catch { /* already closed */ }
|
|
754
|
+
old.closeAllConnections && old.closeAllConnections()
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return {
|
|
759
|
+
start,
|
|
760
|
+
stop,
|
|
761
|
+
get port() { return boundPort },
|
|
762
|
+
/** SHA-256 fingerprint of the certificate presented when no SNI name matches. */
|
|
763
|
+
defaultCertFingerprint() {
|
|
764
|
+
try { return new X509Certificate(defaultSite.certPem).fingerprint256 } catch { return '' }
|
|
765
|
+
},
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export { createGateway }
|