@abstractframework/code 0.4.2 → 0.6.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 +23 -7
- package/bin/cli.js +24 -19
- package/bin/server.js +169 -596
- package/dist/assets/index-0dumrfuU.css +1 -0
- package/dist/assets/index-Bh7omXp3.js +59 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +4 -3
- package/package.json +8 -5
- package/dist/assets/index-BbWQ0HEw.js +0 -56
- package/dist/assets/index-D3pBYdPy.css +0 -1
package/bin/server.js
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AbstractCode web server: the built app (`dist/`) and the app-origin
|
|
3
|
+
* gateway session proxy, on the shared app-server kit
|
|
4
|
+
* (`@abstractframework/app-server`).
|
|
5
|
+
*
|
|
6
|
+
* - Serves under the gateway's `/apps/code/` as well as at its own port
|
|
7
|
+
* (`createMountedHandler`: base path from `X-Forwarded-Prefix`, the
|
|
8
|
+
* browser's address from a loopback peer's `X-Forwarded-For`, the identity
|
|
9
|
+
* header `X-AbstractFramework-App: code; mount=1` on every response). The
|
|
10
|
+
* shell gets `<base href>` and `base_path` (`injectShell`), so every asset
|
|
11
|
+
* and API call resolves under the base.
|
|
12
|
+
* - `/api/connection/gateway` and `/api/gateway/*` go through the kit's
|
|
13
|
+
* session proxy (HttpOnly session cookie + CSRF twin at `Path=<base>/`,
|
|
14
|
+
* browser `Authorization` and cookies stripped, `X-Forwarded-For` = the
|
|
15
|
+
* browser, server-pinned gateway URL).
|
|
16
|
+
* - What this app adds in front of the proxy: a browser mutation must come
|
|
17
|
+
* from this app's own origin, a sign-in must be JSON, and a sign-in over a
|
|
18
|
+
* loopback socket must name a loopback host (DNS-rebinding guard).
|
|
19
|
+
*/
|
|
1
20
|
import * as http from "node:http";
|
|
2
|
-
import * as https from "node:https";
|
|
3
21
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
22
|
import { isIP } from "node:net";
|
|
5
23
|
import { dirname, extname, join, resolve, sep } from "node:path";
|
|
6
24
|
import { fileURLToPath } from "node:url";
|
|
7
25
|
|
|
26
|
+
import {
|
|
27
|
+
createGatewaySessionProxy,
|
|
28
|
+
createMountedHandler,
|
|
29
|
+
injectShell,
|
|
30
|
+
requestContext,
|
|
31
|
+
socketPeerAddress,
|
|
32
|
+
} from "@abstractframework/app-server";
|
|
33
|
+
|
|
34
|
+
export { socketPeerAddress };
|
|
35
|
+
|
|
8
36
|
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
9
37
|
const DEFAULT_DIST_DIR = resolve(BIN_DIR, "..", "dist");
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
"connection",
|
|
17
|
-
"keep-alive",
|
|
18
|
-
"proxy-authenticate",
|
|
19
|
-
"proxy-authorization",
|
|
20
|
-
"te",
|
|
21
|
-
"trailer",
|
|
22
|
-
"trailers",
|
|
23
|
-
"transfer-encoding",
|
|
24
|
-
"upgrade",
|
|
25
|
-
]);
|
|
38
|
+
/** The gateway's catalog id: `/apps/code/` and the identity header. */
|
|
39
|
+
export const APP_ID = "code";
|
|
40
|
+
/** Cookie / CSRF-header prefix (`abstractcode_gateway_*`, `x-abstractcode-csrf`). */
|
|
41
|
+
export const COOKIE_APP_ID = "abstractcode";
|
|
42
|
+
const CONNECTION_PATH = "/api/connection/gateway";
|
|
43
|
+
const GATEWAY_PREFIX = "/api/gateway/";
|
|
26
44
|
const MIME_TYPES = {
|
|
27
45
|
".html": "text/html",
|
|
28
46
|
".js": "application/javascript",
|
|
@@ -37,608 +55,151 @@ const MIME_TYPES = {
|
|
|
37
55
|
".webmanifest": "application/manifest+json",
|
|
38
56
|
};
|
|
39
57
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
.
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
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());
|
|
58
|
+
function sendJson(res, status, payload) {
|
|
59
|
+
if (res.headersSent) {
|
|
60
|
+
res.destroy();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
64
|
+
res.end(JSON.stringify(payload));
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
function
|
|
64
|
-
|
|
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 || "")
|
|
67
|
+
function firstHeader(value) {
|
|
68
|
+
return String(Array.isArray(value) ? value[0] : value || "")
|
|
71
69
|
.split(",", 1)[0]
|
|
72
70
|
.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
71
|
}
|
|
80
72
|
|
|
81
|
-
function
|
|
82
|
-
|
|
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;
|
|
73
|
+
function mutatingMethod(method) {
|
|
74
|
+
return ["POST", "PUT", "PATCH", "DELETE"].includes(String(method || "GET").toUpperCase());
|
|
87
75
|
}
|
|
88
76
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (address.startsWith("::ffff:")) address = address.slice("::ffff:".length);
|
|
94
|
-
return (
|
|
95
|
-
address === "::1" || address === "127.0.0.1" || address.startsWith("127.")
|
|
96
|
-
);
|
|
77
|
+
function hostnameOf(host) {
|
|
78
|
+
const raw = String(host || "").trim().toLowerCase();
|
|
79
|
+
if (raw.startsWith("[")) return raw.slice(1).split("]", 1)[0];
|
|
80
|
+
return (raw.match(/:/g) || []).length === 1 ? raw.split(":")[0] : raw;
|
|
97
81
|
}
|
|
98
82
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
)
|
|
105
|
-
return true;
|
|
106
|
-
const trusted =
|
|
107
|
-
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
108
|
-
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
109
|
-
// A trusted proxy makes its own socket address meaningless. Hosted
|
|
110
|
-
// deployments must explicitly opt in before a browser can choose an SSRF
|
|
111
|
-
// destination; direct loopback development remains convenient.
|
|
112
|
-
return (
|
|
113
|
-
!trusted &&
|
|
114
|
-
isLoopbackPeer(req) &&
|
|
115
|
-
isLoopbackHostname(requestHostname(req, config))
|
|
116
|
-
);
|
|
83
|
+
/** `localhost`, `*.localhost`, `::1` or a 127.x IP LITERAL (never a DNS name starting "127."). */
|
|
84
|
+
function isLoopbackHostname(host) {
|
|
85
|
+
const h = hostnameOf(host);
|
|
86
|
+
if (h === "localhost" || h.endsWith(".localhost") || h === "::1") return true;
|
|
87
|
+
return isIP(h) === 4 && h.startsWith("127.");
|
|
117
88
|
}
|
|
118
89
|
|
|
119
|
-
|
|
120
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The warning printed at start when `--host` is not a loopback address
|
|
92
|
+
* (`0.0.0.0`, `::`, a LAN address or name): the app is then reachable from
|
|
93
|
+
* other machines, and the gateway already serves it at `/apps/code/`.
|
|
94
|
+
* `null` for a loopback host.
|
|
95
|
+
*/
|
|
96
|
+
export function bindExposureWarning(host, port) {
|
|
97
|
+
if (isLoopbackHostname(host)) return null;
|
|
98
|
+
const where = host === "0.0.0.0" || host === "::" || host === "[::]" ? "every network interface" : String(host);
|
|
121
99
|
return (
|
|
122
|
-
`
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function parseCookies(req) {
|
|
128
|
-
const out = {};
|
|
129
|
-
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
130
|
-
const index = part.indexOf("=");
|
|
131
|
-
if (index < 0) continue;
|
|
132
|
-
const key = part.slice(0, index).trim();
|
|
133
|
-
const value = part.slice(index + 1).trim();
|
|
134
|
-
if (!key) continue;
|
|
135
|
-
try {
|
|
136
|
-
out[key] = decodeURIComponent(value);
|
|
137
|
-
} catch {
|
|
138
|
-
out[key] = value;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return out;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function cookieSecure(req, config) {
|
|
145
|
-
if (req.socket?.encrypted) return "; Secure";
|
|
146
|
-
const trusted =
|
|
147
|
-
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
148
|
-
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
149
|
-
return trusted && firstHeader(req.headers["x-forwarded-proto"]).toLowerCase() === "https"
|
|
150
|
-
? "; Secure"
|
|
151
|
-
: "";
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function setSessionCookies(
|
|
155
|
-
res,
|
|
156
|
-
req,
|
|
157
|
-
gatewayUrl,
|
|
158
|
-
sessionId,
|
|
159
|
-
csrfToken,
|
|
160
|
-
persist,
|
|
161
|
-
config,
|
|
162
|
-
) {
|
|
163
|
-
const secure = cookieSecure(req, config);
|
|
164
|
-
const maxAge = persist ? "; Max-Age=2592000" : "";
|
|
165
|
-
const privateAttrs = `; Path=/; HttpOnly; SameSite=Lax${secure}${maxAge}`;
|
|
166
|
-
const csrfAttrs = `; Path=/; SameSite=Lax${secure}${maxAge}`;
|
|
167
|
-
res.setHeader("Set-Cookie", [
|
|
168
|
-
`${URL_COOKIE}=${encodeURIComponent(gatewayUrl)}${privateAttrs}`,
|
|
169
|
-
`${SESSION_COOKIE}=${encodeURIComponent(sessionId)}${privateAttrs}`,
|
|
170
|
-
`${CSRF_COOKIE}=${encodeURIComponent(csrfToken)}${csrfAttrs}`,
|
|
171
|
-
]);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function clearSessionCookies(res, req, config) {
|
|
175
|
-
const secure = cookieSecure(req, config);
|
|
176
|
-
res.setHeader("Set-Cookie", [
|
|
177
|
-
`${URL_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
178
|
-
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
179
|
-
`${CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0${secure}`,
|
|
180
|
-
]);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function browserSession(req, config) {
|
|
184
|
-
const cookies = parseCookies(req);
|
|
185
|
-
const cookieUrl = normalizeGatewayUrl(cookies[URL_COOKIE]);
|
|
186
|
-
const allowCookieUrl =
|
|
187
|
-
envBool(config.env, "ABSTRACTCODE_ALLOW_BROWSER_GATEWAY_URL_COOKIE") ||
|
|
188
|
-
envBool(config.env, "ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG") ||
|
|
189
|
-
envBool(
|
|
190
|
-
config.env,
|
|
191
|
-
"ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG",
|
|
192
|
-
) ||
|
|
193
|
-
connectionConfigAllowed(req, config);
|
|
194
|
-
return {
|
|
195
|
-
gatewayUrl:
|
|
196
|
-
cookieUrl && (allowCookieUrl || cookieUrl === config.defaultGatewayUrl)
|
|
197
|
-
? cookieUrl
|
|
198
|
-
: config.defaultGatewayUrl,
|
|
199
|
-
sessionId: String(cookies[SESSION_COOKIE] || "").trim(),
|
|
200
|
-
csrfToken: String(cookies[CSRF_COOKIE] || "").trim(),
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function resolveBackend(gatewayUrl, config) {
|
|
205
|
-
const url = new URL(
|
|
206
|
-
normalizeGatewayUrl(gatewayUrl) || config.defaultGatewayUrl,
|
|
207
|
-
);
|
|
208
|
-
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
209
|
-
throw new Error("Gateway URL must use http or https");
|
|
210
|
-
if (!url.port) url.port = url.protocol === "https:" ? "443" : "80";
|
|
211
|
-
return {
|
|
212
|
-
url,
|
|
213
|
-
origin: `${url.protocol}//${url.host}`,
|
|
214
|
-
client: url.protocol === "https:" ? https : http,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function mutatingMethod(method) {
|
|
219
|
-
return ["POST", "PUT", "PATCH", "DELETE"].includes(
|
|
220
|
-
String(method || "GET").toUpperCase(),
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function firstHeader(value) {
|
|
225
|
-
return String(Array.isArray(value) ? value[0] : value || "")
|
|
226
|
-
.split(",", 1)[0]
|
|
227
|
-
.trim();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function requestOrigin(req, config) {
|
|
231
|
-
const trusted =
|
|
232
|
-
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
233
|
-
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
234
|
-
const host = firstHeader(
|
|
235
|
-
trusted
|
|
236
|
-
? req.headers["x-forwarded-host"] || req.headers.host
|
|
237
|
-
: req.headers.host,
|
|
100
|
+
`WARNING: --host ${host} exposes AbstractCode Web beyond this machine: it listens on ${where}, ` +
|
|
101
|
+
`so anyone who can reach this computer on port ${port} can open it. ` +
|
|
102
|
+
`The gateway already serves it at /apps/code/; keep the default --host 127.0.0.1 unless you mean this.`
|
|
238
103
|
);
|
|
239
|
-
const protocol =
|
|
240
|
-
firstHeader(trusted ? req.headers["x-forwarded-proto"] : "") ||
|
|
241
|
-
(req.socket?.encrypted ? "https" : "http");
|
|
242
|
-
if (!host || !["http", "https"].includes(protocol.toLowerCase())) return "";
|
|
243
|
-
try {
|
|
244
|
-
return new URL(`${protocol.toLowerCase()}://${host}`).origin;
|
|
245
|
-
} catch {
|
|
246
|
-
return "";
|
|
247
|
-
}
|
|
248
104
|
}
|
|
249
105
|
|
|
250
|
-
|
|
106
|
+
/** A browser mutation must come from the origin the browser sees this app at. */
|
|
107
|
+
function browserMutationAllowed(req, ctx) {
|
|
251
108
|
const origin = firstHeader(req.headers.origin);
|
|
252
109
|
if (origin) {
|
|
253
|
-
let normalized;
|
|
254
110
|
try {
|
|
255
|
-
|
|
111
|
+
return new URL(origin).origin === new URL(`${ctx.proto}://${ctx.host}`).origin;
|
|
256
112
|
} catch {
|
|
257
113
|
return false;
|
|
258
114
|
}
|
|
259
|
-
return normalized === requestOrigin(req, config);
|
|
260
115
|
}
|
|
261
|
-
return (
|
|
262
|
-
firstHeader(req.headers["sec-fetch-site"]).toLowerCase() !== "cross-site"
|
|
263
|
-
);
|
|
116
|
+
return firstHeader(req.headers["sec-fetch-site"]).toLowerCase() !== "cross-site";
|
|
264
117
|
}
|
|
265
118
|
|
|
266
119
|
function jsonRequest(req) {
|
|
267
|
-
return (
|
|
268
|
-
firstHeader(req.headers["content-type"])
|
|
269
|
-
.split(";", 1)[0]
|
|
270
|
-
.trim()
|
|
271
|
-
.toLowerCase() === "application/json"
|
|
272
|
-
);
|
|
120
|
+
return firstHeader(req.headers["content-type"]).split(";", 1)[0].trim().toLowerCase() === "application/json";
|
|
273
121
|
}
|
|
274
122
|
|
|
275
|
-
function
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
281
|
-
res.end(JSON.stringify(payload));
|
|
123
|
+
function isLoopbackPeerSocket(req) {
|
|
124
|
+
const peer = socketPeerAddress(req);
|
|
125
|
+
return peer === "::1" || peer.startsWith("127.");
|
|
282
126
|
}
|
|
283
127
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
291
|
-
resolveValue(raw ? JSON.parse(raw) : {});
|
|
292
|
-
} catch {
|
|
293
|
-
resolveValue({});
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
req.on("error", () => resolveValue({}));
|
|
297
|
-
});
|
|
128
|
+
/** The operator declared a reverse proxy in front of this app (legacy env switches, read by the kit too). */
|
|
129
|
+
function trustProxyDeclared() {
|
|
130
|
+
return ["ABSTRACTCODE_TRUST_PROXY_HEADERS", "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS"].some((name) =>
|
|
131
|
+
["1", "true", "yes", "y", "on"].includes(String(process.env[name] || "").trim().toLowerCase()),
|
|
132
|
+
);
|
|
298
133
|
}
|
|
299
134
|
|
|
300
|
-
function
|
|
301
|
-
return new
|
|
302
|
-
let backend;
|
|
303
|
-
try {
|
|
304
|
-
backend = resolveBackend(gatewayUrl, config);
|
|
305
|
-
} catch (error) {
|
|
306
|
-
resolveValue({
|
|
307
|
-
ok: false,
|
|
308
|
-
status: 0,
|
|
309
|
-
payload: {
|
|
310
|
-
detail: `Invalid gateway URL: ${String(error?.message || error)}`,
|
|
311
|
-
},
|
|
312
|
-
});
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
const request = backend.client.request(
|
|
316
|
-
{
|
|
317
|
-
protocol: backend.url.protocol,
|
|
318
|
-
hostname: backend.url.hostname,
|
|
319
|
-
port: backend.url.port,
|
|
320
|
-
timeout: options.timeout || 4000,
|
|
321
|
-
...options,
|
|
322
|
-
},
|
|
323
|
-
(response) => {
|
|
324
|
-
const chunks = [];
|
|
325
|
-
response.on("data", (chunk) => chunks.push(chunk));
|
|
326
|
-
response.on("end", () => {
|
|
327
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
328
|
-
let payload;
|
|
329
|
-
try {
|
|
330
|
-
payload = raw ? JSON.parse(raw) : {};
|
|
331
|
-
} catch {
|
|
332
|
-
payload = { detail: raw };
|
|
333
|
-
}
|
|
334
|
-
const status = response.statusCode || 0;
|
|
335
|
-
resolveValue({
|
|
336
|
-
ok: status >= 200 && status < 300,
|
|
337
|
-
status,
|
|
338
|
-
payload,
|
|
339
|
-
headers: response.headers,
|
|
340
|
-
origin: backend.origin,
|
|
341
|
-
});
|
|
342
|
-
});
|
|
343
|
-
},
|
|
344
|
-
);
|
|
345
|
-
request.on("timeout", () => {
|
|
346
|
-
request.destroy();
|
|
347
|
-
resolveValue({
|
|
348
|
-
ok: false,
|
|
349
|
-
status: 0,
|
|
350
|
-
payload: { detail: "Gateway request timed out" },
|
|
351
|
-
origin: backend.origin,
|
|
352
|
-
});
|
|
353
|
-
});
|
|
354
|
-
request.on("error", (error) =>
|
|
355
|
-
resolveValue({
|
|
356
|
-
ok: false,
|
|
357
|
-
status: 0,
|
|
358
|
-
payload: { detail: String(error?.message || error) },
|
|
359
|
-
origin: backend.origin,
|
|
360
|
-
}),
|
|
361
|
-
);
|
|
362
|
-
if (body) request.write(body);
|
|
363
|
-
request.end();
|
|
364
|
-
});
|
|
135
|
+
function pathnameOf(req) {
|
|
136
|
+
return new URL(req.url || "/", "http://abstractcode.local").pathname;
|
|
365
137
|
}
|
|
366
138
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
139
|
+
/**
|
|
140
|
+
* The gateway half: connection API + `/api/gateway/*`, with this app's
|
|
141
|
+
* guards in front of the kit proxy. Connect-compatible (`next` for every
|
|
142
|
+
* other path), so the Vite dev server mounts the SAME code.
|
|
143
|
+
*/
|
|
144
|
+
export function createGatewayMiddleware(options = {}) {
|
|
145
|
+
const proxy = createGatewaySessionProxy({
|
|
146
|
+
appId: COOKIE_APP_ID,
|
|
147
|
+
...(options.defaultGatewayUrl ? { defaultGatewayUrl: options.defaultGatewayUrl } : {}),
|
|
148
|
+
connectionPath: CONNECTION_PATH,
|
|
149
|
+
proxyPrefix: GATEWAY_PREFIX,
|
|
150
|
+
});
|
|
151
|
+
function middleware(req, res, next) {
|
|
152
|
+
let pathname;
|
|
153
|
+
try {
|
|
154
|
+
pathname = pathnameOf(req);
|
|
155
|
+
} catch {
|
|
156
|
+
sendJson(res, 400, { detail: "Invalid request URL" });
|
|
157
|
+
return;
|
|
384
158
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
async function handleConnectionApi(req, res, config) {
|
|
390
|
-
if (req.method === "GET") {
|
|
391
|
-
const session = browserSession(req, config);
|
|
392
|
-
if (!session.sessionId) {
|
|
393
|
-
sendJson(res, 200, {
|
|
394
|
-
ok: false,
|
|
395
|
-
gateway_url: session.gatewayUrl,
|
|
396
|
-
has_session: false,
|
|
397
|
-
gateway: { ok: false, error: "Gateway sign-in required" },
|
|
398
|
-
});
|
|
159
|
+
const owned = pathname === CONNECTION_PATH || pathname.startsWith(GATEWAY_PREFIX);
|
|
160
|
+
if (!owned) {
|
|
161
|
+
if (typeof next === "function") next();
|
|
162
|
+
else sendJson(res, 404, { detail: "Not found" });
|
|
399
163
|
return;
|
|
400
164
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
headers: {
|
|
407
|
-
Accept: "application/json",
|
|
408
|
-
"X-AbstractGateway-Session": session.sessionId,
|
|
409
|
-
},
|
|
410
|
-
},
|
|
411
|
-
undefined,
|
|
412
|
-
config,
|
|
413
|
-
);
|
|
414
|
-
sendJson(res, 200, {
|
|
415
|
-
ok: checked.ok,
|
|
416
|
-
gateway_url: session.gatewayUrl,
|
|
417
|
-
has_session: true,
|
|
418
|
-
gateway: checked.payload,
|
|
419
|
-
});
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
if (req.method === "POST") {
|
|
423
|
-
if (!jsonRequest(req)) {
|
|
424
|
-
sendJson(res, 415, {
|
|
425
|
-
detail:
|
|
426
|
-
"Gateway connection requests require Content-Type: application/json",
|
|
427
|
-
});
|
|
165
|
+
let ctx;
|
|
166
|
+
try {
|
|
167
|
+
ctx = requestContext(req);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
sendJson(res, Number(error?.status) || 400, { detail: String(error?.message || error) });
|
|
428
170
|
return;
|
|
429
171
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
172
|
+
// DNS rebinding: a page at a hostile name resolving to 127.0.0.1 reaches
|
|
173
|
+
// this loopback server with its own Host; as a same-origin script it can
|
|
174
|
+
// also add X-Forwarded-* headers, which a loopback peer is believed for.
|
|
175
|
+
// So a sign-in over a loopback socket must name a loopback host (the
|
|
176
|
+
// gateway's /apps/code/ proxy and a local browser both do), unless the
|
|
177
|
+
// operator declared a reverse proxy in front of this app.
|
|
434
178
|
if (
|
|
435
|
-
|
|
436
|
-
|
|
179
|
+
pathname === CONNECTION_PATH &&
|
|
180
|
+
mutatingMethod(req.method) &&
|
|
181
|
+
isLoopbackPeerSocket(req) &&
|
|
182
|
+
!isLoopbackHostname(req.headers.host) &&
|
|
183
|
+
!trustProxyDeclared()
|
|
437
184
|
) {
|
|
438
|
-
sendJson(res, 403, { detail: connectionConfigDenial(req, config) });
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
const body = Buffer.from(
|
|
442
|
-
JSON.stringify({
|
|
443
|
-
user_id: String(payload.gateway_user_id || "").trim(),
|
|
444
|
-
token: String(payload.gateway_token || "").trim(),
|
|
445
|
-
remember: payload.persist === true,
|
|
446
|
-
}),
|
|
447
|
-
);
|
|
448
|
-
const login = await gatewayRequest(
|
|
449
|
-
gatewayUrl,
|
|
450
|
-
{
|
|
451
|
-
method: "POST",
|
|
452
|
-
path: "/api/gateway/session/login",
|
|
453
|
-
headers: {
|
|
454
|
-
Accept: "application/json",
|
|
455
|
-
"Content-Type": "application/json",
|
|
456
|
-
"Content-Length": String(body.length),
|
|
457
|
-
},
|
|
458
|
-
},
|
|
459
|
-
body,
|
|
460
|
-
config,
|
|
461
|
-
);
|
|
462
|
-
const session =
|
|
463
|
-
login.payload && typeof login.payload.session === "object"
|
|
464
|
-
? login.payload.session
|
|
465
|
-
: {};
|
|
466
|
-
const setCookie = login.headers?.["set-cookie"];
|
|
467
|
-
const sessionId =
|
|
468
|
-
cookieValueFromSetCookie(setCookie, "abstractgateway_session") ||
|
|
469
|
-
String(session.session_id || "").trim();
|
|
470
|
-
const csrfToken =
|
|
471
|
-
cookieValueFromSetCookie(setCookie, "abstractgateway_csrf") ||
|
|
472
|
-
String(session.csrf_token || "").trim();
|
|
473
|
-
if (!login.ok || !sessionId || !csrfToken) {
|
|
474
|
-
sendJson(res, login.status || 401, {
|
|
475
|
-
ok: false,
|
|
476
|
-
detail: login.payload?.detail || "Gateway browser session failed",
|
|
477
|
-
gateway: login.payload,
|
|
478
|
-
});
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
setSessionCookies(
|
|
482
|
-
res,
|
|
483
|
-
req,
|
|
484
|
-
gatewayUrl,
|
|
485
|
-
sessionId,
|
|
486
|
-
csrfToken,
|
|
487
|
-
payload.persist === true,
|
|
488
|
-
config,
|
|
489
|
-
);
|
|
490
|
-
sendJson(res, 200, {
|
|
491
|
-
ok: true,
|
|
492
|
-
gateway_url: gatewayUrl,
|
|
493
|
-
has_session: true,
|
|
494
|
-
gateway: login.payload,
|
|
495
|
-
});
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
if (req.method === "DELETE") {
|
|
499
|
-
const session = browserSession(req, config);
|
|
500
|
-
if (session.sessionId) {
|
|
501
|
-
const body = Buffer.from("{}");
|
|
502
|
-
await gatewayRequest(
|
|
503
|
-
session.gatewayUrl,
|
|
504
|
-
{
|
|
505
|
-
method: "POST",
|
|
506
|
-
path: "/api/gateway/session/logout",
|
|
507
|
-
timeout: 2000,
|
|
508
|
-
headers: {
|
|
509
|
-
Accept: "application/json",
|
|
510
|
-
"Content-Type": "application/json",
|
|
511
|
-
"Content-Length": String(body.length),
|
|
512
|
-
"X-AbstractGateway-Session": session.sessionId,
|
|
513
|
-
"X-AbstractGateway-CSRF": session.csrfToken,
|
|
514
|
-
},
|
|
515
|
-
},
|
|
516
|
-
body,
|
|
517
|
-
config,
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
clearSessionCookies(res, req, config);
|
|
521
|
-
sendJson(res, 200, { ok: true });
|
|
522
|
-
return;
|
|
523
|
-
}
|
|
524
|
-
sendJson(res, 405, { detail: "Method not allowed" });
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function proxyHeaders(headers) {
|
|
528
|
-
const out = {};
|
|
529
|
-
for (const [key, value] of Object.entries(headers || {})) {
|
|
530
|
-
const normalized = String(key).toLowerCase();
|
|
531
|
-
if (HOP_HEADERS.has(normalized) || normalized === "content-length")
|
|
532
|
-
continue;
|
|
533
|
-
out[key] = value;
|
|
534
|
-
}
|
|
535
|
-
return out;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function proxyGatewayRequest(req, res, config) {
|
|
539
|
-
const session = browserSession(req, config);
|
|
540
|
-
if (!session.sessionId) {
|
|
541
|
-
sendJson(res, 401, { detail: "Gateway sign-in required" });
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
if (mutatingMethod(req.method)) {
|
|
545
|
-
const presented = String(req.headers["x-abstractcode-csrf"] || "").trim();
|
|
546
|
-
if (!session.csrfToken || presented !== session.csrfToken) {
|
|
547
185
|
sendJson(res, 403, {
|
|
548
|
-
detail:
|
|
549
|
-
reason_code: "
|
|
186
|
+
detail: `Sign-in refused: this local app was reached under the host name ${hostnameOf(req.headers.host) || "(none)"}, not a loopback address. Browser-supplied Gateway URL changes are disabled for it.`,
|
|
187
|
+
reason_code: "host_not_allowed",
|
|
550
188
|
});
|
|
551
189
|
return;
|
|
552
190
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
try {
|
|
556
|
-
backend = resolveBackend(session.gatewayUrl, config);
|
|
557
|
-
} catch (error) {
|
|
558
|
-
sendJson(res, 500, {
|
|
559
|
-
detail: `Invalid gateway URL: ${String(error?.message || error)}`,
|
|
560
|
-
});
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
const headers = { ...req.headers, host: backend.url.host };
|
|
564
|
-
delete headers.cookie;
|
|
565
|
-
delete headers.authorization;
|
|
566
|
-
delete headers["x-forwarded-for"];
|
|
567
|
-
delete headers["x-forwarded-host"];
|
|
568
|
-
delete headers["x-forwarded-proto"];
|
|
569
|
-
delete headers["x-abstractcode-csrf"];
|
|
570
|
-
headers["x-abstractgateway-session"] = session.sessionId;
|
|
571
|
-
if (mutatingMethod(req.method))
|
|
572
|
-
headers["x-abstractgateway-csrf"] = session.csrfToken;
|
|
573
|
-
const proxyRequest = backend.client.request(
|
|
574
|
-
{
|
|
575
|
-
protocol: backend.url.protocol,
|
|
576
|
-
hostname: backend.url.hostname,
|
|
577
|
-
port: backend.url.port,
|
|
578
|
-
method: req.method,
|
|
579
|
-
path: req.url,
|
|
580
|
-
headers,
|
|
581
|
-
},
|
|
582
|
-
(proxyResponse) => {
|
|
583
|
-
res.writeHead(
|
|
584
|
-
proxyResponse.statusCode || 502,
|
|
585
|
-
proxyHeaders(proxyResponse.headers),
|
|
586
|
-
);
|
|
587
|
-
proxyResponse.pipe(res);
|
|
588
|
-
},
|
|
589
|
-
);
|
|
590
|
-
proxyRequest.on("error", (error) =>
|
|
591
|
-
sendJson(res, 502, {
|
|
592
|
-
detail: `Backend not reachable at ${backend.origin} (${String(error?.message || error)})`,
|
|
593
|
-
}),
|
|
594
|
-
);
|
|
595
|
-
req.pipe(proxyRequest);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function requestPathname(req) {
|
|
599
|
-
return new URL(req.url || "/", "http://abstractcode.local").pathname;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/** Connect-compatible middleware shared by Vite and the packaged server. */
|
|
603
|
-
export function createGatewayMiddleware(options = {}) {
|
|
604
|
-
const config = serverConfig(options);
|
|
605
|
-
return function codeGatewayMiddleware(req, res, next) {
|
|
606
|
-
let pathname;
|
|
607
|
-
try {
|
|
608
|
-
pathname = requestPathname(req);
|
|
609
|
-
} catch {
|
|
610
|
-
sendJson(res, 400, { detail: "Invalid request URL" });
|
|
191
|
+
if (mutatingMethod(req.method) && !browserMutationAllowed(req, ctx)) {
|
|
192
|
+
sendJson(res, 403, { detail: "Cross-origin browser requests are not allowed", reason_code: "origin_required" });
|
|
611
193
|
return;
|
|
612
194
|
}
|
|
613
|
-
if (pathname === "
|
|
614
|
-
|
|
615
|
-
sendJson(res, 403, {
|
|
616
|
-
detail: "Cross-origin browser requests are not allowed",
|
|
617
|
-
reason_code: "origin_required",
|
|
618
|
-
});
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
void handleConnectionApi(req, res, config).catch((error) => {
|
|
622
|
-
sendJson(res, 500, {
|
|
623
|
-
detail: `Gateway connection request failed (${String(error?.message || error)})`,
|
|
624
|
-
});
|
|
625
|
-
});
|
|
195
|
+
if (pathname === CONNECTION_PATH && req.method === "POST" && !jsonRequest(req)) {
|
|
196
|
+
sendJson(res, 415, { detail: "Gateway connection requests require Content-Type: application/json" });
|
|
626
197
|
return;
|
|
627
198
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
reason_code: "origin_required",
|
|
633
|
-
});
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
proxyGatewayRequest(req, res, config);
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
if (typeof next === "function") next();
|
|
640
|
-
else sendJson(res, 404, { detail: "Not found" });
|
|
641
|
-
};
|
|
199
|
+
proxy.handle(req, res, pathname);
|
|
200
|
+
}
|
|
201
|
+
middleware.proxy = proxy;
|
|
202
|
+
return middleware;
|
|
642
203
|
}
|
|
643
204
|
|
|
644
205
|
function unsafeStaticPath(req) {
|
|
@@ -652,48 +213,60 @@ function unsafeStaticPath(req) {
|
|
|
652
213
|
return decoded.includes("\0") || decoded.split(/[\\/]+/).includes("..");
|
|
653
214
|
}
|
|
654
215
|
|
|
655
|
-
function
|
|
216
|
+
function isFile(filePath) {
|
|
656
217
|
try {
|
|
657
|
-
|
|
658
|
-
res.writeHead(200, {
|
|
659
|
-
"Content-Type":
|
|
660
|
-
MIME_TYPES[extname(filePath).toLowerCase()] ||
|
|
661
|
-
"application/octet-stream",
|
|
662
|
-
"Cache-Control": "no-cache",
|
|
663
|
-
});
|
|
664
|
-
res.end(readFileSync(filePath));
|
|
665
|
-
return true;
|
|
218
|
+
return existsSync(filePath) && statSync(filePath).isFile();
|
|
666
219
|
} catch {
|
|
667
220
|
return false;
|
|
668
221
|
}
|
|
669
222
|
}
|
|
670
223
|
|
|
224
|
+
function serveFile(res, filePath) {
|
|
225
|
+
if (!isFile(filePath)) return false;
|
|
226
|
+
res.writeHead(200, {
|
|
227
|
+
"Content-Type": MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream",
|
|
228
|
+
"Cache-Control": "no-cache",
|
|
229
|
+
});
|
|
230
|
+
res.end(readFileSync(filePath));
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The app shell with `<base href="<basePath>/">` and `base_path`. */
|
|
235
|
+
function serveShell(res, distDir, basePath, gatewayUrl) {
|
|
236
|
+
const index = join(distDir, "index.html");
|
|
237
|
+
if (!isFile(index)) return false;
|
|
238
|
+
const html = injectShell(readFileSync(index, "utf8"), { basePath, config: { gateway_url: gatewayUrl } });
|
|
239
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
240
|
+
res.end(html);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
671
244
|
export function createCodeRequestHandler(options = {}) {
|
|
672
245
|
const distDir = resolve(options.distDir || DEFAULT_DIST_DIR);
|
|
673
246
|
const gatewayMiddleware = createGatewayMiddleware(options);
|
|
674
|
-
return
|
|
247
|
+
return createMountedHandler({ appId: APP_ID }, (req, res, ctx) => {
|
|
675
248
|
gatewayMiddleware(req, res, () => {
|
|
676
249
|
let pathname;
|
|
677
250
|
try {
|
|
678
|
-
pathname = decodeURIComponent(
|
|
251
|
+
pathname = decodeURIComponent(pathnameOf(req));
|
|
679
252
|
} catch {
|
|
680
253
|
res.writeHead(400);
|
|
681
254
|
res.end("Bad Request");
|
|
682
255
|
return;
|
|
683
256
|
}
|
|
684
|
-
if (
|
|
685
|
-
|
|
686
|
-
pathname.startsWith("/api/") ||
|
|
687
|
-
unsafeStaticPath(req)
|
|
688
|
-
) {
|
|
689
|
-
if (pathname === "/api" || pathname.startsWith("/api/"))
|
|
690
|
-
sendJson(res, 404, { detail: "Not found" });
|
|
691
|
-
else {
|
|
692
|
-
res.writeHead(400);
|
|
693
|
-
res.end("Bad Request");
|
|
694
|
-
}
|
|
257
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
258
|
+
sendJson(res, 404, { detail: "Not found" });
|
|
695
259
|
return;
|
|
696
260
|
}
|
|
261
|
+
if (unsafeStaticPath(req)) {
|
|
262
|
+
res.writeHead(400);
|
|
263
|
+
res.end("Bad Request");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const gatewayUrl = gatewayMiddleware.proxy.defaultGatewayUrl;
|
|
267
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
268
|
+
if (serveShell(res, distDir, ctx.basePath, gatewayUrl)) return;
|
|
269
|
+
}
|
|
697
270
|
const filePath = resolve(distDir, pathname.replace(/^\/+/, ""));
|
|
698
271
|
if (filePath !== distDir && !filePath.startsWith(`${distDir}${sep}`)) {
|
|
699
272
|
res.writeHead(400);
|
|
@@ -702,12 +275,12 @@ export function createCodeRequestHandler(options = {}) {
|
|
|
702
275
|
}
|
|
703
276
|
if (serveFile(res, filePath)) return;
|
|
704
277
|
if (serveFile(res, `${filePath}.html`)) return;
|
|
705
|
-
|
|
706
|
-
if (
|
|
278
|
+
// Single-page app: any other path is the shell.
|
|
279
|
+
if (serveShell(res, distDir, ctx.basePath, gatewayUrl)) return;
|
|
707
280
|
res.writeHead(404);
|
|
708
281
|
res.end("Not Found");
|
|
709
282
|
});
|
|
710
|
-
};
|
|
283
|
+
});
|
|
711
284
|
}
|
|
712
285
|
|
|
713
286
|
export function createCodeServer(options = {}) {
|