@abstractframework/code 0.4.1 → 0.5.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/README.md +20 -7
- package/bin/cli.js +13 -447
- package/bin/server.js +763 -0
- package/dist/assets/index-C5-M1QJI.js +58 -0
- package/dist/assets/index-mDhO5UHl.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +5 -4
- package/dist/assets/index-8DvVErvd.css +0 -1
- package/dist/assets/index-BbemGT6X.js +0 -157
package/bin/server.js
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { dirname, extname, join, resolve, sep } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const DEFAULT_DIST_DIR = resolve(BIN_DIR, "..", "dist");
|
|
10
|
+
const FALLBACK_GATEWAY_URL = "http://127.0.0.1:8080";
|
|
11
|
+
const URL_COOKIE = "abstractcode_gateway_url";
|
|
12
|
+
const SESSION_COOKIE = "abstractcode_gateway_session";
|
|
13
|
+
const CSRF_COOKIE = "abstractcode_gateway_csrf";
|
|
14
|
+
const TRUE_VALUES = new Set(["1", "true", "yes", "y", "on"]);
|
|
15
|
+
const HOP_HEADERS = new Set([
|
|
16
|
+
"connection",
|
|
17
|
+
"keep-alive",
|
|
18
|
+
"proxy-authenticate",
|
|
19
|
+
"proxy-authorization",
|
|
20
|
+
"te",
|
|
21
|
+
"trailer",
|
|
22
|
+
"trailers",
|
|
23
|
+
"transfer-encoding",
|
|
24
|
+
"upgrade",
|
|
25
|
+
]);
|
|
26
|
+
const MIME_TYPES = {
|
|
27
|
+
".html": "text/html",
|
|
28
|
+
".js": "application/javascript",
|
|
29
|
+
".css": "text/css",
|
|
30
|
+
".json": "application/json",
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".jpg": "image/jpeg",
|
|
33
|
+
".jpeg": "image/jpeg",
|
|
34
|
+
".gif": "image/gif",
|
|
35
|
+
".svg": "image/svg+xml",
|
|
36
|
+
".ico": "image/x-icon",
|
|
37
|
+
".webmanifest": "application/manifest+json",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function normalizeGatewayUrl(value) {
|
|
41
|
+
return String(value || "")
|
|
42
|
+
.trim()
|
|
43
|
+
.replace(/\/+$/, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function serverConfig(options = {}) {
|
|
47
|
+
const env = options.env || process.env;
|
|
48
|
+
const defaultGatewayUrl =
|
|
49
|
+
normalizeGatewayUrl(
|
|
50
|
+
options.defaultGatewayUrl ||
|
|
51
|
+
env.ABSTRACTCODE_GATEWAY_URL ||
|
|
52
|
+
env.ABSTRACTGATEWAY_URL ||
|
|
53
|
+
FALLBACK_GATEWAY_URL,
|
|
54
|
+
) || FALLBACK_GATEWAY_URL;
|
|
55
|
+
return { env, defaultGatewayUrl };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function envBool(env, name) {
|
|
59
|
+
const raw = env[name];
|
|
60
|
+
return typeof raw === "string" && TRUE_VALUES.has(raw.trim().toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function requestHostname(req, config) {
|
|
64
|
+
const trusted =
|
|
65
|
+
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
66
|
+
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
67
|
+
const value = trusted
|
|
68
|
+
? req.headers["x-forwarded-host"] || req.headers.host
|
|
69
|
+
: req.headers.host;
|
|
70
|
+
const raw = String(value || "")
|
|
71
|
+
.split(",", 1)[0]
|
|
72
|
+
.trim();
|
|
73
|
+
if (!raw) return "";
|
|
74
|
+
if (raw.startsWith("["))
|
|
75
|
+
return raw.slice(1).split("]", 1)[0].trim().toLowerCase();
|
|
76
|
+
if ((raw.match(/:/g) || []).length === 1)
|
|
77
|
+
return raw.split(":")[0].trim().toLowerCase();
|
|
78
|
+
return raw.toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isLoopbackHostname(hostname) {
|
|
82
|
+
const value = String(hostname || "")
|
|
83
|
+
.trim()
|
|
84
|
+
.toLowerCase();
|
|
85
|
+
if (value === "localhost" || value.endsWith(".localhost") || value === "::1") return true;
|
|
86
|
+
return isIP(value) === 4 && Number(value.split(".", 1)[0]) === 127;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isLoopbackPeer(req) {
|
|
90
|
+
let address = String(req.socket?.remoteAddress || "")
|
|
91
|
+
.trim()
|
|
92
|
+
.toLowerCase();
|
|
93
|
+
if (address.startsWith("::ffff:")) address = address.slice("::ffff:".length);
|
|
94
|
+
return (
|
|
95
|
+
address === "::1" || address === "127.0.0.1" || address.startsWith("127.")
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The browser connection's real transport peer (req.socket.remoteAddress),
|
|
101
|
+
* IPv4-mapped IPv6 unwrapped, or "" when unknown. Headers are never read.
|
|
102
|
+
* Every request this server sends to the gateway on a browser's behalf
|
|
103
|
+
* carries it as `X-Forwarded-For`, OVERWRITING any client value (contract
|
|
104
|
+
* A-2; same rule as abstractuic's app-server). An unknown peer is refused.
|
|
105
|
+
*/
|
|
106
|
+
export function socketPeerAddress(req) {
|
|
107
|
+
let address = String(req?.socket?.remoteAddress || "").trim().toLowerCase();
|
|
108
|
+
if (address.startsWith("::ffff:") && address.includes(".")) address = address.slice(7);
|
|
109
|
+
return address;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const UNKNOWN_PEER = { detail: "Cannot determine the client address of this connection" };
|
|
113
|
+
/** Marks every request this server sends to the gateway as coming through
|
|
114
|
+
* the Code app proxy (the gateway's same-machine fail-safe keys on it). A
|
|
115
|
+
* browser-supplied value is always dropped and replaced. */
|
|
116
|
+
const APP_PROXY_HEADER = "X-AbstractFramework-App-Proxy";
|
|
117
|
+
const APP_PROXY_NAME = "code";
|
|
118
|
+
const FORWARDING_HEADERS = new Set([
|
|
119
|
+
"x-abstractframework-app-proxy",
|
|
120
|
+
"x-forwarded-for",
|
|
121
|
+
"x-forwarded-host",
|
|
122
|
+
"x-forwarded-proto",
|
|
123
|
+
"x-real-ip",
|
|
124
|
+
"forwarded",
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
function connectionConfigAllowed(req, config) {
|
|
128
|
+
if (envBool(config.env, "ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG"))
|
|
129
|
+
return true;
|
|
130
|
+
if (
|
|
131
|
+
envBool(config.env, "ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG")
|
|
132
|
+
)
|
|
133
|
+
return true;
|
|
134
|
+
const trusted =
|
|
135
|
+
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
136
|
+
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
137
|
+
// A trusted proxy makes its own socket address meaningless. Hosted
|
|
138
|
+
// deployments must explicitly opt in before a browser can choose an SSRF
|
|
139
|
+
// destination; direct loopback development remains convenient.
|
|
140
|
+
return (
|
|
141
|
+
!trusted &&
|
|
142
|
+
isLoopbackPeer(req) &&
|
|
143
|
+
isLoopbackHostname(requestHostname(req, config))
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function connectionConfigDenial(req, config) {
|
|
148
|
+
const host = requestHostname(req, config) || "unknown host";
|
|
149
|
+
return (
|
|
150
|
+
`Browser-supplied Gateway URL changes are disabled for this non-local Code host (${host}). ` +
|
|
151
|
+
"Use the server-configured Gateway URL, or set ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG=1 behind your own access control."
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseCookies(req) {
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
158
|
+
const index = part.indexOf("=");
|
|
159
|
+
if (index < 0) continue;
|
|
160
|
+
const key = part.slice(0, index).trim();
|
|
161
|
+
const value = part.slice(index + 1).trim();
|
|
162
|
+
if (!key) continue;
|
|
163
|
+
try {
|
|
164
|
+
out[key] = decodeURIComponent(value);
|
|
165
|
+
} catch {
|
|
166
|
+
out[key] = value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cookieSecure(req, config) {
|
|
173
|
+
if (req.socket?.encrypted) return "; Secure";
|
|
174
|
+
const trusted =
|
|
175
|
+
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
176
|
+
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
177
|
+
return trusted && firstHeader(req.headers["x-forwarded-proto"]).toLowerCase() === "https"
|
|
178
|
+
? "; Secure"
|
|
179
|
+
: "";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function setSessionCookies(
|
|
183
|
+
res,
|
|
184
|
+
req,
|
|
185
|
+
gatewayUrl,
|
|
186
|
+
sessionId,
|
|
187
|
+
csrfToken,
|
|
188
|
+
persist,
|
|
189
|
+
config,
|
|
190
|
+
) {
|
|
191
|
+
const secure = cookieSecure(req, config);
|
|
192
|
+
const maxAge = persist ? "; Max-Age=2592000" : "";
|
|
193
|
+
const privateAttrs = `; Path=/; HttpOnly; SameSite=Lax${secure}${maxAge}`;
|
|
194
|
+
const csrfAttrs = `; Path=/; SameSite=Lax${secure}${maxAge}`;
|
|
195
|
+
res.setHeader("Set-Cookie", [
|
|
196
|
+
`${URL_COOKIE}=${encodeURIComponent(gatewayUrl)}${privateAttrs}`,
|
|
197
|
+
`${SESSION_COOKIE}=${encodeURIComponent(sessionId)}${privateAttrs}`,
|
|
198
|
+
`${CSRF_COOKIE}=${encodeURIComponent(csrfToken)}${csrfAttrs}`,
|
|
199
|
+
]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function clearSessionCookies(res, req, config) {
|
|
203
|
+
const secure = cookieSecure(req, config);
|
|
204
|
+
res.setHeader("Set-Cookie", [
|
|
205
|
+
`${URL_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
206
|
+
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
207
|
+
`${CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0${secure}`,
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function browserSession(req, config) {
|
|
212
|
+
const cookies = parseCookies(req);
|
|
213
|
+
const cookieUrl = normalizeGatewayUrl(cookies[URL_COOKIE]);
|
|
214
|
+
const allowCookieUrl =
|
|
215
|
+
envBool(config.env, "ABSTRACTCODE_ALLOW_BROWSER_GATEWAY_URL_COOKIE") ||
|
|
216
|
+
envBool(config.env, "ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG") ||
|
|
217
|
+
envBool(
|
|
218
|
+
config.env,
|
|
219
|
+
"ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG",
|
|
220
|
+
) ||
|
|
221
|
+
connectionConfigAllowed(req, config);
|
|
222
|
+
return {
|
|
223
|
+
gatewayUrl:
|
|
224
|
+
cookieUrl && (allowCookieUrl || cookieUrl === config.defaultGatewayUrl)
|
|
225
|
+
? cookieUrl
|
|
226
|
+
: config.defaultGatewayUrl,
|
|
227
|
+
sessionId: String(cookies[SESSION_COOKIE] || "").trim(),
|
|
228
|
+
csrfToken: String(cookies[CSRF_COOKIE] || "").trim(),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function resolveBackend(gatewayUrl, config) {
|
|
233
|
+
const url = new URL(
|
|
234
|
+
normalizeGatewayUrl(gatewayUrl) || config.defaultGatewayUrl,
|
|
235
|
+
);
|
|
236
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
237
|
+
throw new Error("Gateway URL must use http or https");
|
|
238
|
+
if (!url.port) url.port = url.protocol === "https:" ? "443" : "80";
|
|
239
|
+
return {
|
|
240
|
+
url,
|
|
241
|
+
origin: `${url.protocol}//${url.host}`,
|
|
242
|
+
client: url.protocol === "https:" ? https : http,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function mutatingMethod(method) {
|
|
247
|
+
return ["POST", "PUT", "PATCH", "DELETE"].includes(
|
|
248
|
+
String(method || "GET").toUpperCase(),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function firstHeader(value) {
|
|
253
|
+
return String(Array.isArray(value) ? value[0] : value || "")
|
|
254
|
+
.split(",", 1)[0]
|
|
255
|
+
.trim();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function requestOrigin(req, config) {
|
|
259
|
+
const trusted =
|
|
260
|
+
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
261
|
+
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
262
|
+
const host = firstHeader(
|
|
263
|
+
trusted
|
|
264
|
+
? req.headers["x-forwarded-host"] || req.headers.host
|
|
265
|
+
: req.headers.host,
|
|
266
|
+
);
|
|
267
|
+
const protocol =
|
|
268
|
+
firstHeader(trusted ? req.headers["x-forwarded-proto"] : "") ||
|
|
269
|
+
(req.socket?.encrypted ? "https" : "http");
|
|
270
|
+
if (!host || !["http", "https"].includes(protocol.toLowerCase())) return "";
|
|
271
|
+
try {
|
|
272
|
+
return new URL(`${protocol.toLowerCase()}://${host}`).origin;
|
|
273
|
+
} catch {
|
|
274
|
+
return "";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function browserMutationAllowed(req, config) {
|
|
279
|
+
const origin = firstHeader(req.headers.origin);
|
|
280
|
+
if (origin) {
|
|
281
|
+
let normalized;
|
|
282
|
+
try {
|
|
283
|
+
normalized = new URL(origin).origin;
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
return normalized === requestOrigin(req, config);
|
|
288
|
+
}
|
|
289
|
+
return (
|
|
290
|
+
firstHeader(req.headers["sec-fetch-site"]).toLowerCase() !== "cross-site"
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function jsonRequest(req) {
|
|
295
|
+
return (
|
|
296
|
+
firstHeader(req.headers["content-type"])
|
|
297
|
+
.split(";", 1)[0]
|
|
298
|
+
.trim()
|
|
299
|
+
.toLowerCase() === "application/json"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function sendJson(res, status, payload) {
|
|
304
|
+
if (res.headersSent) {
|
|
305
|
+
res.destroy();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
309
|
+
res.end(JSON.stringify(payload));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readRequestJson(req) {
|
|
313
|
+
return new Promise((resolveValue) => {
|
|
314
|
+
const chunks = [];
|
|
315
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
316
|
+
req.on("end", () => {
|
|
317
|
+
try {
|
|
318
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
319
|
+
resolveValue(raw ? JSON.parse(raw) : {});
|
|
320
|
+
} catch {
|
|
321
|
+
resolveValue({});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
req.on("error", () => resolveValue({}));
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function gatewayRequest(gatewayUrl, options, body, config) {
|
|
329
|
+
return new Promise((resolveValue) => {
|
|
330
|
+
let backend;
|
|
331
|
+
try {
|
|
332
|
+
backend = resolveBackend(gatewayUrl, config);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
resolveValue({
|
|
335
|
+
ok: false,
|
|
336
|
+
status: 0,
|
|
337
|
+
payload: {
|
|
338
|
+
detail: `Invalid gateway URL: ${String(error?.message || error)}`,
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const request = backend.client.request(
|
|
344
|
+
{
|
|
345
|
+
protocol: backend.url.protocol,
|
|
346
|
+
hostname: backend.url.hostname,
|
|
347
|
+
port: backend.url.port,
|
|
348
|
+
timeout: options.timeout || 4000,
|
|
349
|
+
...options,
|
|
350
|
+
},
|
|
351
|
+
(response) => {
|
|
352
|
+
const chunks = [];
|
|
353
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
354
|
+
response.on("end", () => {
|
|
355
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
356
|
+
let payload;
|
|
357
|
+
try {
|
|
358
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
359
|
+
} catch {
|
|
360
|
+
payload = { detail: raw };
|
|
361
|
+
}
|
|
362
|
+
const status = response.statusCode || 0;
|
|
363
|
+
resolveValue({
|
|
364
|
+
ok: status >= 200 && status < 300,
|
|
365
|
+
status,
|
|
366
|
+
payload,
|
|
367
|
+
headers: response.headers,
|
|
368
|
+
origin: backend.origin,
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
request.on("timeout", () => {
|
|
374
|
+
request.destroy();
|
|
375
|
+
resolveValue({
|
|
376
|
+
ok: false,
|
|
377
|
+
status: 0,
|
|
378
|
+
payload: { detail: "Gateway request timed out" },
|
|
379
|
+
origin: backend.origin,
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
request.on("error", (error) =>
|
|
383
|
+
resolveValue({
|
|
384
|
+
ok: false,
|
|
385
|
+
status: 0,
|
|
386
|
+
payload: { detail: String(error?.message || error) },
|
|
387
|
+
origin: backend.origin,
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
if (body) request.write(body);
|
|
391
|
+
request.end();
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function cookieValueFromSetCookie(rawHeaders, name) {
|
|
396
|
+
const headers = Array.isArray(rawHeaders)
|
|
397
|
+
? rawHeaders
|
|
398
|
+
: rawHeaders
|
|
399
|
+
? [rawHeaders]
|
|
400
|
+
: [];
|
|
401
|
+
for (const header of headers) {
|
|
402
|
+
for (const candidate of String(header || "").split(/,(?=\s*[^;,=]+=)/)) {
|
|
403
|
+
const first = candidate.split(";", 1)[0];
|
|
404
|
+
const index = first.indexOf("=");
|
|
405
|
+
if (index < 0 || first.slice(0, index).trim() !== name) continue;
|
|
406
|
+
const raw = first.slice(index + 1).trim();
|
|
407
|
+
try {
|
|
408
|
+
return decodeURIComponent(raw);
|
|
409
|
+
} catch {
|
|
410
|
+
return raw;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return "";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function handleConnectionApi(req, res, config) {
|
|
418
|
+
const peer = socketPeerAddress(req);
|
|
419
|
+
if (!peer) {
|
|
420
|
+
sendJson(res, 400, UNKNOWN_PEER);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (req.method === "GET") {
|
|
424
|
+
const session = browserSession(req, config);
|
|
425
|
+
if (!session.sessionId) {
|
|
426
|
+
sendJson(res, 200, {
|
|
427
|
+
ok: false,
|
|
428
|
+
gateway_url: session.gatewayUrl,
|
|
429
|
+
has_session: false,
|
|
430
|
+
gateway: { ok: false, error: "Gateway sign-in required" },
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const checked = await gatewayRequest(
|
|
435
|
+
session.gatewayUrl,
|
|
436
|
+
{
|
|
437
|
+
method: "GET",
|
|
438
|
+
path: "/api/gateway/me",
|
|
439
|
+
headers: {
|
|
440
|
+
Accept: "application/json",
|
|
441
|
+
"X-AbstractGateway-Session": session.sessionId,
|
|
442
|
+
"X-Forwarded-For": peer,
|
|
443
|
+
[APP_PROXY_HEADER]: APP_PROXY_NAME,
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
undefined,
|
|
447
|
+
config,
|
|
448
|
+
);
|
|
449
|
+
sendJson(res, 200, {
|
|
450
|
+
ok: checked.ok,
|
|
451
|
+
gateway_url: session.gatewayUrl,
|
|
452
|
+
has_session: true,
|
|
453
|
+
gateway: checked.payload,
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (req.method === "POST") {
|
|
458
|
+
if (!jsonRequest(req)) {
|
|
459
|
+
sendJson(res, 415, {
|
|
460
|
+
detail:
|
|
461
|
+
"Gateway connection requests require Content-Type: application/json",
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const payload = await readRequestJson(req);
|
|
466
|
+
const gatewayUrl =
|
|
467
|
+
normalizeGatewayUrl(payload.gateway_url || config.defaultGatewayUrl) ||
|
|
468
|
+
config.defaultGatewayUrl;
|
|
469
|
+
if (
|
|
470
|
+
!connectionConfigAllowed(req, config) &&
|
|
471
|
+
gatewayUrl !== config.defaultGatewayUrl
|
|
472
|
+
) {
|
|
473
|
+
sendJson(res, 403, { detail: connectionConfigDenial(req, config) });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const body = Buffer.from(
|
|
477
|
+
JSON.stringify({
|
|
478
|
+
user_id: String(payload.gateway_user_id || "").trim(),
|
|
479
|
+
token: String(payload.gateway_token || "").trim(),
|
|
480
|
+
remember: payload.persist === true,
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
const login = await gatewayRequest(
|
|
484
|
+
gatewayUrl,
|
|
485
|
+
{
|
|
486
|
+
method: "POST",
|
|
487
|
+
path: "/api/gateway/session/login",
|
|
488
|
+
headers: {
|
|
489
|
+
Accept: "application/json",
|
|
490
|
+
"Content-Type": "application/json",
|
|
491
|
+
"Content-Length": String(body.length),
|
|
492
|
+
"X-Forwarded-For": peer,
|
|
493
|
+
[APP_PROXY_HEADER]: APP_PROXY_NAME,
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
body,
|
|
497
|
+
config,
|
|
498
|
+
);
|
|
499
|
+
const session =
|
|
500
|
+
login.payload && typeof login.payload.session === "object"
|
|
501
|
+
? login.payload.session
|
|
502
|
+
: {};
|
|
503
|
+
const setCookie = login.headers?.["set-cookie"];
|
|
504
|
+
const sessionId =
|
|
505
|
+
cookieValueFromSetCookie(setCookie, "abstractgateway_session") ||
|
|
506
|
+
String(session.session_id || "").trim();
|
|
507
|
+
const csrfToken =
|
|
508
|
+
cookieValueFromSetCookie(setCookie, "abstractgateway_csrf") ||
|
|
509
|
+
String(session.csrf_token || "").trim();
|
|
510
|
+
if (!login.ok || !sessionId || !csrfToken) {
|
|
511
|
+
sendJson(res, login.status || 401, {
|
|
512
|
+
ok: false,
|
|
513
|
+
detail: login.payload?.detail || "Gateway browser session failed",
|
|
514
|
+
gateway: login.payload,
|
|
515
|
+
});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
setSessionCookies(
|
|
519
|
+
res,
|
|
520
|
+
req,
|
|
521
|
+
gatewayUrl,
|
|
522
|
+
sessionId,
|
|
523
|
+
csrfToken,
|
|
524
|
+
payload.persist === true,
|
|
525
|
+
config,
|
|
526
|
+
);
|
|
527
|
+
sendJson(res, 200, {
|
|
528
|
+
ok: true,
|
|
529
|
+
gateway_url: gatewayUrl,
|
|
530
|
+
has_session: true,
|
|
531
|
+
gateway: login.payload,
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (req.method === "DELETE") {
|
|
536
|
+
const session = browserSession(req, config);
|
|
537
|
+
if (session.sessionId) {
|
|
538
|
+
const body = Buffer.from("{}");
|
|
539
|
+
await gatewayRequest(
|
|
540
|
+
session.gatewayUrl,
|
|
541
|
+
{
|
|
542
|
+
method: "POST",
|
|
543
|
+
path: "/api/gateway/session/logout",
|
|
544
|
+
timeout: 2000,
|
|
545
|
+
headers: {
|
|
546
|
+
Accept: "application/json",
|
|
547
|
+
"Content-Type": "application/json",
|
|
548
|
+
"Content-Length": String(body.length),
|
|
549
|
+
"X-AbstractGateway-Session": session.sessionId,
|
|
550
|
+
"X-AbstractGateway-CSRF": session.csrfToken,
|
|
551
|
+
"X-Forwarded-For": peer,
|
|
552
|
+
[APP_PROXY_HEADER]: APP_PROXY_NAME,
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
body,
|
|
556
|
+
config,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
clearSessionCookies(res, req, config);
|
|
560
|
+
sendJson(res, 200, { ok: true });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
sendJson(res, 405, { detail: "Method not allowed" });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function proxyHeaders(headers) {
|
|
567
|
+
const out = {};
|
|
568
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
569
|
+
const normalized = String(key).toLowerCase();
|
|
570
|
+
if (HOP_HEADERS.has(normalized) || normalized === "content-length")
|
|
571
|
+
continue;
|
|
572
|
+
out[key] = value;
|
|
573
|
+
}
|
|
574
|
+
return out;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function proxyGatewayRequest(req, res, config) {
|
|
578
|
+
const session = browserSession(req, config);
|
|
579
|
+
if (!session.sessionId) {
|
|
580
|
+
sendJson(res, 401, { detail: "Gateway sign-in required" });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const peer = socketPeerAddress(req);
|
|
584
|
+
if (!peer) {
|
|
585
|
+
sendJson(res, 400, UNKNOWN_PEER);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (mutatingMethod(req.method)) {
|
|
589
|
+
const presented = String(req.headers["x-abstractcode-csrf"] || "").trim();
|
|
590
|
+
if (!session.csrfToken || presented !== session.csrfToken) {
|
|
591
|
+
sendJson(res, 403, {
|
|
592
|
+
detail: "Gateway browser session CSRF token missing or invalid",
|
|
593
|
+
reason_code: "csrf_required",
|
|
594
|
+
});
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
let backend;
|
|
599
|
+
try {
|
|
600
|
+
backend = resolveBackend(session.gatewayUrl, config);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
sendJson(res, 500, {
|
|
603
|
+
detail: `Invalid gateway URL: ${String(error?.message || error)}`,
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const headers = { ...req.headers, host: backend.url.host };
|
|
608
|
+
delete headers.cookie;
|
|
609
|
+
delete headers.authorization;
|
|
610
|
+
// Forwarding headers: drop every client-supplied spelling (any case, plus
|
|
611
|
+
// RFC 7239 `Forwarded` and `X-Real-IP`), then X-Forwarded-For = the socket
|
|
612
|
+
// peer, so the gateway can tell whether the browser is on its machine.
|
|
613
|
+
for (const key of Object.keys(headers))
|
|
614
|
+
if (FORWARDING_HEADERS.has(key.toLowerCase())) delete headers[key];
|
|
615
|
+
headers["x-forwarded-for"] = peer;
|
|
616
|
+
headers["x-abstractframework-app-proxy"] = APP_PROXY_NAME;
|
|
617
|
+
delete headers["x-abstractcode-csrf"];
|
|
618
|
+
headers["x-abstractgateway-session"] = session.sessionId;
|
|
619
|
+
if (mutatingMethod(req.method))
|
|
620
|
+
headers["x-abstractgateway-csrf"] = session.csrfToken;
|
|
621
|
+
const proxyRequest = backend.client.request(
|
|
622
|
+
{
|
|
623
|
+
protocol: backend.url.protocol,
|
|
624
|
+
hostname: backend.url.hostname,
|
|
625
|
+
port: backend.url.port,
|
|
626
|
+
method: req.method,
|
|
627
|
+
path: req.url,
|
|
628
|
+
headers,
|
|
629
|
+
},
|
|
630
|
+
(proxyResponse) => {
|
|
631
|
+
res.writeHead(
|
|
632
|
+
proxyResponse.statusCode || 502,
|
|
633
|
+
proxyHeaders(proxyResponse.headers),
|
|
634
|
+
);
|
|
635
|
+
proxyResponse.pipe(res);
|
|
636
|
+
},
|
|
637
|
+
);
|
|
638
|
+
proxyRequest.on("error", (error) =>
|
|
639
|
+
sendJson(res, 502, {
|
|
640
|
+
detail: `Backend not reachable at ${backend.origin} (${String(error?.message || error)})`,
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
req.pipe(proxyRequest);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function requestPathname(req) {
|
|
647
|
+
return new URL(req.url || "/", "http://abstractcode.local").pathname;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Connect-compatible middleware shared by Vite and the packaged server. */
|
|
651
|
+
export function createGatewayMiddleware(options = {}) {
|
|
652
|
+
const config = serverConfig(options);
|
|
653
|
+
return function codeGatewayMiddleware(req, res, next) {
|
|
654
|
+
let pathname;
|
|
655
|
+
try {
|
|
656
|
+
pathname = requestPathname(req);
|
|
657
|
+
} catch {
|
|
658
|
+
sendJson(res, 400, { detail: "Invalid request URL" });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (pathname === "/api/connection/gateway") {
|
|
662
|
+
if (mutatingMethod(req.method) && !browserMutationAllowed(req, config)) {
|
|
663
|
+
sendJson(res, 403, {
|
|
664
|
+
detail: "Cross-origin browser requests are not allowed",
|
|
665
|
+
reason_code: "origin_required",
|
|
666
|
+
});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
void handleConnectionApi(req, res, config).catch((error) => {
|
|
670
|
+
sendJson(res, 500, {
|
|
671
|
+
detail: `Gateway connection request failed (${String(error?.message || error)})`,
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (pathname === "/api/gateway" || pathname.startsWith("/api/gateway/")) {
|
|
677
|
+
if (mutatingMethod(req.method) && !browserMutationAllowed(req, config)) {
|
|
678
|
+
sendJson(res, 403, {
|
|
679
|
+
detail: "Cross-origin browser requests are not allowed",
|
|
680
|
+
reason_code: "origin_required",
|
|
681
|
+
});
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
proxyGatewayRequest(req, res, config);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (typeof next === "function") next();
|
|
688
|
+
else sendJson(res, 404, { detail: "Not found" });
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function unsafeStaticPath(req) {
|
|
693
|
+
const rawPath = String(req.url || "/").split("?", 1)[0];
|
|
694
|
+
let decoded;
|
|
695
|
+
try {
|
|
696
|
+
decoded = decodeURIComponent(rawPath);
|
|
697
|
+
} catch {
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
return decoded.includes("\0") || decoded.split(/[\\/]+/).includes("..");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function serveFile(res, filePath) {
|
|
704
|
+
try {
|
|
705
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) return false;
|
|
706
|
+
res.writeHead(200, {
|
|
707
|
+
"Content-Type":
|
|
708
|
+
MIME_TYPES[extname(filePath).toLowerCase()] ||
|
|
709
|
+
"application/octet-stream",
|
|
710
|
+
"Cache-Control": "no-cache",
|
|
711
|
+
});
|
|
712
|
+
res.end(readFileSync(filePath));
|
|
713
|
+
return true;
|
|
714
|
+
} catch {
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function createCodeRequestHandler(options = {}) {
|
|
720
|
+
const distDir = resolve(options.distDir || DEFAULT_DIST_DIR);
|
|
721
|
+
const gatewayMiddleware = createGatewayMiddleware(options);
|
|
722
|
+
return function codeRequestHandler(req, res) {
|
|
723
|
+
gatewayMiddleware(req, res, () => {
|
|
724
|
+
let pathname;
|
|
725
|
+
try {
|
|
726
|
+
pathname = decodeURIComponent(requestPathname(req));
|
|
727
|
+
} catch {
|
|
728
|
+
res.writeHead(400);
|
|
729
|
+
res.end("Bad Request");
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (
|
|
733
|
+
pathname === "/api" ||
|
|
734
|
+
pathname.startsWith("/api/") ||
|
|
735
|
+
unsafeStaticPath(req)
|
|
736
|
+
) {
|
|
737
|
+
if (pathname === "/api" || pathname.startsWith("/api/"))
|
|
738
|
+
sendJson(res, 404, { detail: "Not found" });
|
|
739
|
+
else {
|
|
740
|
+
res.writeHead(400);
|
|
741
|
+
res.end("Bad Request");
|
|
742
|
+
}
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const filePath = resolve(distDir, pathname.replace(/^\/+/, ""));
|
|
746
|
+
if (filePath !== distDir && !filePath.startsWith(`${distDir}${sep}`)) {
|
|
747
|
+
res.writeHead(400);
|
|
748
|
+
res.end("Bad Request");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (serveFile(res, filePath)) return;
|
|
752
|
+
if (serveFile(res, `${filePath}.html`)) return;
|
|
753
|
+
if (serveFile(res, join(filePath, "index.html"))) return;
|
|
754
|
+
if (serveFile(res, join(distDir, "index.html"))) return;
|
|
755
|
+
res.writeHead(404);
|
|
756
|
+
res.end("Not Found");
|
|
757
|
+
});
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export function createCodeServer(options = {}) {
|
|
762
|
+
return http.createServer(createCodeRequestHandler(options));
|
|
763
|
+
}
|