termbeam 1.8.0 → 1.8.1
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/README.md +1 -0
- package/package.json +5 -1
- package/src/interactive.js +4 -4
- package/src/prompts.js +2 -2
- package/src/routes.js +58 -20
- package/src/server.js +2 -2
- package/src/service.js +4 -4
- package/src/sessions.js +21 -2
- package/src/tunnel.js +2 -2
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
[](https://www.npmjs.com/package/termbeam)
|
|
9
9
|
[](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
|
|
10
10
|
[](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
|
|
11
|
+
[](https://securityscorecards.dev/viewer/?uri=github.com/dorlugasigal/TermBeam)
|
|
11
12
|
[](https://nodejs.org/)
|
|
12
13
|
[](https://opensource.org/licenses/MIT)
|
|
13
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termbeam",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -68,13 +68,17 @@
|
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"cookie-parser": "^1.4.7",
|
|
70
70
|
"express": "^5.2.1",
|
|
71
|
+
"express-rate-limit": "^8.2.1",
|
|
71
72
|
"node-pty": "^1.1.0",
|
|
72
73
|
"qrcode": "^1.5.4",
|
|
73
74
|
"ws": "^8.19.0"
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|
|
77
|
+
"@eslint/js": "^9.39.3",
|
|
76
78
|
"@playwright/test": "^1.58.2",
|
|
77
79
|
"c8": "^11.0.0",
|
|
80
|
+
"eslint": "^10.0.2",
|
|
81
|
+
"eslint-plugin-security": "^4.0.0",
|
|
78
82
|
"husky": "^9.1.7",
|
|
79
83
|
"lint-staged": "^16.2.7",
|
|
80
84
|
"prettier": "^3.8.1"
|
package/src/interactive.js
CHANGED
|
@@ -85,7 +85,7 @@ async function runInteractiveSetup(baseConfig) {
|
|
|
85
85
|
let passwordMode = 'auto';
|
|
86
86
|
if (pwChoice.index === 0) {
|
|
87
87
|
config.password = crypto.randomBytes(16).toString('base64url');
|
|
88
|
-
|
|
88
|
+
process.stdout.write(dim(` Generated password: ${config.password}`) + '\n');
|
|
89
89
|
} else if (pwChoice.index === 1) {
|
|
90
90
|
passwordMode = 'custom';
|
|
91
91
|
config.password = await ask(rl, 'Enter password:');
|
|
@@ -99,7 +99,7 @@ async function runInteractiveSetup(baseConfig) {
|
|
|
99
99
|
}
|
|
100
100
|
decisions.push({
|
|
101
101
|
label: 'Password',
|
|
102
|
-
value: config.password
|
|
102
|
+
value: config.password === null ? yellow('disabled') : '••••••••',
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
// Step 2: Port
|
|
@@ -164,7 +164,7 @@ async function runInteractiveSetup(baseConfig) {
|
|
|
164
164
|
if (config.publicTunnel && !config.password) {
|
|
165
165
|
console.log(yellow(' ⚠ Public tunnels require password authentication.'));
|
|
166
166
|
config.password = crypto.randomBytes(16).toString('base64url');
|
|
167
|
-
|
|
167
|
+
process.stdout.write(dim(` Auto-generated password: ${config.password}`) + '\n');
|
|
168
168
|
passwordMode = 'auto';
|
|
169
169
|
// Update the password decision
|
|
170
170
|
decisions[0] = { label: 'Password', value: '••••••••' };
|
|
@@ -212,7 +212,7 @@ async function runInteractiveSetup(baseConfig) {
|
|
|
212
212
|
showProgress(4);
|
|
213
213
|
console.log(bold('\n── Configuration Summary ──────────────────'));
|
|
214
214
|
console.log(
|
|
215
|
-
` Password: ${config.password
|
|
215
|
+
` Password: ${config.password === null ? yellow('disabled') : cyan('••••••••')}`,
|
|
216
216
|
);
|
|
217
217
|
console.log(` Port: ${cyan(String(config.port))}`);
|
|
218
218
|
console.log(
|
package/src/prompts.js
CHANGED
|
@@ -19,11 +19,11 @@ const dim = (t) => color('2', t);
|
|
|
19
19
|
* If `defaultValue` is provided, it's shown in brackets and used when the user presses Enter.
|
|
20
20
|
*/
|
|
21
21
|
function ask(rl, question, defaultValue) {
|
|
22
|
-
const suffix = defaultValue != null ? ` ${dim(`[${defaultValue}]`)} ` : ' ';
|
|
22
|
+
const suffix = defaultValue != null ? ` ${dim(`[${defaultValue}]`)} ` : ' '; // eslint-disable-line eqeqeq
|
|
23
23
|
return new Promise((resolve) => {
|
|
24
24
|
rl.question(`${question}${suffix}`, (answer) => {
|
|
25
25
|
const trimmed = answer.trim();
|
|
26
|
-
resolve(trimmed || (defaultValue != null ? String(defaultValue) : ''));
|
|
26
|
+
resolve(trimmed || (defaultValue != null ? String(defaultValue) : '')); // eslint-disable-line eqeqeq
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
29
|
}
|
package/src/routes.js
CHANGED
|
@@ -5,10 +5,29 @@ const crypto = require('crypto');
|
|
|
5
5
|
const express = require('express');
|
|
6
6
|
const { detectShells } = require('./shells');
|
|
7
7
|
const log = require('./logger');
|
|
8
|
+
const rateLimit = require('express-rate-limit');
|
|
8
9
|
|
|
9
10
|
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
10
11
|
const uploadedFiles = new Map(); // id -> filepath
|
|
11
12
|
|
|
13
|
+
const pageRateLimit = rateLimit({
|
|
14
|
+
windowMs: 1 * 60 * 1000,
|
|
15
|
+
max: 120,
|
|
16
|
+
standardHeaders: true,
|
|
17
|
+
legacyHeaders: false,
|
|
18
|
+
handler: (_req, res) =>
|
|
19
|
+
res.status(429).json({ error: 'Too many requests, please try again later.' }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const apiRateLimit = rateLimit({
|
|
23
|
+
windowMs: 1 * 60 * 1000,
|
|
24
|
+
max: 120,
|
|
25
|
+
standardHeaders: true,
|
|
26
|
+
legacyHeaders: false,
|
|
27
|
+
handler: (_req, res) =>
|
|
28
|
+
res.status(429).json({ error: 'Too many requests, please try again later.' }),
|
|
29
|
+
});
|
|
30
|
+
|
|
12
31
|
const IMAGE_SIGNATURES = [
|
|
13
32
|
{ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
14
33
|
{ type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
|
|
@@ -74,7 +93,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
74
93
|
if (!ott || !auth.password) return next();
|
|
75
94
|
// Already authenticated (e.g. DevTunnel anti-phishing re-sent the request) — just redirect
|
|
76
95
|
if (req.cookies.pty_token && auth.validateToken(req.cookies.pty_token)) {
|
|
77
|
-
return res.redirect(req.path);
|
|
96
|
+
return res.redirect(req.path === '/terminal' ? '/terminal' : '/');
|
|
78
97
|
}
|
|
79
98
|
if (auth.validateShareToken(ott)) {
|
|
80
99
|
const token = auth.generateToken();
|
|
@@ -86,17 +105,17 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
86
105
|
});
|
|
87
106
|
log.info(`Auth: share-token auto-login from ${req.ip}`);
|
|
88
107
|
// Redirect to the same path without ?ott= to keep the URL clean
|
|
89
|
-
return res.redirect(req.path);
|
|
108
|
+
return res.redirect(req.path === '/terminal' ? '/terminal' : '/');
|
|
90
109
|
}
|
|
91
110
|
log.warn(`Auth: invalid or expired share token from ${req.ip}`);
|
|
92
111
|
next();
|
|
93
112
|
}
|
|
94
113
|
|
|
95
114
|
// Pages
|
|
96
|
-
app.get('/', autoLogin, auth.middleware, (_req, res) =>
|
|
115
|
+
app.get('/', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
|
|
97
116
|
res.sendFile('index.html', { root: PUBLIC_DIR }),
|
|
98
117
|
);
|
|
99
|
-
app.get('/terminal', autoLogin, auth.middleware, (_req, res) =>
|
|
118
|
+
app.get('/terminal', pageRateLimit, autoLogin, auth.middleware, (_req, res) =>
|
|
100
119
|
res.sendFile('terminal.html', { root: PUBLIC_DIR }),
|
|
101
120
|
);
|
|
102
121
|
|
|
@@ -109,11 +128,11 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
109
128
|
});
|
|
110
129
|
|
|
111
130
|
// Session API
|
|
112
|
-
app.get('/api/sessions', auth.middleware, (_req, res) => {
|
|
131
|
+
app.get('/api/sessions', apiRateLimit, auth.middleware, (_req, res) => {
|
|
113
132
|
res.json(sessions.list());
|
|
114
133
|
});
|
|
115
134
|
|
|
116
|
-
app.post('/api/sessions', auth.middleware, (req, res) => {
|
|
135
|
+
app.post('/api/sessions', apiRateLimit, auth.middleware, (req, res) => {
|
|
117
136
|
const { name, shell, args: shellArgs, cwd, initialCommand, color, cols, rows } = req.body || {};
|
|
118
137
|
|
|
119
138
|
// Validate shell field
|
|
@@ -125,6 +144,20 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
125
144
|
}
|
|
126
145
|
}
|
|
127
146
|
|
|
147
|
+
// Validate args field — must be an array of strings
|
|
148
|
+
if (shellArgs !== undefined) {
|
|
149
|
+
if (!Array.isArray(shellArgs) || !shellArgs.every((a) => typeof a === 'string')) {
|
|
150
|
+
return res.status(400).json({ error: 'args must be an array of strings' });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Validate initialCommand field — must be a string
|
|
155
|
+
if (initialCommand !== undefined && initialCommand !== null) {
|
|
156
|
+
if (typeof initialCommand !== 'string') {
|
|
157
|
+
return res.status(400).json({ error: 'initialCommand must be a string' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
128
161
|
// Validate cwd field
|
|
129
162
|
if (cwd) {
|
|
130
163
|
if (!path.isAbsolute(cwd)) {
|
|
@@ -139,16 +172,21 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
139
172
|
}
|
|
140
173
|
}
|
|
141
174
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
let id;
|
|
176
|
+
try {
|
|
177
|
+
id = sessions.create({
|
|
178
|
+
name: name || `Session ${sessions.sessions.size + 1}`,
|
|
179
|
+
shell: shell || config.defaultShell,
|
|
180
|
+
args: shellArgs || [],
|
|
181
|
+
cwd: cwd ? path.resolve(cwd) : config.cwd,
|
|
182
|
+
initialCommand: initialCommand ?? null,
|
|
183
|
+
color: color || null,
|
|
184
|
+
cols: typeof cols === 'number' && cols > 0 && cols <= 500 ? Math.floor(cols) : undefined,
|
|
185
|
+
rows: typeof rows === 'number' && rows > 0 && rows <= 200 ? Math.floor(rows) : undefined,
|
|
186
|
+
});
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return res.status(400).json({ error: err.message || 'Failed to create session' });
|
|
189
|
+
}
|
|
152
190
|
res.json({ id, url: `/terminal?id=${id}` });
|
|
153
191
|
});
|
|
154
192
|
|
|
@@ -260,7 +298,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
260
298
|
});
|
|
261
299
|
|
|
262
300
|
// Serve uploaded files by opaque ID
|
|
263
|
-
app.get('/uploads/:id', auth.middleware, (req, res) => {
|
|
301
|
+
app.get('/uploads/:id', pageRateLimit, auth.middleware, (req, res) => {
|
|
264
302
|
const filepath = uploadedFiles.get(req.params.id);
|
|
265
303
|
if (!filepath) return res.status(404).json({ error: 'not found' });
|
|
266
304
|
if (!fs.existsSync(filepath)) {
|
|
@@ -271,10 +309,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
271
309
|
});
|
|
272
310
|
|
|
273
311
|
// Directory listing for folder browser
|
|
274
|
-
app.get('/api/dirs', auth.middleware, (req, res) => {
|
|
312
|
+
app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
|
|
275
313
|
const query = req.query.q || config.cwd + path.sep;
|
|
276
314
|
const endsWithSep = query.endsWith('/') || query.endsWith('\\');
|
|
277
|
-
const dir = endsWithSep ? query : path.dirname(query);
|
|
315
|
+
const dir = path.resolve(endsWithSep ? query : path.dirname(query));
|
|
278
316
|
const prefix = endsWithSep ? '' : path.basename(query);
|
|
279
317
|
|
|
280
318
|
try {
|
|
@@ -292,7 +330,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
292
330
|
}
|
|
293
331
|
|
|
294
332
|
function cleanupUploadedFiles() {
|
|
295
|
-
for (const [
|
|
333
|
+
for (const [_id, filepath] of uploadedFiles) {
|
|
296
334
|
try {
|
|
297
335
|
if (fs.existsSync(filepath)) {
|
|
298
336
|
fs.unlinkSync(filepath);
|
package/src/server.js
CHANGED
|
@@ -172,7 +172,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
172
172
|
config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
|
|
173
173
|
state.shareBaseUrl = isLanReachable ? localUrl : `http://localhost:${config.port}`;
|
|
174
174
|
const gn = '\x1b[38;5;114m'; // green
|
|
175
|
-
const
|
|
175
|
+
const _dm = '\x1b[2m'; // dim
|
|
176
176
|
|
|
177
177
|
const bl = '\x1b[38;5;75m'; // light blue
|
|
178
178
|
|
|
@@ -220,7 +220,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
console.log(` Scan the QR code or open: ${bl}${qrDisplayUrl}${rs}`);
|
|
223
|
-
if (config.password)
|
|
223
|
+
if (config.password) process.stdout.write(` Password: ${gn}${config.password}${rs}\n`);
|
|
224
224
|
console.log('');
|
|
225
225
|
|
|
226
226
|
resolve({ url: `http://localhost:${config.port}`, defaultId });
|
package/src/service.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { execFileSync
|
|
1
|
+
const { execFileSync } = require('child_process');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
@@ -237,7 +237,7 @@ async function actionInstall() {
|
|
|
237
237
|
]);
|
|
238
238
|
if (pwChoice.index === 0) {
|
|
239
239
|
config.password = crypto.randomBytes(16).toString('base64url');
|
|
240
|
-
|
|
240
|
+
process.stdout.write(dim(` Generated password: ${config.password}`) + '\n');
|
|
241
241
|
} else if (pwChoice.index === 1) {
|
|
242
242
|
config.password = await ask(rl, 'Enter password:');
|
|
243
243
|
while (!config.password) {
|
|
@@ -297,7 +297,7 @@ async function actionInstall() {
|
|
|
297
297
|
if (config.publicTunnel && config.password === false) {
|
|
298
298
|
console.log(yellow(' ⚠ Public tunnels require password authentication.'));
|
|
299
299
|
config.password = crypto.randomBytes(16).toString('base64url');
|
|
300
|
-
|
|
300
|
+
process.stdout.write(dim(` Auto-generated password: ${config.password}`) + '\n');
|
|
301
301
|
}
|
|
302
302
|
} else if (accessChoice.index === 1) {
|
|
303
303
|
// LAN mode: bind to all interfaces, no tunnel
|
|
@@ -352,7 +352,7 @@ async function actionInstall() {
|
|
|
352
352
|
console.log(bold('\n── Configuration Summary ──────────────────'));
|
|
353
353
|
console.log(` Service name: ${cyan(config.name)}`);
|
|
354
354
|
console.log(
|
|
355
|
-
` Password: ${config.password === false ? yellow('disabled') : cyan(
|
|
355
|
+
` Password: ${config.password === false ? yellow('disabled') : cyan('••••••••')}`,
|
|
356
356
|
);
|
|
357
357
|
console.log(` Port: ${cyan(String(config.port))}`);
|
|
358
358
|
console.log(
|
package/src/sessions.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
|
+
const path = require('path');
|
|
2
3
|
const { execSync, exec } = require('child_process');
|
|
3
4
|
const fs = require('fs');
|
|
4
5
|
const pty = require('node-pty');
|
|
5
6
|
const log = require('./logger');
|
|
6
7
|
const { getGitInfo } = require('./git');
|
|
7
8
|
|
|
8
|
-
function
|
|
9
|
+
function _getProcessCwd(pid) {
|
|
9
10
|
try {
|
|
10
11
|
if (process.platform === 'linux') {
|
|
11
12
|
return fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
@@ -108,6 +109,24 @@ class SessionManager {
|
|
|
108
109
|
cols = 120,
|
|
109
110
|
rows = 30,
|
|
110
111
|
}) {
|
|
112
|
+
// Defense-in-depth: reject shells with dangerous characters or relative paths
|
|
113
|
+
if (
|
|
114
|
+
typeof shell !== 'string' ||
|
|
115
|
+
!shell ||
|
|
116
|
+
/[;&|`$(){}\[\]!#~]/.test(shell) ||
|
|
117
|
+
(!path.isAbsolute(shell) && !shell.match(/^[a-zA-Z0-9._-]+(\.exe)?$/))
|
|
118
|
+
) {
|
|
119
|
+
throw new Error('Invalid shell');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Defense-in-depth: validate args and initialCommand types
|
|
123
|
+
if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) {
|
|
124
|
+
throw new Error('args must be an array of strings');
|
|
125
|
+
}
|
|
126
|
+
if (initialCommand !== null && typeof initialCommand !== 'string') {
|
|
127
|
+
throw new Error('initialCommand must be a string');
|
|
128
|
+
}
|
|
129
|
+
|
|
111
130
|
const id = crypto.randomBytes(16).toString('hex');
|
|
112
131
|
if (!color) {
|
|
113
132
|
color = SESSION_COLORS[this.sessions.size % SESSION_COLORS.length];
|
|
@@ -209,7 +228,7 @@ class SessionManager {
|
|
|
209
228
|
}
|
|
210
229
|
|
|
211
230
|
shutdown() {
|
|
212
|
-
for (const [
|
|
231
|
+
for (const [_id, s] of this.sessions) {
|
|
213
232
|
try {
|
|
214
233
|
s.pty.kill();
|
|
215
234
|
} catch {
|
package/src/tunnel.js
CHANGED
|
@@ -67,7 +67,7 @@ function savePersistedTunnel(id) {
|
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function
|
|
70
|
+
function _deletePersisted() {
|
|
71
71
|
const persisted = loadPersistedTunnel();
|
|
72
72
|
if (persisted) {
|
|
73
73
|
try {
|
|
@@ -139,7 +139,7 @@ async function startTunnel(port, options = {}) {
|
|
|
139
139
|
log.info('A code will be displayed — open the URL on any device to authenticate.');
|
|
140
140
|
try {
|
|
141
141
|
execFileSync(devtunnelCmd, ['user', 'login', '-d'], { stdio: 'inherit' });
|
|
142
|
-
} catch (
|
|
142
|
+
} catch (_loginErr) {
|
|
143
143
|
log.error('');
|
|
144
144
|
log.error(' DevTunnel login failed. To use tunnels, run:');
|
|
145
145
|
log.error(' devtunnel user login');
|