dsh-mobile 0.6.10 → 0.6.12

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 (41) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/CONTRIBUTORS.md +24 -6
  3. package/README.en.md +22 -19
  4. package/README.md +22 -19
  5. package/SECURITY.md +9 -0
  6. package/apps/mobile/README.md +71 -90
  7. package/apps/mobile/README.zh-CN.md +131 -0
  8. package/apps/mobile/release.json +3 -3
  9. package/docs/ATTACH_EXISTING_FRPS.en.md +1 -1
  10. package/docs/ATTACH_EXISTING_FRPS.md +1 -1
  11. package/docs/CADDY_MANAGED.en.md +7 -7
  12. package/docs/CADDY_MANAGED.md +7 -7
  13. package/docs/CLIENT_MODULES.en.md +2 -0
  14. package/docs/CLIENT_MODULES.md +2 -0
  15. package/docs/CLOUDFLARE_TUNNEL.en.md +1 -1
  16. package/docs/CLOUDFLARE_TUNNEL.md +1 -1
  17. package/docs/CPOLAR_SUBDOMAIN.en.md +1 -1
  18. package/docs/CPOLAR_SUBDOMAIN.md +1 -1
  19. package/docs/DSH_0.1.5_LAN.md +1 -1
  20. package/docs/EXTENSION_WORKERS.en.md +2 -2
  21. package/docs/EXTENSION_WORKERS.md +2 -2
  22. package/docs/MOBILE_COMPOSER_TOOLBAR.en.md +10 -4
  23. package/docs/MOBILE_COMPOSER_TOOLBAR.md +10 -4
  24. package/docs/README.md +4 -2
  25. package/docs/SELF_HOSTED_FRP.en.md +2 -2
  26. package/docs/SELF_HOSTED_FRP.md +2 -2
  27. package/docs/SELF_HOSTED_ORIGIN.en.md +3 -1
  28. package/docs/SELF_HOSTED_ORIGIN.md +3 -1
  29. package/docs/SKIN_COMPATIBILITY.en.md +1 -0
  30. package/docs/SKIN_COMPATIBILITY.md +1 -0
  31. package/docs/SLOW_CONNECTIONS.en.md +12 -0
  32. package/docs/SLOW_CONNECTIONS.md +12 -0
  33. package/lib/client.js +234 -37
  34. package/lib/client.js.map +1 -1
  35. package/lib/index.d.mts +34 -3
  36. package/lib/index.mjs +402 -209
  37. package/lib/index.mjs.map +1 -1
  38. package/lib/mobile-compat.js +5 -1
  39. package/lib/mobile-compat.js.map +1 -1
  40. package/package.json +6 -1
  41. package/packages/question-fixes/package.json +1 -1
package/lib/index.mjs CHANGED
@@ -64,6 +64,10 @@ var BoundedRateLimiter = class {
64
64
  current.count += 1;
65
65
  return true;
66
66
  }
67
+ /** Discard entries that no longer identify an eligible key in a trusted, finite key set. */
68
+ retainKeys(eligible) {
69
+ for (const key of this.buckets.keys()) if (!eligible.has(key)) this.buckets.delete(key);
70
+ }
67
71
  /** Current table size, exposed for bounded-state assertions. */
68
72
  get size() {
69
73
  return this.buckets.size;
@@ -104,6 +108,7 @@ var AccessController = class {
104
108
  pairLimiter;
105
109
  devices = [];
106
110
  pairingWindow;
111
+ pairPending = false;
107
112
  sessions = /* @__PURE__ */ new Map();
108
113
  sessionEndedListeners = /* @__PURE__ */ new Set();
109
114
  mutation = Promise.resolve();
@@ -212,40 +217,52 @@ var AccessController = class {
212
217
  });
213
218
  });
214
219
  }
220
+ /** Limit invalid guesses by peer without letting them consume a valid one-time secret's admission. */
221
+ rejectPairingAttempt(sourceKey, now) {
222
+ if (!this.pairLimiter.take(sourceKey, now)) throw new AccessError(429, "rate_limited");
223
+ throw new AccessError(401, "authentication_failed");
224
+ }
215
225
  /** Consume the pairing window exactly once and persist only the device-token digest. */
216
226
  async pair(sourceKey, token, label) {
217
227
  this.requireInitialized();
218
- const now = this.now();
219
- if (!this.pairLimiter.take(sourceKey, now)) throw new AccessError(429, "rate_limited");
220
- if (token.length > 512) throw new AccessError(401, "authentication_failed");
221
- return this.exclusive(async () => {
222
- const window = this.pairingWindow;
223
- if (window === void 0 || window.expiresAt <= now || !matchesDigest(token, window.digest)) {
224
- if (window !== void 0 && window.expiresAt <= now) this.pairingWindow = void 0;
225
- throw new AccessError(401, "authentication_failed");
226
- }
227
- this.pairingWindow = void 0;
228
- if (this.devices.filter((device) => device.revokedAt === void 0 && device.expiresAt > now).length >= this.options.maxDevices) throw new AccessError(409, "device_limit");
229
- const deviceToken = opaqueToken();
230
- const device = Object.freeze({
231
- id: randomBytes(16).toString("hex"),
232
- label: normalizeLabel(label),
233
- tokenDigest: digestHex(deviceToken),
234
- createdAt: now,
235
- expiresAt: now + this.options.deviceTtlMs,
236
- lastSeenAt: now,
237
- ...this.options.pairingInstanceId === void 0 ? {} : { nativeInstanceId: this.options.pairingInstanceId }
238
- });
239
- const next = [...this.devices.filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now), device];
240
- await this.store.save(this.snapshot(next));
241
- this.devices = next;
242
- const session = this.createSession(device, now);
243
- return Object.freeze({
244
- ...session,
245
- deviceToken,
246
- deviceExpiresAt: device.expiresAt
228
+ const initialNow = this.now();
229
+ const initialWindow = this.pairingWindow;
230
+ if (token.length > 512 || initialWindow === void 0 || initialWindow.expiresAt <= initialNow || !matchesDigest(token, initialWindow.digest)) this.rejectPairingAttempt(sourceKey, initialNow);
231
+ if (this.pairPending) throw new AccessError(429, "rate_limited");
232
+ this.pairPending = true;
233
+ try {
234
+ return await this.exclusive(async () => {
235
+ const now = this.now();
236
+ const window = this.pairingWindow;
237
+ if (window === void 0 || window.expiresAt <= now || !matchesDigest(token, window.digest)) {
238
+ if (window !== void 0 && window.expiresAt <= now) this.pairingWindow = void 0;
239
+ this.rejectPairingAttempt(sourceKey, now);
240
+ }
241
+ this.pairingWindow = void 0;
242
+ if (this.devices.filter((device) => device.revokedAt === void 0 && device.expiresAt > now).length >= this.options.maxDevices) throw new AccessError(409, "device_limit");
243
+ const deviceToken = opaqueToken();
244
+ const device = Object.freeze({
245
+ id: randomBytes(16).toString("hex"),
246
+ label: normalizeLabel(label),
247
+ tokenDigest: digestHex(deviceToken),
248
+ createdAt: now,
249
+ expiresAt: now + this.options.deviceTtlMs,
250
+ lastSeenAt: now,
251
+ ...this.options.pairingInstanceId === void 0 ? {} : { nativeInstanceId: this.options.pairingInstanceId }
252
+ });
253
+ const next = [...this.devices.filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now), device];
254
+ await this.store.save(this.snapshot(next));
255
+ this.devices = next;
256
+ const session = this.createSession(device, now);
257
+ return Object.freeze({
258
+ ...session,
259
+ deviceToken,
260
+ deviceExpiresAt: device.expiresAt
261
+ });
247
262
  });
248
- });
263
+ } finally {
264
+ this.pairPending = false;
265
+ }
249
266
  }
250
267
  /** Exchange a valid persistent device credential for a new short Session. */
251
268
  async renew(deviceToken) {
@@ -1681,88 +1698,6 @@ var MobileAccessGatewayController = class {
1681
1698
  return run;
1682
1699
  }
1683
1700
  };
1684
- //#endregion
1685
- //#region src/client-module-recovery.ts
1686
- const CLIENT_MODULE_RECOVERY_COPY = {
1687
- zh: {
1688
- title: "移动页面模块需要调整",
1689
- explanation: "电脑上的插件或依赖关系已变化,之前保存的模块选择无法加载当前页面。",
1690
- scope: "可为本设备加载全部模块并重新打开 DSH。不会修改电脑默认设置、其他设备或已安装的插件。",
1691
- action: "为本设备加载全部模块并重试",
1692
- working: "正在更新本设备的模块选择…",
1693
- failed: "未能恢复。请确认电脑和远程通道仍在线,然后重试。"
1694
- },
1695
- en: {
1696
- title: "Mobile page modules need attention",
1697
- explanation: "The computer’s plugins or dependencies have changed. The saved module selection can no longer load this page.",
1698
- scope: "Load all modules for this device and reopen DSH. Computer defaults, other devices, and installed plugins are unchanged.",
1699
- action: "Load all modules for this device and retry",
1700
- working: "Updating this device’s module selection…",
1701
- failed: "Could not restore the page. Check that the computer and remote connection are online, then retry."
1702
- },
1703
- it: {
1704
- title: "I moduli della pagina mobile richiedono attenzione",
1705
- explanation: "I plugin o le dipendenze del computer sono cambiati. La selezione salvata non può più caricare questa pagina.",
1706
- scope: "Carica tutti i moduli per questo dispositivo e riapri DSH. I predefiniti del computer, gli altri dispositivi e i plugin installati non cambiano.",
1707
- action: "Carica tutti i moduli per questo dispositivo e riprova",
1708
- working: "Aggiornamento dei moduli per questo dispositivo…",
1709
- failed: "Ripristino non riuscito. Verifica che il computer e la connessione remota siano online, poi riprova."
1710
- }
1711
- };
1712
- /** Render an explicit device-only recovery action without interpolating graph diagnostics or credentials.
1713
- * @param locale - Negotiated browser display language.
1714
- * @returns An owned HTML page which changes no selection until the user activates its button.
1715
- */
1716
- function renderClientModuleRecoveryPage(locale) {
1717
- const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
1718
- return `<!doctype html>
1719
- <html lang="${locale === "zh" ? "zh-CN" : locale}">
1720
- <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
1721
- <meta name="color-scheme" content="light dark"><title>${copy.title}</title>
1722
- <style>
1723
- body{margin:0;background:Canvas;color:CanvasText;font:16px/1.5 system-ui,sans-serif}
1724
- main{box-sizing:border-box;max-width:640px;margin:32px auto;padding:24px max(24px,env(safe-area-inset-left)) 24px max(24px,env(safe-area-inset-right));overflow-wrap:anywhere}
1725
- h1{font-size:24px;line-height:1.3}button{min-height:48px;max-width:100%;padding:12px 20px;border:1px solid ButtonText;border-radius:12px;background:ButtonFace;color:ButtonText;font:inherit;cursor:pointer}
1726
- button:focus-visible{outline:2px solid Highlight;outline-offset:3px}button:disabled{opacity:.6;cursor:wait}#recovery-status{min-height:1.5em}
1727
- </style></head>
1728
- <body><main><h1>${copy.title}</h1><p>${copy.explanation}</p><p>${copy.scope}</p>
1729
- <button id="load-all-modules" type="button">${copy.action}</button><p id="recovery-status" role="status" aria-live="polite"></p>
1730
- </main><script>${renderClientModuleRecoveryScript(locale)}<\/script></body></html>
1731
- `;
1732
- }
1733
- /** Render the fixed same-origin action; CSRF values are read from the active browser cookie only at user activation.
1734
- * @param locale - Negotiated browser display language.
1735
- * @returns JavaScript that preserves selections on failure and reloads only after an authorized save succeeds.
1736
- */
1737
- function renderClientModuleRecoveryScript(locale) {
1738
- const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
1739
- return `(() => {
1740
- const button = document.getElementById('load-all-modules')
1741
- const status = document.getElementById('recovery-status')
1742
- button.addEventListener('click', async () => {
1743
- if (button.disabled) return
1744
- button.disabled = true
1745
- status.textContent = ${JSON.stringify(copy.working)}
1746
- try {
1747
- const values = document.cookie.split(';').map(value => value.trim())
1748
- const host = values.filter(value => value.startsWith('__Host-dsh_ma_csrf='))
1749
- const candidates = host.length === 0 ? values.filter(value => value.startsWith('dsh_ma_csrf=')) : host
1750
- if (candidates.length !== 1) throw new Error('csrf_unavailable')
1751
- const csrf = candidates[0].slice(candidates[0].indexOf('=') + 1)
1752
- const response = await fetch('/mobile-access/client-modules', {
1753
- method: 'POST', credentials: 'same-origin', redirect: 'error', cache: 'no-store',
1754
- headers: { 'content-type': 'application/json', 'x-dsh-mobile-csrf': csrf },
1755
- body: JSON.stringify({ excludedClientModules: [] }),
1756
- })
1757
- if (!response.ok) throw new Error('recovery_failed')
1758
- location.reload()
1759
- } catch {
1760
- status.textContent = ${JSON.stringify(copy.failed)}
1761
- button.disabled = false
1762
- }
1763
- })
1764
- })()`;
1765
- }
1766
1701
  function unwrapBrackets(hostname) {
1767
1702
  return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1768
1703
  }
@@ -1979,6 +1914,256 @@ function assertLocalAdminTrust(request, requireBrowserOrigin, authenticateDeskto
1979
1914
  if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
1980
1915
  }
1981
1916
  //#endregion
1917
+ //#region src/browser-auth-cookies.ts
1918
+ const HOST_SESSION_COOKIE = "__Host-dsh_ma_session";
1919
+ const HOST_CSRF_COOKIE = "__Host-dsh_ma_csrf";
1920
+ /** Non-secret browser identity; the persistent device credential keeps its narrow renewal path. */
1921
+ const HOST_DEVICE_COOKIE = "__Host-dsh_ma_device_id";
1922
+ const AUTH_COOKIE_NAMES = /* @__PURE__ */ new Set([
1923
+ SESSION_COOKIE,
1924
+ DEVICE_COOKIE,
1925
+ CSRF_COOKIE,
1926
+ HOST_SESSION_COOKIE,
1927
+ HOST_CSRF_COOKIE,
1928
+ HOST_DEVICE_COOKIE
1929
+ ]);
1930
+ /** Read only gateway authentication cookies, retaining distinct values from different scopes. */
1931
+ function browserAuthCookies(header) {
1932
+ if (header === void 0) return /* @__PURE__ */ new Map();
1933
+ if (header.length > 8192) throw new HttpError(401, "authentication_failed");
1934
+ const cookies = /* @__PURE__ */ new Map();
1935
+ for (const part of header.split(";")) {
1936
+ const equals = part.indexOf("=");
1937
+ if (equals <= 0) continue;
1938
+ const name = part.slice(0, equals).trim();
1939
+ if (!AUTH_COOKIE_NAMES.has(name)) continue;
1940
+ const values = cookies.get(name) ?? [];
1941
+ cookies.set(name, values);
1942
+ const value = part.slice(equals + 1).trim();
1943
+ if (!/^[\w\-.~+/=]{1,512}$/u.test(value) || values.includes(value)) continue;
1944
+ values.push(value);
1945
+ }
1946
+ return cookies;
1947
+ }
1948
+ /** Select exactly one authenticated value; never let cookie order choose between valid identities. */
1949
+ function authenticatedCookie(values, authenticate, accepts = () => true) {
1950
+ let selected;
1951
+ for (const value of values) {
1952
+ let result;
1953
+ try {
1954
+ result = authenticate(value);
1955
+ } catch (error) {
1956
+ if (error instanceof AccessError && error.status === 401) continue;
1957
+ throw error;
1958
+ }
1959
+ if (!accepts(result)) continue;
1960
+ if (selected !== void 0) throw new HttpError(401, "authentication_failed");
1961
+ selected = {
1962
+ value,
1963
+ result
1964
+ };
1965
+ }
1966
+ if (selected === void 0) throw new HttpError(401, "authentication_failed");
1967
+ return selected;
1968
+ }
1969
+ //#endregion
1970
+ //#region src/auth-failure-log.ts
1971
+ const LOG_INTERVAL_MS = 3e4;
1972
+ const MAX_LOGGED_ORIGIN_LENGTH = 512;
1973
+ function requestPathCategory(path) {
1974
+ switch (path) {
1975
+ case "/":
1976
+ case "/index.html":
1977
+ case "/api/session/prompt":
1978
+ case "/api/events.mux":
1979
+ case "/api/events.host":
1980
+ case "/api/remote.mux":
1981
+ case "/mobile-access/compat.js":
1982
+ case "/mobile-access/mobile-layout.js":
1983
+ case "/mobile-access/custom.js":
1984
+ case "/mobile-access/custom.css":
1985
+ case "/mobile-access/client-modules":
1986
+ case "/mobile-access/events": return path;
1987
+ default:
1988
+ if (path.startsWith("/api/")) return "/api/*";
1989
+ if (path.startsWith("/mobile-access/extensions/")) return "/mobile-access/extensions/*";
1990
+ if (path.startsWith("/mobile-access/")) return "/mobile-access/*";
1991
+ return "/*";
1992
+ }
1993
+ }
1994
+ function requestMethod(method) {
1995
+ switch (method) {
1996
+ case "GET":
1997
+ case "HEAD":
1998
+ case "POST":
1999
+ case "PUT":
2000
+ case "PATCH":
2001
+ case "DELETE":
2002
+ case "OPTIONS": return method;
2003
+ default: return "OTHER";
2004
+ }
2005
+ }
2006
+ function requestEntry(kind, status, request, path, canonicalOrigin, authorization) {
2007
+ let sessionCookie = false;
2008
+ let csrfCookies = 0;
2009
+ for (const cookie of (request.headers.cookie ?? "").split(";")) {
2010
+ const equals = cookie.indexOf("=");
2011
+ if (equals < 0) continue;
2012
+ const name = cookie.slice(0, equals).trim();
2013
+ if (name === "dsh_ma_session" || name === "__Host-dsh_ma_session") sessionCookie = true;
2014
+ if (name === "dsh_ma_csrf" || name === "__Host-dsh_ma_csrf") csrfCookies += 1;
2015
+ }
2016
+ const site = request.headers["sec-fetch-site"];
2017
+ return {
2018
+ kind,
2019
+ status,
2020
+ method: requestMethod(request.method),
2021
+ path: requestPathCategory(path),
2022
+ sessionCookie,
2023
+ csrfHeader: request.headers[CSRF_HEADER] !== void 0,
2024
+ csrfCookies,
2025
+ ...canonicalOrigin === void 0 || canonicalOrigin.length > MAX_LOGGED_ORIGIN_LENGTH ? {} : { origin: canonicalOrigin },
2026
+ ...site === "same-origin" || site === "same-site" || site === "cross-site" || site === "none" ? { site } : {},
2027
+ ...authorization === void 0 ? {} : { device: authorization.deviceId.slice(0, 8) }
2028
+ };
2029
+ }
2030
+ /**
2031
+ * Gateway-owned diagnostic sampling with two fixed slots and no timer or attacker-selected key.
2032
+ * Each kind records immediately, then at most once per 30 seconds. Suppressed counts accompany
2033
+ * its next recorded request; no credential, request body, arbitrary path or query is retained.
2034
+ * The caller supplies the Origin already normalized by its successful request-trust check.
2035
+ */
2036
+ var AuthFailureRecorder = class {
2037
+ observe;
2038
+ now;
2039
+ reportObserverFailure;
2040
+ windows = {
2041
+ authorize: {
2042
+ nextLogAt: 0,
2043
+ suppressed: 0
2044
+ },
2045
+ csrf: {
2046
+ nextLogAt: 0,
2047
+ suppressed: 0
2048
+ }
2049
+ };
2050
+ observerFailureReported = false;
2051
+ constructor(observe, now = () => performance.now(), reportObserverFailure = () => {
2052
+ process.emitWarning("Mobile access authentication diagnostics could not be recorded", { code: "DSH_MOBILE_AUTH_LOG_FAILED" });
2053
+ }) {
2054
+ this.observe = observe;
2055
+ this.now = now;
2056
+ this.reportObserverFailure = reportObserverFailure;
2057
+ }
2058
+ /** Record a trusted-request rejection without changing its authentication result or retrying it. */
2059
+ record(kind, status, request, path, canonicalOrigin, authorization) {
2060
+ if (this.observe === void 0) return;
2061
+ const now = this.now();
2062
+ const window = this.windows[kind];
2063
+ if (now < window.nextLogAt) {
2064
+ window.suppressed = Math.min(Number.MAX_SAFE_INTEGER, window.suppressed + 1);
2065
+ return;
2066
+ }
2067
+ const suppressed = window.suppressed;
2068
+ window.nextLogAt = now + LOG_INTERVAL_MS;
2069
+ window.suppressed = 0;
2070
+ try {
2071
+ this.observe({
2072
+ ...requestEntry(kind, status, request, path, canonicalOrigin, authorization),
2073
+ ...suppressed === 0 ? {} : { suppressed }
2074
+ });
2075
+ } catch (observerError) {
2076
+ if (this.observerFailureReported) return;
2077
+ this.observerFailureReported = true;
2078
+ try {
2079
+ this.reportObserverFailure();
2080
+ } catch (reporterError) {}
2081
+ }
2082
+ }
2083
+ };
2084
+ //#endregion
2085
+ //#region src/client-module-recovery.ts
2086
+ const CLIENT_MODULE_RECOVERY_COPY = {
2087
+ zh: {
2088
+ title: "移动页面模块需要调整",
2089
+ explanation: "电脑上的插件或依赖关系已变化,之前保存的模块选择无法加载当前页面。",
2090
+ scope: "可为本设备加载全部模块并重新打开 DSH。不会修改电脑默认设置、其他设备或已安装的插件。",
2091
+ action: "为本设备加载全部模块并重试",
2092
+ working: "正在更新本设备的模块选择…",
2093
+ failed: "未能恢复。请确认电脑和远程通道仍在线,然后重试。"
2094
+ },
2095
+ en: {
2096
+ title: "Mobile page modules need attention",
2097
+ explanation: "The computer’s plugins or dependencies have changed. The saved module selection can no longer load this page.",
2098
+ scope: "Load all modules for this device and reopen DSH. Computer defaults, other devices, and installed plugins are unchanged.",
2099
+ action: "Load all modules for this device and retry",
2100
+ working: "Updating this device’s module selection…",
2101
+ failed: "Could not restore the page. Check that the computer and remote connection are online, then retry."
2102
+ },
2103
+ it: {
2104
+ title: "I moduli della pagina mobile richiedono attenzione",
2105
+ explanation: "I plugin o le dipendenze del computer sono cambiati. La selezione salvata non può più caricare questa pagina.",
2106
+ scope: "Carica tutti i moduli per questo dispositivo e riapri DSH. I predefiniti del computer, gli altri dispositivi e i plugin installati non cambiano.",
2107
+ action: "Carica tutti i moduli per questo dispositivo e riprova",
2108
+ working: "Aggiornamento dei moduli per questo dispositivo…",
2109
+ failed: "Ripristino non riuscito. Verifica che il computer e la connessione remota siano online, poi riprova."
2110
+ }
2111
+ };
2112
+ /** Render an explicit device-only recovery action without interpolating graph diagnostics or credentials.
2113
+ * @param locale - Negotiated browser display language.
2114
+ * @returns An owned HTML page which changes no selection until the user activates its button.
2115
+ */
2116
+ function renderClientModuleRecoveryPage(locale) {
2117
+ const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
2118
+ return `<!doctype html>
2119
+ <html lang="${locale === "zh" ? "zh-CN" : locale}">
2120
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
2121
+ <meta name="color-scheme" content="light dark"><title>${copy.title}</title>
2122
+ <style>
2123
+ body{margin:0;background:Canvas;color:CanvasText;font:16px/1.5 system-ui,sans-serif}
2124
+ main{box-sizing:border-box;max-width:640px;margin:32px auto;padding:24px max(24px,env(safe-area-inset-left)) 24px max(24px,env(safe-area-inset-right));overflow-wrap:anywhere}
2125
+ h1{font-size:24px;line-height:1.3}button{min-height:48px;max-width:100%;padding:12px 20px;border:1px solid ButtonText;border-radius:12px;background:ButtonFace;color:ButtonText;font:inherit;cursor:pointer}
2126
+ button:focus-visible{outline:2px solid Highlight;outline-offset:3px}button:disabled{opacity:.6;cursor:wait}#recovery-status{min-height:1.5em}
2127
+ </style></head>
2128
+ <body><main><h1>${copy.title}</h1><p>${copy.explanation}</p><p>${copy.scope}</p>
2129
+ <button id="load-all-modules" type="button">${copy.action}</button><p id="recovery-status" role="status" aria-live="polite"></p>
2130
+ </main><script>${renderClientModuleRecoveryScript(locale)}<\/script></body></html>
2131
+ `;
2132
+ }
2133
+ /** Render the fixed same-origin action; CSRF values are read from the active browser cookie only at user activation.
2134
+ * @param locale - Negotiated browser display language.
2135
+ * @returns JavaScript that preserves selections on failure and reloads only after an authorized save succeeds.
2136
+ */
2137
+ function renderClientModuleRecoveryScript(locale) {
2138
+ const copy = CLIENT_MODULE_RECOVERY_COPY[locale];
2139
+ return `(() => {
2140
+ const button = document.getElementById('load-all-modules')
2141
+ const status = document.getElementById('recovery-status')
2142
+ button.addEventListener('click', async () => {
2143
+ if (button.disabled) return
2144
+ button.disabled = true
2145
+ status.textContent = ${JSON.stringify(copy.working)}
2146
+ try {
2147
+ const values = document.cookie.split(';').map(value => value.trim())
2148
+ const host = values.filter(value => value.startsWith('__Host-dsh_ma_csrf='))
2149
+ const candidates = host.length === 0 ? values.filter(value => value.startsWith('dsh_ma_csrf=')) : host
2150
+ if (candidates.length !== 1) throw new Error('csrf_unavailable')
2151
+ const csrf = candidates[0].slice(candidates[0].indexOf('=') + 1)
2152
+ const response = await fetch('/mobile-access/client-modules', {
2153
+ method: 'POST', credentials: 'same-origin', redirect: 'error', cache: 'no-store',
2154
+ headers: { 'content-type': 'application/json', 'x-dsh-mobile-csrf': csrf },
2155
+ body: JSON.stringify({ excludedClientModules: [] }),
2156
+ })
2157
+ if (!response.ok) throw new Error('recovery_failed')
2158
+ location.reload()
2159
+ } catch {
2160
+ status.textContent = ${JSON.stringify(copy.failed)}
2161
+ button.disabled = false
2162
+ }
2163
+ })
2164
+ })()`;
2165
+ }
2166
+ //#endregion
1982
2167
  //#region src/mobile-compat-bootstrap.ts
1983
2168
  const MOBILE_COMPAT_PATH = `${AUTH_PREFIX}/compat.js`;
1984
2169
  /**
@@ -2076,59 +2261,6 @@ function weakenEntityTag(etag) {
2076
2261
  return opaque === void 0 ? void 0 : `W/${opaque}`;
2077
2262
  }
2078
2263
  //#endregion
2079
- //#region src/browser-auth-cookies.ts
2080
- const HOST_SESSION_COOKIE = "__Host-dsh_ma_session";
2081
- const HOST_CSRF_COOKIE = "__Host-dsh_ma_csrf";
2082
- /** Non-secret browser identity; the persistent device credential keeps its narrow renewal path. */
2083
- const HOST_DEVICE_COOKIE = "__Host-dsh_ma_device_id";
2084
- const AUTH_COOKIE_NAMES = /* @__PURE__ */ new Set([
2085
- SESSION_COOKIE,
2086
- DEVICE_COOKIE,
2087
- CSRF_COOKIE,
2088
- HOST_SESSION_COOKIE,
2089
- HOST_CSRF_COOKIE,
2090
- HOST_DEVICE_COOKIE
2091
- ]);
2092
- /** Read only gateway authentication cookies, retaining distinct values from different scopes. */
2093
- function browserAuthCookies(header) {
2094
- if (header === void 0) return /* @__PURE__ */ new Map();
2095
- if (header.length > 8192) throw new HttpError(401, "authentication_failed");
2096
- const cookies = /* @__PURE__ */ new Map();
2097
- for (const part of header.split(";")) {
2098
- const equals = part.indexOf("=");
2099
- if (equals <= 0) continue;
2100
- const name = part.slice(0, equals).trim();
2101
- if (!AUTH_COOKIE_NAMES.has(name)) continue;
2102
- const values = cookies.get(name) ?? [];
2103
- cookies.set(name, values);
2104
- const value = part.slice(equals + 1).trim();
2105
- if (!/^[\w\-.~+/=]{1,512}$/u.test(value) || values.includes(value)) continue;
2106
- values.push(value);
2107
- }
2108
- return cookies;
2109
- }
2110
- /** Select exactly one authenticated value; never let cookie order choose between valid identities. */
2111
- function authenticatedCookie(values, authenticate, accepts = () => true) {
2112
- let selected;
2113
- for (const value of values) {
2114
- let result;
2115
- try {
2116
- result = authenticate(value);
2117
- } catch (error) {
2118
- if (error instanceof AccessError && error.status === 401) continue;
2119
- throw error;
2120
- }
2121
- if (!accepts(result)) continue;
2122
- if (selected !== void 0) throw new HttpError(401, "authentication_failed");
2123
- selected = {
2124
- value,
2125
- result
2126
- };
2127
- }
2128
- if (selected === void 0) throw new HttpError(401, "authentication_failed");
2129
- return selected;
2130
- }
2131
- //#endregion
2132
2264
  //#region src/websocket-compression.ts
2133
2265
  function payloadBytes(data) {
2134
2266
  if (Array.isArray(data)) return data.reduce((bytes, part) => bytes + part.byteLength, 0);
@@ -5280,19 +5412,25 @@ var MobileAccessGateway = class {
5280
5412
  closeTask;
5281
5413
  removeSessionListener;
5282
5414
  removeExtensionContentListener;
5283
- renewLimiter;
5284
- probeLimiter;
5415
+ renewFailureLimiter;
5416
+ renewDeviceLimiter;
5417
+ renewInFlight = /* @__PURE__ */ new Set();
5418
+ probeFailureLimiter;
5419
+ probeDeviceLimiter;
5420
+ probeInFlight = /* @__PURE__ */ new Set();
5285
5421
  clientModulePreferences;
5286
5422
  clientModuleOperations = /* @__PURE__ */ new Set();
5287
5423
  clientModuleTasks = /* @__PURE__ */ new Set();
5288
5424
  clientModuleRevocations = /* @__PURE__ */ new Set();
5289
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences) {
5425
+ authFailures;
5426
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog) {
5290
5427
  this.config = config;
5291
5428
  this.extensions = extensions;
5292
5429
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
5293
5430
  this.extraWebSocketPaths = extraWebSocketPaths;
5294
5431
  this.blockedUpgradeLog = blockedUpgradeLog;
5295
5432
  this.onDiscoveryDegraded = onDiscoveryDegraded;
5433
+ this.authFailures = new AuthFailureRecorder(authFailureLog);
5296
5434
  this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
5297
5435
  this.listenerTlsEnabled = config.tls.mode === "provided";
5298
5436
  this.tlsEnabled = config.publicTls;
@@ -5308,8 +5446,12 @@ var MobileAccessGateway = class {
5308
5446
  maxPairingAttempts: config.maxPairingAttempts,
5309
5447
  maxRateLimitKeys: config.maxRateLimitKeys
5310
5448
  });
5311
- this.renewLimiter = new BoundedRateLimiter(Math.min(100, config.maxPairingAttempts * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
5312
- this.probeLimiter = new BoundedRateLimiter(Math.min(1e3, config.maxDevices * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
5449
+ const renewLimit = Math.min(100, config.maxPairingAttempts * 4);
5450
+ const probeLimit = Math.min(1e3, config.maxDevices * 4);
5451
+ this.renewFailureLimiter = new BoundedRateLimiter(renewLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
5452
+ this.renewDeviceLimiter = new BoundedRateLimiter(renewLimit, config.rateLimitWindowMs, config.maxDevices);
5453
+ this.probeFailureLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
5454
+ this.probeDeviceLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxDevices);
5313
5455
  this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
5314
5456
  if (reason === "revoked") {
5315
5457
  const cleanup = this.clientModulePreferences.removeDevice(authorization.deviceId).catch(() => {
@@ -5637,6 +5779,27 @@ var MobileAccessGateway = class {
5637
5779
  maxAgeSeconds: (deviceExpiresAt - Date.now()) / 1e3
5638
5780
  })]);
5639
5781
  }
5782
+ /** Separate malformed or invalid requests from device-authenticated work without trusting forwarded addresses. */
5783
+ async withDeviceRequest(request, failureLimiter, deviceLimiter, inFlight, validate, operation) {
5784
+ let device;
5785
+ try {
5786
+ device = await validate();
5787
+ } catch (error) {
5788
+ if ((error instanceof HttpError || error instanceof AccessError) && error.status >= 400 && error.status < 500 && !failureLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5789
+ throw error;
5790
+ }
5791
+ if (inFlight.has(device.deviceId)) throw new HttpError(429, "rate_limited");
5792
+ const now = Date.now();
5793
+ const activeIds = new Set(this.access.listDevices().filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now).map((candidate) => candidate.id));
5794
+ deviceLimiter.retainKeys(activeIds);
5795
+ if (!deviceLimiter.take(device.deviceId, now)) throw new HttpError(429, "rate_limited");
5796
+ inFlight.add(device.deviceId);
5797
+ try {
5798
+ return await operation(device);
5799
+ } finally {
5800
+ inFlight.delete(device.deviceId);
5801
+ }
5802
+ }
5640
5803
  async handlePair(request, response) {
5641
5804
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5642
5805
  if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
@@ -5659,21 +5822,28 @@ var MobileAccessGateway = class {
5659
5822
  }, this.tlsEnabled);
5660
5823
  }
5661
5824
  async handleRenew(request, response) {
5662
- if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5663
- await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5664
- const cookies = browserAuthCookies(request.headers.cookie);
5665
- const bindings = this.tlsEnabled ? cookies.get(HOST_DEVICE_COOKIE) : void 0;
5666
- if (bindings !== void 0 && bindings.length !== 1) throw new HttpError(401, "authentication_failed");
5667
- const device = authenticatedCookie(cookies.get("dsh_ma_device") ?? [], (token) => this.access.authorizeDevice(token), (candidate) => bindings === void 0 || candidate.deviceId === bindings[0]);
5668
- const result = await this.access.renew(device.value);
5669
- this.setSessionCookies(response, result, Date.now());
5670
- this.setBrowserDeviceBinding(response, result.deviceId, device.result.deviceExpiresAt);
5671
- sendJson(response, 200, {
5672
- renewed: true,
5673
- deviceId: result.deviceId,
5674
- csrfToken: result.csrfToken,
5675
- sessionExpiresAt: result.sessionExpiresAt
5676
- }, this.tlsEnabled);
5825
+ await this.withDeviceRequest(request, this.renewFailureLimiter, this.renewDeviceLimiter, this.renewInFlight, async () => {
5826
+ await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5827
+ const cookies = browserAuthCookies(request.headers.cookie);
5828
+ const bindings = this.tlsEnabled ? cookies.get(HOST_DEVICE_COOKIE) : void 0;
5829
+ if (bindings !== void 0 && bindings.length !== 1) throw new HttpError(401, "authentication_failed");
5830
+ const device = authenticatedCookie(cookies.get("dsh_ma_device") ?? [], (token) => this.access.authorizeDevice(token), (candidate) => bindings === void 0 || candidate.deviceId === bindings[0]);
5831
+ return {
5832
+ deviceId: device.result.deviceId,
5833
+ token: device.value,
5834
+ credential: device.result
5835
+ };
5836
+ }, async (selected) => {
5837
+ const result = await this.access.renew(selected.token);
5838
+ this.setSessionCookies(response, result, Date.now());
5839
+ this.setBrowserDeviceBinding(response, result.deviceId, selected.credential.deviceExpiresAt);
5840
+ sendJson(response, 200, {
5841
+ renewed: true,
5842
+ deviceId: result.deviceId,
5843
+ csrfToken: result.csrfToken,
5844
+ sessionExpiresAt: result.sessionExpiresAt
5845
+ }, this.tlsEnabled);
5846
+ });
5677
5847
  }
5678
5848
  /** CA-backed entries retain their pin; public entries project the authenticated credential's identity. */
5679
5849
  nativeResponseInstanceId(result) {
@@ -5694,28 +5864,40 @@ var MobileAccessGateway = class {
5694
5864
  }, this.tlsEnabled);
5695
5865
  }
5696
5866
  async handleNativeRenew(request, response) {
5697
- if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5698
- const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5699
- if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5700
- const result = await this.access.renew(body.deviceToken);
5701
- sendJson(response, 200, {
5702
- instanceId: this.nativeResponseInstanceId(result),
5703
- deviceId: result.deviceId,
5704
- sessionToken: result.sessionToken,
5705
- csrfToken: result.csrfToken,
5706
- sessionExpiresAt: result.sessionExpiresAt
5707
- }, this.tlsEnabled);
5867
+ await this.withDeviceRequest(request, this.renewFailureLimiter, this.renewDeviceLimiter, this.renewInFlight, async () => {
5868
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5869
+ if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5870
+ return {
5871
+ deviceId: this.access.authorizeDevice(body.deviceToken).deviceId,
5872
+ token: body.deviceToken
5873
+ };
5874
+ }, async (selected) => {
5875
+ const result = await this.access.renew(selected.token);
5876
+ sendJson(response, 200, {
5877
+ instanceId: this.nativeResponseInstanceId(result),
5878
+ deviceId: result.deviceId,
5879
+ sessionToken: result.sessionToken,
5880
+ csrfToken: result.csrfToken,
5881
+ sessionExpiresAt: result.sessionExpiresAt
5882
+ }, this.tlsEnabled);
5883
+ });
5708
5884
  }
5709
5885
  async handleNativeProbe(request, response) {
5710
- if (!this.probeLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5711
- const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5712
- if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5713
- const result = await this.access.probe(body.deviceToken);
5714
- sendJson(response, 200, {
5715
- instanceId: this.nativeResponseInstanceId(result),
5716
- deviceId: result.deviceId,
5717
- deviceExpiresAt: result.deviceExpiresAt
5718
- }, this.tlsEnabled);
5886
+ await this.withDeviceRequest(request, this.probeFailureLimiter, this.probeDeviceLimiter, this.probeInFlight, async () => {
5887
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5888
+ if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5889
+ return {
5890
+ deviceId: this.access.authorizeDevice(body.deviceToken).deviceId,
5891
+ token: body.deviceToken
5892
+ };
5893
+ }, async (selected) => {
5894
+ const result = await this.access.probe(selected.token);
5895
+ sendJson(response, 200, {
5896
+ instanceId: this.nativeResponseInstanceId(result),
5897
+ deviceId: result.deviceId,
5898
+ deviceExpiresAt: result.deviceExpiresAt
5899
+ }, this.tlsEnabled);
5900
+ });
5719
5901
  }
5720
5902
  async handleLogout(request, response) {
5721
5903
  await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
@@ -5879,6 +6061,7 @@ var MobileAccessGateway = class {
5879
6061
  authorization = this.authorize(request);
5880
6062
  } catch (error) {
5881
6063
  const mapped = mapError(error);
6064
+ this.authFailures.record("authorize", mapped.status, request, target.decodedPathname, policy.canonicalOrigin(request.headers.origin));
5882
6065
  const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
5883
6066
  const topLevel = request.method === "GET" && acceptsHtml && (request.headers["sec-fetch-dest"] === void 0 || request.headers["sec-fetch-dest"] === "document") && target.decodedPathname !== "/api" && !target.decodedPathname.startsWith("/api/");
5884
6067
  if (mapped.status === 401 && topLevel) {
@@ -5893,7 +6076,12 @@ var MobileAccessGateway = class {
5893
6076
  }
5894
6077
  throw error;
5895
6078
  }
5896
- if (isMutation) this.requireCsrf(request, authorization);
6079
+ if (isMutation) try {
6080
+ this.requireCsrf(request, authorization);
6081
+ } catch (error) {
6082
+ this.authFailures.record("csrf", mapError(error).status, request, target.decodedPathname, policy.canonicalOrigin(request.headers.origin), authorization);
6083
+ throw error;
6084
+ }
5897
6085
  if (clientModules) {
5898
6086
  if (target.search !== "") throw new HttpError(400, "bad_request");
5899
6087
  if (request.method !== "GET" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
@@ -6267,7 +6455,8 @@ var MobileAccessGateway = class {
6267
6455
  headers: {
6268
6456
  host: this.config.upstreamOrigin.host,
6269
6457
  accept: "text/html",
6270
- "accept-encoding": "identity"
6458
+ "accept-encoding": "identity",
6459
+ "sec-fetch-mode": "same-origin"
6271
6460
  },
6272
6461
  agent: false
6273
6462
  });
@@ -17410,7 +17599,9 @@ async function apply(ctx, config) {
17410
17599
  }), frpIdentity, candidateConfig.instanceId !== void 0);
17411
17600
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
17412
17601
  logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
17413
- }, clientModulePreferences);
17602
+ }, clientModulePreferences, (entry) => {
17603
+ logger.warn("auth failure %s", JSON.stringify(entry));
17604
+ });
17414
17605
  await candidate.start();
17415
17606
  lanGateway = candidate;
17416
17607
  const removeTaskSink = taskEventHub.add(candidate);
@@ -17466,7 +17657,9 @@ async function apply(ctx, config) {
17466
17657
  }
17467
17658
  const createRemoteGateway = async (publicOrigin, listenPort = 0) => {
17468
17659
  const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, remoteInstanceId, listenPort, instanceId);
17469
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
17660
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences, (entry) => {
17661
+ logger.warn("auth failure %s", JSON.stringify(entry));
17662
+ });
17470
17663
  await candidate.start();
17471
17664
  return candidate;
17472
17665
  };