termbeam 1.2.8 → 1.2.10
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 +3 -1
- package/package.json +1 -1
- package/public/terminal.html +55 -46
- package/src/auth.js +8 -9
- package/src/routes.js +46 -6
package/README.md
CHANGED
|
@@ -128,7 +128,9 @@ Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `TERMBEAM_LO
|
|
|
128
128
|
|
|
129
129
|
TermBeam auto-generates a password and creates a tunnel by default, so your terminal is protected out of the box. By default, the server binds to `127.0.0.1` (localhost only). Use `--lan` or `--host 0.0.0.0` to allow LAN access, or `--no-tunnel` to disable the tunnel.
|
|
130
130
|
|
|
131
|
-
Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses. The QR code on startup embeds a share token for password-free login — the token is reusable within its 5-minute validity window, which handles tunnel proxy retries and link preview services. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header.
|
|
131
|
+
Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses. The QR code on startup embeds a share token for password-free login — the token is reusable within its 5-minute validity window, which handles tunnel proxy retries and link preview services. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header.
|
|
132
|
+
|
|
133
|
+
For the full threat model, safe usage guidance, and a quick safety checklist, see [SECURITY.md](SECURITY.md). For detailed security feature documentation, see the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/).
|
|
132
134
|
|
|
133
135
|
## Contributing
|
|
134
136
|
|
package/package.json
CHANGED
package/public/terminal.html
CHANGED
|
@@ -3203,23 +3203,7 @@
|
|
|
3203
3203
|
}
|
|
3204
3204
|
|
|
3205
3205
|
async function handlePaste() {
|
|
3206
|
-
// Try
|
|
3207
|
-
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
3208
|
-
try {
|
|
3209
|
-
const text = await navigator.clipboard.readText();
|
|
3210
|
-
if (text) {
|
|
3211
|
-
const ms = managed.get(activeId);
|
|
3212
|
-
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
3213
|
-
ms.ws.send(JSON.stringify({ type: 'input', data: text }));
|
|
3214
|
-
showToast('Pasted!');
|
|
3215
|
-
}
|
|
3216
|
-
return;
|
|
3217
|
-
}
|
|
3218
|
-
} catch (err) {
|
|
3219
|
-
console.warn('clipboard.readText failed:', err.message);
|
|
3220
|
-
}
|
|
3221
|
-
}
|
|
3222
|
-
// Image paste: try clipboard.read() only if readText didn't work
|
|
3206
|
+
// Try image first via clipboard.read()
|
|
3223
3207
|
if (navigator.clipboard && navigator.clipboard.read) {
|
|
3224
3208
|
try {
|
|
3225
3209
|
const items = await navigator.clipboard.read();
|
|
@@ -3237,8 +3221,9 @@
|
|
|
3237
3221
|
const data = await res.json();
|
|
3238
3222
|
const ms = managed.get(activeId);
|
|
3239
3223
|
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
3240
|
-
ms.ws.send(
|
|
3241
|
-
|
|
3224
|
+
ms.ws.send(
|
|
3225
|
+
JSON.stringify({ type: 'input', data: (data.path || data.url) + ' ' }),
|
|
3226
|
+
);
|
|
3242
3227
|
}
|
|
3243
3228
|
return;
|
|
3244
3229
|
}
|
|
@@ -3247,6 +3232,22 @@
|
|
|
3247
3232
|
console.warn('clipboard.read failed:', err.message);
|
|
3248
3233
|
}
|
|
3249
3234
|
}
|
|
3235
|
+
// Text paste
|
|
3236
|
+
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
3237
|
+
try {
|
|
3238
|
+
const text = await navigator.clipboard.readText();
|
|
3239
|
+
if (text) {
|
|
3240
|
+
const ms = managed.get(activeId);
|
|
3241
|
+
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
3242
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: text }));
|
|
3243
|
+
showToast('Pasted!');
|
|
3244
|
+
}
|
|
3245
|
+
return;
|
|
3246
|
+
}
|
|
3247
|
+
} catch (err) {
|
|
3248
|
+
console.warn('clipboard.readText failed:', err.message);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3250
3251
|
openPasteModal();
|
|
3251
3252
|
}
|
|
3252
3253
|
|
|
@@ -3392,36 +3393,44 @@
|
|
|
3392
3393
|
|
|
3393
3394
|
// ===== Image Paste =====
|
|
3394
3395
|
function setupImagePaste() {
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
if (
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
if (
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
const
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3396
|
+
// Use capture phase so we intercept before xterm.js pastes a file path as text
|
|
3397
|
+
document.addEventListener(
|
|
3398
|
+
'paste',
|
|
3399
|
+
async (e) => {
|
|
3400
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
3401
|
+
if (!items) return;
|
|
3402
|
+
|
|
3403
|
+
for (const item of items) {
|
|
3404
|
+
if (item.type.startsWith('image/')) {
|
|
3405
|
+
e.preventDefault();
|
|
3406
|
+
e.stopPropagation();
|
|
3407
|
+
const blob = item.getAsFile();
|
|
3408
|
+
if (!blob) return;
|
|
3409
|
+
|
|
3410
|
+
try {
|
|
3411
|
+
const res = await fetch('/api/upload', {
|
|
3412
|
+
method: 'POST',
|
|
3413
|
+
headers: { 'Content-Type': item.type },
|
|
3414
|
+
body: blob,
|
|
3415
|
+
credentials: 'same-origin',
|
|
3416
|
+
});
|
|
3417
|
+
if (!res.ok) throw new Error('Upload failed');
|
|
3418
|
+
const data = await res.json();
|
|
3419
|
+
const ms = managed.get(activeId);
|
|
3420
|
+
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
3421
|
+
ms.ws.send(
|
|
3422
|
+
JSON.stringify({ type: 'input', data: (data.path || data.url) + ' ' }),
|
|
3423
|
+
);
|
|
3424
|
+
}
|
|
3425
|
+
} catch (err) {
|
|
3426
|
+
showToast('Image paste failed');
|
|
3417
3427
|
}
|
|
3418
|
-
|
|
3419
|
-
showToast('Image paste failed');
|
|
3428
|
+
return;
|
|
3420
3429
|
}
|
|
3421
|
-
return;
|
|
3422
3430
|
}
|
|
3423
|
-
}
|
|
3424
|
-
|
|
3431
|
+
},
|
|
3432
|
+
true,
|
|
3433
|
+
);
|
|
3425
3434
|
}
|
|
3426
3435
|
|
|
3427
3436
|
// ===== New Session Modal =====
|
package/src/auth.js
CHANGED
|
@@ -86,26 +86,25 @@ function createAuth(password) {
|
|
|
86
86
|
const token = crypto.randomBytes(32).toString('hex');
|
|
87
87
|
const expiry = Date.now() + 5 * 60 * 1000;
|
|
88
88
|
shareTokens.set(token, expiry); // 5 minute expiry
|
|
89
|
-
log.info(
|
|
89
|
+
log.info('Share: created new token (expires in 5m)');
|
|
90
|
+
log.debug(`Share: token expires at ${new Date(expiry).toISOString()}`);
|
|
90
91
|
return token;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
function validateShareToken(token) {
|
|
94
95
|
const expiry = shareTokens.get(token);
|
|
95
|
-
const tag = token.slice(0, 8);
|
|
96
96
|
if (!expiry) {
|
|
97
|
-
log.warn(
|
|
97
|
+
log.warn('Share: unknown token presented');
|
|
98
98
|
return false;
|
|
99
99
|
}
|
|
100
100
|
const remaining = Math.round((expiry - Date.now()) / 1000);
|
|
101
101
|
if (remaining <= 0) {
|
|
102
102
|
shareTokens.delete(token);
|
|
103
|
-
log.warn(
|
|
103
|
+
log.warn('Share: expired token presented');
|
|
104
104
|
return false;
|
|
105
105
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
log.info(`Share: valid token ${tag}… (${min}m ${sec}s remaining)`);
|
|
106
|
+
shareTokens.delete(token);
|
|
107
|
+
log.info('share token consumed');
|
|
109
108
|
return true;
|
|
110
109
|
}
|
|
111
110
|
|
|
@@ -145,8 +144,8 @@ function createAuth(password) {
|
|
|
145
144
|
authAttempts.set(ip, recent);
|
|
146
145
|
return res.status(401).json({ error: 'unauthorized' });
|
|
147
146
|
}
|
|
148
|
-
if (req.
|
|
149
|
-
res.
|
|
147
|
+
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' });
|
|
148
|
+
res.redirect('/login');
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
function rateLimit(req, res, next) {
|
package/src/routes.js
CHANGED
|
@@ -7,7 +7,31 @@ const { detectShells } = require('./shells');
|
|
|
7
7
|
const log = require('./logger');
|
|
8
8
|
|
|
9
9
|
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
10
|
-
const uploadedFiles =
|
|
10
|
+
const uploadedFiles = new Map(); // id -> filepath
|
|
11
|
+
|
|
12
|
+
const IMAGE_SIGNATURES = [
|
|
13
|
+
{ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
14
|
+
{ type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
|
|
15
|
+
{ type: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
|
|
16
|
+
{ type: 'image/webp', offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
|
17
|
+
{ type: 'image/bmp', bytes: [0x42, 0x4d] },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function validateMagicBytes(buffer, contentType) {
|
|
21
|
+
const sig = IMAGE_SIGNATURES.find((s) => s.type === contentType);
|
|
22
|
+
if (!sig) return true; // unknown type, skip validation
|
|
23
|
+
const offset = sig.offset || 0;
|
|
24
|
+
if (buffer.length < offset + sig.bytes.length) return false;
|
|
25
|
+
const match = sig.bytes.every((b, i) => buffer[offset + i] === b);
|
|
26
|
+
if (!match) return false;
|
|
27
|
+
// WebP requires RIFF header at offset 0
|
|
28
|
+
if (contentType === 'image/webp') {
|
|
29
|
+
const riff = [0x52, 0x49, 0x46, 0x46];
|
|
30
|
+
if (buffer.length < 4) return false;
|
|
31
|
+
return riff.every((b, i) => buffer[i] === b);
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
11
35
|
|
|
12
36
|
function setupRoutes(app, { auth, sessions, config, state }) {
|
|
13
37
|
// Serve static files (manifest.json, sw.js, icons, etc.)
|
|
@@ -204,6 +228,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
204
228
|
if (!buffer.length) {
|
|
205
229
|
return res.status(400).json({ error: 'No image data' });
|
|
206
230
|
}
|
|
231
|
+
if (!validateMagicBytes(buffer, contentType)) {
|
|
232
|
+
log.warn(`Upload rejected: content-type "${contentType}" does not match file signature`);
|
|
233
|
+
return res.status(400).json({ error: 'File content does not match declared image type' });
|
|
234
|
+
}
|
|
207
235
|
const ext =
|
|
208
236
|
{
|
|
209
237
|
'image/png': '.png',
|
|
@@ -212,12 +240,13 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
212
240
|
'image/webp': '.webp',
|
|
213
241
|
'image/bmp': '.bmp',
|
|
214
242
|
}[contentType] || '.png';
|
|
215
|
-
const
|
|
243
|
+
const id = crypto.randomUUID();
|
|
244
|
+
const filename = `termbeam-${id}${ext}`;
|
|
216
245
|
const filepath = path.join(os.tmpdir(), filename);
|
|
217
246
|
fs.writeFileSync(filepath, buffer);
|
|
218
|
-
uploadedFiles.
|
|
247
|
+
uploadedFiles.set(id, filepath);
|
|
219
248
|
log.info(`Upload: ${filename} (${buffer.length} bytes)`);
|
|
220
|
-
res.json({ path: filepath });
|
|
249
|
+
res.json({ id, url: `/uploads/${id}`, path: filepath });
|
|
221
250
|
});
|
|
222
251
|
|
|
223
252
|
req.on('error', (err) => {
|
|
@@ -226,6 +255,17 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
226
255
|
});
|
|
227
256
|
});
|
|
228
257
|
|
|
258
|
+
// Serve uploaded files by opaque ID
|
|
259
|
+
app.get('/uploads/:id', auth.middleware, (req, res) => {
|
|
260
|
+
const filepath = uploadedFiles.get(req.params.id);
|
|
261
|
+
if (!filepath) return res.status(404).json({ error: 'not found' });
|
|
262
|
+
if (!fs.existsSync(filepath)) {
|
|
263
|
+
uploadedFiles.delete(req.params.id);
|
|
264
|
+
return res.status(404).json({ error: 'not found' });
|
|
265
|
+
}
|
|
266
|
+
res.sendFile(filepath);
|
|
267
|
+
});
|
|
268
|
+
|
|
229
269
|
// Directory listing for folder browser
|
|
230
270
|
app.get('/api/dirs', auth.middleware, (req, res) => {
|
|
231
271
|
const query = req.query.q || config.cwd + path.sep;
|
|
@@ -248,7 +288,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
248
288
|
}
|
|
249
289
|
|
|
250
290
|
function cleanupUploadedFiles() {
|
|
251
|
-
for (const filepath of uploadedFiles) {
|
|
291
|
+
for (const [id, filepath] of uploadedFiles) {
|
|
252
292
|
try {
|
|
253
293
|
if (fs.existsSync(filepath)) {
|
|
254
294
|
fs.unlinkSync(filepath);
|
|
@@ -257,7 +297,7 @@ function cleanupUploadedFiles() {
|
|
|
257
297
|
log.error(`Failed to cleanup ${filepath}: ${err.message}`);
|
|
258
298
|
}
|
|
259
299
|
}
|
|
260
|
-
uploadedFiles.
|
|
300
|
+
uploadedFiles.clear();
|
|
261
301
|
}
|
|
262
302
|
|
|
263
303
|
module.exports = { setupRoutes, cleanupUploadedFiles };
|