@gen3/workspaces 0.12.71 → 0.12.73
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/dist/dts/api/workspaceAssetsApi.d.ts +1 -1
- package/dist/dts/api/workspaceAssetsApi.d.ts.map +1 -1
- package/dist/dts/api/workspaceAssetsApi.js +17 -1
- package/dist/dts/api/workspaceAssetsApi.js.map +1 -1
- package/dist/dts/server/workspaceAssetsHandler.d.ts +43 -2
- package/dist/dts/server/workspaceAssetsHandler.d.ts.map +1 -1
- package/dist/dts/server/workspaceAssetsHandler.js +134 -1
- package/dist/dts/server/workspaceAssetsHandler.js.map +1 -1
- package/dist/jupyterlite-builds/remote-private/requirements.txt +1 -0
- package/dist/server/cjs/server.js +278 -130
- package/dist/server/cjs/server.js.map +1 -1
- package/dist/server/esm/server.js +278 -130
- package/dist/server/esm/server.js.map +1 -1
- package/dist/server.d.ts +43 -2
- package/jupyterlite-builds/remote-private/requirements.txt +1 -0
- package/package.json +4 -4
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { GEN3_API, GEN3_WORKSPACE_API, GEN3_FENCE_SERVICE, fetchFence, GEN3_COMMONS_NAME } from '@gen3/core';
|
|
2
2
|
import nodePath from 'path';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
+
import http from 'http';
|
|
5
|
+
import https from 'https';
|
|
4
6
|
import { getCookie } from 'cookies-next';
|
|
5
7
|
import { GEN3_FENCE_API } from '@gen3/core/server';
|
|
6
8
|
import { getAccessToken } from '@gen3/frontend/server';
|
|
@@ -11,6 +13,134 @@ process.env.GEN3_KERNEL_API || `${GEN3_API}/lw-workspace/proxy/jeg-panel/api`;
|
|
|
11
13
|
process.env.JEG_GATEWAY_API || `${GEN3_API}/lw-workspace/proxy/jeg-panel/api`;
|
|
12
14
|
const JEG_SERVICE_API = 'http://jupyter-enterprise-gateway.jupyter-pods.svc.cluster.local:8888';
|
|
13
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Shared HTTP proxy utility for server-side proxy handlers.
|
|
18
|
+
* Streams request body through and forwards response status + body.
|
|
19
|
+
*
|
|
20
|
+
* Also exports two token helpers used by the proxy factories:
|
|
21
|
+
* - extractTokenFallback: minimal cookie/header extraction used when the host
|
|
22
|
+
* app has not injected its own getToken (standalone / test use).
|
|
23
|
+
* - decodeJwtClaims: base64url decode of the JWT payload for REMOTE_USER header.
|
|
24
|
+
* Crypto verification is Ambassador's job — we only need the sub/username claim.
|
|
25
|
+
*/ /**
|
|
26
|
+
* Minimal JWT extraction fallback — used only when the host app does not inject
|
|
27
|
+
* its own getToken. Prefers Authorization: Bearer, then access_token cookie.
|
|
28
|
+
* In gen3-vectis production this is never reached — getAccessToken is always wired.
|
|
29
|
+
*/ function extractTokenFallback(req) {
|
|
30
|
+
const auth = req.headers['authorization'];
|
|
31
|
+
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
|
32
|
+
return auth.slice(7).trim() || null;
|
|
33
|
+
}
|
|
34
|
+
const cookie = req.headers['cookie'] ?? '';
|
|
35
|
+
const m = cookie.match(/(?:^|;\s*)access_token=([^;]+)/);
|
|
36
|
+
return m ? decodeURIComponent(m[1]).trim() || null : null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Decode JWT payload claims without cryptographic verification.
|
|
40
|
+
* Ambassador has already verified the RS256 signature — we just need the sub claim
|
|
41
|
+
* to set the REMOTE_USER header for per-user isolation enforcement downstream.
|
|
42
|
+
*/ // Maximum JWT string length accepted before base64 decoding.
|
|
43
|
+
// GA4GH RAS Passport JWTs embed multiple signed Visa JWTs as claims, so the
|
|
44
|
+
// outer passport can easily exceed 8 KiB (50 visas × ~2 KB each ≈ 136 KB encoded).
|
|
45
|
+
// 512 KiB covers the most complex real-world passports while still guarding against
|
|
46
|
+
// crafted JWTs designed to exhaust server memory. Ambassador has already verified
|
|
47
|
+
// the RS256 signature — this cap only defends the local base64/JSON decode step.
|
|
48
|
+
const MAX_JWT_LENGTH = 524288; // 512 KiB
|
|
49
|
+
function decodeJwtClaims(jwt) {
|
|
50
|
+
if (jwt.length > MAX_JWT_LENGTH) return null;
|
|
51
|
+
try {
|
|
52
|
+
const parts = jwt.split('.');
|
|
53
|
+
if (parts.length < 2) return null;
|
|
54
|
+
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(parts[1].length / 4) * 4, '=');
|
|
55
|
+
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function createProxyRequest({ req, res, targetUrl, method, extraHeaders = {}, forwardBody = false }) {
|
|
61
|
+
const headers = {
|
|
62
|
+
...extraHeaders
|
|
63
|
+
};
|
|
64
|
+
if (forwardBody && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
65
|
+
headers['Content-Type'] ??= 'application/json';
|
|
66
|
+
}
|
|
67
|
+
let body;
|
|
68
|
+
if (forwardBody && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
69
|
+
let buf;
|
|
70
|
+
try {
|
|
71
|
+
buf = await bufferBody(req);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const statusCode = err.statusCode ?? 400;
|
|
74
|
+
const msg = err instanceof Error ? err.message : 'Bad request';
|
|
75
|
+
res.status(statusCode).json({
|
|
76
|
+
error: msg
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Node.js Buffer is a Uint8Array subclass; cast to silence DOM BodyInit mismatch
|
|
81
|
+
body = buf;
|
|
82
|
+
if (buf.length > 0) {
|
|
83
|
+
headers['Content-Length'] = String(buf.length);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let upRes;
|
|
87
|
+
try {
|
|
88
|
+
upRes = await fetch(targetUrl, {
|
|
89
|
+
method,
|
|
90
|
+
headers,
|
|
91
|
+
body,
|
|
92
|
+
signal: AbortSignal.timeout(30_000)
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
96
|
+
res.status(502).json({
|
|
97
|
+
error: `Upstream unreachable: ${msg}`
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Forward status and content-type
|
|
102
|
+
const contentType = upRes.headers.get('content-type') || 'application/json';
|
|
103
|
+
res.setHeader('Content-Type', contentType);
|
|
104
|
+
if (upRes.status === 204 || upRes.status === 304) {
|
|
105
|
+
res.status(upRes.status).end();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Do not forward raw upstream error bodies — they may contain stack traces or
|
|
109
|
+
// internal hostnames. Log them server-side (CloudWatch) and return a generic message.
|
|
110
|
+
if (upRes.status >= 500) {
|
|
111
|
+
const errBody = await upRes.text().catch(()=>'');
|
|
112
|
+
console.error('[proxy] upstream %d from %s: %s', upRes.status, targetUrl, errBody);
|
|
113
|
+
res.status(502).json({
|
|
114
|
+
error: 'Upstream service error.'
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const responseBody = await upRes.text();
|
|
119
|
+
res.status(upRes.status).send(responseBody);
|
|
120
|
+
}
|
|
121
|
+
// Maximum request body size accepted by the proxy (1 MiB).
|
|
122
|
+
// Prevents memory exhaustion from oversized payloads before any business logic runs.
|
|
123
|
+
const MAX_BODY_BYTES = 1 * 1024 * 1024;
|
|
124
|
+
function bufferBody(req) {
|
|
125
|
+
return new Promise((resolve, reject)=>{
|
|
126
|
+
const chunks = [];
|
|
127
|
+
let total = 0;
|
|
128
|
+
req.on('data', (chunk)=>{
|
|
129
|
+
total += chunk.length;
|
|
130
|
+
if (total > MAX_BODY_BYTES) {
|
|
131
|
+
req.destroy();
|
|
132
|
+
reject(Object.assign(new Error('Request body too large'), {
|
|
133
|
+
statusCode: 413
|
|
134
|
+
}));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
chunks.push(chunk);
|
|
138
|
+
});
|
|
139
|
+
req.on('end', ()=>resolve(Buffer.concat(chunks)));
|
|
140
|
+
req.on('error', reject);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
14
144
|
const MAX_FILE_SIZE_BYTES = 67108864; // 64 * 1024 * 1024
|
|
15
145
|
// ---------- constants ----------
|
|
16
146
|
const ALLOWED_TIERS = new Set([
|
|
@@ -36,6 +166,14 @@ const MIME_TYPES = {
|
|
|
36
166
|
'.ipynb': 'application/json; charset=utf-8'
|
|
37
167
|
};
|
|
38
168
|
const DEFAULT_FULL_THEMES_URL = '/workspace-api/workspace-assets/remote/build/themes';
|
|
169
|
+
/**
|
|
170
|
+
* First path segments that identify server extension REST APIs (not static assets).
|
|
171
|
+
* Requests whose first segment matches one of these are proxied to
|
|
172
|
+
* `serverExtensionProxyPath` when that option is configured for the remote tier.
|
|
173
|
+
*/ const SERVER_EXTENSION_PREFIXES = new Set([
|
|
174
|
+
'module',
|
|
175
|
+
'server-proxy'
|
|
176
|
+
]);
|
|
39
177
|
const REMOTE_DISABLED_EXTENSIONS = [
|
|
40
178
|
'@jupyterlite/services-extension:config-section-manager',
|
|
41
179
|
'@jupyterlite/services-extension:default-drive',
|
|
@@ -176,6 +314,112 @@ function injectBranding(html, branding) {
|
|
|
176
314
|
});
|
|
177
315
|
return replaceTitle(result, branding);
|
|
178
316
|
}
|
|
317
|
+
// ---------- server-extension proxy ----------
|
|
318
|
+
function readBody$2(req) {
|
|
319
|
+
return new Promise((resolve, reject)=>{
|
|
320
|
+
const chunks = [];
|
|
321
|
+
req.on('data', (chunk)=>chunks.push(chunk));
|
|
322
|
+
req.on('end', ()=>resolve(Buffer.concat(chunks)));
|
|
323
|
+
req.on('error', reject);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
async function proxyServerExtension(req, res, targetUrl, getToken) {
|
|
327
|
+
const forwardHeaders = {};
|
|
328
|
+
for (const [key, value] of Object.entries(req.headers)){
|
|
329
|
+
if (!value) continue;
|
|
330
|
+
const lower = key.toLowerCase();
|
|
331
|
+
if (lower === 'host' || lower === 'content-length' || lower === 'transfer-encoding') continue;
|
|
332
|
+
forwardHeaders[key] = Array.isArray(value) ? value.join(',') : value;
|
|
333
|
+
}
|
|
334
|
+
// Inject identity headers so workspace-proxy auth passes.
|
|
335
|
+
// Direct server-side calls bypass nginx (which normally injects X-Gen3-User-ID /
|
|
336
|
+
// REMOTE_USER from the validated access_token), so we must add them explicitly.
|
|
337
|
+
// SECURITY: decodeJwtClaims below does not verify the JWT signature — it relies on
|
|
338
|
+
// Ambassador/revproxy having already done so before this code path is reached. When
|
|
339
|
+
// serverExtensionProxyBaseUrl (WORKSPACE_PROXY_URL) targets workspace-proxy directly,
|
|
340
|
+
// that verification is skipped entirely, so a caller could supply a correctly-shaped
|
|
341
|
+
// but unsigned/forged JWT to impersonate another user via REMOTE_USER/X-Gen3-User-ID.
|
|
342
|
+
// Only point serverExtensionProxyBaseUrl at a network path a user's own request cannot
|
|
343
|
+
// otherwise forge, or add real signature verification (e.g. against Fence's JWKS)
|
|
344
|
+
// here before trusting these claims.
|
|
345
|
+
const jwt = getToken ? getToken(req) : extractTokenFallback(req);
|
|
346
|
+
if (jwt) {
|
|
347
|
+
const claims = decodeJwtClaims(jwt);
|
|
348
|
+
const rawUsername = claims?.sub ?? claims?.preferred_username ?? claims?.username;
|
|
349
|
+
if (typeof rawUsername === 'string') {
|
|
350
|
+
const safeUsername = rawUsername.replace(/[\r\n]/g, '');
|
|
351
|
+
const safeJwt = jwt.replace(/[\r\n]/g, '');
|
|
352
|
+
forwardHeaders['Authorization'] = `Bearer ${safeJwt}`;
|
|
353
|
+
forwardHeaders['REMOTE_USER'] = safeUsername;
|
|
354
|
+
forwardHeaders['X-Gen3-User-ID'] = safeUsername;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
358
|
+
let body;
|
|
359
|
+
if (![
|
|
360
|
+
'GET',
|
|
361
|
+
'HEAD'
|
|
362
|
+
].includes(method)) {
|
|
363
|
+
try {
|
|
364
|
+
body = await readBody$2(req);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
367
|
+
console.error(`[workspace-assets] failed to read request body for ${method} ${targetUrl}: ${message}`);
|
|
368
|
+
res.status(500).end('Failed to read request body');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (body?.length) {
|
|
372
|
+
forwardHeaders['content-length'] = String(body.length);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const startedAt = Date.now();
|
|
376
|
+
console.warn(`[workspace-assets] proxying ${method} ${targetUrl}${body?.length ? ` (body: ${body.length} bytes)` : ''}`);
|
|
377
|
+
await new Promise((resolve)=>{
|
|
378
|
+
const parsed = new URL(targetUrl);
|
|
379
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
380
|
+
const reqOptions = {
|
|
381
|
+
method,
|
|
382
|
+
hostname: parsed.hostname,
|
|
383
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
384
|
+
path: parsed.pathname + parsed.search,
|
|
385
|
+
headers: forwardHeaders
|
|
386
|
+
};
|
|
387
|
+
const proxyReq = transport.request(reqOptions, (proxyRes)=>{
|
|
388
|
+
const statusCode = proxyRes.statusCode ?? 502;
|
|
389
|
+
res.status(statusCode);
|
|
390
|
+
const skip = new Set([
|
|
391
|
+
'transfer-encoding',
|
|
392
|
+
'content-length'
|
|
393
|
+
]);
|
|
394
|
+
for (const [key, value] of Object.entries(proxyRes.headers)){
|
|
395
|
+
if (!skip.has(key.toLowerCase()) && value !== undefined) {
|
|
396
|
+
res.setHeader(key, value);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const log = statusCode >= 400 ? console.error : console.warn;
|
|
400
|
+
log(`[workspace-assets] ${method} ${targetUrl} -> ${statusCode} (${Date.now() - startedAt}ms)`);
|
|
401
|
+
proxyRes.pipe(res, {
|
|
402
|
+
end: true
|
|
403
|
+
});
|
|
404
|
+
proxyRes.on('end', resolve);
|
|
405
|
+
});
|
|
406
|
+
proxyReq.on('error', (err)=>{
|
|
407
|
+
console.error(`[workspace-assets] extension proxy failed for ${method} ${targetUrl} after ${Date.now() - startedAt}ms: ${err.code ?? 'UNKNOWN'} ${err.message}`);
|
|
408
|
+
if (!res.headersSent) {
|
|
409
|
+
res.status(502).end('Extension proxy unreachable');
|
|
410
|
+
}
|
|
411
|
+
resolve();
|
|
412
|
+
});
|
|
413
|
+
proxyReq.on('timeout', ()=>{
|
|
414
|
+
console.error(`[workspace-assets] extension proxy timed out for ${method} ${targetUrl} after ${Date.now() - startedAt}ms`);
|
|
415
|
+
proxyReq.destroy();
|
|
416
|
+
});
|
|
417
|
+
if (body?.length) {
|
|
418
|
+
proxyReq.write(body);
|
|
419
|
+
}
|
|
420
|
+
proxyReq.end();
|
|
421
|
+
});
|
|
422
|
+
}
|
|
179
423
|
// ---------- factory ----------
|
|
180
424
|
function createWorkspaceAssetsHandler(options) {
|
|
181
425
|
const gatewayBaseUrl = options?.gatewayBaseUrl ?? process.env.JUPYTER_GATEWAY_BASE_URL ?? '/api/workspace/gateway/';
|
|
@@ -191,6 +435,9 @@ function createWorkspaceAssetsHandler(options) {
|
|
|
191
435
|
faviconUrl: options.faviconUrl,
|
|
192
436
|
pageTitle: options.pageTitle
|
|
193
437
|
} : undefined;
|
|
438
|
+
const serverExtensionProxyPath = options?.serverExtensionProxyPath;
|
|
439
|
+
const serverExtensionProxyBaseUrl = options?.serverExtensionProxyBaseUrl;
|
|
440
|
+
const getToken = options?.getToken;
|
|
194
441
|
const remoteInjectionConfig = {
|
|
195
442
|
gatewayBaseUrl,
|
|
196
443
|
disabledExtensions,
|
|
@@ -249,6 +496,20 @@ function createWorkspaceAssetsHandler(options) {
|
|
|
249
496
|
}
|
|
250
497
|
safeSegments.push(nodePath.basename(seg));
|
|
251
498
|
}
|
|
499
|
+
// Proxy server extension API calls (e.g. jupyterlmod /module/*, jupyter-server-proxy
|
|
500
|
+
// /server-proxy/*) to the configured upstream rather than attempting a static file
|
|
501
|
+
// lookup, which would always 404.
|
|
502
|
+
if (tier === 'remote' && serverExtensionProxyPath && safeSegments.length > 0 && SERVER_EXTENSION_PREFIXES.has(safeSegments[0])) {
|
|
503
|
+
const extensionPath = safeSegments.join('/');
|
|
504
|
+
const rawQuery = req.url?.split('?')[1];
|
|
505
|
+
const qs = rawQuery ? `?${rawQuery}` : '';
|
|
506
|
+
const proxyBase = serverExtensionProxyBaseUrl ?? (()=>{
|
|
507
|
+
const { proto, host } = resolveOrigin(req);
|
|
508
|
+
return `${proto}://${host}`;
|
|
509
|
+
})();
|
|
510
|
+
const targetUrl = `${proxyBase}${serverExtensionProxyPath}/${extensionPath}${qs}`;
|
|
511
|
+
return proxyServerExtension(req, res, targetUrl, getToken);
|
|
512
|
+
}
|
|
252
513
|
const allowedFiles = allowedFilesByTier[tier];
|
|
253
514
|
const candidatePath = nodePath.resolve(tierRoot, ...safeSegments);
|
|
254
515
|
const candidateIndex = nodePath.join(candidatePath, 'index.html');
|
|
@@ -402,134 +663,6 @@ function withJupyterWorkspaces(nextConfig, options) {
|
|
|
402
663
|
};
|
|
403
664
|
}
|
|
404
665
|
|
|
405
|
-
/**
|
|
406
|
-
* Shared HTTP proxy utility for server-side proxy handlers.
|
|
407
|
-
* Streams request body through and forwards response status + body.
|
|
408
|
-
*
|
|
409
|
-
* Also exports two token helpers used by the proxy factories:
|
|
410
|
-
* - extractTokenFallback: minimal cookie/header extraction used when the host
|
|
411
|
-
* app has not injected its own getToken (standalone / test use).
|
|
412
|
-
* - decodeJwtClaims: base64url decode of the JWT payload for REMOTE_USER header.
|
|
413
|
-
* Crypto verification is Ambassador's job — we only need the sub/username claim.
|
|
414
|
-
*/ /**
|
|
415
|
-
* Minimal JWT extraction fallback — used only when the host app does not inject
|
|
416
|
-
* its own getToken. Prefers Authorization: Bearer, then access_token cookie.
|
|
417
|
-
* In gen3-vectis production this is never reached — getAccessToken is always wired.
|
|
418
|
-
*/ function extractTokenFallback(req) {
|
|
419
|
-
const auth = req.headers['authorization'];
|
|
420
|
-
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
|
421
|
-
return auth.slice(7).trim() || null;
|
|
422
|
-
}
|
|
423
|
-
const cookie = req.headers['cookie'] ?? '';
|
|
424
|
-
const m = cookie.match(/(?:^|;\s*)access_token=([^;]+)/);
|
|
425
|
-
return m ? decodeURIComponent(m[1]).trim() || null : null;
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Decode JWT payload claims without cryptographic verification.
|
|
429
|
-
* Ambassador has already verified the RS256 signature — we just need the sub claim
|
|
430
|
-
* to set the REMOTE_USER header for per-user isolation enforcement downstream.
|
|
431
|
-
*/ // Maximum JWT string length accepted before base64 decoding.
|
|
432
|
-
// GA4GH RAS Passport JWTs embed multiple signed Visa JWTs as claims, so the
|
|
433
|
-
// outer passport can easily exceed 8 KiB (50 visas × ~2 KB each ≈ 136 KB encoded).
|
|
434
|
-
// 512 KiB covers the most complex real-world passports while still guarding against
|
|
435
|
-
// crafted JWTs designed to exhaust server memory. Ambassador has already verified
|
|
436
|
-
// the RS256 signature — this cap only defends the local base64/JSON decode step.
|
|
437
|
-
const MAX_JWT_LENGTH = 524288; // 512 KiB
|
|
438
|
-
function decodeJwtClaims(jwt) {
|
|
439
|
-
if (jwt.length > MAX_JWT_LENGTH) return null;
|
|
440
|
-
try {
|
|
441
|
-
const parts = jwt.split('.');
|
|
442
|
-
if (parts.length < 2) return null;
|
|
443
|
-
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(parts[1].length / 4) * 4, '=');
|
|
444
|
-
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
|
445
|
-
} catch {
|
|
446
|
-
return null;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
async function createProxyRequest({ req, res, targetUrl, method, extraHeaders = {}, forwardBody = false }) {
|
|
450
|
-
const headers = {
|
|
451
|
-
...extraHeaders
|
|
452
|
-
};
|
|
453
|
-
if (forwardBody && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
454
|
-
headers['Content-Type'] ??= 'application/json';
|
|
455
|
-
}
|
|
456
|
-
let body;
|
|
457
|
-
if (forwardBody && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
458
|
-
let buf;
|
|
459
|
-
try {
|
|
460
|
-
buf = await bufferBody(req);
|
|
461
|
-
} catch (err) {
|
|
462
|
-
const statusCode = err.statusCode ?? 400;
|
|
463
|
-
const msg = err instanceof Error ? err.message : 'Bad request';
|
|
464
|
-
res.status(statusCode).json({
|
|
465
|
-
error: msg
|
|
466
|
-
});
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
// Node.js Buffer is a Uint8Array subclass; cast to silence DOM BodyInit mismatch
|
|
470
|
-
body = buf;
|
|
471
|
-
if (buf.length > 0) {
|
|
472
|
-
headers['Content-Length'] = String(buf.length);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
let upRes;
|
|
476
|
-
try {
|
|
477
|
-
upRes = await fetch(targetUrl, {
|
|
478
|
-
method,
|
|
479
|
-
headers,
|
|
480
|
-
body,
|
|
481
|
-
signal: AbortSignal.timeout(30_000)
|
|
482
|
-
});
|
|
483
|
-
} catch (err) {
|
|
484
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
485
|
-
res.status(502).json({
|
|
486
|
-
error: `Upstream unreachable: ${msg}`
|
|
487
|
-
});
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
// Forward status and content-type
|
|
491
|
-
const contentType = upRes.headers.get('content-type') || 'application/json';
|
|
492
|
-
res.setHeader('Content-Type', contentType);
|
|
493
|
-
if (upRes.status === 204 || upRes.status === 304) {
|
|
494
|
-
res.status(upRes.status).end();
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
// Do not forward raw upstream error bodies — they may contain stack traces or
|
|
498
|
-
// internal hostnames. Log them server-side (CloudWatch) and return a generic message.
|
|
499
|
-
if (upRes.status >= 500) {
|
|
500
|
-
const errBody = await upRes.text().catch(()=>'');
|
|
501
|
-
console.error('[proxy] upstream %d from %s: %s', upRes.status, targetUrl, errBody);
|
|
502
|
-
res.status(502).json({
|
|
503
|
-
error: 'Upstream service error.'
|
|
504
|
-
});
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
const responseBody = await upRes.text();
|
|
508
|
-
res.status(upRes.status).send(responseBody);
|
|
509
|
-
}
|
|
510
|
-
// Maximum request body size accepted by the proxy (1 MiB).
|
|
511
|
-
// Prevents memory exhaustion from oversized payloads before any business logic runs.
|
|
512
|
-
const MAX_BODY_BYTES = 1 * 1024 * 1024;
|
|
513
|
-
function bufferBody(req) {
|
|
514
|
-
return new Promise((resolve, reject)=>{
|
|
515
|
-
const chunks = [];
|
|
516
|
-
let total = 0;
|
|
517
|
-
req.on('data', (chunk)=>{
|
|
518
|
-
total += chunk.length;
|
|
519
|
-
if (total > MAX_BODY_BYTES) {
|
|
520
|
-
req.destroy();
|
|
521
|
-
reject(Object.assign(new Error('Request body too large'), {
|
|
522
|
-
statusCode: 413
|
|
523
|
-
}));
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
chunks.push(chunk);
|
|
527
|
-
});
|
|
528
|
-
req.on('end', ()=>resolve(Buffer.concat(chunks)));
|
|
529
|
-
req.on('error', reject);
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
|
|
533
666
|
/* ------------------------------------------------------------------ */ /* Default billing sink — structured CloudWatch-compatible JSON log */ /* ------------------------------------------------------------------ */ function defaultBillingSink(event) {
|
|
534
667
|
// CloudWatch Logs Insights picks up structured JSON automatically
|
|
535
668
|
process.stdout.write(JSON.stringify({
|
|
@@ -1057,7 +1190,20 @@ const DEFAULT_OPTIONS = {
|
|
|
1057
1190
|
// launches/channels to the container; GPU kernel launches are gated (403)
|
|
1058
1191
|
// with a message to use the Kernel Panel.
|
|
1059
1192
|
remoteKernelsPath: '/lw-workspace/proxy/jeg-proxy',
|
|
1060
|
-
//
|
|
1193
|
+
// Proxy server extension REST calls (jupyterlmod /module/*, jupyter-server-proxy
|
|
1194
|
+
// /server-proxy/*) to jeg-proxy. These calls originate from JupyterLab extensions
|
|
1195
|
+
// in the remote build and resolve relative to the JupyterLite base URL, which points
|
|
1196
|
+
// at this static-file handler — without a proxy they always 404.
|
|
1197
|
+
serverExtensionProxyPath: '/lw-workspace/proxy',
|
|
1198
|
+
// Optional internal k8s service base URL for the extension proxy. When set,
|
|
1199
|
+
// proxy requests target this address directly instead of routing through the
|
|
1200
|
+
// ingress (avoiding self-signed-cert issues). Readable from the JSON config
|
|
1201
|
+
// file or the WORKSPACE_PROXY_URL env var.
|
|
1202
|
+
serverExtensionProxyBaseUrl: process.env.WORKSPACE_PROXY_URL || undefined,
|
|
1203
|
+
// Direct calls to serverExtensionProxyBaseUrl bypass nginx, which normally
|
|
1204
|
+
// injects the X-Gen3-User-ID/REMOTE_USER headers workspace-proxy requires —
|
|
1205
|
+
// reuse this app's cookie extraction to inject them explicitly instead.
|
|
1206
|
+
getToken: (req)=>getAccessToken(req.headers['cookie'] ?? '') ?? null,
|
|
1061
1207
|
// in-browser Pyodide kernel must not be offered alongside it.
|
|
1062
1208
|
additionalDisabledExtensions: [
|
|
1063
1209
|
'@jupyterlite/pyodide-kernel-extension:kernel'
|
|
@@ -1071,7 +1217,9 @@ const STRING_OPTIONS = [
|
|
|
1071
1217
|
'faviconUrl',
|
|
1072
1218
|
'pageTitle',
|
|
1073
1219
|
'remoteKernelsPath',
|
|
1074
|
-
'fullThemesUrl'
|
|
1220
|
+
'fullThemesUrl',
|
|
1221
|
+
'serverExtensionProxyPath',
|
|
1222
|
+
'serverExtensionProxyBaseUrl'
|
|
1075
1223
|
];
|
|
1076
1224
|
function configFilePath() {
|
|
1077
1225
|
return nodePath.resolve(process.cwd(), CONFIG_ROOT, GEN3_COMMONS_NAME, CONFIG_FILENAME);
|