@gen3/workspaces 0.12.72 → 0.12.74

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.
@@ -334,22 +334,41 @@ async function proxyServerExtension(req, res, targetUrl, getToken) {
334
334
  // Inject identity headers so workspace-proxy auth passes.
335
335
  // Direct server-side calls bypass nginx (which normally injects X-Gen3-User-ID /
336
336
  // REMOTE_USER from the validated access_token), so we must add them explicitly.
337
- // only add in production mode
338
- // TODO: refactor this to handle development mode better
339
- if (process.env.NODE_ENV === 'production') {
340
- const jwt = getToken ? getToken(req) : extractTokenFallback(req);
341
- if (jwt) {
342
- const claims = decodeJwtClaims(jwt);
343
- const rawUsername = claims?.sub ?? claims?.preferred_username ?? claims?.username;
344
- if (typeof rawUsername === 'string') {
345
- const safeUsername = rawUsername.replace(/[\r\n]/g, '');
346
- const safeJwt = jwt.replace(/[\r\n]/g, '');
347
- forwardHeaders['Authorization'] = `Bearer ${safeJwt}`;
348
- forwardHeaders['REMOTE_USER'] = safeUsername;
349
- forwardHeaders['X-Gen3-User-ID'] = safeUsername;
350
- }
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
+ // --- auth header injection with diagnostic logging ---
346
+ const tokenSource = getToken ? 'getToken(injected)' : 'extractTokenFallback';
347
+ const jwt = getToken ? getToken(req) : extractTokenFallback(req);
348
+ // Log incoming auth signals so we can diagnose missing REMOTE_USER / 502s.
349
+ const hasBearerHeader = typeof req.headers['authorization'] === 'string' && req.headers['authorization'].startsWith('Bearer ');
350
+ const cookieHeader = req.headers['cookie'] ?? '';
351
+ const hasAccessTokenCookie = /(?:^|;\s*)access_token=/.test(cookieHeader);
352
+ const cookieNames = cookieHeader.split(';').map((p)=>p.trim().split('=')[0]).filter(Boolean);
353
+ console.warn(`[workspace-assets] proxy auth-diag: tokenSource=${tokenSource} jwtFound=${!!jwt}` + ` hasBearerHeader=${hasBearerHeader} hasAccessTokenCookie=${hasAccessTokenCookie}` + ` cookieNames=[${cookieNames.join(',')}]`);
354
+ if (jwt) {
355
+ const claims = decodeJwtClaims(jwt);
356
+ const rawUsername = claims?.sub ?? claims?.preferred_username ?? claims?.username;
357
+ if (typeof rawUsername === 'string') {
358
+ const safeUsername = rawUsername.replace(/[\r\n]/g, '');
359
+ const safeJwt = jwt.replace(/[\r\n]/g, '');
360
+ forwardHeaders['Authorization'] = `Bearer ${safeJwt}`;
361
+ forwardHeaders['REMOTE_USER'] = safeUsername;
362
+ forwardHeaders['X-Gen3-User-ID'] = safeUsername;
363
+ console.warn(`[workspace-assets] proxy auth-diag: injected REMOTE_USER/X-Gen3-User-ID for sub="${safeUsername}"` + ` claimsKeys=[${Object.keys(claims ?? {}).join(',')}]`);
364
+ } else {
365
+ console.warn(`[workspace-assets] proxy auth-diag: JWT decoded but no sub/preferred_username/username claim found` + ` claimsKeys=[${Object.keys(claims ?? {}).join(',')}]` + ` (REMOTE_USER NOT injected — workspace-proxy will likely reject)`);
351
366
  }
367
+ } else {
368
+ console.warn(`[workspace-assets] proxy auth-diag: no JWT found via ${tokenSource}` + ` — REMOTE_USER/X-Gen3-User-ID NOT injected — workspace-proxy will likely 502`);
352
369
  }
370
+ // Log the set of header keys being forwarded (no values — headers can contain tokens).
371
+ console.warn(`[workspace-assets] proxy forwarding headers: [${Object.keys(forwardHeaders).join(',')}]`);
353
372
  const method = (req.method || 'GET').toUpperCase();
354
373
  let body;
355
374
  if (![
@@ -358,7 +377,9 @@ async function proxyServerExtension(req, res, targetUrl, getToken) {
358
377
  ].includes(method)) {
359
378
  try {
360
379
  body = await readBody$2(req);
361
- } catch {
380
+ } catch (error) {
381
+ const message = error instanceof Error ? error.message : String(error);
382
+ console.error(`[workspace-assets] failed to read request body for ${method} ${targetUrl}: ${message}`);
362
383
  res.status(500).end('Failed to read request body');
363
384
  return;
364
385
  }
@@ -366,7 +387,8 @@ async function proxyServerExtension(req, res, targetUrl, getToken) {
366
387
  forwardHeaders['content-length'] = String(body.length);
367
388
  }
368
389
  }
369
- console.log('[workspace-assets] proxying extension API to', targetUrl);
390
+ const startedAt = Date.now();
391
+ console.warn(`[workspace-assets] proxying ${method} ${targetUrl}${body?.length ? ` (body: ${body.length} bytes)` : ''}`);
370
392
  await new Promise((resolve)=>{
371
393
  const parsed = new URL(targetUrl);
372
394
  const transport = parsed.protocol === 'https:' ? https : http;
@@ -375,10 +397,14 @@ async function proxyServerExtension(req, res, targetUrl, getToken) {
375
397
  hostname: parsed.hostname,
376
398
  port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
377
399
  path: parsed.pathname + parsed.search,
378
- headers: forwardHeaders
400
+ headers: forwardHeaders,
401
+ // 30-second socket timeout — without this, a stalled upstream holds the
402
+ // connection open indefinitely and the client sees no response.
403
+ timeout: 30_000
379
404
  };
380
405
  const proxyReq = transport.request(reqOptions, (proxyRes)=>{
381
- res.status(proxyRes.statusCode ?? 502);
406
+ const statusCode = proxyRes.statusCode ?? 502;
407
+ res.status(statusCode);
382
408
  const skip = new Set([
383
409
  'transfer-encoding',
384
410
  'content-length'
@@ -388,18 +414,29 @@ async function proxyServerExtension(req, res, targetUrl, getToken) {
388
414
  res.setHeader(key, value);
389
415
  }
390
416
  }
417
+ const log = statusCode >= 400 ? console.error : console.warn;
418
+ log(`[workspace-assets] ${method} ${targetUrl} -> ${statusCode} (${Date.now() - startedAt}ms)`);
419
+ if (statusCode >= 400) {
420
+ // Log upstream response headers for non-2xx to surface auth/routing rejections.
421
+ const upstreamHeaders = Object.entries(proxyRes.headers).filter(([k])=>!k.toLowerCase().includes('cookie')).map(([k, v])=>`${k}:${Array.isArray(v) ? v.join(',') : v}`).join(' | ');
422
+ console.error(`[workspace-assets] upstream response headers: ${upstreamHeaders}`);
423
+ }
391
424
  proxyRes.pipe(res, {
392
425
  end: true
393
426
  });
394
427
  proxyRes.on('end', resolve);
395
428
  });
396
429
  proxyReq.on('error', (err)=>{
397
- console.error('[workspace-assets] extension proxy failed for', targetUrl, err.message);
430
+ console.error(`[workspace-assets] extension proxy failed for ${method} ${targetUrl} after ${Date.now() - startedAt}ms: ${err.code ?? 'UNKNOWN'} ${err.message}`);
398
431
  if (!res.headersSent) {
399
432
  res.status(502).end('Extension proxy unreachable');
400
433
  }
401
434
  resolve();
402
435
  });
436
+ proxyReq.on('timeout', ()=>{
437
+ console.error(`[workspace-assets] extension proxy timed out for ${method} ${targetUrl} after ${Date.now() - startedAt}ms`);
438
+ proxyReq.destroy();
439
+ });
403
440
  if (body?.length) {
404
441
  proxyReq.write(body);
405
442
  }
@@ -494,7 +531,6 @@ function createWorkspaceAssetsHandler(options) {
494
531
  return `${proto}://${host}`;
495
532
  })();
496
533
  const targetUrl = `${proxyBase}${serverExtensionProxyPath}/${extensionPath}${qs}`;
497
- console.log('[workspace-assets] proxying extension API to', targetUrl);
498
534
  return proxyServerExtension(req, res, targetUrl, getToken);
499
535
  }
500
536
  const allowedFiles = allowedFilesByTier[tier];
@@ -1185,8 +1221,12 @@ const DEFAULT_OPTIONS = {
1185
1221
  // Optional internal k8s service base URL for the extension proxy. When set,
1186
1222
  // proxy requests target this address directly instead of routing through the
1187
1223
  // ingress (avoiding self-signed-cert issues). Readable from the JSON config
1188
- // file or the WORKSPACE_EXTENSION_PROXY_BASE_URL env var.
1189
- serverExtensionProxyBaseUrl: 'http://workspace-proxy-service:8080',
1224
+ // file or the WORKSPACE_PROXY_URL env var.
1225
+ serverExtensionProxyBaseUrl: process.env.WORKSPACE_PROXY_URL || undefined,
1226
+ // Direct calls to serverExtensionProxyBaseUrl bypass nginx, which normally
1227
+ // injects the X-Gen3-User-ID/REMOTE_USER headers workspace-proxy requires —
1228
+ // reuse this app's cookie extraction to inject them explicitly instead.
1229
+ getToken: (req)=>getAccessToken(req.headers['cookie'] ?? '') ?? null,
1190
1230
  // in-browser Pyodide kernel must not be offered alongside it.
1191
1231
  additionalDisabledExtensions: [
1192
1232
  '@jupyterlite/pyodide-kernel-extension:kernel'