@jxsuite/server 1.1.1 → 1.3.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Shenk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,21 +35,21 @@
35
35
  "test:coverage": "bun test --isolate --coverage"
36
36
  },
37
37
  "dependencies": {
38
- "@jxsuite/collab": "^0.4.1",
39
- "@jxsuite/compiler": "^1.2.0",
40
- "@jxsuite/create": "^1.1.1",
41
- "@jxsuite/import": "^0.37.1",
42
- "@jxsuite/protocol": "^0.5.1",
43
- "@jxsuite/runtime": "^1.2.0",
44
- "@jxsuite/schema": "^1.2.0",
45
- "@jxsuite/starters": "^1.1.1",
38
+ "@jxsuite/collab": "^0.5.1",
39
+ "@jxsuite/compiler": "^1.4.0",
40
+ "@jxsuite/create": "^1.2.1",
41
+ "@jxsuite/import": "^0.38.1",
42
+ "@jxsuite/protocol": "^0.6.1",
43
+ "@jxsuite/runtime": "^1.3.1",
44
+ "@jxsuite/schema": "^1.4.0",
45
+ "@jxsuite/starters": "^1.2.1",
46
46
  "chokidar": "^5.0.0",
47
47
  "kysely": "^0.29.4",
48
48
  "zod": "^4.4.3"
49
49
  },
50
50
  "devDependencies": {
51
- "@jxsuite/auth": "^0.3.1",
52
- "@jxsuite/connector": "^0.3.1",
53
- "@jxsuite/parser": "^1.2.0"
51
+ "@jxsuite/auth": "^0.4.1",
52
+ "@jxsuite/connector": "^0.4.1",
53
+ "@jxsuite/parser": "^1.4.0"
54
54
  }
55
55
  }
package/src/ai-api.ts CHANGED
@@ -7,13 +7,15 @@
7
7
  *
8
8
  * API key flow:
9
9
  * 1. Request header X-Api-Key or Authorization: Bearer <key>
10
- * 2. Fallback: OPENAI_API_KEY env var
10
+ * 2. Fallback: OPENAI_API_KEY env var — attached ONLY to the env/default base URL, never to a
11
+ * caller-supplied X-Api-Base-URL (prevents exfiltrating the server key to a chosen endpoint)
11
12
  * 3. If neither → 401 with error message
12
13
  *
13
14
  * Base URL flow:
14
- * 1. Request header X-Api-Base-URL
15
+ * 1. Request header X-Api-Base-URL — allowed only alongside a header API key
15
16
  * 2. Fallback: OPENAI_BASE_URL env var
16
17
  * 3. Default: https://api.openai.com/v1
18
+ * A base URL resolving to a cloud metadata / link-local host is refused (SSRF defense).
17
19
  *
18
20
  * @license MIT
19
21
  */
@@ -46,31 +48,88 @@ interface ModelEntry {
46
48
  owned_by?: string;
47
49
  }
48
50
 
49
- function getConfig(req: Request): { apiKey: string | null; baseUrl: string; missingKey: boolean } {
51
+ /**
52
+ * SSRF defense: refuse to proxy to a cloud metadata endpoint or a link-local host. Loopback and
53
+ * private-LAN hosts are intentionally allowed — self-hosted / local LLMs run there. An unparseable
54
+ * base URL is blocked. `169.254.169.254` (the AWS/GCP/Azure metadata IP) lives in the link-local
55
+ * `169.254.0.0/16` range, which is the primary thing this stops.
56
+ */
57
+ function isBlockedHost(rawUrl: string): boolean {
58
+ let host: string;
59
+ try {
60
+ host = new URL(rawUrl).hostname.toLowerCase();
61
+ } catch {
62
+ return true;
63
+ }
64
+ const h = host.startsWith("[") ? host.slice(1, -1) : host;
65
+ if (h === "metadata.google.internal") {
66
+ return true;
67
+ }
68
+ if (h.startsWith("169.254.")) {
69
+ return true; // IPv4 link-local, incl. the cloud metadata IP
70
+ }
71
+ if (h.startsWith("fe80:")) {
72
+ return true; // IPv6 link-local
73
+ }
74
+ return false;
75
+ }
76
+
77
+ interface AiConfig {
78
+ apiKey: string | null;
79
+ baseUrl: string;
80
+ missingKey: boolean;
81
+ reject?: { status: number; message: string };
82
+ }
83
+
84
+ function getConfig(req: Request): AiConfig {
50
85
  const authHeader = req.headers.get("Authorization") || "";
51
86
  const apiKeyHeader = req.headers.get("X-Api-Key") || "";
52
87
  const baseUrlHeader = req.headers.get("X-Api-Base-URL") || "";
53
88
 
54
- let apiKey = null;
55
-
56
- // Check Authorization: Bearer <key>
89
+ // Track the PROVENANCE of the key: a header-supplied key may ride a caller's custom base URL; the
90
+ // Server's env key never leaves for a caller-chosen endpoint (key-exfiltration defense).
91
+ let apiKey: string | null = null;
92
+ let keyFromHeader = false;
57
93
  if (authHeader.startsWith("Bearer ")) {
58
94
  apiKey = authHeader.slice(7).trim();
95
+ keyFromHeader = true;
59
96
  }
60
-
61
- // Check X-Api-Key header (overrides Bearer)
62
97
  if (apiKeyHeader) {
63
- apiKey = apiKeyHeader.trim();
98
+ apiKey = apiKeyHeader.trim(); // X-Api-Key overrides Bearer
99
+ keyFromHeader = true;
64
100
  }
65
101
 
66
- // Check env var (fallback)
102
+ const baseFromHeader = Boolean(baseUrlHeader);
103
+ const baseUrl = baseUrlHeader || process.env.OPENAI_BASE_URL || DEFAULT_BASE_URL;
104
+
105
+ if (isBlockedHost(baseUrl)) {
106
+ return {
107
+ apiKey: null,
108
+ baseUrl,
109
+ missingKey: true,
110
+ reject: { status: 403, message: "Base URL host is not permitted." },
111
+ };
112
+ }
113
+
114
+ // A caller-supplied base URL requires a caller-supplied key. Refuse to forward the env
115
+ // OPENAI_API_KEY to an endpoint the request chose.
116
+ if (baseFromHeader && !keyFromHeader) {
117
+ return {
118
+ apiKey: null,
119
+ baseUrl,
120
+ missingKey: true,
121
+ reject: {
122
+ status: 401,
123
+ message: "A custom base URL requires an explicit API key (X-Api-Key).",
124
+ },
125
+ };
126
+ }
127
+
128
+ // Env key is only ever attached to the env/default base URL.
67
129
  if (!apiKey && process.env.OPENAI_API_KEY) {
68
130
  apiKey = process.env.OPENAI_API_KEY;
69
131
  }
70
132
 
71
- // Check base URL
72
- const baseUrl = baseUrlHeader || process.env.OPENAI_BASE_URL || DEFAULT_BASE_URL;
73
-
74
133
  return { apiKey, baseUrl, missingKey: !apiKey };
75
134
  }
76
135
 
@@ -97,7 +156,10 @@ function jsonError(status: number, message: string): Response {
97
156
 
98
157
  /** Handle POST /__studio/ai/chat — proxy chat completions to OpenAI via SSE. */
99
158
  export async function handleChat(req: Request): Promise<Response> {
100
- const { apiKey, baseUrl, missingKey } = getConfig(req);
159
+ const { apiKey, baseUrl, missingKey, reject } = getConfig(req);
160
+ if (reject) {
161
+ return jsonError(reject.status, reject.message);
162
+ }
101
163
  if (missingKey) {
102
164
  return jsonError(
103
165
  401,
@@ -374,7 +436,10 @@ export async function handleChat(req: Request): Promise<Response> {
374
436
  * key is available.
375
437
  */
376
438
  export async function handleModels(req: Request): Promise<Response> {
377
- const { apiKey, baseUrl, missingKey } = getConfig(req);
439
+ const { apiKey, baseUrl, missingKey, reject } = getConfig(req);
440
+ if (reject) {
441
+ return jsonError(reject.status, reject.message);
442
+ }
378
443
 
379
444
  // No key available → return hardcoded defaults so the UI can at least render.
380
445
  if (missingKey) {
package/src/code-api.ts CHANGED
@@ -7,7 +7,8 @@
7
7
 
8
8
  import { tmpdir } from "node:os";
9
9
  import { errorMessage } from "@jxsuite/schema/parse";
10
- import { join, resolve } from "node:path";
10
+ import { dirname, join } from "node:path";
11
+ import { existsSync } from "node:fs";
11
12
  import { unlink } from "node:fs/promises";
12
13
  import { format } from "oxfmt";
13
14
 
@@ -26,11 +27,32 @@ interface CodeApiBody {
26
27
  args?: string[];
27
28
  }
28
29
 
29
- const OXLINT_BIN = resolve(
30
- import.meta.dir,
31
- "../../node_modules/.bin",
32
- process.platform === "win32" ? "oxlint.exe" : "oxlint",
33
- );
30
+ const OXLINT_NAME = process.platform === "win32" ? "oxlint.exe" : "oxlint";
31
+
32
+ /**
33
+ * Locate the oxlint CLI: explicit JX_OXLINT_BIN override (empty value = disable lint outright),
34
+ * then a walk up from this module probing node_modules/.bin (finds the workspace root in the
35
+ * monorepo and the hoisted bin for npm consumers), then PATH. Null when oxlint is unavailable —
36
+ * e.g. the packaged desktop app, where the lint endpoint quietly returns no diagnostics.
37
+ */
38
+ export function resolveOxlintBin(startDir: string = import.meta.dir): string | null {
39
+ const override = process.env.JX_OXLINT_BIN;
40
+ if (override !== undefined) {
41
+ return override || null;
42
+ }
43
+ let dir = startDir;
44
+ for (;;) {
45
+ const candidate = join(dir, "node_modules", ".bin", OXLINT_NAME);
46
+ if (existsSync(candidate)) {
47
+ return candidate;
48
+ }
49
+ const parent = dirname(dir);
50
+ if (parent === dir) {
51
+ return Bun.which("oxlint", { PATH: process.env.PATH ?? "" });
52
+ }
53
+ dir = parent;
54
+ }
55
+ }
34
56
 
35
57
  // ─── Wrapper utilities ───────────────────────────────────────────────────────
36
58
 
@@ -151,6 +173,12 @@ export async function handleCodeApi(req: Request, url: URL) {
151
173
  return Response.json({ diagnostics: [] });
152
174
  }
153
175
 
176
+ const oxlintBin = resolveOxlintBin();
177
+ if (!oxlintBin) {
178
+ // No oxlint anywhere (packaged desktop app) — lint quietly disables.
179
+ return Response.json({ diagnostics: [] });
180
+ }
181
+
154
182
  const wrapped = wrapBody(code, args);
155
183
  const headerLen = wrapped.indexOf("\n") + 1;
156
184
  const tmpFile = join(
@@ -160,7 +188,7 @@ export async function handleCodeApi(req: Request, url: URL) {
160
188
 
161
189
  try {
162
190
  await Bun.write(tmpFile, wrapped);
163
- const proc = Bun.spawn([OXLINT_BIN, "--format=json", "-A", "no-unused-vars", tmpFile], {
191
+ const proc = Bun.spawn([oxlintBin, "--format=json", "-A", "no-unused-vars", tmpFile], {
164
192
  stderr: "pipe",
165
193
  stdout: "pipe",
166
194
  });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Shared network/path security primitives for the server package.
3
+ *
4
+ * These were originally private to `project-server.ts` (the hardened, token-gated desktop server).
5
+ * They are extracted here so the plain dev server (`server.ts`) can reuse the SAME loopback checks,
6
+ * realpath containment, and URL-decode hardening instead of its previous string-prefix logic.
7
+ *
8
+ * Security model. Loopback bind (127.0.0.1) is the primary control: LAN pages and other local
9
+ * processes cannot read a loopback page's location, so cross-origin requests are the realistic
10
+ * threat. `originHostGate` and `loopbackGate` are the request-level gate that rejects a
11
+ * non-loopback Origin or a non-loopback Host (anti-CSRF and anti-DNS-rebinding); `loopbackGate`
12
+ * additionally checks a token, which the desktop server's cross-origin canvas iframe needs but the
13
+ * same-origin dev server does not. `containedPath`, `serveContained`, and `serveProjectFile`
14
+ * contain a filesystem path within a root via a lexical relative() check plus a realpath re-check
15
+ * (symlink escape defense). `decodeAndNormalizePath` decodes the URL pathname once and rejects
16
+ * over-encoded dot and slash sequences.
17
+ */
18
+
19
+ import { isAbsolute, relative, resolve, sep } from "node:path";
20
+ import { realpathSync } from "node:fs";
21
+ import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
22
+ import type { AssetMount } from "@jxsuite/schema/asset-paths";
23
+
24
+ export const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
25
+
26
+ /** True when a hostname (optionally with :port) is a loopback host. */
27
+ export function isLoopbackHost(host: string | null | undefined): boolean {
28
+ if (!host) {
29
+ return false;
30
+ }
31
+ // Strip a trailing :port (but keep bracketed IPv6 intact for the literal set).
32
+ let h = host;
33
+ if (h.startsWith("[")) {
34
+ const end = h.indexOf("]");
35
+ h = end === -1 ? h : h.slice(0, end + 1);
36
+ } else {
37
+ const colon = h.indexOf(":");
38
+ if (colon !== -1) {
39
+ h = h.slice(0, colon);
40
+ }
41
+ }
42
+ return LOOPBACK_HOSTS.has(h.toLowerCase());
43
+ }
44
+
45
+ /**
46
+ * Accept when the Origin's host is loopback OR Origin is absent (Bun-native / test clients send no
47
+ * Origin). Do NOT hardcode-match one literal origin — localhost and 127.0.0.1 are distinct origins,
48
+ * and a too-strict check would 403 legitimate clients.
49
+ */
50
+ export function originIsLoopbackOrAbsent(req: Request): boolean {
51
+ const origin = req.headers.get("origin");
52
+ if (!origin) {
53
+ return true;
54
+ }
55
+ try {
56
+ return isLoopbackHost(new URL(origin).hostname);
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Reject a Host header that is present and NOT loopback (defeats DNS-rebinding, where Host is the
64
+ * attacker domain). An absent Host (Bun-native clients) is accepted.
65
+ */
66
+ export function hostIsLoopbackOrAbsent(req: Request): boolean {
67
+ const host = req.headers.get("host");
68
+ if (!host) {
69
+ return true;
70
+ }
71
+ return isLoopbackHost(host);
72
+ }
73
+
74
+ /**
75
+ * Origin/Host-only request gate (no token). Returns a 403 Response when the request is not
76
+ * loopback-safe, else null. Suitable for a same-origin dev server: the browser sends a loopback
77
+ * Origin on cross-origin POSTs, so this closes CSRF + DNS-rebinding without a shared token.
78
+ */
79
+ export function originHostGate(req: Request): Response | null {
80
+ if (!originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
81
+ return new Response("Forbidden", { status: 403 });
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Token + Origin/Host gate. Returns a 403 Response when the token mismatches OR the request is not
88
+ * loopback-safe, else null. Used by the cross-origin desktop server whose canvas iframe carries the
89
+ * token in the URL. Pass `token: null` to skip the token check (Origin/Host only).
90
+ */
91
+ export function loopbackGate(req: Request, url: URL, token: string | null): Response | null {
92
+ if (token !== null && url.searchParams.get("token") !== token) {
93
+ return new Response("Forbidden", { status: 403 });
94
+ }
95
+ return originHostGate(req);
96
+ }
97
+
98
+ /** Normalize a path for cross-platform containment comparison (separators + case on Windows). */
99
+ export function normalizeForCompare(p: string): string {
100
+ const slashed = p.replaceAll("\\", "/");
101
+ return process.platform === "win32" ? slashed.toLowerCase() : slashed;
102
+ }
103
+
104
+ /**
105
+ * Contain `absPath` within `root`: a lexical relative() check followed by a realpath re-check so a
106
+ * symlink inside the tree cannot point outside it. Returns the (possibly realpath'd) absolute path
107
+ * when contained, or null otherwise. The caller must already have decoded the URL pathname.
108
+ */
109
+ export function containedPath(absPath: string, root: string): string | null {
110
+ // (1) Lexical containment.
111
+ const rel = relative(root, absPath);
112
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
113
+ return null;
114
+ }
115
+ // (2) realpath BOTH and re-check the realpath is still under realRoot (symlink containment).
116
+ let realRoot: string;
117
+ let realPath: string;
118
+ try {
119
+ realRoot = realpathSync(root);
120
+ } catch {
121
+ // Root itself is unresolvable: fall back to lexical-only containment.
122
+ return absPath;
123
+ }
124
+ try {
125
+ realPath = realpathSync(absPath);
126
+ } catch {
127
+ // Target does not exist yet — lexical containment already passed; let the caller's existence
128
+ // Check decide. (Reads will 404; the write-path resolves its own parent separately.)
129
+ return absPath;
130
+ }
131
+ const nRoot = normalizeForCompare(realRoot);
132
+ const nPath = normalizeForCompare(realPath);
133
+ if (nPath === nRoot || nPath.startsWith(nRoot.endsWith("/") ? nRoot : `${nRoot}/`)) {
134
+ return realPath;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Serve a file under `root` if it both exists and is contained. Returns the Response, or null when
141
+ * missing/traversed (caller decides the fallthrough status).
142
+ */
143
+ export async function serveContained(absPath: string, root: string): Promise<Response | null> {
144
+ const safe = containedPath(absPath, root);
145
+ if (!safe) {
146
+ return null;
147
+ }
148
+ const file = Bun.file(safe);
149
+ if (await file.exists()) {
150
+ return new Response(file);
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Try to serve a project file at its natural URL: an extension asset mount, then
157
+ * absolute-under-root, then root-relative, then public/. Each candidate goes through containedPath
158
+ * (realpath). `decodedPath` is the once-decoded URL pathname (leading slash, forward slashes).
159
+ *
160
+ * Mounts come first and are contained against their own directory rather than the project root:
161
+ * they exist precisely to publish directories that may sit outside it (extensions.md §8.5).
162
+ */
163
+ export async function serveProjectFile(
164
+ decodedPath: string,
165
+ root: string,
166
+ mounts: readonly AssetMount[] = [],
167
+ ): Promise<Response | null> {
168
+ for (const mount of [...mounts].toSorted((a, b) => b.urlPrefix.length - a.urlPrefix.length)) {
169
+ const mounted = resolveAssetUrl([mount], decodedPath);
170
+ if (!mounted) {
171
+ continue;
172
+ }
173
+ const res = await serveContained(mounted, mount.dir);
174
+ if (res) {
175
+ return res;
176
+ }
177
+ }
178
+ // Map the URL pathname back to a filesystem path. On Windows the browser requests an absolute
179
+ // Path as /C:/Users/… (leading slash + forward slashes); drop the slash before the drive letter.
180
+ // A POSIX absolute path arrives as //abs/path.
181
+ const fsPath = decodedPath.startsWith("//")
182
+ ? decodedPath.slice(1)
183
+ : decodedPath.replace(/^\/([A-Za-z]:)/, "$1");
184
+
185
+ // 1. Absolute path that falls under the project root.
186
+ if (normalizeForCompare(fsPath).startsWith(normalizeForCompare(root))) {
187
+ const res = await serveContained(fsPath, root);
188
+ if (res) {
189
+ return res;
190
+ }
191
+ }
192
+
193
+ // 2. Root-relative.
194
+ const relRes = await serveContained(resolve(root, `.${decodedPath}`), root);
195
+ if (relRes) {
196
+ return relRes;
197
+ }
198
+
199
+ // 3. public/ subdirectory.
200
+ const pubRes = await serveContained(resolve(root, "public", `.${decodedPath}`), root);
201
+ if (pubRes) {
202
+ return pubRes;
203
+ }
204
+
205
+ return null;
206
+ }
207
+
208
+ /** Result of decoding a URL pathname: the safe decoded/normalized path, or a rejection Response. */
209
+ export type DecodedPath = { path: string; normPath: string } | { reject: Response };
210
+
211
+ /**
212
+ * Decode a URL pathname ONCE and reject over-encoding bypasses. Returns `{ path, normPath }` where
213
+ * `path` is the once-decoded pathname and `normPath` collapses runs of leading slashes to one, or
214
+ * `{ reject }` with a 400/404 Response when the input is malformed or still contains an encoded
215
+ * dot/slash after one decode.
216
+ */
217
+ export function decodeAndNormalizePath(url: URL): DecodedPath {
218
+ let path: string;
219
+ try {
220
+ path = decodeURIComponent(url.pathname);
221
+ } catch {
222
+ return { reject: new Response("Bad request", { status: 400 }) };
223
+ }
224
+ if (/%2e|%2f/i.test(path)) {
225
+ return { reject: new Response("Not found", { status: 404 }) };
226
+ }
227
+ const normPath = path.replace(/^\/{2,}/, "/");
228
+ return { path, normPath };
229
+ }
@@ -25,14 +25,19 @@
25
25
  * realpath re-check (symlink containment).
26
26
  */
27
27
 
28
- import { isAbsolute, relative, resolve, sep } from "node:path";
29
- import { realpathSync } from "node:fs";
28
+ import { resolve, sep } from "node:path";
30
29
  import { handleJxMounts } from "./jx-mounts.ts";
31
- import { handleResolve, handleServerFunction } from "./resolve.ts";
30
+ import { handleResolve, handleServerFunction, projectAssetMounts } from "./resolve.ts";
32
31
  import { handleAiApi } from "./ai-api.ts";
33
32
  import { handleImportApi } from "./import-api.ts";
34
33
  import { resolveNpmPath } from "./server.ts";
35
34
  import type { ImportApiOptions } from "./import-api.ts";
35
+ import {
36
+ decodeAndNormalizePath,
37
+ loopbackGate,
38
+ serveContained,
39
+ serveProjectFile,
40
+ } from "./net-guard.ts";
36
41
 
37
42
  /** A resolved per-window session: its project root plus its RPC handler map. */
38
43
  export interface ProjectServerSession {
@@ -71,151 +76,6 @@ export interface ProjectServerHandle {
71
76
  stop: () => void;
72
77
  }
73
78
 
74
- const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
75
-
76
- /** True when a hostname (no port) is a loopback host. Used for best-effort Origin/Host checks. */
77
- function isLoopbackHost(host: string | null | undefined): boolean {
78
- if (!host) {
79
- return false;
80
- }
81
- // Strip a trailing :port (but keep bracketed IPv6 intact for the literal set).
82
- let h = host;
83
- if (h.startsWith("[")) {
84
- const end = h.indexOf("]");
85
- h = end === -1 ? h : h.slice(0, end + 1);
86
- } else {
87
- const colon = h.indexOf(":");
88
- if (colon !== -1) {
89
- h = h.slice(0, colon);
90
- }
91
- }
92
- return LOOPBACK_HOSTS.has(h.toLowerCase());
93
- }
94
-
95
- /**
96
- * Best-effort loopback Origin/Host check (defense-in-depth, not the hard gate).
97
- *
98
- * Accept when the Origin's host is loopback OR Origin is absent (Bun-native / test clients send no
99
- * Origin). Do NOT hardcode-match one literal origin — localhost and 127.0.0.1 are distinct origins,
100
- * and a too-strict check would 403 legitimate clients.
101
- */
102
- function originIsLoopbackOrAbsent(req: Request): boolean {
103
- const origin = req.headers.get("origin");
104
- if (!origin) {
105
- return true;
106
- }
107
- try {
108
- return isLoopbackHost(new URL(origin).hostname);
109
- } catch {
110
- return false;
111
- }
112
- }
113
-
114
- /**
115
- * Reject a Host header that is present and NOT loopback (defeats DNS-rebinding, where Host is the
116
- * attacker domain). An absent Host (Bun-native clients) is accepted.
117
- */
118
- function hostIsLoopbackOrAbsent(req: Request): boolean {
119
- const host = req.headers.get("host");
120
- if (!host) {
121
- return true;
122
- }
123
- return isLoopbackHost(host);
124
- }
125
-
126
- /** Normalize a path for cross-platform containment comparison (separators + case on Windows). */
127
- function normalizeForCompare(p: string): string {
128
- const slashed = p.replaceAll("\\", "/");
129
- return process.platform === "win32" ? slashed.toLowerCase() : slashed;
130
- }
131
-
132
- /**
133
- * Contain `absPath` within `root`: a lexical relative() check followed by a realpath re-check so a
134
- * symlink inside the tree cannot point outside it. Returns the (possibly realpath'd) absolute path
135
- * when contained, or null otherwise. The caller must already have decoded the URL pathname.
136
- */
137
- function containedPath(absPath: string, root: string): string | null {
138
- // (1) Lexical containment.
139
- const rel = relative(root, absPath);
140
- if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
141
- return null;
142
- }
143
- // (2) realpath BOTH and re-check the realpath is still under realRoot (symlink containment).
144
- let realRoot: string;
145
- let realPath: string;
146
- try {
147
- realRoot = realpathSync(root);
148
- } catch {
149
- // Root itself is unresolvable: fall back to lexical-only containment.
150
- return absPath;
151
- }
152
- try {
153
- realPath = realpathSync(absPath);
154
- } catch {
155
- // Target does not exist yet — lexical containment already passed; let the caller's existence
156
- // Check decide. (Reads will 404; the write-path resolves its own parent separately.)
157
- return absPath;
158
- }
159
- const nRoot = normalizeForCompare(realRoot);
160
- const nPath = normalizeForCompare(realPath);
161
- if (nPath === nRoot || nPath.startsWith(nRoot.endsWith("/") ? nRoot : `${nRoot}/`)) {
162
- return realPath;
163
- }
164
- return null;
165
- }
166
-
167
- /**
168
- * Serve a file under studioDir (or any root) if it both exists and is contained. Returns the
169
- * Response, or null when missing/traversed (caller decides the fallthrough status).
170
- */
171
- async function serveContained(absPath: string, root: string): Promise<Response | null> {
172
- const safe = containedPath(absPath, root);
173
- if (!safe) {
174
- return null;
175
- }
176
- const file = Bun.file(safe);
177
- if (await file.exists()) {
178
- return new Response(file);
179
- }
180
- return null;
181
- }
182
-
183
- /**
184
- * Try to serve a project file at its natural URL: absolute-under-root, then root-relative, then
185
- * public/. Each candidate goes through containedPath (realpath). `decodedPath` is the once-decoded
186
- * URL pathname (leading slash, forward slashes).
187
- */
188
- async function serveProjectFile(decodedPath: string, root: string): Promise<Response | null> {
189
- // Map the URL pathname back to a filesystem path. On Windows the browser requests an absolute
190
- // Path as /C:/Users/… (leading slash + forward slashes); drop the slash before the drive letter.
191
- // A POSIX absolute path arrives as //abs/path.
192
- const fsPath = decodedPath.startsWith("//")
193
- ? decodedPath.slice(1)
194
- : decodedPath.replace(/^\/([A-Za-z]:)/, "$1");
195
-
196
- // 1. Absolute path that falls under the project root.
197
- if (normalizeForCompare(fsPath).startsWith(normalizeForCompare(root))) {
198
- const res = await serveContained(fsPath, root);
199
- if (res) {
200
- return res;
201
- }
202
- }
203
-
204
- // 2. Root-relative.
205
- const relRes = await serveContained(resolve(root, `.${decodedPath}`), root);
206
- if (relRes) {
207
- return relRes;
208
- }
209
-
210
- // 3. public/ subdirectory.
211
- const pubRes = await serveContained(resolve(root, "public", `.${decodedPath}`), root);
212
- if (pubRes) {
213
- return pubRes;
214
- }
215
-
216
- return null;
217
- }
218
-
219
79
  /**
220
80
  * Create and start a shared project server bound to loopback.
221
81
  *
@@ -243,27 +103,20 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
243
103
  const url = new URL(req.url);
244
104
  // Decode the pathname ONCE, then run ALL containment on the decoded path. Reject a path that
245
105
  // Still contains an encoded dot/slash after one decode (over-encoding bypass attempt).
246
- let path: string;
247
- try {
248
- path = decodeURIComponent(url.pathname);
249
- } catch {
250
- return new Response("Bad request", { status: 400 });
251
- }
252
- if (/%2e|%2f/i.test(path)) {
253
- return new Response("Not found", { status: 404 });
106
+ const decoded = decodeAndNormalizePath(url);
107
+ if ("reject" in decoded) {
108
+ return decoded.reject;
254
109
  }
255
- // Collapse runs of leading slashes to a single one. The natural-URL branch re-expands a POSIX
256
- // //abs prefix explicitly, so a single leading slash here is the right normal form.
257
- const normPath = path.replace(/^\/{2,}/, "/");
110
+ const { normPath } = decoded;
258
111
 
259
112
  const winId = url.searchParams.get("win");
260
113
 
261
114
  // 1. WebSocket upgrade — token + loopback Origin/Host are the hard gate.
262
115
  const upgrade = req.headers.get("upgrade");
263
116
  if (upgrade && upgrade.toLowerCase() === "websocket") {
264
- const token = url.searchParams.get("token");
265
- if (token !== rpcToken || !originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
266
- return new Response("Forbidden", { status: 403 });
117
+ const gate = loopbackGate(req, url, rpcToken);
118
+ if (gate) {
119
+ return gate;
267
120
  }
268
121
  if (srv.upgrade(req, { data: { winId } })) {
269
122
  return;
@@ -288,9 +141,9 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
288
141
  if (!importApi) {
289
142
  return new Response("Not found", { status: 404 });
290
143
  }
291
- const token = url.searchParams.get("token");
292
- if (token !== rpcToken || !originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
293
- return new Response("Forbidden", { status: 403 });
144
+ const gate = loopbackGate(req, url, rpcToken);
145
+ if (gate) {
146
+ return gate;
294
147
  }
295
148
  const importUrl = new URL(req.url);
296
149
  importUrl.pathname = "/__studio/import-site";
@@ -343,9 +196,9 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
343
196
  (normPath === "/__jx_resolve__" || normPath === "/__jx_server__") &&
344
197
  req.method === "POST"
345
198
  ) {
346
- const token = url.searchParams.get("token");
347
- if (token !== rpcToken || !originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
348
- return new Response("Forbidden", { status: 403 });
199
+ const gate = loopbackGate(req, url, rpcToken);
200
+ if (gate) {
201
+ return gate;
349
202
  }
350
203
  if (normPath === "/__jx_resolve__") {
351
204
  return handleResolve(req, root, root);
@@ -353,8 +206,9 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
353
206
  return handleServerFunction(req, root);
354
207
  }
355
208
 
356
- // 6. Project files at natural URLs.
357
- const fileRes = await serveProjectFile(normPath, root);
209
+ // 6. Project files at natural URLs, including extension asset mounts (§8.5) — that is what
210
+ // Lets a canvas preview show an image a content entry references relative to itself.
211
+ const fileRes = await serveProjectFile(normPath, root, await projectAssetMounts(root));
358
212
  if (fileRes) {
359
213
  return fileRes;
360
214
  }
package/src/resolve.ts CHANGED
@@ -4,8 +4,11 @@ import { dirname, relative, resolve } from "node:path";
4
4
  import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
5
5
  import { existsSync, readFileSync, statSync } from "node:fs";
6
6
  import { createRequire } from "node:module";
7
+ import { containedPath } from "./net-guard.ts";
7
8
  import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
8
9
  import { loadProjectSections } from "@jxsuite/compiler/project-sections";
10
+ import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
11
+ import type { AssetMount } from "@jxsuite/schema/asset-paths";
9
12
  import type { DynamicClass } from "@jxsuite/runtime/types";
10
13
  import type { JxClassDef, ProjectConfig } from "@jxsuite/schema/types";
11
14
 
@@ -36,8 +39,26 @@ interface ServerFunctionBody {
36
39
  [key: string]: unknown;
37
40
  }
38
41
 
42
+ /**
43
+ * True when `p` is contained within `root` or (when set) `activeProjectRoot`. Guards every
44
+ * `import()`/read of a caller-supplied `$src`/`$base`/`$implementation` so a `../` or absolute path
45
+ * cannot pull in arbitrary modules from the filesystem.
46
+ */
47
+ function isImportable(p: string, root: string, activeProjectRoot: string | null): boolean {
48
+ if (containedPath(p, root) !== null) {
49
+ return true;
50
+ }
51
+ if (activeProjectRoot && containedPath(p, activeProjectRoot) !== null) {
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+
39
57
  /** Per-project context cache, invalidated when project.json changes on disk. */
40
- const projectContextCache = new Map<string, { mtime: number; context: Record<string, unknown> }>();
58
+ const projectContextCache = new Map<
59
+ string,
60
+ { mtime: number; context: Record<string, unknown>; mounts: AssetMount[] }
61
+ >();
41
62
 
42
63
  /**
43
64
  * Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
@@ -48,6 +69,12 @@ const projectContextCache = new Map<string, { mtime: number; context: Record<str
48
69
  * @returns {Promise<Record<string, unknown> | null>} `{ config, root, ...sections }` or null
49
70
  */
50
71
  async function loadProjectContext(projectRoot: string) {
72
+ const entry = await loadProjectEntry(projectRoot);
73
+ return entry?.context ?? null;
74
+ }
75
+
76
+ /** Load (or reuse) the cached project context plus its extension asset mounts. */
77
+ async function loadProjectEntry(projectRoot: string) {
51
78
  const projectJsonPath = resolve(projectRoot, "project.json");
52
79
  if (!existsSync(projectJsonPath)) {
53
80
  return null;
@@ -56,19 +83,33 @@ async function loadProjectContext(projectRoot: string) {
56
83
  const { mtimeMs } = statSync(projectJsonPath);
57
84
  const cached = projectContextCache.get(projectRoot);
58
85
  if (cached && cached.mtime === mtimeMs) {
59
- return cached.context;
86
+ return cached;
60
87
  }
61
88
  const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
62
89
  const registry = await buildProjectExtensionRegistry(projectRoot, config);
63
90
  const sections = await loadProjectSections(projectRoot, config, registry);
91
+ const { mounts } = await loadAssetMounts(registry, config, projectRoot);
64
92
  const context = { config, root: projectRoot, ...sections };
65
- projectContextCache.set(projectRoot, { context, mtime: mtimeMs });
66
- return context;
93
+ const entry = { context, mounts, mtime: mtimeMs };
94
+ projectContextCache.set(projectRoot, entry);
95
+ return entry;
67
96
  } catch {
68
97
  return null;
69
98
  }
70
99
  }
71
100
 
101
+ /**
102
+ * Extension asset mounts for a project (specs/extensions.md §8.5) — the directories the dev server
103
+ * serves at their site URLs, so content-relative images resolve exactly as they will once built.
104
+ *
105
+ * @param {string} projectRoot
106
+ * @returns {Promise<AssetMount[]>} Mounts, or an empty list when there is no project here
107
+ */
108
+ export async function projectAssetMounts(projectRoot: string): Promise<AssetMount[]> {
109
+ const entry = await loadProjectEntry(projectRoot);
110
+ return entry?.mounts ?? [];
111
+ }
112
+
72
113
  /**
73
114
  * Handle POST /**jx_resolve** — proxy $prototype + $src entries.
74
115
  *
@@ -94,6 +135,10 @@ export async function handleResolve(
94
135
  }
95
136
 
96
137
  let moduleAbsPath;
138
+ // A bare specifier resolves through Node's module resolution (an installed package), so the
139
+ // Class file — and the `$implementation` sibling it names — are as trusted as the package's own
140
+ // Code. The root-escape guard only binds a caller-supplied relative `$src`/`$base`.
141
+ let trustedResolution = false;
97
142
  try {
98
143
  if ($src.startsWith("./") || $src.startsWith("../")) {
99
144
  // Relative path
@@ -104,6 +149,9 @@ export async function handleResolve(
104
149
  } else {
105
150
  moduleAbsPath = resolve(activeProjectRoot || root, $src);
106
151
  }
152
+ if (!isImportable(moduleAbsPath, root, activeProjectRoot)) {
153
+ return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
154
+ }
107
155
  } else {
108
156
  // Npm/bare specifier — use createRequire from project root, fall back to server package
109
157
  const projectRoot = activeProjectRoot || root;
@@ -114,6 +162,7 @@ export async function handleResolve(
114
162
  const serverRequire = createRequire(import.meta.url);
115
163
  moduleAbsPath = serverRequire.resolve($src);
116
164
  }
165
+ trustedResolution = true;
117
166
  }
118
167
  } catch (error) {
119
168
  return new Response(`Cannot resolve $src "${$src}": ${errorMessage(error)}`, { status: 400 });
@@ -148,6 +197,11 @@ export async function handleResolve(
148
197
  if (classDef.$implementation) {
149
198
  // Hybrid mode: redirect to the JS implementation
150
199
  const implPath = resolve(dirname(moduleAbsPath), classDef.$implementation);
200
+ // A trusted (bare-specifier) class file names its own sibling implementation; only a
201
+ // Project-local relative $src is held to the root-escape guard.
202
+ if (!trustedResolution && !isImportable(implPath, root, activeProjectRoot)) {
203
+ return new Response(`$implementation escapes the project root`, { status: 403 });
204
+ }
151
205
  const exportName = xport ?? classDef.title ?? $prototype;
152
206
  const mod = (await import(implPath)) as ModuleNamespace;
153
207
  const ExportedClass =
@@ -229,6 +283,10 @@ export async function handleServerFunction(req: Request, root: string) {
229
283
  });
230
284
  }
231
285
 
286
+ if (!isImportable(moduleAbsPath, root, null)) {
287
+ return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
288
+ }
289
+
232
290
  let mod: ModuleNamespace;
233
291
  try {
234
292
  mod = (await import(moduleAbsPath)) as ModuleNamespace;
@@ -246,7 +304,12 @@ export async function handleServerFunction(req: Request, root: string) {
246
304
  }
247
305
 
248
306
  try {
249
- const result = await (fn as (args: Record<string, unknown>) => unknown)(args);
307
+ // Match the production contract `fn(args, env)` (compiler emits `fn(args, c.env)`): the dev
308
+ // Proxy runs server-side, so `process.env` is the analogous environment binding.
309
+ const result = await (fn as (args: Record<string, unknown>, env: unknown) => unknown)(
310
+ args,
311
+ process.env,
312
+ );
250
313
  return Response.json(result ?? null);
251
314
  } catch (error) {
252
315
  return Response.json({ error: errorMessage(error) }, { status: 500 });
package/src/server.ts CHANGED
@@ -18,8 +18,14 @@ import { buildAll } from "./build.ts";
18
18
  import { createCollabRegistry } from "./collab.ts";
19
19
  import { createWatcher, injectSSE } from "./watch.ts";
20
20
  import { handleJxMounts } from "./jx-mounts.ts";
21
- import { handleResolve, handleServerFunction } from "./resolve.ts";
21
+ import { handleResolve, handleServerFunction, projectAssetMounts } from "./resolve.ts";
22
22
  import { assertAccessible, handleStudioApi } from "./studio-api.ts";
23
+ import {
24
+ containedPath,
25
+ decodeAndNormalizePath,
26
+ originHostGate,
27
+ serveProjectFile,
28
+ } from "./net-guard.ts";
23
29
  import { handleCodeApi } from "./code-api.ts";
24
30
  import { handleAiApi } from "./ai-api.ts";
25
31
  import { handleImportApi } from "./import-api.ts";
@@ -173,6 +179,18 @@ export { resolveNpmPath };
173
179
  export async function createDevServer(options: {
174
180
  root: string;
175
181
  port?: number;
182
+ /**
183
+ * Bind hostname. Defaults to loopback `127.0.0.1` — the primary security control. Binding a
184
+ * non-loopback host (e.g. `0.0.0.0` inside a container) exposes the RCE-capable resolve routes
185
+ * and the filesystem API to the network; only do so behind trusted isolation.
186
+ */
187
+ hostname?: string;
188
+ /**
189
+ * Extra filesystem roots (besides `root`) that `/__studio/activate` may switch to. Studio can
190
+ * activate an external project dir; a root that is neither under `root` nor listed here is
191
+ * refused.
192
+ */
193
+ allowedRoots?: string[];
176
194
  builds?: {
177
195
  entrypoints: string[];
178
196
  outdir: string;
@@ -186,6 +204,8 @@ export async function createDevServer(options: {
186
204
  const {
187
205
  root,
188
206
  port = 3000,
207
+ hostname = "127.0.0.1",
208
+ allowedRoots = [],
189
209
  builds = [],
190
210
  watch = true,
191
211
  studio: enableStudio = true,
@@ -247,7 +267,14 @@ export async function createDevServer(options: {
247
267
  const server = Bun.serve({
248
268
  async fetch(req, bunServer) {
249
269
  const url = new URL(req.url);
250
- let path = decodeURIComponent(url.pathname);
270
+ // Decode the pathname ONCE and reject over-encoded traversal (%252e → %2e). Single-encoded
271
+ // ".." is caught later by realpath containment on the static path.
272
+ const decoded = decodeAndNormalizePath(url);
273
+ if ("reject" in decoded) {
274
+ return decoded.reject;
275
+ }
276
+ // Keep the un-collapsed decoded path: the static branch detects a POSIX-absolute "//abs" prefix.
277
+ let { path } = decoded;
251
278
  if (path.endsWith("/")) {
252
279
  path += "index.html";
253
280
  } else if (path === "") {
@@ -259,6 +286,24 @@ export async function createDevServer(options: {
259
286
  return handleSSE();
260
287
  }
261
288
 
289
+ /*
290
+ * The RCE-capable resolve routes, the extension mounts, and the studio API are gated by a
291
+ * loopback Origin/Host check: the browser Studio and the served site are same-origin (they
292
+ * send a loopback Origin on cross-origin POSTs), while a malicious external page is rejected.
293
+ * The loopback bind is the primary control; this defeats CSRF + DNS-rebinding on top of it.
294
+ */
295
+ const isPrivileged =
296
+ path === "/__jx_resolve__" ||
297
+ path === "/__jx_server__" ||
298
+ path.startsWith("/_jx/") ||
299
+ (enableStudio && path.startsWith("/__studio/"));
300
+ if (isPrivileged) {
301
+ const gate = originHostGate(req);
302
+ if (gate) {
303
+ return gate;
304
+ }
305
+ }
306
+
262
307
  // $prototype + $src proxy
263
308
  if (path === "/__jx_resolve__" && req.method === "POST") {
264
309
  return handleResolve(req, absRoot, activeProjectRoot);
@@ -285,12 +330,24 @@ export async function createDevServer(options: {
285
330
  return collabRegistry.handleRequest(req, bunServer);
286
331
  }
287
332
 
288
- // Activate project — tells the server which project root to use for static file fallback
333
+ // Activate project — tells the server which project root to use for static file fallback.
334
+ // Contain the requested root: it must be under absRoot or an explicitly allowed root, so a
335
+ // Hostile activate cannot widen the filesystem API to arbitrary directories.
289
336
  if (path === "/__studio/activate" && req.method === "POST") {
290
337
  const body = (await req.json()) as { root?: string };
291
338
  const raw = body.root || null;
292
- // Always store as absolute path
293
- activeProjectRoot = raw ? resolve(absRoot, raw) : null;
339
+ if (!raw) {
340
+ activeProjectRoot = null;
341
+ return Response.json({ ok: true, root: null });
342
+ }
343
+ const requested = resolve(absRoot, raw);
344
+ const permitted =
345
+ containedPath(requested, absRoot) !== null ||
346
+ allowedRoots.some((allowed) => containedPath(requested, resolve(allowed)) !== null);
347
+ if (!permitted) {
348
+ return Response.json({ ok: false, error: "root not permitted" }, { status: 403 });
349
+ }
350
+ activeProjectRoot = requested;
294
351
  return Response.json({ ok: true, root: activeProjectRoot });
295
352
  }
296
353
 
@@ -332,7 +389,8 @@ export async function createDevServer(options: {
332
389
  }
333
390
  }
334
391
 
335
- // Static files
392
+ // Static files. Every candidate is contained (lexical + realpath) against its root, so an
393
+ // Encoded "../" traversal that survived URL normalization cannot escape to arbitrary files.
336
394
 
337
395
  // If the URL path is an absolute filesystem path under the active project, serve directly.
338
396
  // A POSIX absolute path arrives as "//abs/path"; a Windows one as "/C:/dir/file" (leading
@@ -341,24 +399,54 @@ export async function createDevServer(options: {
341
399
  const fsPath = path.startsWith("//") ? path.slice(1) : path.replace(/^\/([A-Za-z]:)/, "$1");
342
400
  const normSep = (p: string) => p.replaceAll("\\", "/");
343
401
  if (activeProjectRoot && normSep(fsPath).startsWith(normSep(activeProjectRoot))) {
344
- const file = Bun.file(fsPath);
345
- if (await file.exists()) {
346
- return fileResponse(file);
402
+ const safe = containedPath(fsPath, activeProjectRoot);
403
+ if (safe) {
404
+ const file = Bun.file(safe);
405
+ if (await file.exists()) {
406
+ return fileResponse(file);
407
+ }
347
408
  }
348
409
  }
349
410
 
350
- const file = Bun.file(resolve(absRoot, `.${path}`));
351
- if (!(await file.exists())) {
411
+ // Extension asset mounts (§8.5) publish directories that may sit outside the project root,
412
+ // Such as an external content source's co-located images. They resolve here exactly as they
413
+ // Will once the site is built, so dev and production render the same URLs.
414
+ if (activeProjectRoot) {
415
+ const mountRes = await serveProjectFile(
416
+ path,
417
+ activeProjectRoot,
418
+ await projectAssetMounts(activeProjectRoot),
419
+ );
420
+ if (mountRes) {
421
+ return new Response(mountRes.body, { headers: NO_CACHE });
422
+ }
423
+ }
424
+
425
+ const rootSafe = containedPath(resolve(absRoot, `.${path}`), absRoot);
426
+ const file = rootSafe ? Bun.file(rootSafe) : null;
427
+ if (!file || !(await file.exists())) {
352
428
  // Try resolving relative to active studio project root
353
429
  if (activeProjectRoot) {
354
- const projectFile = Bun.file(resolve(activeProjectRoot, `.${path}`));
355
- if (await projectFile.exists()) {
356
- return fileResponse(projectFile);
430
+ const projectSafe = containedPath(
431
+ resolve(activeProjectRoot, `.${path}`),
432
+ activeProjectRoot,
433
+ );
434
+ if (projectSafe) {
435
+ const projectFile = Bun.file(projectSafe);
436
+ if (await projectFile.exists()) {
437
+ return fileResponse(projectFile);
438
+ }
357
439
  }
358
440
  // Mirror production: public/ contents are served at root
359
- const publicFile = Bun.file(resolve(activeProjectRoot, "public", `.${path}`));
360
- if (await publicFile.exists()) {
361
- return fileResponse(publicFile);
441
+ const publicSafe = containedPath(
442
+ resolve(activeProjectRoot, "public", `.${path}`),
443
+ activeProjectRoot,
444
+ );
445
+ if (publicSafe) {
446
+ const publicFile = Bun.file(publicSafe);
447
+ if (await publicFile.exists()) {
448
+ return fileResponse(publicFile);
449
+ }
362
450
  }
363
451
  }
364
452
 
@@ -408,6 +496,7 @@ export async function createDevServer(options: {
408
496
  return fileResponse(file);
409
497
  },
410
498
 
499
+ hostname,
411
500
  port,
412
501
  // Keep SSE connections alive — heartbeats are every 15 s, and AI streaming can take
413
502
  // 30+ s. The default 10 s idleTimeout kills them prematurely.
@@ -415,7 +504,7 @@ export async function createDevServer(options: {
415
504
  websocket: collabRegistry.websocket,
416
505
  });
417
506
 
418
- console.log(`\n@jxsuite/server listening on http://localhost:${server.port}`);
507
+ console.log(`\n@jxsuite/server listening on http://${hostname}:${server.port}`);
419
508
 
420
509
  return server;
421
510
  }
package/src/studio-api.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  } from "@jxsuite/compiler/format-host";
18
18
  import { readBundledProjectSchemas } from "@jxsuite/compiler/schema-command";
19
19
  import { handleDataApi } from "./data-api.ts";
20
+ import { containedPath } from "./net-guard.ts";
20
21
  import { applyRename } from "./refactor/apply.ts";
21
22
  import {
22
23
  bunExecutable,
@@ -164,15 +165,12 @@ async function getFormatRegistry(projectRoot: string): Promise<FormatRegistry> {
164
165
  * @param {string | null} activeProjectRoot
165
166
  */
166
167
  export function assertAccessible(filePath: string, root: string, activeProjectRoot: string | null) {
167
- const rel = relative(root, filePath);
168
- if (!rel.startsWith("..") && !rel.startsWith("/")) {
168
+ // Realpath containment (symlink-safe) against the server root, or an explicitly-activated project.
169
+ if (containedPath(filePath, root) !== null) {
169
170
  return;
170
171
  }
171
- if (activeProjectRoot) {
172
- const relActive = relative(activeProjectRoot, filePath);
173
- if (!relActive.startsWith("..") && !relActive.startsWith("/")) {
174
- return;
175
- }
172
+ if (activeProjectRoot && containedPath(filePath, activeProjectRoot) !== null) {
173
+ return;
176
174
  }
177
175
  throw new Error("Path outside project root");
178
176
  }