mdbrowse-cli 0.1.2 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saleeh K
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -7,8 +7,7 @@
7
7
 
8
8
  Zero-install CLI that spins up a local web UI with a file tree, rendered markdown, live reload, and optional Cloudflare Tunnel for remote access.
9
9
 
10
- <!-- TODO: Add screenshot here -->
11
- <!-- ![mdbrowse screenshot](docs/screenshot.png) -->
10
+ ![mdbrowse-cli demo](docs/screenshots/demo.gif)
12
11
 
13
12
  ## Quick start
14
13
 
@@ -22,6 +21,71 @@ Opens in your browser automatically. On SSH/headless servers, grab the printed U
22
21
 
23
22
  ## Features
24
23
 
24
+ <table>
25
+ <tr>
26
+ <td width="50%">
27
+
28
+ **File tree + markdown rendering**
29
+
30
+ Browse files in the sidebar, view beautifully rendered markdown with GFM tables, task lists, and more.
31
+
32
+ ![Light theme](docs/screenshots/hero-light.png)
33
+
34
+ </td>
35
+ <td width="50%">
36
+
37
+ **Dark / light theme**
38
+
39
+ Auto-detects system preference. Toggle with one click.
40
+
41
+ ![Dark theme](docs/screenshots/hero-dark.png)
42
+
43
+ </td>
44
+ </tr>
45
+ <tr>
46
+ <td width="50%">
47
+
48
+ **Syntax highlighting**
49
+
50
+ VS Code-quality code blocks via Shiki — JavaScript, Python, Rust, and 100+ languages.
51
+
52
+ ![Code highlighting](docs/screenshots/code-highlight.png)
53
+
54
+ </td>
55
+ <td width="50%">
56
+
57
+ **Search**
58
+
59
+ Filename + content search with Ctrl+K. Results highlighted in context.
60
+
61
+ ![Search](docs/screenshots/search.png)
62
+
63
+ </td>
64
+ </tr>
65
+ <tr>
66
+ <td width="50%">
67
+
68
+ **Mermaid diagrams + math**
69
+
70
+ Flowcharts, sequence diagrams, and LaTeX math rendered inline.
71
+
72
+ ![Mermaid diagrams](docs/screenshots/mermaid.png)
73
+
74
+ </td>
75
+ <td width="50%">
76
+
77
+ **Edit mode**
78
+
79
+ Toggle to edit any file, Ctrl+S to save, with tab indentation support.
80
+
81
+ ![Edit mode](docs/screenshots/edit-mode.png)
82
+
83
+ </td>
84
+ </tr>
85
+ </table>
86
+
87
+ **Full feature list:**
88
+
25
89
  - 📁 **File tree sidebar** — browse all files in the directory
26
90
  - 📝 **Markdown rendering** — GFM tables, task lists, strikethrough, and more
27
91
  - 🎨 **Syntax highlighting** — VS Code-quality code blocks via Shiki
@@ -41,12 +105,48 @@ Opens in your browser automatically. On SSH/headless servers, grab the printed U
41
105
  |------|---------|-------------|
42
106
  | `[directory]` | `.` | Directory to serve |
43
107
  | `-p, --port <number>` | `3000` | Port to listen on |
44
- | `--host <address>` | `localhost` | Host to bind to (use `0.0.0.0` for all interfaces) |
108
+ | `--host <address>` | `0.0.0.0` | Host to bind to |
45
109
  | `--tunnel` | off | Expose via Cloudflare Tunnel (requires `cloudflared`) |
46
110
  | `--auth <user:pass>` | off | Require basic HTTP authentication |
47
111
  | `--read-only` | off | Disable file editing |
48
112
  | `--no-ignore` | off | Show all files (don't respect `.gitignore`) |
49
113
 
114
+ If the port is in use, mdbrowse-cli automatically tries the next available port.
115
+
116
+ ## Configuration
117
+
118
+ mdbrowse-cli supports a config file and environment variables so you don't have to pass flags every time.
119
+
120
+ **Priority chain:** `CLI args > Environment variables > .mdbrowse.json > Built-in defaults`
121
+
122
+ ### Config file (`.mdbrowse.json`)
123
+
124
+ Place a `.mdbrowse.json` in the directory you're serving:
125
+
126
+ ```json
127
+ {
128
+ "port": 4000,
129
+ "host": "0.0.0.0",
130
+ "tunnel": false,
131
+ "auth": "admin:secret",
132
+ "readOnly": true,
133
+ "noIgnore": false
134
+ }
135
+ ```
136
+
137
+ ### Environment variables
138
+
139
+ | Variable | Maps to | Example |
140
+ |----------|---------|---------|
141
+ | `MDBROWSE_PORT` | `--port` | `MDBROWSE_PORT=8080` |
142
+ | `MDBROWSE_HOST` | `--host` | `MDBROWSE_HOST=localhost` |
143
+ | `MDBROWSE_TUNNEL` | `--tunnel` | `MDBROWSE_TUNNEL=1` |
144
+ | `MDBROWSE_AUTH` | `--auth` | `MDBROWSE_AUTH=admin:secret` |
145
+ | `MDBROWSE_READ_ONLY` | `--read-only` | `MDBROWSE_READ_ONLY=1` |
146
+ | `MDBROWSE_NO_IGNORE` | `--no-ignore` | `MDBROWSE_NO_IGNORE=1` |
147
+
148
+ See [docs/configuration.md](docs/configuration.md) for full details, examples, and shell profile setup.
149
+
50
150
  ## Use cases
51
151
 
52
152
  **Remote / headless servers** — Working on a cloud dev box or VPS? Run `npx mdbrowse-cli . --tunnel` to get a public URL and view rendered markdown from any browser.
@@ -55,6 +155,14 @@ Opens in your browser automatically. On SSH/headless servers, grab the printed U
55
155
 
56
156
  **Documentation browsing** — Point it at your `docs/` folder for a quick local docs site with search, live reload, and edit support.
57
157
 
158
+ ## Try the demo
159
+
160
+ ```bash
161
+ git clone https://github.com/saleehk/mdbrowse.git
162
+ cd mdbrowse && npm install
163
+ npx mdbrowse-cli docs/
164
+ ```
165
+
58
166
  ## Tech stack
59
167
 
60
168
  [Hono](https://hono.dev/) server, [unified](https://unifiedjs.com/)/remark markdown pipeline, [Shiki](https://shiki.style/) syntax highlighting, [KaTeX](https://katex.org/) math, [Mermaid](https://mermaid.js.org/) diagrams, [chokidar](https://github.com/paulmillr/chokidar) file watching, vanilla JS frontend — no build step.
@@ -1,17 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from 'fs';
4
+ import path from 'path';
3
5
  import { Command } from 'commander';
4
6
  import { startServer } from '../src/server.js';
5
7
 
8
+ /**
9
+ * Load .mdbrowse.json from the target directory.
10
+ * Returns an empty object if not found or invalid.
11
+ */
12
+ function loadConfigFile(directory) {
13
+ const configPath = path.resolve(directory, '.mdbrowse.json');
14
+ try {
15
+ if (fs.existsSync(configPath)) {
16
+ const raw = fs.readFileSync(configPath, 'utf-8');
17
+ const config = JSON.parse(raw);
18
+ return config;
19
+ }
20
+ } catch (err) {
21
+ console.warn(` Warning: Could not parse .mdbrowse.json — ${err.message}`);
22
+ }
23
+ return {};
24
+ }
25
+
26
+ // Parse directory arg early so we can load config file before commander defaults
27
+ const dirArg = process.argv.find((a, i) => i >= 2 && !a.startsWith('-')) || '.';
28
+ const fileConfig = loadConfigFile(dirArg);
29
+
30
+ // Priority: CLI args > env vars > .mdbrowse.json > built-in defaults
31
+ const defaults = {
32
+ port: process.env.MDBROWSE_PORT || fileConfig.port?.toString() || '3000',
33
+ host: process.env.MDBROWSE_HOST || fileConfig.host || '0.0.0.0',
34
+ tunnel: process.env.MDBROWSE_TUNNEL === '1' || fileConfig.tunnel === true,
35
+ auth: process.env.MDBROWSE_AUTH || fileConfig.auth || null,
36
+ readOnly: process.env.MDBROWSE_READ_ONLY === '1' || fileConfig.readOnly === true,
37
+ noIgnore: process.env.MDBROWSE_NO_IGNORE === '1' || fileConfig.noIgnore === true,
38
+ };
39
+
6
40
  const program = new Command();
7
41
 
8
42
  program
9
43
  .name('mdbrowse-cli')
10
44
  .description('Browse and preview markdown files in any directory')
11
- .version('0.1.0')
45
+ .version('0.2.0')
12
46
  .argument('[directory]', 'Directory to serve', '.')
13
- .option('-p, --port <number>', 'Port to listen on', '3000')
14
- .option('--host <address>', 'Host to bind to', '0.0.0.0')
47
+ .option('-p, --port <number>', 'Port to listen on', defaults.port)
48
+ .option('--host <address>', 'Host to bind to', defaults.host)
15
49
  .option('--no-ignore', 'Show all files (ignore .gitignore)')
16
50
  .option('--auth <credentials>', 'Require basic auth (user:pass)')
17
51
  .option('--read-only', 'Disable file editing')
@@ -19,16 +53,18 @@ program
19
53
  .action(async (directory, options) => {
20
54
  const port = parseInt(options.port, 10);
21
55
  const host = options.host;
22
- const respectIgnore = options.ignore !== false;
23
- const readOnly = !!options.readOnly;
56
+ const respectIgnore = defaults.noIgnore ? false : options.ignore !== false;
57
+ const readOnly = options.readOnly || defaults.readOnly;
24
58
 
59
+ // Auth: CLI arg > env var > none
60
+ const authStr = options.auth || defaults.auth;
25
61
  let auth;
26
- if (options.auth) {
27
- if (!options.auth.includes(':')) {
28
- console.error('Error: --auth must be in user:pass format');
62
+ if (authStr) {
63
+ if (!authStr.includes(':')) {
64
+ console.error('Error: --auth (or MDBROWSE_AUTH) must be in user:pass format');
29
65
  process.exit(1);
30
66
  }
31
- const [user, ...rest] = options.auth.split(':');
67
+ const [user, ...rest] = authStr.split(':');
32
68
  auth = { username: user, password: rest.join(':') };
33
69
  }
34
70
 
@@ -41,6 +77,17 @@ program
41
77
  readOnly,
42
78
  });
43
79
 
80
+ // Security warnings
81
+ if ((options.tunnel || defaults.tunnel) && !authStr) {
82
+ console.warn('\n ⚠ WARNING: Tunnel is public with no authentication.');
83
+ }
84
+ if (authStr && host === '0.0.0.0') {
85
+ console.warn('\n ⚠ Warning: Basic auth over HTTP transmits credentials in cleartext.');
86
+ }
87
+ if (fileConfig.auth) {
88
+ console.warn('\n ⚠ Warning: Auth credentials in .mdbrowse.json — use MDBROWSE_AUTH env var instead.');
89
+ }
90
+
44
91
  const localUrl = `http://localhost:${actualPort}`;
45
92
  console.log(`\n mdbrowse-cli serving ${directory}`);
46
93
  console.log(` → Local: ${localUrl}`);
@@ -58,7 +105,7 @@ program
58
105
  }
59
106
  console.log();
60
107
 
61
- if (options.tunnel) {
108
+ if (options.tunnel || defaults.tunnel) {
62
109
  const { startTunnel, registerCleanup } = await import('../src/tunnel.js');
63
110
  try {
64
111
  console.log(' Starting Cloudflare Tunnel...');
@@ -79,14 +126,14 @@ program
79
126
  );
80
127
 
81
128
  if (!isHeadless) {
82
- const { exec } = await import('child_process');
129
+ const { execFile } = await import('child_process');
83
130
  const cmd =
84
131
  process.platform === 'darwin'
85
132
  ? 'open'
86
133
  : process.platform === 'win32'
87
134
  ? 'start'
88
135
  : 'xdg-open';
89
- exec(`${cmd} ${localUrl}`);
136
+ execFile(cmd, [localUrl], () => {});
90
137
  }
91
138
  });
92
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdbrowse-cli",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "CLI tool to browse and preview markdown files in any directory",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,8 @@
31
31
  "hono": "^4.7.0",
32
32
  "ignore": "^7.0.0",
33
33
  "rehype-katex": "^7.0.0",
34
+ "rehype-parse": "^9.0.1",
35
+ "rehype-sanitize": "^6.0.0",
34
36
  "rehype-stringify": "^10.0.0",
35
37
  "remark-gfm": "^4.0.0",
36
38
  "remark-html": "^16.0.0",
package/public/app.js CHANGED
@@ -239,6 +239,7 @@ function initMermaid() {
239
239
  mermaid.initialize({
240
240
  startOnLoad: false,
241
241
  theme: isDark ? 'dark' : 'default',
242
+ securityLevel: 'strict',
242
243
  });
243
244
 
244
245
  blocks.forEach((block, i) => {
package/src/renderer.js CHANGED
@@ -4,11 +4,50 @@ import remarkParse from 'remark-parse';
4
4
  import remarkGfm from 'remark-gfm';
5
5
  import remarkMath from 'remark-math';
6
6
  import remarkHtml from 'remark-html';
7
- import rehypeKatex from 'rehype-katex';
7
+ import rehypeParse from 'rehype-parse';
8
+ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
8
9
  import rehypeStringify from 'rehype-stringify';
9
10
  import matter from 'gray-matter';
10
11
  import { createHighlighter } from 'shiki';
11
12
 
13
+ // Permissive sanitization schema based on GitHub defaults
14
+ // Allows tables, images, links (http/https only), structural HTML
15
+ // Strips scripts, event handlers, javascript: URLs, iframes, object, embed
16
+ const sanitizeSchema = {
17
+ ...defaultSchema,
18
+ tagNames: [
19
+ ...(defaultSchema.tagNames || []),
20
+ 'div', 'span', 'details', 'summary',
21
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
22
+ 'img', 'a', 'p', 'br', 'hr',
23
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
24
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
25
+ 'pre', 'code', 'blockquote',
26
+ 'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins',
27
+ 'sup', 'sub', 'mark', 'abbr', 'kbd', 'var', 'samp',
28
+ 'figure', 'figcaption', 'picture', 'source',
29
+ 'input', // for task lists
30
+ ],
31
+ attributes: {
32
+ ...defaultSchema.attributes,
33
+ '*': ['className', 'id'],
34
+ a: ['href', 'title', 'target', 'rel'],
35
+ img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
36
+ td: ['align', 'valign', 'colSpan', 'rowSpan'],
37
+ th: ['align', 'valign', 'colSpan', 'rowSpan', 'scope'],
38
+ input: ['type', 'checked', 'disabled'],
39
+ code: ['className'],
40
+ div: ['className'],
41
+ span: ['className'],
42
+ source: ['srcSet', 'type', 'media'],
43
+ },
44
+ protocols: {
45
+ href: ['http', 'https', 'mailto'],
46
+ src: ['http', 'https', '/raw/', 'data'],
47
+ },
48
+ strip: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'textarea', 'select', 'button'],
49
+ };
50
+
12
51
  let highlighterPromise = null;
13
52
 
14
53
  function getHighlighter() {
@@ -59,7 +98,7 @@ export async function renderMarkdown(content, filePath) {
59
98
 
60
99
  // Build the remark pipeline
61
100
  // We use remark-html which outputs an HTML string directly,
62
- // then do a second pass for math with rehype
101
+ // then sanitize the output with rehype-sanitize
63
102
  const remarkResult = await unified()
64
103
  .use(remarkParse)
65
104
  .use(remarkGfm)
@@ -67,7 +106,14 @@ export async function renderMarkdown(content, filePath) {
67
106
  .use(remarkHtml, { sanitize: false })
68
107
  .process(body);
69
108
 
70
- let html = String(remarkResult);
109
+ // Sanitize HTML to prevent XSS from malicious markdown
110
+ const sanitized = await unified()
111
+ .use(rehypeParse, { fragment: true })
112
+ .use(rehypeSanitize, sanitizeSchema)
113
+ .use(rehypeStringify)
114
+ .process(String(remarkResult));
115
+
116
+ let html = String(sanitized);
71
117
 
72
118
  // Highlight code blocks in the HTML
73
119
  // Match <code class="language-xxx">...</code> blocks
package/src/server.js CHANGED
@@ -72,6 +72,7 @@ const MIME_TYPES = {
72
72
 
73
73
  /**
74
74
  * Validate that a requested path stays within the root directory.
75
+ * Resolves symlinks to prevent traversal via symbolic links.
75
76
  * Returns the resolved absolute path or null if invalid.
76
77
  */
77
78
  function safePath(rootDir, requestedPath) {
@@ -79,9 +80,40 @@ function safePath(rootDir, requestedPath) {
79
80
  if (!resolved.startsWith(rootDir + path.sep) && resolved !== rootDir) {
80
81
  return null;
81
82
  }
82
- return resolved;
83
+ try {
84
+ const real = fs.realpathSync(resolved);
85
+ const realRoot = fs.realpathSync(rootDir);
86
+ if (!real.startsWith(realRoot + path.sep) && real !== realRoot) {
87
+ return null;
88
+ }
89
+ return real;
90
+ } catch {
91
+ return resolved; // file may not exist yet
92
+ }
93
+ }
94
+
95
+ /**
96
+ * CSRF check: validate Origin/Referer header on state-changing requests.
97
+ */
98
+ function csrfCheck(c) {
99
+ const origin = c.req.header('origin');
100
+ const referer = c.req.header('referer');
101
+ const source = origin || referer;
102
+ if (source) {
103
+ try {
104
+ const url = new URL(source);
105
+ if (url.hostname !== 'localhost' && url.hostname !== '127.0.0.1' && !url.hostname.endsWith('.trycloudflare.com')) {
106
+ return false;
107
+ }
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+ return true;
83
113
  }
84
114
 
115
+ const MAX_WS_CLIENTS = 100;
116
+
85
117
  export async function startServer({ directory, port, host, respectIgnore, auth, readOnly }) {
86
118
  const rootDir = path.resolve(directory);
87
119
 
@@ -92,6 +124,25 @@ export async function startServer({ directory, port, host, respectIgnore, auth,
92
124
 
93
125
  const app = new Hono();
94
126
 
127
+ // Security headers middleware
128
+ const CSP = [
129
+ "default-src 'self'",
130
+ "script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'",
131
+ "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
132
+ "img-src 'self' data: https:",
133
+ "connect-src 'self' ws: wss:",
134
+ "frame-src 'none'",
135
+ "object-src 'none'",
136
+ ].join('; ');
137
+
138
+ app.use('*', async (c, next) => {
139
+ await next();
140
+ c.header('X-Content-Type-Options', 'nosniff');
141
+ c.header('X-Frame-Options', 'DENY');
142
+ c.header('Referrer-Policy', 'no-referrer');
143
+ c.header('Content-Security-Policy', CSP);
144
+ });
145
+
95
146
  if (auth) {
96
147
  app.use('*', basicAuth({ username: auth.username, password: auth.password }));
97
148
  }
@@ -153,6 +204,11 @@ export async function startServer({ directory, port, host, respectIgnore, auth,
153
204
  return c.json({ error: 'Read-only mode' }, 403);
154
205
  }
155
206
 
207
+ // CSRF protection: validate Origin header
208
+ if (!csrfCheck(c)) {
209
+ return c.json({ error: 'Forbidden: invalid origin' }, 403);
210
+ }
211
+
156
212
  const filePath = c.req.query('path');
157
213
  if (!filePath) {
158
214
  return c.json({ error: 'Missing path parameter' }, 400);
@@ -173,6 +229,9 @@ export async function startServer({ directory, port, host, respectIgnore, auth,
173
229
  }
174
230
 
175
231
  const body = await c.req.text();
232
+ if (body.length > MAX_FILE_SIZE) {
233
+ return c.json({ error: 'Request too large' }, 413);
234
+ }
176
235
  await fs.promises.writeFile(absPath, body, 'utf-8');
177
236
  return c.json({ ok: true });
178
237
  });
@@ -200,6 +259,10 @@ export async function startServer({ directory, port, host, respectIgnore, auth,
200
259
  return c.text('File not found', 404);
201
260
  }
202
261
 
262
+ if (stats.size > MAX_FILE_SIZE) {
263
+ return c.text('File too large', 413);
264
+ }
265
+
203
266
  const content = fs.readFileSync(absPath, 'utf-8');
204
267
  return c.text(content);
205
268
  });
@@ -264,10 +327,21 @@ export async function startServer({ directory, port, host, respectIgnore, auth,
264
327
  return c.text('Forbidden', 403);
265
328
  }
266
329
 
267
- if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) {
330
+ let stats;
331
+ try {
332
+ stats = fs.statSync(absPath);
333
+ } catch {
334
+ return c.text('Not found', 404);
335
+ }
336
+
337
+ if (!stats.isFile()) {
268
338
  return c.text('Not found', 404);
269
339
  }
270
340
 
341
+ if (stats.size > MAX_FILE_SIZE) {
342
+ return c.text('File too large', 413);
343
+ }
344
+
271
345
  const content = fs.readFileSync(absPath);
272
346
  const ext = path.extname(absPath).toLowerCase();
273
347
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
@@ -303,11 +377,45 @@ export async function startServer({ directory, port, host, respectIgnore, auth,
303
377
  hostname: host,
304
378
  });
305
379
 
306
- // WebSocket server
307
- const wss = new WebSocketServer({ server });
380
+ // WebSocket server with origin validation
381
+ const wss = new WebSocketServer({
382
+ server,
383
+ verifyClient: (info) => {
384
+ const origin = info.origin || info.req.headers.origin;
385
+ if (!origin) return true; // Allow non-browser clients (curl, etc.)
386
+ try {
387
+ const url = new URL(origin);
388
+ return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname.endsWith('.trycloudflare.com');
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+ });
308
394
 
309
395
  const clients = new Set();
310
- wss.on('connection', (ws) => {
396
+ wss.on('connection', (ws, req) => {
397
+ // Authenticate WebSocket connections when auth is enabled
398
+ if (auth) {
399
+ const authHeader = req.headers.authorization;
400
+ if (!authHeader || !authHeader.startsWith('Basic ')) {
401
+ ws.close(1008, 'Unauthorized');
402
+ return;
403
+ }
404
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
405
+ const [user, ...rest] = decoded.split(':');
406
+ const pass = rest.join(':');
407
+ if (user !== auth.username || pass !== auth.password) {
408
+ ws.close(1008, 'Unauthorized');
409
+ return;
410
+ }
411
+ }
412
+
413
+ // Connection limit
414
+ if (clients.size >= MAX_WS_CLIENTS) {
415
+ ws.close(1013, 'Too many connections');
416
+ return;
417
+ }
418
+
311
419
  clients.add(ws);
312
420
  ws.on('close', () => clients.delete(ws));
313
421
  ws.on('error', () => clients.delete(ws));