@linxin666/dsh-remote-web-ui 0.3.23 → 0.4.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/README.i18n.yaml +2 -2
- package/README.md +13 -10
- package/README.zh.md +13 -10
- package/icon.svg +3 -0
- package/lib/client.js +396 -132
- package/lib/client.js.map +1 -1
- package/lib/index.js +604 -262
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FenceNotice.js +0 -3
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +14 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +13 -4
- package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +14 -8
- package/lib/types/client/UpdateEntry.js +2 -2
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.d.ts +16 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +56 -32
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +6 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +143 -28
- package/lib/types/client/pair-api.d.ts +32 -3
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +44 -6
- package/lib/types/client/plugin-card-seat.d.ts +104 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
- package/lib/types/client/plugin-card-seat.js +143 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +18 -5
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/console-output.d.ts +41 -0
- package/lib/types/console-output.d.ts.map +1 -0
- package/lib/types/console-output.js +89 -0
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +19 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +91 -76
- package/lib/types/inner-auth.d.ts +3 -2
- package/lib/types/inner-auth.d.ts.map +1 -1
- package/lib/types/inner-auth.js +3 -2
- package/lib/types/pair-grant.d.ts +66 -0
- package/lib/types/pair-grant.d.ts.map +1 -0
- package/lib/types/pair-grant.js +95 -0
- package/lib/types/pairing.d.ts +44 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +69 -20
- package/lib/types/pairing.persist.test.js +26 -0
- package/lib/types/posture.d.ts +11 -3
- package/lib/types/posture.d.ts.map +1 -1
- package/lib/types/posture.js +8 -3
- package/lib/types/remote-api.d.ts +10 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +34 -34
- package/lib/types/remote-channel-boot.d.ts +6 -1
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +16 -8
- package/lib/types/remote-channel-rules.d.ts +14 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +8 -0
- package/lib/types/remote-methods.d.ts +1 -1
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +1 -0
- package/lib/types/routes.d.ts +47 -5
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +160 -58
- package/lib/types/tunnel.d.ts +21 -6
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +58 -11
- package/lib/types/update.d.ts +4 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +74 -33
- package/package.json +23 -20
- package/src/client/FenceNotice.tsx +0 -2
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/RemoteEntry.tsx +14 -9
- package/src/client/RemotePanel.tsx +17 -7
- package/src/client/RemoteSettingsCard.tsx +16 -9
- package/src/client/UpdateEntry.tsx +2 -2
- package/src/client/UpdatePanel.tsx +3 -3
- package/src/client/deep-link.ts +2 -2
- package/src/client/index.ts +70 -40
- package/src/client/locales.ts +6 -8
- package/src/client/mobile-adapt.ts +136 -25
- package/src/client/pair-api.ts +47 -7
- package/src/client/plugin-card-seat.ts +192 -0
- package/src/client/remote-channel.ts +19 -4
- package/src/client/remote.module.css +38 -0
- package/src/client/settings-form.ts +34 -31
- package/src/console-output.ts +97 -0
- package/src/http.ts +23 -1
- package/src/index.ts +102 -79
- package/src/inner-auth.ts +3 -2
- package/src/pair-grant.ts +131 -0
- package/src/pairing.persist.test.ts +36 -0
- package/src/pairing.ts +75 -24
- package/src/posture.ts +12 -4
- package/src/remote-api.ts +34 -41
- package/src/remote-channel-boot.ts +16 -8
- package/src/remote-channel-rules.ts +17 -0
- package/src/remote-methods.ts +1 -0
- package/src/routes.ts +165 -57
- package/src/tunnel.ts +56 -11
- package/src/update.ts +74 -29
package/src/pairing.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* Security invariants:
|
|
8
8
|
* - One active token at a time; `issue()` replaces it, so a refreshed QR
|
|
9
9
|
* immediately invalidates the previous link.
|
|
10
|
-
* - A token
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - A token is a bearer credential for its whole window: it is refused only
|
|
11
|
+
* when unknown, expired, or after `stop()`, and the same link may pair
|
|
12
|
+
* repeatedly within that window (each accept mints a fresh device session).
|
|
13
13
|
* Mobile flows routinely split across cookie contexts (camera preview to
|
|
14
14
|
* in-app browser to the system browser), and the later context must be
|
|
15
15
|
* able to complete its own pairing from the same link.
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { randomBytes } from 'node:crypto'
|
|
25
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
26
26
|
import { dirname } from 'node:path'
|
|
27
27
|
import type { PostureSnapshot } from './posture.ts'
|
|
28
28
|
|
|
@@ -45,8 +45,6 @@ export interface TokenRecord {
|
|
|
45
45
|
issuedAt: number
|
|
46
46
|
/** Absolute expiry (ms epoch); accept() past this is refused. */
|
|
47
47
|
expiresAt: number
|
|
48
|
-
/** Consumed by the first successful accept(). */
|
|
49
|
-
consumed: boolean
|
|
50
48
|
/** Opaque, non-secret identifier surfaced in snapshots (never the pairing secret). */
|
|
51
49
|
id: string
|
|
52
50
|
/** Workspace the QR link should land the phone in (optional). */
|
|
@@ -167,10 +165,14 @@ export interface PairingConfig {
|
|
|
167
165
|
devicesFile?: string
|
|
168
166
|
}
|
|
169
167
|
|
|
170
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Result of one accept() attempt. A token is a bearer credential for its whole
|
|
170
|
+
* window (see the module doc), so the only refusal is `invalid`: unknown,
|
|
171
|
+
* expired, or after stop(). There is no "already used" outcome.
|
|
172
|
+
*/
|
|
171
173
|
export type AcceptResult =
|
|
172
174
|
| { ok: true; deviceId: string }
|
|
173
|
-
| { ok: false; code: 'invalid'
|
|
175
|
+
| { ok: false; code: 'invalid' }
|
|
174
176
|
|
|
175
177
|
/** Thrown by issue() for an address outside the sampled LAN literals. */
|
|
176
178
|
export class UnknownLanAddressError extends Error {
|
|
@@ -189,6 +191,12 @@ export interface PairingClock {
|
|
|
189
191
|
randomToken(): string
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
/** File-operation seam for the revocation-durability step (tests). */
|
|
195
|
+
export interface PairingFsSeams {
|
|
196
|
+
/** Remove the stale device store (defaults to fs.rmSync with force). */
|
|
197
|
+
removeFile?(path: string): void
|
|
198
|
+
}
|
|
199
|
+
|
|
192
200
|
/** Real clock/entropy: 32 random hex chars per token. */
|
|
193
201
|
export const defaultClock: PairingClock = {
|
|
194
202
|
now: () => Date.now(),
|
|
@@ -231,6 +239,7 @@ export class PairingService {
|
|
|
231
239
|
constructor(
|
|
232
240
|
public config: PairingConfig,
|
|
233
241
|
private readonly clock: PairingClock = defaultClock,
|
|
242
|
+
private readonly fs: PairingFsSeams = {},
|
|
234
243
|
) {
|
|
235
244
|
this.loadPersisted()
|
|
236
245
|
}
|
|
@@ -263,7 +272,7 @@ export class PairingService {
|
|
|
263
272
|
})
|
|
264
273
|
}
|
|
265
274
|
this.clampToMaxDevices()
|
|
266
|
-
if (this.evictIdle()) this.
|
|
275
|
+
if (this.evictIdle()) this.persistRevocation()
|
|
267
276
|
} catch {
|
|
268
277
|
// Unreadable/corrupt: start empty rather than refusing to boot.
|
|
269
278
|
}
|
|
@@ -301,9 +310,9 @@ export class PairingService {
|
|
|
301
310
|
* cookie's device id), so the file is written 0600 via a temp file and
|
|
302
311
|
* atomic rename; a crash mid-write can never leave a half-written store.
|
|
303
312
|
*/
|
|
304
|
-
private persist():
|
|
313
|
+
private persist(): boolean {
|
|
305
314
|
const file = this.config.devicesFile
|
|
306
|
-
if (file === undefined) return
|
|
315
|
+
if (file === undefined) return true
|
|
307
316
|
try {
|
|
308
317
|
mkdirSync(dirname(file), { recursive: true })
|
|
309
318
|
const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
|
|
@@ -312,8 +321,32 @@ export class PairingService {
|
|
|
312
321
|
writeFileSync(temp, JSON.stringify(payload), { mode: 0o600 })
|
|
313
322
|
renameSync(temp, file)
|
|
314
323
|
this.dirty = false
|
|
324
|
+
return true
|
|
315
325
|
} catch (error) {
|
|
316
326
|
console.error('remote-web-ui: failed to persist paired devices', error)
|
|
327
|
+
return false
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Persist a table change that REVOKES access (stop, per-device revoke, idle
|
|
333
|
+
* eviction). A failed write would leave the revoked session on disk, and
|
|
334
|
+
* {@link loadPersisted} restores that file verbatim on the next start — the
|
|
335
|
+
* device's still-valid cookie would authorize again after an explicit
|
|
336
|
+
* revocation. When the write cannot be made durable, the stale store is
|
|
337
|
+
* removed instead (a missing file already loads as an empty table), so the
|
|
338
|
+
* failure costs a re-pair rather than silently undoing the revocation.
|
|
339
|
+
*/
|
|
340
|
+
private persistRevocation(): void {
|
|
341
|
+
if (this.persist()) return
|
|
342
|
+
const file = this.config.devicesFile
|
|
343
|
+
if (file === undefined) return
|
|
344
|
+
try {
|
|
345
|
+
if (this.fs.removeFile !== undefined) this.fs.removeFile(file)
|
|
346
|
+
else rmSync(file, { force: true })
|
|
347
|
+
console.error('remote-web-ui: the device revocation could not be persisted; removed the stale device store so a restart cannot restore it')
|
|
348
|
+
} catch (error) {
|
|
349
|
+
console.error('remote-web-ui: the device revocation could not be persisted and the stale device store could not be removed', error)
|
|
317
350
|
}
|
|
318
351
|
}
|
|
319
352
|
|
|
@@ -343,9 +376,14 @@ export class PairingService {
|
|
|
343
376
|
return this.publicBase
|
|
344
377
|
}
|
|
345
378
|
|
|
346
|
-
/**
|
|
379
|
+
/**
|
|
380
|
+
* Set or clear the public base URL (a tunnel in front of this server). The
|
|
381
|
+
* value is canonicalized to its origin: a trailing slash (what a browser
|
|
382
|
+
* address-bar copy produces) or a path would mint a dead `//pair-accept`
|
|
383
|
+
* link, which WHATWG resolves as an authority rather than a path.
|
|
384
|
+
*/
|
|
347
385
|
setPublicBaseUrl(url: string | undefined): void {
|
|
348
|
-
this.publicBase = url
|
|
386
|
+
this.publicBase = url === undefined ? undefined : canonicalBaseUrl(url)
|
|
349
387
|
this.notify()
|
|
350
388
|
}
|
|
351
389
|
|
|
@@ -395,7 +433,6 @@ export class PairingService {
|
|
|
395
433
|
id: `t${this.tokenSerial}`,
|
|
396
434
|
issuedAt: now,
|
|
397
435
|
expiresAt: now + this.config.tokenTtlMs,
|
|
398
|
-
consumed: false,
|
|
399
436
|
...(workspaceId !== undefined ? { workspaceId } : {}),
|
|
400
437
|
...(address !== undefined ? { address } : {}),
|
|
401
438
|
})
|
|
@@ -404,21 +441,20 @@ export class PairingService {
|
|
|
404
441
|
}
|
|
405
442
|
|
|
406
443
|
/**
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
444
|
+
* Redeem a token and bind a device session. The token is a bearer
|
|
445
|
+
* credential for its whole window, not a single-use nonce: it is refused
|
|
446
|
+
* only when unknown, past its expiry, or after stop(), and every successful
|
|
447
|
+
* call — including a repeat within the same window — mints a fresh device
|
|
448
|
+
* session (see the module doc for the mobile cookie-context rationale).
|
|
410
449
|
* @param token - the token secret from the QR link.
|
|
411
450
|
* @param userAgent - optional User-Agent header captured at accept.
|
|
412
451
|
* @returns the new device id, or a refusal code.
|
|
413
452
|
*/
|
|
414
453
|
accept(token: string, userAgent?: string): AcceptResult {
|
|
415
454
|
const record = this.tokens.get(token)
|
|
416
|
-
// A consumed token stays a valid bearer credential until expiry or
|
|
417
|
-
// replacement (see the module doc): re-accept mints a fresh device.
|
|
418
455
|
if (record === undefined || this.stopped || this.clock.now() > record.expiresAt) {
|
|
419
456
|
return { ok: false, code: 'invalid' }
|
|
420
457
|
}
|
|
421
|
-
record.consumed = true
|
|
422
458
|
const deviceId = this.clock.randomToken()
|
|
423
459
|
const now = this.clock.now()
|
|
424
460
|
if (this.devices.size >= this.config.maxDevices) {
|
|
@@ -448,7 +484,7 @@ export class PairingService {
|
|
|
448
484
|
stop(): void {
|
|
449
485
|
this.tokens.clear()
|
|
450
486
|
this.devices.clear()
|
|
451
|
-
this.
|
|
487
|
+
this.persistRevocation()
|
|
452
488
|
this.stopped = true
|
|
453
489
|
this.notify()
|
|
454
490
|
}
|
|
@@ -462,7 +498,7 @@ export class PairingService {
|
|
|
462
498
|
revoke(deviceId: string): boolean {
|
|
463
499
|
if (this.stopped) return false
|
|
464
500
|
if (!this.devices.delete(deviceId)) return false
|
|
465
|
-
this.
|
|
501
|
+
this.persistRevocation()
|
|
466
502
|
this.notify()
|
|
467
503
|
return true
|
|
468
504
|
}
|
|
@@ -501,7 +537,8 @@ export class PairingService {
|
|
|
501
537
|
*/
|
|
502
538
|
sweep(): void {
|
|
503
539
|
const evicted = this.evictIdle()
|
|
504
|
-
if (evicted
|
|
540
|
+
if (evicted) this.persistRevocation()
|
|
541
|
+
else if (this.dirty) this.persist()
|
|
505
542
|
this.notify()
|
|
506
543
|
}
|
|
507
544
|
|
|
@@ -559,7 +596,7 @@ export class PairingService {
|
|
|
559
596
|
const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS
|
|
560
597
|
if (this.clock.now() - session.lastSeenAt > limit) {
|
|
561
598
|
this.devices.delete(deviceId)
|
|
562
|
-
this.
|
|
599
|
+
this.persistRevocation()
|
|
563
600
|
this.notify()
|
|
564
601
|
return undefined
|
|
565
602
|
}
|
|
@@ -649,6 +686,20 @@ function sameStrings(a: readonly string[], b: readonly string[]): boolean {
|
|
|
649
686
|
return a.length === b.length && a.every((value, index) => value === b[index])
|
|
650
687
|
}
|
|
651
688
|
|
|
689
|
+
/**
|
|
690
|
+
* Canonical origin of a public base URL. An unparsable value is returned
|
|
691
|
+
* verbatim so the caller's malformed-base warning stays the single owner of
|
|
692
|
+
* that case.
|
|
693
|
+
* @param url - the configured public base.
|
|
694
|
+
*/
|
|
695
|
+
export function canonicalBaseUrl(url: string): string {
|
|
696
|
+
try {
|
|
697
|
+
return new URL(url).origin
|
|
698
|
+
} catch {
|
|
699
|
+
return url
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
652
703
|
/** Strip control characters and cap the User-Agent stored with a session. */
|
|
653
704
|
export function sanitizeUserAgent(raw: string | undefined): string | undefined {
|
|
654
705
|
if (raw === undefined) return undefined
|
package/src/posture.ts
CHANGED
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The probe issues loopback requests with a forged Host header — the exact
|
|
11
11
|
* shape a tunnel or LAN client produces — and treats anything other than a
|
|
12
|
-
* 403 as exposed
|
|
13
|
-
*
|
|
12
|
+
* 403 or a 401 as exposed. The fence is documented to refuse with 403; the
|
|
13
|
+
* harness answers 401 when the Host fence PASSED but the browser-auth cookie
|
|
14
|
+
* is missing (the connection plugin checks the fence first and the cookie
|
|
15
|
+
* second). A 401 therefore means the /api surface is still gated — reporting
|
|
16
|
+
* it as open produced a false CRITICAL on exactly the supported LAN
|
|
17
|
+
* deployment (0.0.0.0 bind auto-trusts the LAN literals) and desensitized the
|
|
18
|
+
* one alarm that should mean a real exposure.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import type { ClientRequest, ClientRequestArgs } from 'node:http'
|
|
@@ -20,7 +25,10 @@ import http from 'node:http'
|
|
|
20
25
|
export interface PostureHost {
|
|
21
26
|
/** The forged Host header value that was probed. */
|
|
22
27
|
host: string
|
|
23
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* True when the probe reached the RPC bridge without any gate refusing it
|
|
30
|
+
* (a status that is neither the fence's 403 nor the browser-auth 401).
|
|
31
|
+
*/
|
|
24
32
|
exposed: boolean
|
|
25
33
|
}
|
|
26
34
|
|
|
@@ -97,7 +105,7 @@ async function probeHost(port: number, hostHeader: string, request: ProbeRequest
|
|
|
97
105
|
path: '/api/session.list',
|
|
98
106
|
headers: { host: hostHeader, 'content-type': 'application/json' },
|
|
99
107
|
timeout: timeoutMs,
|
|
100
|
-
}, status => { finish(status !== 403) })
|
|
108
|
+
}, status => { finish(status !== 403 && status !== 401) })
|
|
101
109
|
const timer = setTimeout(() => { finish(false) }, timeoutMs + 1_000)
|
|
102
110
|
handle.on('error', () => { finish(false) })
|
|
103
111
|
handle.end('{}')
|
package/src/remote-api.ts
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* consult.
|
|
8
8
|
*
|
|
9
9
|
* Security model:
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* - Every request must carry a live paired-device credential, enforced before
|
|
11
|
+
* any bytes are forwarded and before any host call. This gate is
|
|
12
|
+
* unconditional and does NOT follow `requirePairingForLan`: the channel
|
|
13
|
+
* re-issues traffic with the process's own browser-auth credential, so it is
|
|
14
|
+
* an authentication amplifier — an unpaired caller admitted here would reach
|
|
15
|
+
* the full host API without ever holding that credential. The LAN policy
|
|
16
|
+
* governs the plain `/api` surface only (whether the harness fence's LAN
|
|
17
|
+
* auto-trust stands on its own); it can never widen this channel.
|
|
14
18
|
* - A paired remote desktop is a full-control credential: the browser half
|
|
15
19
|
* flips the official UI into host mode (the transport ownsHost hook), so
|
|
16
20
|
* the configuration plane (settings, credentials, presets, deliverables)
|
|
@@ -69,15 +73,8 @@ function isSafeSegment(segment: string): boolean {
|
|
|
69
73
|
export interface RemoteApiDeps {
|
|
70
74
|
/** The pairing service (device gate + cookie name). */
|
|
71
75
|
service: PairingService
|
|
72
|
-
/**
|
|
76
|
+
/** Local webServer port the loopback proxy connects to. */
|
|
73
77
|
port: number
|
|
74
|
-
/**
|
|
75
|
-
* Live policy: whether the paired-device cookie gates the /remote channel.
|
|
76
|
-
* When false, requests are proxied without a cookie (the loopback-only
|
|
77
|
-
* denials still apply). A function is re-read per request, so a settings
|
|
78
|
-
* edit takes effect without a restart. Defaults to true.
|
|
79
|
-
*/
|
|
80
|
-
requirePairingForLan?: boolean | (() => boolean)
|
|
81
78
|
/**
|
|
82
79
|
* The process's inner browser-auth credential attached to re-issued
|
|
83
80
|
* requests (the connection plugin's /api route enforces that cookie and
|
|
@@ -139,24 +136,22 @@ export function pairedDeviceIdOf(req: IncomingMessage, service: PairingService):
|
|
|
139
136
|
|
|
140
137
|
/**
|
|
141
138
|
* Build the remote desktop channel HTTP routes.
|
|
142
|
-
* @param deps - pairing service + local port +
|
|
139
|
+
* @param deps - pairing service + local port + inner credential.
|
|
143
140
|
* @returns the routes to register on webServer.
|
|
144
141
|
*/
|
|
145
142
|
export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
|
|
146
|
-
const { service, port
|
|
143
|
+
const { service, port } = deps
|
|
147
144
|
|
|
148
145
|
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return
|
|
159
|
-
}
|
|
146
|
+
// Paired credential first — before any bytes are buffered or forwarded.
|
|
147
|
+
// Unconditional by design: this channel attaches the process's own
|
|
148
|
+
// browser-auth cookie to every re-issued call, so admitting an unpaired
|
|
149
|
+
// caller would hand out that credential's authority (issue #1665).
|
|
150
|
+
const paired = pairedDeviceIdOf(req, service)
|
|
151
|
+
if (paired === undefined) {
|
|
152
|
+
req.resume()
|
|
153
|
+
envelopeError(res, 403, 'invalid-request', 'unpaired', 'this device is not paired with the desktop')
|
|
154
|
+
return
|
|
160
155
|
}
|
|
161
156
|
|
|
162
157
|
const method = req.method ?? 'GET'
|
|
@@ -209,24 +204,22 @@ export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: strin
|
|
|
209
204
|
* @returns the upgrade routes to register on webServer.
|
|
210
205
|
*/
|
|
211
206
|
export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute[] {
|
|
212
|
-
const { service, port
|
|
207
|
+
const { service, port } = deps
|
|
213
208
|
|
|
214
209
|
const handlerFor = (fallbackPath: string) => (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
return
|
|
229
|
-
}
|
|
210
|
+
// WebSocket handshakes cannot carry headers from the Web API, so the
|
|
211
|
+
// cookieless credential rides the query; the cookie stays the primary.
|
|
212
|
+
// The gate is unconditional here too (issue #1665).
|
|
213
|
+
let queryDevice: string | undefined
|
|
214
|
+
try {
|
|
215
|
+
queryDevice = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get(REMOTE_DEVICE_QUERY) ?? undefined
|
|
216
|
+
} catch { /* fall through to the cookie */ }
|
|
217
|
+
const deviceId = pairedDeviceIdOf(req, service)
|
|
218
|
+
const paired = deviceId ?? (queryDevice !== undefined && service.touchDevice(queryDevice) ? queryDevice : undefined)
|
|
219
|
+
if (paired === undefined) {
|
|
220
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
|
221
|
+
socket.destroy()
|
|
222
|
+
return
|
|
230
223
|
}
|
|
231
224
|
const inner = upgradeInnerPath(req.url, fallbackPath)
|
|
232
225
|
const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner)
|
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
* transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
|
|
18
18
|
* connection plugin reads it: the paired remote desktop gets the full
|
|
19
19
|
* settings/credentials/presets surface, and every call still rides the
|
|
20
|
-
* gated /remote channel.
|
|
20
|
+
* gated /remote channel. Host mode is server-granted, not origin-asserted:
|
|
21
|
+
* the hook is installed only when the device-gated app landing (/pair-app)
|
|
22
|
+
* published the grant marker (REMOTE_HOST_GRANT_GLOBAL) ahead of this
|
|
23
|
+
* script, so a shell served to an unpaired browser - a fence-open
|
|
24
|
+
* deployment - never presents itself as the machine owner. It finally
|
|
25
|
+
* publishes the official pre-Cordis
|
|
21
26
|
* upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
|
|
22
27
|
* transport otherwise runs inside a Web Worker whose own globals the
|
|
23
28
|
* main-thread rewrite cannot reach (issue #1580). The script self-skips on
|
|
@@ -40,14 +45,17 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
|
|
|
40
45
|
return '(function(){' +
|
|
41
46
|
'try{' +
|
|
42
47
|
'var w=window,loc=w.location,h=loc.hostname;' +
|
|
43
|
-
// Loopback origins
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// owner, so the official UI keeps its full configuration surface (the
|
|
47
|
-
// settings mirror, document controller, and deliverables open actions
|
|
48
|
-
// all branch on connection.isLoopback). Must run before any boot entry.
|
|
49
|
-
'try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}' +
|
|
48
|
+
// Loopback origins (including the bracketed IPv6 literal WHATWG returns)
|
|
49
|
+
// keep the original paths (mirrors isLoopbackHostname).
|
|
50
|
+
"if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
|
|
50
51
|
'var R=' + json + ';' +
|
|
52
|
+
// Host mode is server-granted: only the device-gated app landing
|
|
53
|
+
// publishes the grant marker (in a capture script that runs ahead of
|
|
54
|
+
// this one), so a shell that merely sits on a non-loopback origin - an
|
|
55
|
+
// unpaired browser reaching a fence-open deployment - keeps the official
|
|
56
|
+
// UI's memory-scope presentation instead of posing as the machine owner.
|
|
57
|
+
// Must run before any boot entry.
|
|
58
|
+
'try{if(w[R.hostGrantGlobal]===true){if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}}catch(e){}' +
|
|
51
59
|
// The cookieless device credential: read lazily per call - the
|
|
52
60
|
// /pair-app capture script sets it in head AFTER this boot script ran,
|
|
53
61
|
// so a parse-time read would always see null.
|
|
@@ -16,6 +16,13 @@ export { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY }
|
|
|
16
16
|
/** Connection-plugin method prefix under the gated channel. */
|
|
17
17
|
export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The window global the device-gated app landing publishes to grant host mode.
|
|
21
|
+
* Set by the /pair-app capture script, read by the parse-time boot patch: the
|
|
22
|
+
* transport hook (ownsHost) is server-granted, not asserted from the origin.
|
|
23
|
+
*/
|
|
24
|
+
export const REMOTE_HOST_GRANT_GLOBAL = '__DSH_REMOTE_HOST_GRANT__'
|
|
25
|
+
|
|
19
26
|
/** Every decision input of the remote-channel rewrite, JSON-serializable. */
|
|
20
27
|
export interface RemoteChannelRules {
|
|
21
28
|
readonly remotePrefix: string
|
|
@@ -37,6 +44,14 @@ export interface RemoteChannelRules {
|
|
|
37
44
|
readonly uploadPath: string
|
|
38
45
|
/** Page global the pre-Cordis upload hook is published under. */
|
|
39
46
|
readonly uploadHookGlobal: string
|
|
47
|
+
/**
|
|
48
|
+
* Page global carrying the server-issued host-mode grant. Only the plugin's
|
|
49
|
+
* own device-gated app landing (/pair-app) publishes it; the boot patch
|
|
50
|
+
* therefore enables the official UI's host mode only for a shell the server
|
|
51
|
+
* actually granted, never for one that merely happens to sit on a
|
|
52
|
+
* non-loopback origin.
|
|
53
|
+
*/
|
|
54
|
+
readonly hostGrantGlobal: string
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
/** The live rule set. */
|
|
@@ -56,6 +71,7 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
|
|
|
56
71
|
'/api/remote.mux',
|
|
57
72
|
'/sidebar/ws/terminal',
|
|
58
73
|
'/sidebar/ws/agent-terminals',
|
|
74
|
+
'/sidebar/ws/agent-opens',
|
|
59
75
|
'/api/dsh-ssh/terminal',
|
|
60
76
|
],
|
|
61
77
|
deviceHeader: REMOTE_DEVICE_HEADER,
|
|
@@ -63,6 +79,7 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
|
|
|
63
79
|
deviceQuery: REMOTE_DEVICE_QUERY,
|
|
64
80
|
uploadPath: '/api/session/uploadFileBinary',
|
|
65
81
|
uploadHookGlobal: '__DSH_FILE_UPLOAD__',
|
|
82
|
+
hostGrantGlobal: REMOTE_HOST_GRANT_GLOBAL,
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
/** The window global the boot patch publishes its seat under. */
|
package/src/remote-methods.ts
CHANGED
|
@@ -39,6 +39,7 @@ export const REMOTE_UPGRADE_PATHS = [
|
|
|
39
39
|
REMOTE_API_PATHS.mux,
|
|
40
40
|
`${REMOTE_PREFIX}/sidebar/ws/terminal`,
|
|
41
41
|
`${REMOTE_PREFIX}/sidebar/ws/agent-terminals`,
|
|
42
|
+
`${REMOTE_PREFIX}/sidebar/ws/agent-opens`,
|
|
42
43
|
`${REMOTE_API_PREFIX}/dsh-ssh/terminal`,
|
|
43
44
|
] as const
|
|
44
45
|
|