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/README.md +1 -1
- package/bin/serve-sim-bin +0 -0
- package/dist/middleware.js +25 -25
- package/dist/serve-sim.js +37 -34
- package/package.json +1 -1
- package/src/middleware.ts +87 -3
package/package.json
CHANGED
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.
|
|
966
|
-
//
|
|
967
|
-
//
|
|
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 ?? "";
|