serve-sim 0.1.23 → 0.1.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serve-sim",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -27,11 +27,18 @@
27
27
  "dist/serve-sim.js",
28
28
  "dist/middleware.js",
29
29
  "dist/middleware.cjs",
30
+ "dist/simcam/libSimCameraInjector.dylib",
31
+ "dist/simcam/serve-sim-camera-helper",
30
32
  "src/ax-shared.ts",
31
33
  "src/ax.ts",
32
34
  "src/middleware.ts",
33
35
  "src/state.ts",
34
- "bin/serve-sim-bin"
36
+ "bin/serve-sim-bin",
37
+ "Sources/SimCameraInjector/SimCameraInjector.m",
38
+ "Sources/SimCameraInjector/include/SimCamShared.h",
39
+ "Sources/SimCameraInjector/build.sh",
40
+ "Sources/SimCameraHelper/main.m",
41
+ "Sources/SimCameraHelper/build.sh"
35
42
  ],
36
43
  "exports": {
37
44
  "./middleware": {
package/src/middleware.ts CHANGED
@@ -3,6 +3,7 @@ import { execSync, spawn, exec, execFile, type ChildProcess, type ExecException
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { createServer as createNetServer } from "net";
6
+ import { randomBytes, timingSafeEqual } from "crypto";
6
7
  import type { IncomingMessage, ServerResponse } from "http";
7
8
  import { createAxStreamerCache } from "./ax";
8
9
 
@@ -383,6 +384,19 @@ function bridgeWsHost(_reqHost: string | undefined, bridgePort: number): string
383
384
  }
384
385
 
385
386
  let _html: string | null = null;
387
+ /**
388
+ * Best-effort absolute path to the running serve-sim entry script. Used so
389
+ * the in-page Camera tool can `node <path> camera ...` regardless of PATH.
390
+ * Falls back to the literal `serve-sim` if we can't determine a usable path.
391
+ */
392
+ function serveSimBinPath(): string {
393
+ try {
394
+ const argv = process.argv;
395
+ if (argv[1] && existsSync(argv[1])) return argv[1];
396
+ } catch {}
397
+ return "serve-sim";
398
+ }
399
+
386
400
  function loadHtml(): string {
387
401
  if (!_html) {
388
402
  _html = Buffer.from(__PREVIEW_HTML_B64__, "base64").toString("utf-8");
@@ -569,6 +583,27 @@ export interface SimMiddlewareOptions {
569
583
  basePath?: string;
570
584
  /** Pin this preview server to a specific simulator UDID. */
571
585
  device?: string;
586
+ /**
587
+ * Per-session bearer token gating the `/exec` shell-exec route.
588
+ * Auto-generated if omitted. The token is injected into the preview HTML
589
+ * so the in-page UI can call `/exec` same-origin; LAN attackers and
590
+ * cross-origin pages cannot read it.
591
+ */
592
+ execToken?: string;
593
+ }
594
+
595
+ function safeEqualString(a: string, b: string): boolean {
596
+ const ab = Buffer.from(a);
597
+ const bb = Buffer.from(b);
598
+ if (ab.length !== bb.length) return false;
599
+ return timingSafeEqual(ab, bb);
600
+ }
601
+
602
+ function isJsonContentType(value: string | undefined): boolean {
603
+ if (!value) return false;
604
+ // `application/json; charset=utf-8` etc. — only the media type matters.
605
+ const mediaType = value.split(";", 1)[0]!.trim().toLowerCase();
606
+ return mediaType === "application/json";
572
607
  }
573
608
 
574
609
  /**
@@ -582,6 +617,10 @@ export interface SimMiddlewareOptions {
582
617
  */
583
618
  export function simMiddleware(options?: SimMiddlewareOptions) {
584
619
  const base = (options?.basePath ?? "/.sim").replace(/\/+$/, "");
620
+ // Per-process random token. Anyone who can read the preview HTML same-origin
621
+ // can call /exec; cross-origin pages and LAN clients cannot, because they
622
+ // can't read this value (it's only injected into the preview page's config).
623
+ const execToken = options?.execToken ?? randomBytes(32).toString("base64url");
585
624
 
586
625
  return (req: SimReq, res: SimRes, next?: SimNext) => {
587
626
  const rawUrl: string = req.url ?? "";
@@ -630,6 +669,17 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
630
669
  const state = selectServeSimState(states, selectedDevice);
631
670
  let html = loadHtml();
632
671
 
672
+ if (!state) {
673
+ // Empty-state UI still polls /exec (boot/list helpers), so the page
674
+ // needs the bearer token even before a helper attaches. Inject a
675
+ // minimal config with just the basePath + token.
676
+ const minimal = JSON.stringify({ basePath: base, execToken });
677
+ html = html.replace(
678
+ "<!--__SIM_PREVIEW_CONFIG__-->",
679
+ `<script>window.__SIM_PREVIEW__=${minimal}</script>`,
680
+ );
681
+ }
682
+
633
683
  if (state) {
634
684
  // Pass real serve-sim URLs directly. The client parses the MJPEG
635
685
  // stream via fetch() (CORS is fine — serve-sim sends Access-Control-Allow-Origin: *)
@@ -642,11 +692,16 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
642
692
  appStateEndpoint: endpoint(base, "/appstate", state.device),
643
693
  axEndpoint: endpoint(base, "/ax", state.device),
644
694
  devtoolsEndpoint: endpoint(base, "/devtools", state.device),
695
+ // Forward the absolute path of the running serve-sim entry script
696
+ // so the in-page Camera tool can shell out via `node <bin> camera`
697
+ // without depending on `serve-sim` being on PATH.
698
+ serveSimBin: serveSimBinPath(),
645
699
  gridApiEndpoint: gridApiBase,
646
700
  gridStartEndpoint: gridApiBase + "/start",
647
701
  gridShutdownEndpoint: gridApiBase + "/shutdown",
648
702
  gridMemoryEndpoint: gridApiBase + "/memory",
649
703
  previewEndpoint: base === "" ? "/" : base,
704
+ execToken,
650
705
  });
651
706
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
652
707
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
@@ -945,15 +1000,61 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
945
1000
  return;
946
1001
  }
947
1002
 
948
- // POST /exec — run a shell command on the host. The preview server binds
949
- // to localhost only and is meant for local dev, so we shell through
950
- // /bin/sh and return stdout/stderr/exitCode.
1003
+ // POST /exec — run a shell command on the host. Gated by a per-process
1004
+ // bearer token injected only into the same-origin preview HTML, with
1005
+ // Content-Type + Origin checks to block CORS-simple CSRF (a malicious
1006
+ // page POSTing `text/plain` JSON to a dev server bound to a public iface)
1007
+ // and LAN attackers who can reach the port but can't read the token.
951
1008
  if ((url === base + "/exec" || url === base + "/exec/") && req.method === "POST") {
1009
+ // 1. Reject anything that isn't a JSON request, killing the
1010
+ // `enctype="text/plain"` CORS-simple form-POST path.
1011
+ if (!isJsonContentType(req.headers["content-type"])) {
1012
+ res.writeHead(415, { "Content-Type": "application/json" });
1013
+ res.end(JSON.stringify({ stdout: "", stderr: "Unsupported Media Type", exitCode: 1 }));
1014
+ return;
1015
+ }
1016
+ // 2. If the browser supplied an Origin, require it match this server.
1017
+ // Same-origin XHR from the preview page sets Origin to our own URL;
1018
+ // a cross-origin page's Origin won't match.
1019
+ const origin = req.headers.origin;
1020
+ if (origin) {
1021
+ try {
1022
+ const originHost = new URL(origin).host;
1023
+ if (originHost !== req.headers.host) {
1024
+ res.writeHead(403, { "Content-Type": "application/json" });
1025
+ res.end(JSON.stringify({ stdout: "", stderr: "Cross-origin request blocked", exitCode: 1 }));
1026
+ return;
1027
+ }
1028
+ } catch {
1029
+ res.writeHead(403, { "Content-Type": "application/json" });
1030
+ res.end(JSON.stringify({ stdout: "", stderr: "Invalid Origin", exitCode: 1 }));
1031
+ return;
1032
+ }
1033
+ }
1034
+ // 3. Require the per-session bearer token. Cross-origin pages cannot
1035
+ // read it from window.__SIM_PREVIEW__; non-browser callers must
1036
+ // have copied it from the CLI output.
1037
+ const authHeader = req.headers.authorization ?? "";
1038
+ const match = /^Bearer\s+(.+)$/i.exec(authHeader);
1039
+ if (!match || !safeEqualString(match[1]!.trim(), execToken)) {
1040
+ res.writeHead(401, { "Content-Type": "application/json" });
1041
+ res.end(JSON.stringify({ stdout: "", stderr: "Unauthorized", exitCode: 1 }));
1042
+ return;
1043
+ }
952
1044
  let body = "";
1045
+ let aborted = false;
953
1046
  req.on("data", (chunk: Buffer | string) => {
954
1047
  body += typeof chunk === "string" ? chunk : chunk.toString();
1048
+ // Cheap belt-and-braces cap so a runaway POST can't OOM the dev server.
1049
+ if (body.length > 4 * 1024 * 1024) {
1050
+ aborted = true;
1051
+ res.writeHead(413, { "Content-Type": "application/json" });
1052
+ res.end(JSON.stringify({ stdout: "", stderr: "Payload Too Large", exitCode: 1 }));
1053
+ req.destroy();
1054
+ }
955
1055
  });
956
1056
  req.on("end", () => {
1057
+ if (aborted) return;
957
1058
  let command = "";
958
1059
  try {
959
1060
  command = (JSON.parse(body) as ExecRequestBody).command ?? "";