mdbrowse-cli 0.2.0 → 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 +21 -0
- package/README.md +111 -3
- package/bin/mdbrowse-cli.js +59 -12
- package/package.json +3 -1
- package/public/app.js +1 -0
- package/src/renderer.js +49 -3
- package/src/server.js +113 -5
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
|
-
|
|
11
|
-
<!--  -->
|
|
10
|
+

|
|
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
|
+

|
|
33
|
+
|
|
34
|
+
</td>
|
|
35
|
+
<td width="50%">
|
|
36
|
+
|
|
37
|
+
**Dark / light theme**
|
|
38
|
+
|
|
39
|
+
Auto-detects system preference. Toggle with one click.
|
|
40
|
+
|
|
41
|
+

|
|
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
|
+

|
|
53
|
+
|
|
54
|
+
</td>
|
|
55
|
+
<td width="50%">
|
|
56
|
+
|
|
57
|
+
**Search**
|
|
58
|
+
|
|
59
|
+
Filename + content search with Ctrl+K. Results highlighted in context.
|
|
60
|
+
|
|
61
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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>` | `
|
|
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.
|
package/bin/mdbrowse-cli.js
CHANGED
|
@@ -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.
|
|
45
|
+
.version('0.2.0')
|
|
12
46
|
.argument('[directory]', 'Directory to serve', '.')
|
|
13
|
-
.option('-p, --port <number>', 'Port to listen on',
|
|
14
|
-
.option('--host <address>', 'Host to bind to',
|
|
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 =
|
|
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 (
|
|
27
|
-
if (!
|
|
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] =
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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));
|