@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 +21 -0
- package/package.json +12 -12
- package/src/ai-api.ts +80 -15
- package/src/code-api.ts +35 -7
- package/src/net-guard.ts +229 -0
- package/src/project-server.ts +24 -170
- package/src/resolve.ts +68 -5
- package/src/server.ts +107 -18
- 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.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.
|
|
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.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.
|
|
52
|
-
"@jxsuite/connector": "^0.
|
|
53
|
-
"@jxsuite/parser": "^1.
|
|
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
|
-
|
|
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/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 {
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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([
|
|
191
|
+
const proc = Bun.spawn([oxlintBin, "--format=json", "-A", "no-unused-vars", tmpFile], {
|
|
164
192
|
stderr: "pipe",
|
|
165
193
|
stdout: "pipe",
|
|
166
194
|
});
|
package/src/net-guard.ts
ADDED
|
@@ -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
|
+
}
|
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
|
-
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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
345
|
-
if (
|
|
346
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|