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/README.md +54 -2
- package/Sources/SimCameraHelper/build.sh +32 -0
- package/Sources/SimCameraHelper/main.m +859 -0
- package/Sources/SimCameraInjector/SimCameraInjector.m +1160 -0
- package/Sources/SimCameraInjector/build.sh +33 -0
- package/Sources/SimCameraInjector/include/SimCamShared.h +55 -0
- package/bin/serve-sim-bin +0 -0
- package/dist/middleware.js +25 -25
- package/dist/serve-sim.js +79 -32
- package/dist/simcam/libSimCameraInjector.dylib +0 -0
- package/dist/simcam/serve-sim-camera-helper +0 -0
- package/package.json +9 -2
- package/src/middleware.ts +104 -3
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serve-sim",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
949
|
-
//
|
|
950
|
-
//
|
|
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 ?? "";
|