@webda/debug 4.0.0-beta.4 → 4.0.0-beta.6

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.
@@ -32,15 +32,15 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
32
32
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
33
  done = true;
34
34
  };
35
- import { Service, ServiceParameters, useDynamicService, useCoreEvents, useRouter } from "@webda/core";
35
+ import { Service, ServiceParameters, useDynamicService, useCoreEvents, useRouter, useApplication } from "@webda/core";
36
36
  import { Command } from "@webda/core";
37
37
  import { createServer } from "node:http";
38
- import { exec } from "node:child_process";
39
- import { readFileSync, existsSync } from "node:fs";
40
- import { platform } from "node:os";
38
+ import { readFileSync, existsSync, statSync } from "node:fs";
41
39
  import { fileURLToPath } from "node:url";
40
+ import { createRequire } from "node:module";
42
41
  import { dirname, join, extname } from "node:path";
43
42
  import { WebSocketServer, WebSocket } from "ws";
43
+ import { BOOTSTRAP_CODE_TTL, DEBUG_API_VERSION, HOSTED_DASHBOARD_URL, LOCAL_PAGE_CSP, WS_PROTOCOL, allowedOrigins, buildDebugUrl, debugOrigins, extractBearerToken, extractWsToken, generateToken, isAllowedHost, isAllowedOrigin, openInBrowser, resolveTelemetry, safeEqual } from "./security.js";
44
44
  import { RequestLog } from "./requestlog.js";
45
45
  import { LogBuffer } from "./logbuffer.js";
46
46
  import { captureBody, normalizeHeaders } from "./bodycapture.js";
@@ -49,36 +49,61 @@ import { DebugTui } from "./tui/tui.js";
49
49
  import { CancelablePromise } from "@webda/utils";
50
50
  const __dirname = dirname(fileURLToPath(import.meta.url));
51
51
  const WEBUI_DIR = join(__dirname, "..", "webui");
52
- /** Origins that are always allowed to access the debug API. */
53
- const ALLOWED_ORIGIN_EXACT = new Set([
54
- "http://localhost:3000",
55
- "http://127.0.0.1:3000",
56
- "https://webda.io"
57
- ]);
52
+ export { isAllowedOrigin, allowedOrigins, DEBUG_API_VERSION } from "./security.js";
58
53
  /**
59
- * Determine whether the given Origin header value is allowed to access the debug API.
54
+ * Page served at `/` in hosted mode: no token, no bundle, just directions.
60
55
  *
61
- * Allowed origins:
62
- * - http://localhost:3000 (docs dev server)
63
- * - http://127.0.0.1:3000 (docs dev server, alternate address)
64
- * - https://webda.io (production docs)
65
- * - https://*.webda.io (any HTTPS subdomain of webda.io)
56
+ * @param hostedBase - the hosted dashboard URL
57
+ * @returns the HTML
58
+ */
59
+ function hostedPlaceholderPage(hostedBase) {
60
+ const base = hostedBase.replace(/[<>&"]/g, "");
61
+ return `<!doctype html>
62
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
63
+ <title>Webda debug server</title></head>
64
+ <body style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; line-height: 1.6">
65
+ <h1>Webda debug server</h1>
66
+ <p>This is the API of <code>webda debug --web</code>. The dashboard is hosted at
67
+ <a href="${base}">${base}</a>: open the URL printed by the command, it carries your session.</p>
68
+ <p>To serve the dashboard from here instead, restart with <code>webda debug --web --local</code>.</p>
69
+ </body></html>
70
+ `;
71
+ }
72
+ /**
73
+ * Version of this package, read once from its package.json.
74
+ * @returns the version string, or "unknown" when the file cannot be read
75
+ */
76
+ function readDebugVersion() {
77
+ try {
78
+ return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version || "unknown";
79
+ }
80
+ catch {
81
+ return "unknown";
82
+ }
83
+ }
84
+ const DEBUG_VERSION = readDebugVersion();
85
+ /**
86
+ * Version of the framework the application runs on.
66
87
  *
67
- * @param origin - The value of the HTTP `Origin` request header.
68
- * @returns `true` if the origin is allowed, `false` otherwise.
88
+ * Asks the application first, then reads the package.json of the `@webda/core`
89
+ * this package resolves (the application's one in a normal install).
90
+ * @returns the `@webda/core` version, or `undefined` when unavailable
69
91
  */
70
- export function isAllowedOrigin(origin) {
71
- if (!origin)
72
- return false;
73
- if (ALLOWED_ORIGIN_EXACT.has(origin))
74
- return true;
75
- // Allow any https:// subdomain of webda.io
92
+ function frameworkVersion() {
76
93
  try {
77
- const u = new URL(origin);
78
- return u.protocol === "https:" && u.hostname.endsWith(".webda.io");
94
+ const version = useApplication().getWebdaVersion();
95
+ if (version)
96
+ return version;
79
97
  }
80
98
  catch {
81
- return false;
99
+ // application context unavailable, or getWebdaVersion failed
100
+ }
101
+ try {
102
+ const pkg = createRequire(import.meta.url).resolve("@webda/core/package.json");
103
+ return JSON.parse(readFileSync(pkg, "utf8")).version || undefined;
104
+ }
105
+ catch {
106
+ return undefined;
82
107
  }
83
108
  }
84
109
  /** Map file extensions to MIME types for static file serving. */
@@ -89,7 +114,12 @@ const MIME_TYPES = {
89
114
  ".json": "application/json; charset=utf-8",
90
115
  ".svg": "image/svg+xml",
91
116
  ".png": "image/png",
92
- ".ico": "image/x-icon"
117
+ ".ico": "image/x-icon",
118
+ ".webp": "image/webp",
119
+ ".woff": "font/woff",
120
+ ".woff2": "font/woff2",
121
+ ".map": "application/json; charset=utf-8",
122
+ ".txt": "text/plain; charset=utf-8"
93
123
  };
94
124
  /**
95
125
  * Configuration parameters for the {@link DebugService}.
@@ -134,12 +164,60 @@ let DebugService = (() => {
134
164
  this.requestLog = (__runInitializers(this, _instanceExtraInitializers), new RequestLog());
135
165
  /** Ring buffer of application log entries */
136
166
  this.logBuffer = new LogBuffer();
167
+ /** HTTP servers for the debug API (one per loopback address) */
168
+ this.servers = [];
137
169
  /** Connected WebSocket clients */
138
170
  this.clients = new Set();
139
171
  /** Unsubscribe functions for core event listeners */
140
172
  this.unsubscribers = [];
141
173
  /** Timing map: requestId -> start timestamp */
142
174
  this.timings = new Map();
175
+ /** Per-session token required on the API and the websocket */
176
+ this.token = generateToken();
177
+ /** Port the debug server actually listens on (resolved after listen) */
178
+ this.listeningPort = 0;
179
+ /** Whether the bundled dashboard is served (`--local`) */
180
+ this.localMode = false;
181
+ /** Origins allowed by CORS (computed at start from the environment) */
182
+ this.origins = allowedOrigins(process.env);
183
+ }
184
+ /**
185
+ * Port the debug server listens on (resolved after start).
186
+ * @returns the port
187
+ */
188
+ getPort() {
189
+ return this.listeningPort;
190
+ }
191
+ /**
192
+ * Loopback addresses the debug server is bound to.
193
+ * @returns the addresses, e.g. `["127.0.0.1", "::1"]`
194
+ */
195
+ getBoundAddresses() {
196
+ return this.servers
197
+ .map(server => server.address()?.address)
198
+ .filter((a) => !!a);
199
+ }
200
+ /**
201
+ * Issue a one-time bootstrap code for the local page (`--local`).
202
+ *
203
+ * The code is random, single-use and expires after {@link BOOTSTRAP_CODE_TTL};
204
+ * `POST /api/session` exchanges it for the session token.
205
+ * @param ttl - lifetime in milliseconds (tests use a negative value for an expired code)
206
+ * @returns the code
207
+ */
208
+ issueBootstrapCode(ttl = BOOTSTRAP_CODE_TTL) {
209
+ const code = generateToken();
210
+ this.bootstrap = { code, expires: Date.now() + ttl };
211
+ return code;
212
+ }
213
+ /**
214
+ * Session token required on every `/api/*` request and websocket connection.
215
+ *
216
+ * It is generated when the service is created and never logged.
217
+ * @returns the token
218
+ */
219
+ getToken() {
220
+ return this.token;
143
221
  }
144
222
  /**
145
223
  * Resolve the typed parameters and the capture knobs (with safe defaults
@@ -161,6 +239,9 @@ let DebugService = (() => {
161
239
  */
162
240
  resolve() {
163
241
  super.resolve();
242
+ // The HTTP handler runs outside the application's async context, so the
243
+ // framework version is read here, where useApplication() is available.
244
+ this.frameworkVersion = frameworkVersion();
164
245
  this.subscribeToEvents();
165
246
  return this;
166
247
  }
@@ -303,8 +384,7 @@ let DebugService = (() => {
303
384
  catch {
304
385
  respBuf = undefined;
305
386
  }
306
- const respContentType = (responseHeaders && (responseHeaders["Content-Type"] ?? responseHeaders["content-type"])) ||
307
- undefined;
387
+ const respContentType = (responseHeaders && (responseHeaders["Content-Type"] ?? responseHeaders["content-type"])) || undefined;
308
388
  if (respBuf !== undefined) {
309
389
  details.responseBody = captureBody(respBuf, typeof respContentType === "string" ? respContentType : undefined, settings.bodyLimit, settings.binaryPreview);
310
390
  }
@@ -327,7 +407,10 @@ let DebugService = (() => {
327
407
  *
328
408
  * @param port - Port for the debug dashboard API
329
409
  * @param servePort - Port for the application HTTP server
330
- * @param web - Disable TUI and only serve the web dashboard
410
+ * @param web - Disable TUI and open the web dashboard instead
411
+ * @param local - Serve the bundled dashboard from the debug server instead of the hosted one
412
+ * @param open - Open the dashboard in the default browser (`--no-open` to disable)
413
+ * @param telemetry - Allow usage analytics on the hosted dashboard (`--no-telemetry` to disable)
331
414
  * @returns a promise settled once the service stops or the TUI quits
332
415
  */
333
416
  debug(
@@ -335,12 +418,18 @@ let DebugService = (() => {
335
418
  port = 18181,
336
419
  /** @alias s @description Application server port */
337
420
  servePort = 18080,
338
- /** @description Disable TUI and only serve the web dashboard */
339
- web) {
421
+ /** @description Disable TUI and open the web dashboard (hosted on webda.io) */
422
+ web,
423
+ /** @description With --web: serve the bundled dashboard from the debug port instead of webda.io */
424
+ local,
425
+ /** @description With --web: open the dashboard in the browser (--no-open to only print the URL) */
426
+ open = true,
427
+ /** @description With --web: allow usage analytics on the hosted dashboard (--no-telemetry or WEBDA_TELEMETRY=0 to disable) */
428
+ telemetry = true) {
340
429
  // Stays pending until the service stops or the TUI quits
341
430
  return new CancelablePromise((resolve, reject) => {
342
431
  this.running = resolve;
343
- this.startDebug(port, servePort, web).catch(reject);
432
+ this.startDebug(port, servePort, web, local, open, telemetry).catch(reject);
344
433
  }, async () => {
345
434
  this.tui?.stop();
346
435
  });
@@ -350,9 +439,12 @@ let DebugService = (() => {
350
439
  *
351
440
  * @param port - Port for the debug dashboard API
352
441
  * @param servePort - Port for the application HTTP server
353
- * @param web - Disable TUI and only serve the web dashboard
442
+ * @param web - Disable TUI and open the web dashboard
443
+ * @param local - Serve the bundled dashboard instead of the hosted one
444
+ * @param open - Open the browser
445
+ * @param telemetry - Allow usage analytics on the hosted dashboard
354
446
  */
355
- async startDebug(port, servePort, web) {
447
+ async startDebug(port, servePort, web, local, open = true, telemetry = true) {
356
448
  // Start the main application server
357
449
  const httpServer = useDynamicService("HttpServer");
358
450
  if (httpServer?.start) {
@@ -360,23 +452,46 @@ let DebugService = (() => {
360
452
  this.log("INFO", `Application server started on port ${servePort}`);
361
453
  }
362
454
  // Start the debug HTTP + WebSocket server
363
- await this.startDebugServer(port);
364
- this.log("INFO", `Debug dashboard API listening on port ${port}`);
455
+ await this.startDebugServer(port, { local: !!(web && local) });
456
+ this.log("INFO", `Debug dashboard API listening on 127.0.0.1:${this.listeningPort}`);
365
457
  // Launch TUI by default, unless --web is passed
366
458
  if (!web) {
367
- this.tui = new DebugTui(port);
459
+ this.tui = new DebugTui(this.listeningPort, this.token);
368
460
  // Quitting the TUI ends the command
369
461
  this.tui.onStop = () => this.settle();
370
462
  await this.tui.start();
463
+ return;
371
464
  }
372
- else if (!process.env.WEBDA_DEBUG_NO_BROWSER) {
373
- // Headless callers (CI, Playwright's `webServer`, scripted smoke
374
- // tests) set WEBDA_DEBUG_NO_BROWSER=1 to suppress the auto-open —
375
- // they manage their own browser instance and don't want the local
376
- // Chrome to pop a stray tab on every server start.
377
- this.openBrowser(`http://localhost:${port}`);
465
+ const url = buildDebugUrl({
466
+ port: this.listeningPort,
467
+ token: this.token,
468
+ local,
469
+ code: local ? this.issueBootstrapCode() : undefined,
470
+ telemetry: resolveTelemetry(telemetry, process.env),
471
+ hostedBase: process.env.WEBDA_DEBUG_UI_URL
472
+ });
473
+ this.dashboardUrl = url;
474
+ this.printDashboardUrl(url);
475
+ // Headless callers (CI, Playwright's `webServer`, scripted smoke
476
+ // tests) set WEBDA_DEBUG_NO_BROWSER=1 to suppress the auto-open —
477
+ // they manage their own browser instance and don't want the local
478
+ // Chrome to pop a stray tab on every server start.
479
+ if (open && !process.env.WEBDA_DEBUG_NO_BROWSER) {
480
+ this.openBrowser(url);
378
481
  }
379
482
  }
483
+ /**
484
+ * Print the dashboard URL for the user.
485
+ *
486
+ * The hosted URL carries the session token in its fragment, so it is written
487
+ * straight to stdout rather than through the logger: log output is captured
488
+ * by the log buffer, broadcast to the dashboard and may be persisted by other
489
+ * loggers.
490
+ * @param url - the dashboard URL
491
+ */
492
+ printDashboardUrl(url) {
493
+ process.stdout.write(`\nWebda debug dashboard: ${url}\n\n`);
494
+ }
380
495
  /**
381
496
  * Settle the running debug command
382
497
  */
@@ -389,42 +504,144 @@ let DebugService = (() => {
389
504
  * @param url - URL to open
390
505
  */
391
506
  openBrowser(url) {
392
- const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
393
- exec(`${cmd} ${url}`);
507
+ openInBrowser(url);
394
508
  }
395
509
  /**
396
510
  * Create and start the debug HTTP server with WebSocket support.
397
- * @param port - Port to listen on
511
+ *
512
+ * One server per loopback address: `127.0.0.1` and `::1`, so that no other
513
+ * local process can squat the IPv6 side of `localhost`. A port already taken
514
+ * on either address is an error; an unavailable IPv6 stack is tolerated.
515
+ * The websocket upgrade is handled by hand so that the `Host`, `Origin` and
516
+ * token checks run before `ws` completes the handshake.
517
+ * @param port - Port to listen on (0 for a random one)
518
+ * @param options - local mode
398
519
  */
399
- async startDebugServer(port) {
400
- this.server = createServer((req, res) => this.handleRequest(req, res));
401
- this.wss = new WebSocketServer({ server: this.server });
520
+ async startDebugServer(port, options = {}) {
521
+ this.localMode = !!options.local;
522
+ this.origins = allowedOrigins(process.env);
523
+ this.wss = new WebSocketServer({
524
+ noServer: true,
525
+ handleProtocols: protocols => (protocols.has(WS_PROTOCOL) ? WS_PROTOCOL : false)
526
+ });
402
527
  this.wss.on("connection", (ws) => {
403
528
  this.clients.add(ws);
404
529
  ws.on("close", () => this.clients.delete(ws));
405
530
  ws.on("error", () => this.clients.delete(ws));
406
531
  });
407
- await new Promise(resolve => {
408
- this.server.listen(port, () => resolve());
532
+ const v4 = await this.listen(port, "127.0.0.1");
533
+ this.server = v4;
534
+ this.listeningPort = v4.address().port;
535
+ try {
536
+ await this.listen(this.listeningPort, "::1");
537
+ }
538
+ catch (err) {
539
+ if (err?.code === "EADDRINUSE") {
540
+ // Close without settling the command: the caller must see the failure
541
+ await this.closeServers();
542
+ throw new Error(`[::1]:${this.listeningPort} is already in use: refusing to start with half of localhost exposed`);
543
+ }
544
+ // EADDRNOTAVAIL / EAFNOSUPPORT: no IPv6 loopback on this host
545
+ this.log("WARN", `IPv6 loopback unavailable (${err?.code || err}), listening on 127.0.0.1 only`);
546
+ }
547
+ }
548
+ /**
549
+ * Close every bound server.
550
+ */
551
+ async closeServers() {
552
+ for (const server of this.servers) {
553
+ await new Promise(resolve => server.close(() => resolve()));
554
+ }
555
+ this.servers = [];
556
+ this.server = undefined;
557
+ }
558
+ /**
559
+ * Start one HTTP server on an address.
560
+ * @param port - the port
561
+ * @param address - the loopback address
562
+ * @returns the listening server
563
+ */
564
+ listen(port, address) {
565
+ return new Promise((resolve, reject) => {
566
+ const server = createServer((req, res) => this.handleRequest(req, res));
567
+ server.on("upgrade", (req, socket, head) => this.handleUpgrade(req, socket, head));
568
+ server.once("error", reject);
569
+ server.listen(port, address, () => {
570
+ server.removeListener("error", reject);
571
+ this.servers.push(server);
572
+ resolve(server);
573
+ });
574
+ });
575
+ }
576
+ /**
577
+ * Authenticate a websocket upgrade before handing it to `ws`.
578
+ * @param req - the upgrade request
579
+ * @param socket - the underlying socket
580
+ * @param head - the first packet of the upgraded stream
581
+ */
582
+ handleUpgrade(req, socket, head) {
583
+ const reject = (status, reason) => {
584
+ socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
585
+ socket.destroy();
586
+ };
587
+ if (!isAllowedHost(req.headers.host, this.listeningPort)) {
588
+ reject(403, "Forbidden");
589
+ return;
590
+ }
591
+ // Browsers always send Origin on a websocket handshake: only the hosted
592
+ // dashboard and the debug origin itself may connect. No Origin = not a browser.
593
+ const origin = req.headers.origin;
594
+ if (origin && !this.isTrustedOrigin(origin)) {
595
+ reject(403, "Forbidden");
596
+ return;
597
+ }
598
+ if (!safeEqual(extractWsToken(req.headers["sec-websocket-protocol"]), this.token)) {
599
+ reject(401, "Unauthorized");
600
+ return;
601
+ }
602
+ this.wss.handleUpgrade(req, socket, head, ws => {
603
+ this.wss.emit("connection", ws, req);
409
604
  });
410
605
  }
606
+ /**
607
+ * Whether an Origin is the hosted dashboard, the dev server or the debug server itself.
608
+ * @param origin - the Origin header value
609
+ * @returns `true` when trusted
610
+ */
611
+ isTrustedOrigin(origin) {
612
+ return isAllowedOrigin(origin, this.origins) || debugOrigins(this.listeningPort).includes(origin);
613
+ }
411
614
  /**
412
615
  * Route incoming HTTP requests to introspection handlers.
413
616
  * @param req - Incoming HTTP request
414
617
  * @param res - Server response
415
618
  */
416
619
  handleRequest(req, res) {
620
+ // DNS rebinding: a page on evil.com whose DNS answer flips to 127.0.0.1
621
+ // still sends `Host: evil.com`; only loopback hosts are served.
622
+ if (!isAllowedHost(req.headers.host, this.listeningPort)) {
623
+ this.sendJson(res, { error: "Forbidden" }, 403);
624
+ return;
625
+ }
417
626
  // CORS: echo the origin back only if it is on the allowlist.
418
627
  // If there is no matching origin the browser will block cross-origin access,
419
628
  // which is the desired behaviour. We also set Vary: Origin so that CDN /
420
629
  // proxy caches do not serve a response with an allowed origin header to a
421
630
  // different (disallowed) origin.
422
631
  const origin = req.headers.origin;
423
- if (isAllowedOrigin(origin)) {
632
+ const originAllowed = isAllowedOrigin(origin, this.origins);
633
+ // Vary on every response so that no cache serves one origin's answer to another
634
+ res.setHeader("Vary", "Origin");
635
+ if (originAllowed) {
424
636
  res.setHeader("Access-Control-Allow-Origin", origin);
425
- res.setHeader("Vary", "Origin");
426
- res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
427
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
637
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
638
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
639
+ res.setHeader("Access-Control-Max-Age", "600");
640
+ // Private Network Access: the hosted dashboard (public) talks to
641
+ // localhost (local); Chrome asks for this on the preflight.
642
+ if (req.method === "OPTIONS" && req.headers["access-control-request-private-network"] === "true") {
643
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
644
+ }
428
645
  }
429
646
  if (req.method === "OPTIONS") {
430
647
  res.writeHead(204);
@@ -433,10 +650,29 @@ let DebugService = (() => {
433
650
  }
434
651
  const url = req.url || "/";
435
652
  const pathname = url.split("?")[0];
653
+ if (!pathname.startsWith("/api/")) {
654
+ this.serveStaticFile(pathname, req, res);
655
+ return;
656
+ }
657
+ if (pathname === "/api/session") {
658
+ this.handleSessionExchange(req, res);
659
+ return;
660
+ }
661
+ // Every API response needs the session token; no data leaves without it.
662
+ if (!safeEqual(extractBearerToken(req.headers.authorization), this.token)) {
663
+ res.setHeader("WWW-Authenticate", 'Bearer realm="webda-debug"');
664
+ this.sendJson(res, { error: "Unauthorized" }, 401);
665
+ return;
666
+ }
436
667
  try {
437
668
  // Route to handlers
438
669
  if (pathname === "/api/info") {
439
- this.sendJson(res, getAppInfo());
670
+ this.sendJson(res, {
671
+ ...getAppInfo(),
672
+ debugApiVersion: DEBUG_API_VERSION,
673
+ debugVersion: DEBUG_VERSION,
674
+ frameworkVersion: this.frameworkVersion
675
+ });
440
676
  }
441
677
  else if (pathname === "/api/models") {
442
678
  this.sendJson(res, getModels());
@@ -486,7 +722,7 @@ let DebugService = (() => {
486
722
  this.sendJson(res, query ? this.logBuffer.search(query) : this.logBuffer.getEntries());
487
723
  }
488
724
  else {
489
- this.serveStaticFile(pathname, res);
725
+ this.sendJson(res, { error: "Not found" }, 404);
490
726
  }
491
727
  }
492
728
  catch (err) {
@@ -515,29 +751,136 @@ let DebugService = (() => {
515
751
  }
516
752
  }
517
753
  /**
518
- * Serve a static file from the webui directory.
519
- * Falls back to index.html for SPA-style routing.
754
+ * `POST /api/session`: exchange the one-time bootstrap code for the session token (`--local` only).
755
+ *
756
+ * The code is printed in the local URL fragment; the page sends it once and
757
+ * keeps the token in memory. Accepted only from the debug origin itself (or
758
+ * without Origin, for non-browser clients); 404 in hosted mode so the route
759
+ * reveals nothing there. The code is consumed on a successful match only, and a
760
+ * reuse after it was spent is logged (the link was used by another client).
761
+ * @param req - the request
762
+ * @param res - the response
763
+ */
764
+ handleSessionExchange(req, res) {
765
+ if (!this.localMode) {
766
+ this.sendJson(res, { error: "Not found" }, 404);
767
+ return;
768
+ }
769
+ if (req.method !== "POST") {
770
+ this.sendJson(res, { error: "Method not allowed" }, 405);
771
+ return;
772
+ }
773
+ const origin = req.headers.origin;
774
+ if (origin && !debugOrigins(this.listeningPort).includes(origin)) {
775
+ this.sendJson(res, { error: "Forbidden" }, 403);
776
+ return;
777
+ }
778
+ let body = "";
779
+ req.on("data", chunk => {
780
+ body += chunk;
781
+ if (body.length > 4096)
782
+ req.destroy();
783
+ });
784
+ req.on("end", () => {
785
+ let code;
786
+ try {
787
+ code = JSON.parse(body || "null")?.code;
788
+ }
789
+ catch {
790
+ code = undefined;
791
+ }
792
+ if (typeof code !== "string" || !code) {
793
+ this.sendJson(res, { error: "Missing code" }, 400);
794
+ return;
795
+ }
796
+ const pending = this.bootstrap;
797
+ if (!pending) {
798
+ // Either never issued, or already exchanged: a second correct-format attempt
799
+ // after the exchange means another client used the link first
800
+ this.log("WARN", "Debug session link refused: it was already used by another client or has expired");
801
+ this.sendJson(res, { error: "Invalid or expired code" }, 403);
802
+ return;
803
+ }
804
+ if (pending.expires < Date.now()) {
805
+ this.bootstrap = undefined;
806
+ this.sendJson(res, { error: "Invalid or expired code" }, 403);
807
+ return;
808
+ }
809
+ if (!safeEqual(code, pending.code)) {
810
+ // A wrong guess (256-bit code: brute force is infeasible) must not burn the real one
811
+ this.sendJson(res, { error: "Invalid or expired code" }, 403);
812
+ return;
813
+ }
814
+ // Single use: consumed only on a successful match
815
+ this.bootstrap = undefined;
816
+ this.sendJson(res, { token: this.token, debugApiVersion: DEBUG_API_VERSION });
817
+ });
818
+ }
819
+ /**
820
+ * Security headers of the pages and assets the debug server serves.
821
+ * @param res - the response
822
+ * @param html - whether the response is a page
823
+ */
824
+ pageHeaders(res, html) {
825
+ res.setHeader("Cache-Control", "no-store");
826
+ res.setHeader("X-Content-Type-Options", "nosniff");
827
+ if (html) {
828
+ res.setHeader("Content-Security-Policy", LOCAL_PAGE_CSP);
829
+ res.setHeader("X-Frame-Options", "DENY");
830
+ res.setHeader("Referrer-Policy", "no-referrer");
831
+ }
832
+ }
833
+ /**
834
+ * Serve a static file from the webui directory (`--local`), or the hosted
835
+ * placeholder page.
836
+ *
837
+ * The page never carries the token: the local page obtains it through the
838
+ * one-time code exchange. Unknown paths fall back to index.html (SPA routing);
839
+ * directories are not found.
520
840
  * @param pathname - Request pathname
841
+ * @param req - Incoming request
521
842
  * @param res - Server response
522
843
  */
523
- serveStaticFile(pathname, res) {
844
+ serveStaticFile(pathname, req, res) {
845
+ if (!this.localMode) {
846
+ if (pathname === "/" || pathname === "/index.html") {
847
+ this.pageHeaders(res, true);
848
+ res.writeHead(200, { "Content-Type": MIME_TYPES[".html"] });
849
+ res.end(hostedPlaceholderPage(process.env.WEBDA_DEBUG_UI_URL || HOSTED_DASHBOARD_URL));
850
+ return;
851
+ }
852
+ this.sendJson(res, { error: "Not found" }, 404);
853
+ return;
854
+ }
524
855
  // Prevent directory traversal
525
856
  const safePath = pathname.replace(/\.\./g, "").replace(/\/+/g, "/");
526
857
  let filePath = join(WEBUI_DIR, safePath === "/" ? "index.html" : safePath);
527
- // If the file doesn't exist, serve index.html (SPA fallback)
528
- if (!existsSync(filePath)) {
858
+ // SPA fallback for unknown paths (not for existing directories such as /assets)
859
+ const isFile = (file) => {
860
+ try {
861
+ return statSync(file).isFile();
862
+ }
863
+ catch {
864
+ return false;
865
+ }
866
+ };
867
+ if (!isFile(filePath)) {
868
+ if (existsSync(filePath) || safePath.startsWith("/assets")) {
869
+ this.sendJson(res, { error: "Not found" }, 404);
870
+ return;
871
+ }
529
872
  filePath = join(WEBUI_DIR, "index.html");
530
873
  }
531
- if (!existsSync(filePath)) {
874
+ if (!isFile(filePath)) {
532
875
  this.sendJson(res, { error: "Not found" }, 404);
533
876
  return;
534
877
  }
535
878
  try {
536
- const content = readFileSync(filePath);
537
879
  const ext = extname(filePath);
538
880
  const mime = MIME_TYPES[ext] || "application/octet-stream";
881
+ this.pageHeaders(res, ext === ".html");
539
882
  res.writeHead(200, { "Content-Type": mime });
540
- res.end(content);
883
+ res.end(readFileSync(filePath));
541
884
  }
542
885
  catch {
543
886
  this.sendJson(res, { error: "Internal server error" }, 500);
@@ -550,7 +893,7 @@ let DebugService = (() => {
550
893
  * @param statusCode - HTTP status code
551
894
  */
552
895
  sendJson(res, data, statusCode = 200) {
553
- res.writeHead(statusCode, { "Content-Type": "application/json" });
896
+ res.writeHead(statusCode, { "Content-Type": "application/json", "Cache-Control": "no-store" });
554
897
  res.end(JSON.stringify(data));
555
898
  }
556
899
  /**
@@ -585,10 +928,8 @@ let DebugService = (() => {
585
928
  this.wss.close();
586
929
  this.wss = undefined;
587
930
  }
588
- if (this.server) {
589
- await new Promise(resolve => this.server.close(() => resolve()));
590
- this.server = undefined;
591
- }
931
+ await this.closeServers();
932
+ this.bootstrap = undefined;
592
933
  this.timings.clear();
593
934
  this.tui?.stop();
594
935
  this.tui = undefined;
@@ -598,7 +939,10 @@ let DebugService = (() => {
598
939
  },
599
940
  (() => {
600
941
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
601
- _debug_decorators = [Command("debug", { description: "Start dev server with debug dashboard", requires: ["router", "rest-domain", "http-server"] })];
942
+ _debug_decorators = [Command("debug", {
943
+ description: "Start dev server with debug dashboard",
944
+ requires: ["router", "rest-domain", "http-server"]
945
+ })];
602
946
  __esDecorate(_a, null, _debug_decorators, { kind: "method", name: "debug", static: false, private: false, access: { has: obj => "debug" in obj, get: obj => obj.debug }, metadata: _metadata }, null, _instanceExtraInitializers);
603
947
  if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
604
948
  })(),