serve-sim 0.1.24 → 0.1.26

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serve-sim",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -35,6 +35,8 @@
35
35
  "src/state.ts",
36
36
  "bin/serve-sim-bin",
37
37
  "Sources/SimCameraInjector/SimCameraInjector.m",
38
+ "Sources/SimCameraInjector/SimCam*.h",
39
+ "Sources/SimCameraInjector/SimCam*.m",
38
40
  "Sources/SimCameraInjector/include/SimCamShared.h",
39
41
  "Sources/SimCameraInjector/build.sh",
40
42
  "Sources/SimCameraHelper/main.m",
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
 
@@ -249,6 +250,43 @@ function endpoint(base: string, path: string, device: string): string {
249
250
  return `${value}?device=${encodeURIComponent(device)}`;
250
251
  }
251
252
 
253
+ export function previewConfigForState(
254
+ state: ServeSimState,
255
+ base: string,
256
+ serveSimBin: string,
257
+ execToken: string,
258
+ ): ServeSimState & {
259
+ basePath: string;
260
+ logsEndpoint: string;
261
+ appStateEndpoint: string;
262
+ axEndpoint: string;
263
+ devtoolsEndpoint: string;
264
+ serveSimBin: string;
265
+ gridApiEndpoint: string;
266
+ gridStartEndpoint: string;
267
+ gridShutdownEndpoint: string;
268
+ gridMemoryEndpoint: string;
269
+ previewEndpoint: string;
270
+ execToken: string;
271
+ } {
272
+ const gridApiBase = (base === "" ? "" : base) + "/grid/api";
273
+ return {
274
+ ...state,
275
+ basePath: base,
276
+ logsEndpoint: endpoint(base, "/logs", state.device),
277
+ appStateEndpoint: endpoint(base, "/appstate", state.device),
278
+ axEndpoint: endpoint(base, "/ax", state.device),
279
+ devtoolsEndpoint: endpoint(base, "/devtools", state.device),
280
+ serveSimBin,
281
+ gridApiEndpoint: gridApiBase,
282
+ gridStartEndpoint: gridApiBase + "/start",
283
+ gridShutdownEndpoint: gridApiBase + "/shutdown",
284
+ gridMemoryEndpoint: gridApiBase + "/memory",
285
+ previewEndpoint: base === "" ? "/" : base,
286
+ execToken,
287
+ };
288
+ }
289
+
252
290
  async function isLocalPortFree(port: number): Promise<boolean> {
253
291
  return new Promise((resolve) => {
254
292
  const server = createNetServer();
@@ -582,6 +620,27 @@ export interface SimMiddlewareOptions {
582
620
  basePath?: string;
583
621
  /** Pin this preview server to a specific simulator UDID. */
584
622
  device?: string;
623
+ /**
624
+ * Per-session bearer token gating the `/exec` shell-exec route.
625
+ * Auto-generated if omitted. The token is injected into the preview HTML
626
+ * so the in-page UI can call `/exec` same-origin; LAN attackers and
627
+ * cross-origin pages cannot read it.
628
+ */
629
+ execToken?: string;
630
+ }
631
+
632
+ function safeEqualString(a: string, b: string): boolean {
633
+ const ab = Buffer.from(a);
634
+ const bb = Buffer.from(b);
635
+ if (ab.length !== bb.length) return false;
636
+ return timingSafeEqual(ab, bb);
637
+ }
638
+
639
+ function isJsonContentType(value: string | undefined): boolean {
640
+ if (!value) return false;
641
+ // `application/json; charset=utf-8` etc. — only the media type matters.
642
+ const mediaType = value.split(";", 1)[0]!.trim().toLowerCase();
643
+ return mediaType === "application/json";
585
644
  }
586
645
 
587
646
  /**
@@ -595,6 +654,10 @@ export interface SimMiddlewareOptions {
595
654
  */
596
655
  export function simMiddleware(options?: SimMiddlewareOptions) {
597
656
  const base = (options?.basePath ?? "/.sim").replace(/\/+$/, "");
657
+ // Per-process random token. Anyone who can read the preview HTML same-origin
658
+ // can call /exec; cross-origin pages and LAN clients cannot, because they
659
+ // can't read this value (it's only injected into the preview page's config).
660
+ const execToken = options?.execToken ?? randomBytes(32).toString("base64url");
598
661
 
599
662
  return (req: SimReq, res: SimRes, next?: SimNext) => {
600
663
  const rawUrl: string = req.url ?? "";
@@ -643,28 +706,19 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
643
706
  const state = selectServeSimState(states, selectedDevice);
644
707
  let html = loadHtml();
645
708
 
709
+ if (!state) {
710
+ // Empty-state UI still polls /exec (boot/list helpers), so the page
711
+ // needs the bearer token even before a helper attaches. Inject a
712
+ // minimal config with just the basePath + token.
713
+ const minimal = JSON.stringify({ basePath: base, execToken });
714
+ html = html.replace(
715
+ "<!--__SIM_PREVIEW_CONFIG__-->",
716
+ `<script>window.__SIM_PREVIEW__=${minimal}</script>`,
717
+ );
718
+ }
719
+
646
720
  if (state) {
647
- // Pass real serve-sim URLs directly. The client parses the MJPEG
648
- // stream via fetch() (CORS is fine — serve-sim sends Access-Control-Allow-Origin: *)
649
- // and connects to the WS directly (WS has no CORS).
650
- const gridApiBase = (base === "" ? "" : base) + "/grid/api";
651
- const config = JSON.stringify({
652
- ...state,
653
- basePath: base,
654
- logsEndpoint: endpoint(base, "/logs", state.device),
655
- appStateEndpoint: endpoint(base, "/appstate", state.device),
656
- axEndpoint: endpoint(base, "/ax", state.device),
657
- devtoolsEndpoint: endpoint(base, "/devtools", state.device),
658
- // Forward the absolute path of the running serve-sim entry script
659
- // so the in-page Camera tool can shell out via `node <bin> camera`
660
- // without depending on `serve-sim` being on PATH.
661
- serveSimBin: serveSimBinPath(),
662
- gridApiEndpoint: gridApiBase,
663
- gridStartEndpoint: gridApiBase + "/start",
664
- gridShutdownEndpoint: gridApiBase + "/shutdown",
665
- gridMemoryEndpoint: gridApiBase + "/memory",
666
- previewEndpoint: base === "" ? "/" : base,
667
- });
721
+ const config = JSON.stringify(previewConfigForState(state, base, serveSimBinPath(), execToken));
668
722
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
669
723
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
670
724
  }
@@ -936,7 +990,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
936
990
  "Content-Type": "application/json",
937
991
  "Cache-Control": "no-store",
938
992
  });
939
- res.end(JSON.stringify(state || null));
993
+ res.end(JSON.stringify(state ? previewConfigForState(state, base, serveSimBinPath(), execToken) : null));
940
994
  return;
941
995
  }
942
996
 
@@ -962,15 +1016,61 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
962
1016
  return;
963
1017
  }
964
1018
 
965
- // POST /exec — run a shell command on the host. The preview server binds
966
- // to localhost only and is meant for local dev, so we shell through
967
- // /bin/sh and return stdout/stderr/exitCode.
1019
+ // POST /exec — run a shell command on the host. Gated by a per-process
1020
+ // bearer token injected only into the same-origin preview HTML, with
1021
+ // Content-Type + Origin checks to block CORS-simple CSRF (a malicious
1022
+ // page POSTing `text/plain` JSON to a dev server bound to a public iface)
1023
+ // and LAN attackers who can reach the port but can't read the token.
968
1024
  if ((url === base + "/exec" || url === base + "/exec/") && req.method === "POST") {
1025
+ // 1. Reject anything that isn't a JSON request, killing the
1026
+ // `enctype="text/plain"` CORS-simple form-POST path.
1027
+ if (!isJsonContentType(req.headers["content-type"])) {
1028
+ res.writeHead(415, { "Content-Type": "application/json" });
1029
+ res.end(JSON.stringify({ stdout: "", stderr: "Unsupported Media Type", exitCode: 1 }));
1030
+ return;
1031
+ }
1032
+ // 2. If the browser supplied an Origin, require it match this server.
1033
+ // Same-origin XHR from the preview page sets Origin to our own URL;
1034
+ // a cross-origin page's Origin won't match.
1035
+ const origin = req.headers.origin;
1036
+ if (origin) {
1037
+ try {
1038
+ const originHost = new URL(origin).host;
1039
+ if (originHost !== req.headers.host) {
1040
+ res.writeHead(403, { "Content-Type": "application/json" });
1041
+ res.end(JSON.stringify({ stdout: "", stderr: "Cross-origin request blocked", exitCode: 1 }));
1042
+ return;
1043
+ }
1044
+ } catch {
1045
+ res.writeHead(403, { "Content-Type": "application/json" });
1046
+ res.end(JSON.stringify({ stdout: "", stderr: "Invalid Origin", exitCode: 1 }));
1047
+ return;
1048
+ }
1049
+ }
1050
+ // 3. Require the per-session bearer token. Cross-origin pages cannot
1051
+ // read it from window.__SIM_PREVIEW__; non-browser callers must
1052
+ // have copied it from the CLI output.
1053
+ const authHeader = req.headers.authorization ?? "";
1054
+ const match = /^Bearer\s+(.+)$/i.exec(authHeader);
1055
+ if (!match || !safeEqualString(match[1]!.trim(), execToken)) {
1056
+ res.writeHead(401, { "Content-Type": "application/json" });
1057
+ res.end(JSON.stringify({ stdout: "", stderr: "Unauthorized", exitCode: 1 }));
1058
+ return;
1059
+ }
969
1060
  let body = "";
1061
+ let aborted = false;
970
1062
  req.on("data", (chunk: Buffer | string) => {
971
1063
  body += typeof chunk === "string" ? chunk : chunk.toString();
1064
+ // Cheap belt-and-braces cap so a runaway POST can't OOM the dev server.
1065
+ if (body.length > 4 * 1024 * 1024) {
1066
+ aborted = true;
1067
+ res.writeHead(413, { "Content-Type": "application/json" });
1068
+ res.end(JSON.stringify({ stdout: "", stderr: "Payload Too Large", exitCode: 1 }));
1069
+ req.destroy();
1070
+ }
972
1071
  });
973
1072
  req.on("end", () => {
1073
+ if (aborted) return;
974
1074
  let command = "";
975
1075
  try {
976
1076
  command = (JSON.parse(body) as ExecRequestBody).command ?? "";