@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.
Files changed (116) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -10
  3. package/README.zh.md +13 -10
  4. package/icon.svg +3 -0
  5. package/lib/client.js +396 -132
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +604 -262
  8. package/lib/types/client/FenceNotice.d.ts.map +1 -1
  9. package/lib/types/client/FenceNotice.js +0 -3
  10. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +3 -2
  13. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  14. package/lib/types/client/RemoteEntry.js +14 -9
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +13 -4
  17. package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +14 -8
  20. package/lib/types/client/UpdateEntry.js +2 -2
  21. package/lib/types/client/UpdatePanel.js +2 -2
  22. package/lib/types/client/deep-link.js +2 -2
  23. package/lib/types/client/index.d.ts +16 -8
  24. package/lib/types/client/index.d.ts.map +1 -1
  25. package/lib/types/client/index.js +56 -32
  26. package/lib/types/client/locales.d.ts +0 -2
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/locales.js +6 -8
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  30. package/lib/types/client/mobile-adapt.js +143 -28
  31. package/lib/types/client/pair-api.d.ts +32 -3
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +44 -6
  34. package/lib/types/client/plugin-card-seat.d.ts +104 -0
  35. package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
  36. package/lib/types/client/plugin-card-seat.js +143 -0
  37. package/lib/types/client/remote-channel.d.ts.map +1 -1
  38. package/lib/types/client/remote-channel.js +18 -5
  39. package/lib/types/client/settings-form.d.ts +17 -17
  40. package/lib/types/client/settings-form.d.ts.map +1 -1
  41. package/lib/types/client/settings-form.js +30 -27
  42. package/lib/types/console-output.d.ts +41 -0
  43. package/lib/types/console-output.d.ts.map +1 -0
  44. package/lib/types/console-output.js +89 -0
  45. package/lib/types/http.d.ts +18 -1
  46. package/lib/types/http.d.ts.map +1 -1
  47. package/lib/types/http.js +22 -1
  48. package/lib/types/index.d.ts +19 -11
  49. package/lib/types/index.d.ts.map +1 -1
  50. package/lib/types/index.js +91 -76
  51. package/lib/types/inner-auth.d.ts +3 -2
  52. package/lib/types/inner-auth.d.ts.map +1 -1
  53. package/lib/types/inner-auth.js +3 -2
  54. package/lib/types/pair-grant.d.ts +66 -0
  55. package/lib/types/pair-grant.d.ts.map +1 -0
  56. package/lib/types/pair-grant.js +95 -0
  57. package/lib/types/pairing.d.ts +44 -12
  58. package/lib/types/pairing.d.ts.map +1 -1
  59. package/lib/types/pairing.js +69 -20
  60. package/lib/types/pairing.persist.test.js +26 -0
  61. package/lib/types/posture.d.ts +11 -3
  62. package/lib/types/posture.d.ts.map +1 -1
  63. package/lib/types/posture.js +8 -3
  64. package/lib/types/remote-api.d.ts +10 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +34 -34
  67. package/lib/types/remote-channel-boot.d.ts +6 -1
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +16 -8
  70. package/lib/types/remote-channel-rules.d.ts +14 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +8 -0
  73. package/lib/types/remote-methods.d.ts +1 -1
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +1 -0
  76. package/lib/types/routes.d.ts +47 -5
  77. package/lib/types/routes.d.ts.map +1 -1
  78. package/lib/types/routes.js +160 -58
  79. package/lib/types/tunnel.d.ts +21 -6
  80. package/lib/types/tunnel.d.ts.map +1 -1
  81. package/lib/types/tunnel.js +58 -11
  82. package/lib/types/update.d.ts +4 -0
  83. package/lib/types/update.d.ts.map +1 -1
  84. package/lib/types/update.js +74 -33
  85. package/package.json +23 -20
  86. package/src/client/FenceNotice.tsx +0 -2
  87. package/src/client/PluginSettingsCard.tsx +7 -2
  88. package/src/client/RemoteEntry.tsx +14 -9
  89. package/src/client/RemotePanel.tsx +17 -7
  90. package/src/client/RemoteSettingsCard.tsx +16 -9
  91. package/src/client/UpdateEntry.tsx +2 -2
  92. package/src/client/UpdatePanel.tsx +3 -3
  93. package/src/client/deep-link.ts +2 -2
  94. package/src/client/index.ts +70 -40
  95. package/src/client/locales.ts +6 -8
  96. package/src/client/mobile-adapt.ts +136 -25
  97. package/src/client/pair-api.ts +47 -7
  98. package/src/client/plugin-card-seat.ts +192 -0
  99. package/src/client/remote-channel.ts +19 -4
  100. package/src/client/remote.module.css +38 -0
  101. package/src/client/settings-form.ts +34 -31
  102. package/src/console-output.ts +97 -0
  103. package/src/http.ts +23 -1
  104. package/src/index.ts +102 -79
  105. package/src/inner-auth.ts +3 -2
  106. package/src/pair-grant.ts +131 -0
  107. package/src/pairing.persist.test.ts +36 -0
  108. package/src/pairing.ts +75 -24
  109. package/src/posture.ts +12 -4
  110. package/src/remote-api.ts +34 -41
  111. package/src/remote-channel-boot.ts +16 -8
  112. package/src/remote-channel-rules.ts +17 -0
  113. package/src/remote-methods.ts +1 -0
  114. package/src/routes.ts +165 -57
  115. package/src/tunnel.ts +56 -11
  116. package/src/update.ts +74 -29
package/src/routes.ts CHANGED
@@ -11,6 +11,13 @@
11
11
  * on the 0.1.2-alpha.2 cohort nothing emits api/gate — direct /api is
12
12
  * governed by the harness fence + browser auth — while the /remote channel
13
13
  * always enforces the pairing cookie itself.
14
+ *
15
+ * The app landing is reached with a one-time grant, never with the device id
16
+ * itself: /pair-accept mints a short-lived, single-consume grant
17
+ * (pair-grant.ts) and redirects to /pair-app?grant=<g>, and only the landing's
18
+ * own response carries the device credential into the shell. The device cookie
19
+ * stays the primary credential; the grant only keeps a live session credential
20
+ * out of the URL, the address bar, and every log on the redirect path.
14
21
  */
15
22
 
16
23
  import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -20,6 +27,8 @@ import { UnknownLanAddressError, type PairingService, type PairingSnapshot } fro
20
27
  import { isLoopbackAddress } from './loopback.ts'
21
28
  import { isLoopbackClient, readCookie } from './gate.ts'
22
29
  import { readJsonBody, writeJson } from './http.ts'
30
+ import { createPairGrantStore, type PairGrantStore } from './pair-grant.ts'
31
+ import { REMOTE_HOST_GRANT_GLOBAL } from './remote-channel-rules.ts'
23
32
 
24
33
  /**
25
34
  * Browser-trust fence for the /api/pair routes, mirroring the connection
@@ -74,7 +83,16 @@ export function isTrustedHost(request: IncomingMessage, trustedHosts: readonly s
74
83
  return isLoopbackClient(request) || trustedHosts.some(entry => {
75
84
  // A port-less entry matches the hostname on any port; an exact host:port
76
85
  // entry matches that authority verbatim (WHATWG normalization both sides).
77
- const entryUrl = new URL(`http://${entry}`)
86
+ // A malformed entry (a config or DSH_REMOTE_TRUSTED_HOSTS typo) contributes
87
+ // no trust instead of throwing out of the fence: every pairing request from
88
+ // a non-loopback origin would otherwise answer 400 with only a generic
89
+ // webserver warning, and the valid entries would never be consulted.
90
+ let entryUrl: URL
91
+ try {
92
+ entryUrl = new URL(`http://${entry}`)
93
+ } catch {
94
+ return false
95
+ }
78
96
  return entryUrl.port === '' ? entryUrl.hostname === hostname : entryUrl.host === hostUrl.host
79
97
  })
80
98
  }
@@ -174,20 +192,34 @@ const MAX_BODY_BYTES = 4096
174
192
 
175
193
  /**
176
194
  * The rate-limit bucket key for one accept attempt (pure; unit-tested).
177
- * The first client-visible XFF hop separates buckets behind the auto-tunnel
178
- * (every internet client arrives from 127.0.0.1 there) — but only for
179
- * loopback peers, since a direct LAN client can rotate the header freely.
195
+ * A forwarded hop separates buckets behind the auto-tunnel (every internet
196
+ * client arrives from 127.0.0.1 there) — but only for loopback peers, since a
197
+ * direct LAN client can rotate the header freely. The caller passes the hop it
198
+ * trusts (the edge-appended LAST one; see rateLimitAccept), and the hop is
199
+ * truncated so one oversized header cannot mint a huge key.
180
200
  * @param socketIp - the socket peer address.
181
- * @param forwarded - the first XFF hop, already trimmed, if any.
201
+ * @param forwarded - the trusted XFF hop, already trimmed, if any.
182
202
  * @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
183
203
  */
184
204
  export function acceptLimitKey(socketIp: string, forwarded: string | undefined, bucket: 'page' | 'api'): string {
185
205
  if (isLoopbackAddress(socketIp) && forwarded !== undefined && forwarded !== '') {
186
- return `${bucket}|${socketIp}|${forwarded}`
206
+ return `${bucket}|${socketIp}|${forwarded.slice(0, MAX_FORWARDED_KEY_CHARS)}`
187
207
  }
188
208
  return `${bucket}|${socketIp}`
189
209
  }
190
210
 
211
+ /** Longest forwarded hop kept in a bucket key (the header is caller-sized). */
212
+ const MAX_FORWARDED_KEY_CHARS = 64
213
+
214
+ /**
215
+ * Hard cap on live accept buckets. The key is caller-influenced (a forwarded
216
+ * hop), so a rotating flood must not grow the table without bound: past the
217
+ * cap the oldest bucket is evicted, the same FIFO stance as
218
+ * {@link addBounded}. A legitimate client that loses its bucket merely starts
219
+ * a fresh window.
220
+ */
221
+ export const MAX_ACCEPT_BUCKETS = 1024
222
+
191
223
  /**
192
224
  * Cap on the dynamically trusted hosts (see {@link addBounded}): room for
193
225
  * every legitimate router/proxy aliasing shape, small enough that a flood of
@@ -271,17 +303,25 @@ export const APP_DEVICE_STORAGE_KEY = 'dsh-remote-device'
271
303
 
272
304
  export function appShellCaptureScript(deviceId: string): string {
273
305
  const safeId = JSON.stringify(deviceId)
306
+ const grantGlobal = JSON.stringify(REMOTE_HOST_GRANT_GLOBAL)
274
307
  const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`
275
- return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});localStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}${register}})()</script>`
308
+ return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()</script>`
276
309
  }
277
310
 
278
- /** Patch the official index document with the device-capture script. */
311
+ /**
312
+ * Patch the official index document with the device-capture script. The script
313
+ * goes immediately after the opening <head> tag, ahead of the harness-injected
314
+ * parse-time channel boot patch: that patch installs the transport host-mode
315
+ * hook only when this server-served marker is already set (see
316
+ * remote-channel-boot.ts), so host mode is granted by the device-gated landing
317
+ * instead of being asserted from the origin.
318
+ */
279
319
  export function patchAppShell(html: string, deviceId: string): string {
280
320
  const script = appShellCaptureScript(deviceId)
281
- const marker = '</head>'
282
- const at = html.indexOf(marker)
283
- if (at === -1) return script + html
284
- return html.slice(0, at) + script + html.slice(at)
321
+ const head = /<head[^>]*>/i.exec(html)
322
+ if (head === null) return script + html
323
+ const end = head.index + head[0].length
324
+ return html.slice(0, end) + script + html.slice(end)
285
325
  }
286
326
 
287
327
  /**
@@ -511,6 +551,11 @@ export interface PairRoutesDeps {
511
551
  indexDocument?: (deviceId: string) => Promise<string | undefined>
512
552
  /** Extra trusted non-loopback hosts (from config or environment). */
513
553
  trustedHosts?: readonly string[] | (() => readonly string[])
554
+ /**
555
+ * The one-time grant table behind the /pair-accept → /pair-app redirect.
556
+ * Defaults to a fresh store; tests inject one with a fixed clock/entropy.
557
+ */
558
+ grants?: PairGrantStore
514
559
  }
515
560
 
516
561
  /**
@@ -523,6 +568,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
523
568
  const pairingRequired = (): boolean => typeof requirePairingForLan === 'function'
524
569
  ? requirePairingForLan()
525
570
  : requirePairingForLan
571
+ const grants = deps.grants ?? createPairGrantStore()
526
572
  const events = new PairingEventsStream(service)
527
573
  const dynamicTrustedHosts = new Set<string>()
528
574
 
@@ -553,10 +599,17 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
553
599
  if (privateLanHost !== undefined) {
554
600
  const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName)
555
601
  let queryDeviceId: string | null = null
602
+ let queryGrant: string | undefined
556
603
  try {
557
- queryDeviceId = new URL(req.url ?? '/', 'http://pair.invalid').searchParams.get('device')
604
+ const params = new URL(req.url ?? '/', 'http://pair.invalid').searchParams
605
+ queryDeviceId = params.get('device')
606
+ queryGrant = params.get('grant') ?? undefined
558
607
  } catch {}
559
- const deviceId = cookieDeviceId ?? queryDeviceId ?? undefined
608
+ // The app landing carries a one-time grant instead of the device id;
609
+ // peek (never spend) so the fence can trust a container/NAT authority
610
+ // whose Host is not among the advertised ones.
611
+ const granted = grants.peek(queryGrant)
612
+ const deviceId = cookieDeviceId ?? queryDeviceId ?? granted ?? undefined
560
613
  if (deviceId !== undefined && service.hasDevice(deviceId)) {
561
614
  addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
562
615
  return true
@@ -581,26 +634,48 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
581
634
  * count separately: a QR re-scan (page navigation) must not consume a
582
635
  * brute-force budget that belongs to token guessing (and vice versa).
583
636
  */
637
+ /**
638
+ * Drop expired buckets from the FRONT of the insertion-ordered table, then
639
+ * FIFO-evict past the hard cap. The front always holds the oldest window
640
+ * because a re-armed bucket is re-inserted at the tail, so this is amortized
641
+ * O(1) per request: every iteration deletes an entry that was inserted once.
642
+ * It replaces a full-table scan that ran on every pairing request once the
643
+ * table passed 256 entries (and deleted nothing while all windows were
644
+ * fresh).
645
+ */
646
+ const pruneAcceptAttempts = (nowMs: number): void => {
647
+ while (acceptAttempts.size > 0) {
648
+ const oldest = acceptAttempts.keys().next().value
649
+ if (oldest === undefined) break
650
+ const attempt = acceptAttempts.get(oldest)
651
+ if (attempt !== undefined && nowMs - attempt.windowStart <= ACCEPT_WINDOW_MS) break
652
+ acceptAttempts.delete(oldest)
653
+ }
654
+ while (acceptAttempts.size > MAX_ACCEPT_BUCKETS) {
655
+ const oldest = acceptAttempts.keys().next().value
656
+ if (oldest === undefined) break
657
+ acceptAttempts.delete(oldest)
658
+ }
659
+ }
660
+
584
661
  const rateLimitAccept = (req: IncomingMessage, bucket: 'page' | 'api'): boolean => {
585
662
  const socketIp = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
586
663
  // XFF is honored only for loopback peers (the tunnel edge); see
587
664
  // acceptLimitKey. It is untrusted for authentication and never grants
588
- // access.
589
- const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
590
- ? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
591
- : undefined
665
+ // access. Only the LAST hop is used: an edge APPENDS the address it saw, so
666
+ // any earlier hop is whatever the client sent — keying on the first one let
667
+ // an attacker rotate the header for a fresh brute-force budget per request.
668
+ const hops = typeof req.headers['x-forwarded-for'] === 'string'
669
+ ? req.headers['x-forwarded-for'].split(',')
670
+ : []
671
+ const forwarded = (hops[hops.length - 1] ?? '').trim()
592
672
  const ip = acceptLimitKey(socketIp, forwarded, bucket)
593
673
  const nowMs = Date.now()
594
- // The map lives as long as the plugin: prune expired windows once the
595
- // table grows past a modest size so distinct source IPs (LAN clients,
596
- // brute-force scans) cannot accumulate forever.
597
- if (acceptAttempts.size > 256) {
598
- for (const [key, attempt] of acceptAttempts) {
599
- if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key)
600
- }
601
- }
674
+ pruneAcceptAttempts(nowMs)
602
675
  const entry = acceptAttempts.get(ip)
603
676
  if (entry === undefined || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
677
+ // Re-insert at the tail so insertion order stays window order.
678
+ acceptAttempts.delete(ip)
604
679
  acceptAttempts.set(ip, { count: 1, windowStart: nowMs })
605
680
  return false
606
681
  }
@@ -679,19 +754,18 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
679
754
  writeJson(res, 403, { ok: false, code: 'forbidden' })
680
755
  return
681
756
  }
682
- writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
757
+ writeJson(res, 404, { ok: false, code: result.code })
683
758
  return
684
759
  }
685
760
  if (privateLanHost !== undefined) {
686
761
  addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
687
762
  }
688
- // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
689
- // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
690
- // top-level navigations working while blocking cross-site subrequests.
763
+ // The Secure attribute follows the transport (deviceCookie): off on
764
+ // plain-HTTP LAN, where a Secure cookie is dropped by the browser, and on
765
+ // for a tunneled request the edge stamped as https. Lax keeps top-level
766
+ // navigations working while blocking cross-site subrequests.
691
767
  writeJson(res, 200, { ok: true, deviceId: result.deviceId }, {
692
- 'set-cookie': [
693
- `${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
694
- ],
768
+ 'set-cookie': [deviceCookie(req, service.config.cookieName, result.deviceId)],
695
769
  })
696
770
  }
697
771
 
@@ -757,6 +831,11 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
757
831
  }
758
832
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
759
833
  const paired = deviceId !== undefined && service.hasDevice(deviceId)
834
+ // The desktop (loopback) keeps the full pairing-relevant view; the LAN IP
835
+ // literals stay behind a live pairing or loopback so an unauthenticated
836
+ // caller on a tunnel cannot map the internal network. A phone already knows
837
+ // its usable literal from the URL it opened.
838
+ const local = isLoopbackClient(req)
760
839
  const snapshot = service.snapshot()
761
840
  // Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
762
841
  // token expiry, public tunnel URL, and counts are an oracle for targeting
@@ -764,9 +843,9 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
764
843
  // session credentials) is never returned here — only the loopback events
765
844
  // stream carries it to the desktop panel.
766
845
  const { devices: _devices, ...rest } = snapshot
767
- const visible = paired
846
+ const visible = paired || local
768
847
  ? rest
769
- : { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses }
848
+ : { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable }
770
849
  writeJson(res, 200, { ok: true, paired, requirePairingForLan: pairingRequired(), ...visible })
771
850
  }
772
851
 
@@ -833,9 +912,10 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
833
912
  // instead of the harness 401 dead end.
834
913
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
835
914
  if (deviceId !== undefined && service.hasDevice(deviceId)) {
836
- // An already-paired device re-opening a dead link goes straight to
837
- // the cookieless app landing with its live device credential.
838
- res.writeHead(303, { location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(deviceId)}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
915
+ // An already-paired device re-opening a dead link goes straight to the
916
+ // cookieless app landing, again on a fresh one-time grant.
917
+ const { grant } = grants.issue(deviceId)
918
+ res.writeHead(303, { location: `${appOrigin(req)}/pair-app?grant=${encodeURIComponent(grant)}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
839
919
  res.end()
840
920
  return
841
921
  }
@@ -847,25 +927,28 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
847
927
  addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
848
928
  }
849
929
  // Land the paired device on the cookieless app page: the official shell
850
- // served by this plugin, with the device id in the URL. No harness index
851
- // gate, no browser-auth cookie hop - the channel gate accepts the device
852
- // credential whether or not the browser stores cookies.
930
+ // served by this plugin. The URL carries a one-time grant, never the
931
+ // device id - a session credential must not travel in a URL (history, the
932
+ // address bar, every log on the redirect path) - and the landing's own
933
+ // response hands the device id to the shell. No harness index gate, no
934
+ // browser-auth cookie hop: the channel gate accepts the device credential
935
+ // whether or not the browser stores cookies.
936
+ const { grant } = grants.issue(result.deviceId)
853
937
  res.writeHead(303, {
854
- location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(result.deviceId)}`,
938
+ location: `${appOrigin(req)}/pair-app?grant=${encodeURIComponent(grant)}`,
855
939
  'cache-control': 'no-store',
856
940
  'referrer-policy': 'no-referrer',
857
- 'set-cookie': [
858
- `${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
859
- ],
941
+ 'set-cookie': [deviceCookie(req, service.config.cookieName, result.deviceId)],
860
942
  })
861
943
  res.end()
862
944
  }
863
945
 
864
946
  /**
865
- * The cookieless app landing. A paired device (device query or live
866
- * pairing cookie) receives the official index shell patched with the
867
- * device-capture script; the shell itself is the official document and
868
- * carries no data, so serving it needs only the device credential.
947
+ * The cookieless app landing. A paired device (a one-time grant minted by
948
+ * /pair-accept, or a live pairing cookie on a reopen) receives the official
949
+ * index shell patched with the device-capture script; the shell itself is
950
+ * the official document and carries no data, so serving it needs only the
951
+ * device credential.
869
952
  */
870
953
  const handleAppPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
871
954
  if (!requireMethod(req, res, 'GET')) return
@@ -881,10 +964,14 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
881
964
  return
882
965
  }
883
966
  const url = new URL(req.url ?? '/', 'http://pair.invalid')
884
- const device = url.searchParams.get('device') ?? ''
967
+ // The one-time grant is spent before anything else: a replayed or expired
968
+ // grant resolves to nothing (pair-grant.ts), so a leaked landing URL can
969
+ // never be re-run. The cookie stays the primary credential for a reopen
970
+ // (the service worker's network-first navigation carries it).
971
+ const grantedDevice = grants.consume(url.searchParams.get('grant') ?? undefined)
885
972
  const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName)
886
- const id = (device !== '' && service.touchDevice(device))
887
- ? device
973
+ const id = (grantedDevice !== undefined && service.touchDevice(grantedDevice))
974
+ ? grantedDevice
888
975
  : (cookieDevice !== undefined && service.touchDevice(cookieDevice))
889
976
  ? cookieDevice
890
977
  : undefined
@@ -903,9 +990,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
903
990
  'content-type': 'text/html; charset=utf-8',
904
991
  'cache-control': 'no-store',
905
992
  'referrer-policy': 'no-referrer',
906
- 'set-cookie': [
907
- `${service.config.cookieName}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
908
- ],
993
+ 'set-cookie': [deviceCookie(req, service.config.cookieName, id)],
909
994
  })
910
995
  res.end(patchAppShell(html, id))
911
996
  }
@@ -956,9 +1041,32 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
956
1041
  return routes
957
1042
  }
958
1043
 
1044
+ /**
1045
+ * Whether the request reached this process over TLS. The harness itself serves
1046
+ * plain HTTP; a tunnel edge terminates TLS and stamps `x-forwarded-proto`
1047
+ * (the same signal {@link appOrigin} trusts when it rebuilds a redirect).
1048
+ */
1049
+ export function isHttpsRequest(req: IncomingMessage): boolean {
1050
+ const forwarded = req.headers['x-forwarded-proto']
1051
+ if (typeof forwarded === 'string') {
1052
+ const first = forwarded.split(',')[0]?.trim().toLowerCase()
1053
+ if (first === 'https') return true
1054
+ }
1055
+ return (req.socket as { encrypted?: boolean }).encrypted === true
1056
+ }
1057
+
1058
+ /**
1059
+ * The device cookie for one request. `Secure` is added only when the request
1060
+ * arrived over TLS: LAN pairing runs on plain HTTP, where a Secure cookie is
1061
+ * simply dropped by the browser and the phone would silently lose its session.
1062
+ */
1063
+ export function deviceCookie(req: IncomingMessage, cookieName: string, deviceId: string): string {
1064
+ const secure = isHttpsRequest(req) ? '; Secure' : ''
1065
+ return `${cookieName}=${deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}${secure}`
1066
+ }
1067
+
959
1068
  /** The request origin (https when a tunnel edge says so). */
960
1069
  function appOrigin(req: IncomingMessage): string {
961
- const proto = req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'
962
1070
  const origin = `http://${req.headers.host ?? '127.0.0.1'}`
963
- return proto === 'https' ? origin.replace('http://', 'https://') : origin
1071
+ return isHttpsRequest(req) ? origin.replace('http://', 'https://') : origin
964
1072
  }
package/src/tunnel.ts CHANGED
@@ -164,20 +164,37 @@ export function createBinaryReadiness(executable: string, seams: BinaryReadiness
164
164
  const runs = seams.runs ?? binaryRuns
165
165
  const installBinary = seams.install ?? install
166
166
  const validated = new Set<string>()
167
+ let installs = 0
168
+ let lastFailure: Error | undefined
167
169
  return async () => {
168
170
  if (validated.has(executable)) return
171
+ // The manager retries forever with backoff, so a permanently unrunnable
172
+ // binary must not re-download the whole platform archive on every attempt
173
+ // (a wrong-arch payload would otherwise fetch it once per restart round).
174
+ // A transient install failure still gets its retries; past the cap the
175
+ // remembered error is rethrown until the process restarts.
176
+ if (installs >= MAX_BINARY_INSTALL_ATTEMPTS && lastFailure !== undefined) throw lastFailure
169
177
  if (exists(executable) && await runs(executable)) {
170
178
  validated.add(executable)
171
179
  return
172
180
  }
173
- await installBinary(executable)
174
- if (!(await runs(executable))) {
175
- throw new Error(`the cloudflared binary at ${executable} still does not run after the reinstall`)
181
+ installs += 1
182
+ try {
183
+ await installBinary(executable)
184
+ if (!(await runs(executable))) {
185
+ throw new Error(`the cloudflared binary at ${executable} still does not run after the reinstall`)
186
+ }
187
+ } catch (error) {
188
+ lastFailure = error instanceof Error ? error : new Error(String(error))
189
+ throw lastFailure
176
190
  }
177
191
  validated.add(executable)
178
192
  }
179
193
  }
180
194
 
195
+ /** How many archive downloads one process may attempt before giving up. */
196
+ export const MAX_BINARY_INSTALL_ATTEMPTS = 3
197
+
181
198
  /** The readiness policy the manager uses by default: the package's real binary path. */
182
199
  const defaultBinaryReadiness = createBinaryReadiness(bin)
183
200
 
@@ -204,14 +221,42 @@ export function quickTunnelFlags(originHostHeader?: string): Record<string, stri
204
221
  }
205
222
 
206
223
  /**
207
- * Command-line arguments for a named tunnel with a persistent Cloudflare token.
208
- * Note: `--no-autoupdate` and `--protocol` are flags for the `tunnel` command
209
- * and must appear BEFORE the `run` subcommand. Putting them after `run` causes
210
- * cloudflared to exit immediately with "flag provided but not defined: -no-autoupdate"
211
- * (issues #1432, #1433).
224
+ * Command-line arguments for a named tunnel. The account token is deliberately
225
+ * NOT part of the argv: `--token <t>` would expose an account credential to any
226
+ * local user through ps/proc for the whole tunnel lifetime, while the flag's own
227
+ * environment variable (TUNNEL_TOKEN, see cloudflared's tunnel run flags) is not
228
+ * readable that way. Note: `--no-autoupdate` and `--protocol` are flags for the
229
+ * `tunnel` command and must appear BEFORE the `run` subcommand. Putting them
230
+ * after `run` causes cloudflared to exit immediately with "flag provided but not
231
+ * defined: -no-autoupdate" (issues #1432, #1433).
212
232
  */
213
- export function namedTunnelArgs(token: string): string[] {
214
- return ['tunnel', '--no-autoupdate', '--protocol', 'http2', 'run', '--token', token]
233
+ export function namedTunnelArgs(): string[] {
234
+ return ['tunnel', '--no-autoupdate', '--protocol', 'http2', 'run']
235
+ }
236
+
237
+ /**
238
+ * Run one spawn call with TUNNEL_TOKEN set, restoring the previous value
239
+ * afterwards. The cloudflared package's Tunnel spawns with the inherited
240
+ * environment inside its constructor, so the variable only has to exist for
241
+ * that call; the child keeps its own copy of the environment.
242
+ * @param token - the named-tunnel token.
243
+ * @param spawnTunnel - the call that creates the child process.
244
+ * @returns whatever the spawn call returns.
245
+ */
246
+ export function withTunnelTokenEnv<T>(token: string, spawnTunnel: () => T): T {
247
+ const previous = process.env.TUNNEL_TOKEN
248
+ process.env.TUNNEL_TOKEN = token
249
+ try {
250
+ return spawnTunnel()
251
+ } finally {
252
+ if (previous === undefined) delete process.env.TUNNEL_TOKEN
253
+ else process.env.TUNNEL_TOKEN = previous
254
+ }
255
+ }
256
+
257
+ /** Spawn one named tunnel without putting its token in the child's argv. */
258
+ function spawnNamedTunnel(token: string): NamedTunnelProcess {
259
+ return withTunnelTokenEnv(token, () => new Tunnel(namedTunnelArgs()))
215
260
  }
216
261
 
217
262
  /** Default factory: the cloudflared package's quick and named tunnels. */
@@ -227,7 +272,7 @@ function defaultFactory(target: TunnelTarget): TunnelHandle {
227
272
  if (target.kind === 'quick') {
228
273
  return Tunnel.quick(target.targetUrl, quickTunnelFlags(target.originHostHeader))
229
274
  }
230
- return namedTunnelHandle(new Tunnel(namedTunnelArgs(target.token)), target.publicUrl)
275
+ return namedTunnelHandle(spawnNamedTunnel(target.token), target.publicUrl)
231
276
  }
232
277
 
233
278
  /** Node timers. */