serve-sim 0.1.24 → 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.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
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
 
@@ -582,6 +583,27 @@ export interface SimMiddlewareOptions {
582
583
  basePath?: string;
583
584
  /** Pin this preview server to a specific simulator UDID. */
584
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";
585
607
  }
586
608
 
587
609
  /**
@@ -595,6 +617,10 @@ export interface SimMiddlewareOptions {
595
617
  */
596
618
  export function simMiddleware(options?: SimMiddlewareOptions) {
597
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");
598
624
 
599
625
  return (req: SimReq, res: SimRes, next?: SimNext) => {
600
626
  const rawUrl: string = req.url ?? "";
@@ -643,6 +669,17 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
643
669
  const state = selectServeSimState(states, selectedDevice);
644
670
  let html = loadHtml();
645
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
+
646
683
  if (state) {
647
684
  // Pass real serve-sim URLs directly. The client parses the MJPEG
648
685
  // stream via fetch() (CORS is fine — serve-sim sends Access-Control-Allow-Origin: *)
@@ -664,6 +701,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
664
701
  gridShutdownEndpoint: gridApiBase + "/shutdown",
665
702
  gridMemoryEndpoint: gridApiBase + "/memory",
666
703
  previewEndpoint: base === "" ? "/" : base,
704
+ execToken,
667
705
  });
668
706
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
669
707
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
@@ -962,15 +1000,61 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
962
1000
  return;
963
1001
  }
964
1002
 
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.
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.
968
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
+ }
969
1044
  let body = "";
1045
+ let aborted = false;
970
1046
  req.on("data", (chunk: Buffer | string) => {
971
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
+ }
972
1055
  });
973
1056
  req.on("end", () => {
1057
+ if (aborted) return;
974
1058
  let command = "";
975
1059
  try {
976
1060
  command = (JSON.parse(body) as ExecRequestBody).command ?? "";