@jxsuite/server 1.1.1 → 1.2.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.2.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.0",
39
+ "@jxsuite/compiler": "^1.3.0",
40
+ "@jxsuite/create": "^1.2.0",
41
+ "@jxsuite/import": "^0.38.0",
42
+ "@jxsuite/protocol": "^0.6.0",
43
+ "@jxsuite/runtime": "^1.3.0",
44
+ "@jxsuite/schema": "^1.3.0",
45
+ "@jxsuite/starters": "^1.2.0",
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.0",
52
+ "@jxsuite/connector": "^0.4.0",
53
+ "@jxsuite/parser": "^1.3.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) {
@@ -0,0 +1,213 @@
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
+
22
+ export const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
23
+
24
+ /** True when a hostname (optionally with :port) is a loopback host. */
25
+ export function isLoopbackHost(host: string | null | undefined): boolean {
26
+ if (!host) {
27
+ return false;
28
+ }
29
+ // Strip a trailing :port (but keep bracketed IPv6 intact for the literal set).
30
+ let h = host;
31
+ if (h.startsWith("[")) {
32
+ const end = h.indexOf("]");
33
+ h = end === -1 ? h : h.slice(0, end + 1);
34
+ } else {
35
+ const colon = h.indexOf(":");
36
+ if (colon !== -1) {
37
+ h = h.slice(0, colon);
38
+ }
39
+ }
40
+ return LOOPBACK_HOSTS.has(h.toLowerCase());
41
+ }
42
+
43
+ /**
44
+ * Accept when the Origin's host is loopback OR Origin is absent (Bun-native / test clients send no
45
+ * Origin). Do NOT hardcode-match one literal origin — localhost and 127.0.0.1 are distinct origins,
46
+ * and a too-strict check would 403 legitimate clients.
47
+ */
48
+ export function originIsLoopbackOrAbsent(req: Request): boolean {
49
+ const origin = req.headers.get("origin");
50
+ if (!origin) {
51
+ return true;
52
+ }
53
+ try {
54
+ return isLoopbackHost(new URL(origin).hostname);
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Reject a Host header that is present and NOT loopback (defeats DNS-rebinding, where Host is the
62
+ * attacker domain). An absent Host (Bun-native clients) is accepted.
63
+ */
64
+ export function hostIsLoopbackOrAbsent(req: Request): boolean {
65
+ const host = req.headers.get("host");
66
+ if (!host) {
67
+ return true;
68
+ }
69
+ return isLoopbackHost(host);
70
+ }
71
+
72
+ /**
73
+ * Origin/Host-only request gate (no token). Returns a 403 Response when the request is not
74
+ * loopback-safe, else null. Suitable for a same-origin dev server: the browser sends a loopback
75
+ * Origin on cross-origin POSTs, so this closes CSRF + DNS-rebinding without a shared token.
76
+ */
77
+ export function originHostGate(req: Request): Response | null {
78
+ if (!originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
79
+ return new Response("Forbidden", { status: 403 });
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Token + Origin/Host gate. Returns a 403 Response when the token mismatches OR the request is not
86
+ * loopback-safe, else null. Used by the cross-origin desktop server whose canvas iframe carries the
87
+ * token in the URL. Pass `token: null` to skip the token check (Origin/Host only).
88
+ */
89
+ export function loopbackGate(req: Request, url: URL, token: string | null): Response | null {
90
+ if (token !== null && url.searchParams.get("token") !== token) {
91
+ return new Response("Forbidden", { status: 403 });
92
+ }
93
+ return originHostGate(req);
94
+ }
95
+
96
+ /** Normalize a path for cross-platform containment comparison (separators + case on Windows). */
97
+ export function normalizeForCompare(p: string): string {
98
+ const slashed = p.replaceAll("\\", "/");
99
+ return process.platform === "win32" ? slashed.toLowerCase() : slashed;
100
+ }
101
+
102
+ /**
103
+ * Contain `absPath` within `root`: a lexical relative() check followed by a realpath re-check so a
104
+ * symlink inside the tree cannot point outside it. Returns the (possibly realpath'd) absolute path
105
+ * when contained, or null otherwise. The caller must already have decoded the URL pathname.
106
+ */
107
+ export function containedPath(absPath: string, root: string): string | null {
108
+ // (1) Lexical containment.
109
+ const rel = relative(root, absPath);
110
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
111
+ return null;
112
+ }
113
+ // (2) realpath BOTH and re-check the realpath is still under realRoot (symlink containment).
114
+ let realRoot: string;
115
+ let realPath: string;
116
+ try {
117
+ realRoot = realpathSync(root);
118
+ } catch {
119
+ // Root itself is unresolvable: fall back to lexical-only containment.
120
+ return absPath;
121
+ }
122
+ try {
123
+ realPath = realpathSync(absPath);
124
+ } catch {
125
+ // Target does not exist yet — lexical containment already passed; let the caller's existence
126
+ // Check decide. (Reads will 404; the write-path resolves its own parent separately.)
127
+ return absPath;
128
+ }
129
+ const nRoot = normalizeForCompare(realRoot);
130
+ const nPath = normalizeForCompare(realPath);
131
+ if (nPath === nRoot || nPath.startsWith(nRoot.endsWith("/") ? nRoot : `${nRoot}/`)) {
132
+ return realPath;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Serve a file under `root` if it both exists and is contained. Returns the Response, or null when
139
+ * missing/traversed (caller decides the fallthrough status).
140
+ */
141
+ export async function serveContained(absPath: string, root: string): Promise<Response | null> {
142
+ const safe = containedPath(absPath, root);
143
+ if (!safe) {
144
+ return null;
145
+ }
146
+ const file = Bun.file(safe);
147
+ if (await file.exists()) {
148
+ return new Response(file);
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Try to serve a project file at its natural URL: absolute-under-root, then root-relative, then
155
+ * public/. Each candidate goes through containedPath (realpath). `decodedPath` is the once-decoded
156
+ * URL pathname (leading slash, forward slashes).
157
+ */
158
+ export async function serveProjectFile(
159
+ decodedPath: string,
160
+ root: string,
161
+ ): Promise<Response | null> {
162
+ // Map the URL pathname back to a filesystem path. On Windows the browser requests an absolute
163
+ // Path as /C:/Users/… (leading slash + forward slashes); drop the slash before the drive letter.
164
+ // A POSIX absolute path arrives as //abs/path.
165
+ const fsPath = decodedPath.startsWith("//")
166
+ ? decodedPath.slice(1)
167
+ : decodedPath.replace(/^\/([A-Za-z]:)/, "$1");
168
+
169
+ // 1. Absolute path that falls under the project root.
170
+ if (normalizeForCompare(fsPath).startsWith(normalizeForCompare(root))) {
171
+ const res = await serveContained(fsPath, root);
172
+ if (res) {
173
+ return res;
174
+ }
175
+ }
176
+
177
+ // 2. Root-relative.
178
+ const relRes = await serveContained(resolve(root, `.${decodedPath}`), root);
179
+ if (relRes) {
180
+ return relRes;
181
+ }
182
+
183
+ // 3. public/ subdirectory.
184
+ const pubRes = await serveContained(resolve(root, "public", `.${decodedPath}`), root);
185
+ if (pubRes) {
186
+ return pubRes;
187
+ }
188
+
189
+ return null;
190
+ }
191
+
192
+ /** Result of decoding a URL pathname: the safe decoded/normalized path, or a rejection Response. */
193
+ export type DecodedPath = { path: string; normPath: string } | { reject: Response };
194
+
195
+ /**
196
+ * Decode a URL pathname ONCE and reject over-encoding bypasses. Returns `{ path, normPath }` where
197
+ * `path` is the once-decoded pathname and `normPath` collapses runs of leading slashes to one, or
198
+ * `{ reject }` with a 400/404 Response when the input is malformed or still contains an encoded
199
+ * dot/slash after one decode.
200
+ */
201
+ export function decodeAndNormalizePath(url: URL): DecodedPath {
202
+ let path: string;
203
+ try {
204
+ path = decodeURIComponent(url.pathname);
205
+ } catch {
206
+ return { reject: new Response("Bad request", { status: 400 }) };
207
+ }
208
+ if (/%2e|%2f/i.test(path)) {
209
+ return { reject: new Response("Not found", { status: 404 }) };
210
+ }
211
+ const normPath = path.replace(/^\/{2,}/, "/");
212
+ return { path, normPath };
213
+ }
@@ -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
30
  import { handleResolve, handleServerFunction } 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);
package/src/resolve.ts CHANGED
@@ -4,6 +4,7 @@ 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";
9
10
  import type { DynamicClass } from "@jxsuite/runtime/types";
@@ -36,6 +37,21 @@ interface ServerFunctionBody {
36
37
  [key: string]: unknown;
37
38
  }
38
39
 
40
+ /**
41
+ * True when `p` is contained within `root` or (when set) `activeProjectRoot`. Guards every
42
+ * `import()`/read of a caller-supplied `$src`/`$base`/`$implementation` so a `../` or absolute path
43
+ * cannot pull in arbitrary modules from the filesystem.
44
+ */
45
+ function isImportable(p: string, root: string, activeProjectRoot: string | null): boolean {
46
+ if (containedPath(p, root) !== null) {
47
+ return true;
48
+ }
49
+ if (activeProjectRoot && containedPath(p, activeProjectRoot) !== null) {
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+
39
55
  /** Per-project context cache, invalidated when project.json changes on disk. */
40
56
  const projectContextCache = new Map<string, { mtime: number; context: Record<string, unknown> }>();
41
57
 
@@ -94,6 +110,10 @@ export async function handleResolve(
94
110
  }
95
111
 
96
112
  let moduleAbsPath;
113
+ // A bare specifier resolves through Node's module resolution (an installed package), so the
114
+ // Class file — and the `$implementation` sibling it names — are as trusted as the package's own
115
+ // Code. The root-escape guard only binds a caller-supplied relative `$src`/`$base`.
116
+ let trustedResolution = false;
97
117
  try {
98
118
  if ($src.startsWith("./") || $src.startsWith("../")) {
99
119
  // Relative path
@@ -104,6 +124,9 @@ export async function handleResolve(
104
124
  } else {
105
125
  moduleAbsPath = resolve(activeProjectRoot || root, $src);
106
126
  }
127
+ if (!isImportable(moduleAbsPath, root, activeProjectRoot)) {
128
+ return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
129
+ }
107
130
  } else {
108
131
  // Npm/bare specifier — use createRequire from project root, fall back to server package
109
132
  const projectRoot = activeProjectRoot || root;
@@ -114,6 +137,7 @@ export async function handleResolve(
114
137
  const serverRequire = createRequire(import.meta.url);
115
138
  moduleAbsPath = serverRequire.resolve($src);
116
139
  }
140
+ trustedResolution = true;
117
141
  }
118
142
  } catch (error) {
119
143
  return new Response(`Cannot resolve $src "${$src}": ${errorMessage(error)}`, { status: 400 });
@@ -148,6 +172,11 @@ export async function handleResolve(
148
172
  if (classDef.$implementation) {
149
173
  // Hybrid mode: redirect to the JS implementation
150
174
  const implPath = resolve(dirname(moduleAbsPath), classDef.$implementation);
175
+ // A trusted (bare-specifier) class file names its own sibling implementation; only a
176
+ // Project-local relative $src is held to the root-escape guard.
177
+ if (!trustedResolution && !isImportable(implPath, root, activeProjectRoot)) {
178
+ return new Response(`$implementation escapes the project root`, { status: 403 });
179
+ }
151
180
  const exportName = xport ?? classDef.title ?? $prototype;
152
181
  const mod = (await import(implPath)) as ModuleNamespace;
153
182
  const ExportedClass =
@@ -229,6 +258,10 @@ export async function handleServerFunction(req: Request, root: string) {
229
258
  });
230
259
  }
231
260
 
261
+ if (!isImportable(moduleAbsPath, root, null)) {
262
+ return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
263
+ }
264
+
232
265
  let mod: ModuleNamespace;
233
266
  try {
234
267
  mod = (await import(moduleAbsPath)) as ModuleNamespace;
@@ -246,7 +279,12 @@ export async function handleServerFunction(req: Request, root: string) {
246
279
  }
247
280
 
248
281
  try {
249
- const result = await (fn as (args: Record<string, unknown>) => unknown)(args);
282
+ // Match the production contract `fn(args, env)` (compiler emits `fn(args, c.env)`): the dev
283
+ // Proxy runs server-side, so `process.env` is the analogous environment binding.
284
+ const result = await (fn as (args: Record<string, unknown>, env: unknown) => unknown)(
285
+ args,
286
+ process.env,
287
+ );
250
288
  return Response.json(result ?? null);
251
289
  } catch (error) {
252
290
  return Response.json({ error: errorMessage(error) }, { status: 500 });
package/src/server.ts CHANGED
@@ -20,6 +20,7 @@ import { createWatcher, injectSSE } from "./watch.ts";
20
20
  import { handleJxMounts } from "./jx-mounts.ts";
21
21
  import { handleResolve, handleServerFunction } from "./resolve.ts";
22
22
  import { assertAccessible, handleStudioApi } from "./studio-api.ts";
23
+ import { containedPath, decodeAndNormalizePath, originHostGate } from "./net-guard.ts";
23
24
  import { handleCodeApi } from "./code-api.ts";
24
25
  import { handleAiApi } from "./ai-api.ts";
25
26
  import { handleImportApi } from "./import-api.ts";
@@ -173,6 +174,18 @@ export { resolveNpmPath };
173
174
  export async function createDevServer(options: {
174
175
  root: string;
175
176
  port?: number;
177
+ /**
178
+ * Bind hostname. Defaults to loopback `127.0.0.1` — the primary security control. Binding a
179
+ * non-loopback host (e.g. `0.0.0.0` inside a container) exposes the RCE-capable resolve routes
180
+ * and the filesystem API to the network; only do so behind trusted isolation.
181
+ */
182
+ hostname?: string;
183
+ /**
184
+ * Extra filesystem roots (besides `root`) that `/__studio/activate` may switch to. Studio can
185
+ * activate an external project dir; a root that is neither under `root` nor listed here is
186
+ * refused.
187
+ */
188
+ allowedRoots?: string[];
176
189
  builds?: {
177
190
  entrypoints: string[];
178
191
  outdir: string;
@@ -186,6 +199,8 @@ export async function createDevServer(options: {
186
199
  const {
187
200
  root,
188
201
  port = 3000,
202
+ hostname = "127.0.0.1",
203
+ allowedRoots = [],
189
204
  builds = [],
190
205
  watch = true,
191
206
  studio: enableStudio = true,
@@ -247,7 +262,14 @@ export async function createDevServer(options: {
247
262
  const server = Bun.serve({
248
263
  async fetch(req, bunServer) {
249
264
  const url = new URL(req.url);
250
- let path = decodeURIComponent(url.pathname);
265
+ // Decode the pathname ONCE and reject over-encoded traversal (%252e → %2e). Single-encoded
266
+ // ".." is caught later by realpath containment on the static path.
267
+ const decoded = decodeAndNormalizePath(url);
268
+ if ("reject" in decoded) {
269
+ return decoded.reject;
270
+ }
271
+ // Keep the un-collapsed decoded path: the static branch detects a POSIX-absolute "//abs" prefix.
272
+ let { path } = decoded;
251
273
  if (path.endsWith("/")) {
252
274
  path += "index.html";
253
275
  } else if (path === "") {
@@ -259,6 +281,24 @@ export async function createDevServer(options: {
259
281
  return handleSSE();
260
282
  }
261
283
 
284
+ /*
285
+ * The RCE-capable resolve routes, the extension mounts, and the studio API are gated by a
286
+ * loopback Origin/Host check: the browser Studio and the served site are same-origin (they
287
+ * send a loopback Origin on cross-origin POSTs), while a malicious external page is rejected.
288
+ * The loopback bind is the primary control; this defeats CSRF + DNS-rebinding on top of it.
289
+ */
290
+ const isPrivileged =
291
+ path === "/__jx_resolve__" ||
292
+ path === "/__jx_server__" ||
293
+ path.startsWith("/_jx/") ||
294
+ (enableStudio && path.startsWith("/__studio/"));
295
+ if (isPrivileged) {
296
+ const gate = originHostGate(req);
297
+ if (gate) {
298
+ return gate;
299
+ }
300
+ }
301
+
262
302
  // $prototype + $src proxy
263
303
  if (path === "/__jx_resolve__" && req.method === "POST") {
264
304
  return handleResolve(req, absRoot, activeProjectRoot);
@@ -285,12 +325,24 @@ export async function createDevServer(options: {
285
325
  return collabRegistry.handleRequest(req, bunServer);
286
326
  }
287
327
 
288
- // Activate project — tells the server which project root to use for static file fallback
328
+ // Activate project — tells the server which project root to use for static file fallback.
329
+ // Contain the requested root: it must be under absRoot or an explicitly allowed root, so a
330
+ // Hostile activate cannot widen the filesystem API to arbitrary directories.
289
331
  if (path === "/__studio/activate" && req.method === "POST") {
290
332
  const body = (await req.json()) as { root?: string };
291
333
  const raw = body.root || null;
292
- // Always store as absolute path
293
- activeProjectRoot = raw ? resolve(absRoot, raw) : null;
334
+ if (!raw) {
335
+ activeProjectRoot = null;
336
+ return Response.json({ ok: true, root: null });
337
+ }
338
+ const requested = resolve(absRoot, raw);
339
+ const permitted =
340
+ containedPath(requested, absRoot) !== null ||
341
+ allowedRoots.some((allowed) => containedPath(requested, resolve(allowed)) !== null);
342
+ if (!permitted) {
343
+ return Response.json({ ok: false, error: "root not permitted" }, { status: 403 });
344
+ }
345
+ activeProjectRoot = requested;
294
346
  return Response.json({ ok: true, root: activeProjectRoot });
295
347
  }
296
348
 
@@ -332,7 +384,8 @@ export async function createDevServer(options: {
332
384
  }
333
385
  }
334
386
 
335
- // Static files
387
+ // Static files. Every candidate is contained (lexical + realpath) against its root, so an
388
+ // Encoded "../" traversal that survived URL normalization cannot escape to arbitrary files.
336
389
 
337
390
  // If the URL path is an absolute filesystem path under the active project, serve directly.
338
391
  // A POSIX absolute path arrives as "//abs/path"; a Windows one as "/C:/dir/file" (leading
@@ -341,24 +394,40 @@ export async function createDevServer(options: {
341
394
  const fsPath = path.startsWith("//") ? path.slice(1) : path.replace(/^\/([A-Za-z]:)/, "$1");
342
395
  const normSep = (p: string) => p.replaceAll("\\", "/");
343
396
  if (activeProjectRoot && normSep(fsPath).startsWith(normSep(activeProjectRoot))) {
344
- const file = Bun.file(fsPath);
345
- if (await file.exists()) {
346
- return fileResponse(file);
397
+ const safe = containedPath(fsPath, activeProjectRoot);
398
+ if (safe) {
399
+ const file = Bun.file(safe);
400
+ if (await file.exists()) {
401
+ return fileResponse(file);
402
+ }
347
403
  }
348
404
  }
349
405
 
350
- const file = Bun.file(resolve(absRoot, `.${path}`));
351
- if (!(await file.exists())) {
406
+ const rootSafe = containedPath(resolve(absRoot, `.${path}`), absRoot);
407
+ const file = rootSafe ? Bun.file(rootSafe) : null;
408
+ if (!file || !(await file.exists())) {
352
409
  // Try resolving relative to active studio project root
353
410
  if (activeProjectRoot) {
354
- const projectFile = Bun.file(resolve(activeProjectRoot, `.${path}`));
355
- if (await projectFile.exists()) {
356
- return fileResponse(projectFile);
411
+ const projectSafe = containedPath(
412
+ resolve(activeProjectRoot, `.${path}`),
413
+ activeProjectRoot,
414
+ );
415
+ if (projectSafe) {
416
+ const projectFile = Bun.file(projectSafe);
417
+ if (await projectFile.exists()) {
418
+ return fileResponse(projectFile);
419
+ }
357
420
  }
358
421
  // 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);
422
+ const publicSafe = containedPath(
423
+ resolve(activeProjectRoot, "public", `.${path}`),
424
+ activeProjectRoot,
425
+ );
426
+ if (publicSafe) {
427
+ const publicFile = Bun.file(publicSafe);
428
+ if (await publicFile.exists()) {
429
+ return fileResponse(publicFile);
430
+ }
362
431
  }
363
432
  }
364
433
 
@@ -408,6 +477,7 @@ export async function createDevServer(options: {
408
477
  return fileResponse(file);
409
478
  },
410
479
 
480
+ hostname,
411
481
  port,
412
482
  // Keep SSE connections alive — heartbeats are every 15 s, and AI streaming can take
413
483
  // 30+ s. The default 10 s idleTimeout kills them prematurely.
@@ -415,7 +485,7 @@ export async function createDevServer(options: {
415
485
  websocket: collabRegistry.websocket,
416
486
  });
417
487
 
418
- console.log(`\n@jxsuite/server listening on http://localhost:${server.port}`);
488
+ console.log(`\n@jxsuite/server listening on http://${hostname}:${server.port}`);
419
489
 
420
490
  return server;
421
491
  }
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
  }