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 CHANGED
@@ -8,6 +8,7 @@
8
8
  [![npm downloads](https://img.shields.io/npm/dm/termbeam.svg)](https://www.npmjs.com/package/termbeam)
9
9
  [![CI](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml/badge.svg)](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
10
10
  [![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/dorlugasigal/TermBeam/coverage-data/endpoint.json)](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
11
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/dorlugasigal/TermBeam/badge)](https://securityscorecards.dev/viewer/?uri=github.com/dorlugasigal/TermBeam)
11
12
  [![Node.js](https://img.shields.io/node/v/termbeam.svg)](https://nodejs.org/)
12
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.0",
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"
@@ -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
- console.log(dim(` Generated password: ${config.password}`));
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 == null ? yellow('disabled') : '••••••••',
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
- console.log(dim(` Auto-generated password: ${config.password}`));
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 == null ? yellow('disabled') : cyan('••••••••')}`,
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
- const id = sessions.create({
143
- name: name || `Session ${sessions.sessions.size + 1}`,
144
- shell: shell || config.defaultShell,
145
- args: shellArgs || [],
146
- cwd: cwd || config.cwd,
147
- initialCommand: initialCommand || null,
148
- color: color || null,
149
- cols: typeof cols === 'number' && cols > 0 && cols <= 500 ? Math.floor(cols) : undefined,
150
- rows: typeof rows === 'number' && rows > 0 && rows <= 200 ? Math.floor(rows) : undefined,
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 [id, filepath] of uploadedFiles) {
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 dm = '\x1b[2m'; // dim
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) console.log(` Password: ${gn}${config.password}${rs}`);
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, execFile } = require('child_process');
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
- console.log(dim(` Generated password: ${config.password}`));
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
- console.log(dim(` Auto-generated password: ${config.password}`));
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(config.password)}`,
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 getProcessCwd(pid) {
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 [id, s] of this.sessions) {
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 deletePersisted() {
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 (loginErr) {
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');