dsh-mobile 0.4.0 → 0.4.1

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/lib/index.d.mts CHANGED
@@ -436,8 +436,10 @@ declare class MobileExtensionError extends Error {
436
436
  constructor(code: string, message: string, status?: number);
437
437
  }
438
438
  /** One host-side action exposed by an extension. */
439
+ type CallableActionInput = (value?: never, options?: never) => unknown;
439
440
  interface MobileHostAction {
440
- readonly input?: {
441
+ /** A callable Schemastery schema or an adapter exposing parse(). */
442
+ readonly input?: CallableActionInput | {
441
443
  parse(value: unknown): unknown;
442
444
  };
443
445
  readonly run: (context: MobileActionContext, input: unknown) => unknown | Promise<unknown>;
package/lib/index.mjs CHANGED
@@ -389,7 +389,7 @@ var AccessController = class {
389
389
  };
390
390
  //#endregion
391
391
  //#region src/network.ts
392
- function parseIpv4(address) {
392
+ function parseIpv4$1(address) {
393
393
  const parts = address.split(".");
394
394
  if (parts.length !== 4) throw new Error(`invalid IPv4 address ${JSON.stringify(address)}`);
395
395
  let value = 0n;
@@ -403,7 +403,7 @@ function parseIpv4(address) {
403
403
  }
404
404
  function parseIpv6Part(part, address) {
405
405
  if (part.includes(".")) {
406
- const ipv4 = parseIpv4(part);
406
+ const ipv4 = parseIpv4$1(part);
407
407
  return [Number(ipv4 >> 16n & 65535n), Number(ipv4 & 65535n)];
408
408
  }
409
409
  if (!/^[\da-f]{1,4}$/iu.test(part)) throw new Error(`invalid IPv6 address ${JSON.stringify(address)}`);
@@ -433,12 +433,12 @@ function parseIp(address) {
433
433
  const mapped = mappedIpv4(unwrapped);
434
434
  if (mapped !== void 0) return {
435
435
  bits: 32,
436
- value: parseIpv4(mapped)
436
+ value: parseIpv4$1(mapped)
437
437
  };
438
438
  const version = isIP(unwrapped.split("%", 1)[0] ?? unwrapped);
439
439
  if (version === 4) return {
440
440
  bits: 32,
441
- value: parseIpv4(unwrapped)
441
+ value: parseIpv4$1(unwrapped)
442
442
  };
443
443
  if (version === 6) return {
444
444
  bits: 128,
@@ -1079,6 +1079,46 @@ var MobileAccessGatewayController = class {
1079
1079
  }
1080
1080
  };
1081
1081
  //#endregion
1082
+ //#region src/local-admin-host.ts
1083
+ /** Browser-safe desktop-admin Host checks. Do not import Node APIs here. */
1084
+ function unwrapBrackets(hostname) {
1085
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1086
+ }
1087
+ function parseIpv4(hostname) {
1088
+ const parts = hostname.split(".");
1089
+ if (parts.length !== 4) return void 0;
1090
+ const octets = [];
1091
+ for (const part of parts) {
1092
+ if (!/^(?:0|[1-9]\d{0,2})$/u.test(part)) return void 0;
1093
+ const value = Number(part);
1094
+ if (value > 255) return void 0;
1095
+ octets.push(value);
1096
+ }
1097
+ return octets;
1098
+ }
1099
+ function isLoopbackIpv4(octets) {
1100
+ return octets[0] === 127;
1101
+ }
1102
+ function isPrivateIpv4(octets) {
1103
+ return octets[0] === 10 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31 || octets[0] === 192 && octets[1] === 168;
1104
+ }
1105
+ function isLinkLocalIpv4(octets) {
1106
+ return octets[0] === 169 && octets[1] === 254;
1107
+ }
1108
+ /**
1109
+ * Hostnames that may appear on the DSH desktop admin surface.
1110
+ * Loopback, RFC1918, and IPv4 link-local are accepted. Public IPs,
1111
+ * CGNAT, and arbitrary DNS names stay rejected so DNS rebinding
1112
+ * cannot reach `/api/mobile-access`.
1113
+ */
1114
+ function isLocalAdminHostname(hostname) {
1115
+ const host = unwrapBrackets(hostname).trim().toLowerCase();
1116
+ if (host === "localhost" || host === "::1") return true;
1117
+ const octets = parseIpv4(host);
1118
+ if (octets === void 0) return false;
1119
+ return isLoopbackIpv4(octets) || isPrivateIpv4(octets) || isLinkLocalIpv4(octets);
1120
+ }
1121
+ //#endregion
1082
1122
  //#region src/http-security.ts
1083
1123
  const DEVICE_COOKIE = "dsh_ma_device";
1084
1124
  const SESSION_COOKIE = "dsh_ma_session";
@@ -1122,26 +1162,43 @@ function parseRequestTarget(raw) {
1122
1162
  search: parsed.search
1123
1163
  });
1124
1164
  }
1125
- /** Set the gateway-owned browser protections and non-cacheability. */
1126
- function setSecurityHeaders(response, tls) {
1165
+ /**
1166
+ * The frame sources a proxied GUI document may embed: its own routes, blob:
1167
+ * (the PDF viewer's object URL) and external http(s) pages (the sidebar
1168
+ * browser tab). The framed document runs in the tab's existing sandbox
1169
+ * (opaque origin, no same-origin privileges), so this does not hand the
1170
+ * embedded page anything it did not already have.
1171
+ */
1172
+ const PROXIED_FRAME_SRC = "frame-src 'self' blob: https: http:";
1173
+ /**
1174
+ * Set the gateway browser protections and non-cacheability.
1175
+ * @param response - the response to decorate.
1176
+ * @param tls - whether the connection is TLS (adds HSTS).
1177
+ * @param framing - the framing policy; defaults to the strict gateway one, so
1178
+ * every existing call site keeps refusing to be framed. Only the two PROXIED
1179
+ * forwarders pass `'proxied'`.
1180
+ */
1181
+ function setSecurityHeaders(response, tls, framing = "gateway") {
1182
+ const proxied = framing === "proxied";
1127
1183
  response.setHeader("Cache-Control", "no-store");
1128
1184
  response.setHeader("Content-Security-Policy", [
1129
1185
  "default-src 'self'",
1130
1186
  "base-uri 'none'",
1131
1187
  "object-src 'none'",
1132
- "frame-ancestors 'none'",
1188
+ proxied ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
1133
1189
  "form-action 'self'",
1134
1190
  "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
1135
1191
  "style-src 'self' 'unsafe-inline'",
1136
1192
  "img-src 'self' data: blob:",
1137
1193
  "font-src 'self' data:",
1138
1194
  "connect-src 'self'",
1139
- "worker-src 'self' blob:"
1195
+ "worker-src 'self' blob:",
1196
+ ...proxied ? [PROXIED_FRAME_SRC] : []
1140
1197
  ].join("; "));
1141
1198
  response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
1142
1199
  response.setHeader("Referrer-Policy", "no-referrer");
1143
1200
  response.setHeader("X-Content-Type-Options", "nosniff");
1144
- response.setHeader("X-Frame-Options", "DENY");
1201
+ response.setHeader("X-Frame-Options", proxied ? "SAMEORIGIN" : "DENY");
1145
1202
  response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
1146
1203
  if (tls) response.setHeader("Strict-Transport-Security", "max-age=31536000");
1147
1204
  }
@@ -1238,7 +1295,7 @@ function localAuthority(header) {
1238
1295
  function assertLocalAdminTrust(request, requireBrowserOrigin) {
1239
1296
  if (request.socket.remoteAddress === void 0 || !isLoopbackAddress(request.socket.remoteAddress)) throw new HttpError(403, "forbidden");
1240
1297
  const host = localAuthority(request.headers.host);
1241
- if (host === void 0 || host.hostname !== "localhost" && !isLoopbackAddress(host.hostname)) throw new HttpError(403, "forbidden");
1298
+ if (host === void 0 || !isLocalAdminHostname(host.hostname)) throw new HttpError(403, "forbidden");
1242
1299
  const site = request.headers["sec-fetch-site"];
1243
1300
  if (site !== void 0 && site !== "same-origin" && site !== "none") throw new HttpError(403, "forbidden");
1244
1301
  const origin = request.headers.origin;
@@ -1249,7 +1306,8 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
1249
1306
  if (error instanceof HttpError) throw error;
1250
1307
  throw new HttpError(403, "forbidden");
1251
1308
  }
1252
- if (requireBrowserOrigin && site !== void 0 && (origin === void 0 || site !== "same-origin")) throw new HttpError(403, "forbidden");
1309
+ if (requireBrowserOrigin && origin === void 0) throw new HttpError(403, "forbidden");
1310
+ if (requireBrowserOrigin && site !== void 0 && site !== "same-origin") throw new HttpError(403, "forbidden");
1253
1311
  }
1254
1312
  //#endregion
1255
1313
  //#region src/version.ts
@@ -1641,6 +1699,12 @@ function combineSignalLifetime(first, second) {
1641
1699
  cleanup
1642
1700
  };
1643
1701
  }
1702
+ /** Validate and normalize an action input through either supported schema form. */
1703
+ function parseActionInput(schema, input) {
1704
+ if (schema === void 0) return input;
1705
+ if (typeof schema === "function") return schema(input);
1706
+ return schema.parse(input) ?? input;
1707
+ }
1644
1708
  /** Host registry and service consumed by both npm plugins and local extensions. */
1645
1709
  var MobileAccessService = class extends Service {
1646
1710
  registered = /* @__PURE__ */ new Map();
@@ -1772,9 +1836,9 @@ var MobileAccessService = class extends Service {
1772
1836
  if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
1773
1837
  const action = ("host" in extension ? extension.host : extension).actions?.[actionName];
1774
1838
  if (action === void 0) throw new MobileExtensionError("action_not_found", "action not found", 404);
1775
- let parsed = input;
1839
+ let parsed;
1776
1840
  try {
1777
- parsed = action.input?.parse(input) ?? input;
1841
+ parsed = parseActionInput(action.input, input);
1778
1842
  } catch {
1779
1843
  throw new MobileExtensionError("invalid_action_input", "action input is invalid", 400);
1780
1844
  }
@@ -3766,7 +3830,7 @@ var MobileAccessGateway = class {
3766
3830
  delete headers["content-length"];
3767
3831
  delete headers["content-encoding"];
3768
3832
  delete headers.etag;
3769
- setSecurityHeaders(response, this.tlsEnabled);
3833
+ setSecurityHeaders(response, this.tlsEnabled, "proxied");
3770
3834
  response.writeHead(200, {
3771
3835
  ...headers,
3772
3836
  "Content-Type": "text/html; charset=utf-8",
@@ -4018,7 +4082,7 @@ var MobileAccessGateway = class {
4018
4082
  }
4019
4083
  bodyDone.catch(reject);
4020
4084
  });
4021
- setSecurityHeaders(response, this.tlsEnabled);
4085
+ setSecurityHeaders(response, this.tlsEnabled, "proxied");
4022
4086
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
4023
4087
  const cacheControl = revisionedStaticCacheControl(request$2);
4024
4088
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
@@ -6283,11 +6347,11 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
6283
6347
  2. 电脑端能力 —— 手机需要读电脑文件、执行命令或访问硬件时,创建扩展:
6284
6348
  - 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
6285
6349
  - extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
6286
- - host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理
6350
+ - host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理。input 可以直接使用 api.schema.object(...) 等 Schemastery schema,也可以传入带 parse(value) 的适配器;两种形式都会在动作执行前校验并规范化输入。
6287
6351
  - mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
6288
6352
  - mobile.css:手机端样式(可选)
6289
6353
  - assets/:手机端静态资源(可选)
6290
- - mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action,api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
6354
+ - mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action(请求会按 application/json 发送),api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
6291
6355
  - 也可以先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改
6292
6356
 
6293
6357
  安全约束:
@@ -8803,6 +8867,8 @@ const VIRTUAL_INTERFACE_MARKERS = [
8803
8867
  "wsl",
8804
8868
  "zerotier"
8805
8869
  ];
8870
+ /** Route inspection is advisory; never let a system command hold setup open indefinitely. */
8871
+ const ROUTE_COMMAND_TIMEOUT_MS = 5e3;
8806
8872
  function requiredString(value, name) {
8807
8873
  if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
8808
8874
  return value;
@@ -8872,7 +8938,7 @@ function likelyVirtualInterface(name) {
8872
8938
  async function runRouteCommand(file, args) {
8873
8939
  return (await execFileText(file, [...args], {
8874
8940
  encoding: "utf8",
8875
- windowsHide: true
8941
+ timeout: ROUTE_COMMAND_TIMEOUT_MS
8876
8942
  })).stdout;
8877
8943
  }
8878
8944
  function uniqueLines(output) {