@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 +21 -0
- package/package.json +12 -12
- package/src/ai-api.ts +80 -15
- package/src/net-guard.ts +213 -0
- package/src/project-server.ts +20 -167
- package/src/resolve.ts +39 -1
- package/src/server.ts +87 -17
- package/src/studio-api.ts +5 -7
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.
|
|
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.
|
|
39
|
-
"@jxsuite/compiler": "^1.
|
|
40
|
-
"@jxsuite/create": "^1.
|
|
41
|
-
"@jxsuite/import": "^0.
|
|
42
|
-
"@jxsuite/protocol": "^0.
|
|
43
|
-
"@jxsuite/runtime": "^1.
|
|
44
|
-
"@jxsuite/schema": "^1.
|
|
45
|
-
"@jxsuite/starters": "^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.
|
|
52
|
-
"@jxsuite/connector": "^0.
|
|
53
|
-
"@jxsuite/parser": "^1.
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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/net-guard.ts
ADDED
|
@@ -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
|
+
}
|
package/src/project-server.ts
CHANGED
|
@@ -25,14 +25,19 @@
|
|
|
25
25
|
* realpath re-check (symlink containment).
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
265
|
-
if (
|
|
266
|
-
return
|
|
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
|
|
292
|
-
if (
|
|
293
|
-
return
|
|
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
|
|
347
|
-
if (
|
|
348
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
345
|
-
if (
|
|
346
|
-
|
|
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
|
|
351
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
if (
|
|
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
|
-
|
|
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
|
}
|