herdr-remote 0.2.8 → 0.2.9
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/dist/tui.mjs +5 -2
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/config.js +4 -1
- package/src/host-connector.js +25 -0
- package/src/pasted-files.js +176 -0
package/dist/tui.mjs
CHANGED
|
@@ -76,7 +76,10 @@ var require_config = __commonJS({
|
|
|
76
76
|
publicUrl: "",
|
|
77
77
|
// Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
|
|
78
78
|
remoteUrl: "",
|
|
79
|
-
|
|
79
|
+
// Kept in step with the relay package's own default: the CLI starts a
|
|
80
|
+
// relay of its own, and a smaller ceiling here would reject an upload the
|
|
81
|
+
// hosted relay accepts.
|
|
82
|
+
maxPayloadBytes: 5 * 1024 * 1024,
|
|
80
83
|
maxClientsPerHost: 16,
|
|
81
84
|
maxHosts: 1024,
|
|
82
85
|
maxPendingHandshakes: 1024,
|
|
@@ -4012,7 +4015,7 @@ function About({ ctx }) {
|
|
|
4012
4015
|
children: updateLabel
|
|
4013
4016
|
}
|
|
4014
4017
|
) }),
|
|
4015
|
-
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.
|
|
4018
|
+
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.9" }) }),
|
|
4016
4019
|
/* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
|
|
4017
4020
|
/* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
|
|
4018
4021
|
/* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
|
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -62,7 +62,10 @@ const DEFAULTS = {
|
|
|
62
62
|
publicUrl: '',
|
|
63
63
|
// Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
|
|
64
64
|
remoteUrl: '',
|
|
65
|
-
|
|
65
|
+
// Kept in step with the relay package's own default: the CLI starts a
|
|
66
|
+
// relay of its own, and a smaller ceiling here would reject an upload the
|
|
67
|
+
// hosted relay accepts.
|
|
68
|
+
maxPayloadBytes: 5 * 1024 * 1024,
|
|
66
69
|
maxClientsPerHost: 16,
|
|
67
70
|
maxHosts: 1024,
|
|
68
71
|
maxPendingHandshakes: 1024,
|
package/src/host-connector.js
CHANGED
|
@@ -15,6 +15,7 @@ const { resolveHerdrCommand, verifyHerdrCommand, herdrNotFoundMessage } = requir
|
|
|
15
15
|
const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
|
|
16
16
|
const { resolveHostPalette } = require('./terminal-palette');
|
|
17
17
|
const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
|
|
18
|
+
const { savePastedFile, cleanPastedDir } = require('./pasted-files');
|
|
18
19
|
|
|
19
20
|
function randomId(prefix) {
|
|
20
21
|
return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
|
|
@@ -89,6 +90,7 @@ class HostConnector {
|
|
|
89
90
|
|| process.env.HERDR_REMOTE_HOST_LOCK
|
|
90
91
|
|| path.join(stateDir(), 'host-connector.lock');
|
|
91
92
|
this.lockFd = null;
|
|
93
|
+
try { cleanPastedDir(); } catch {}
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
acquireLock() {
|
|
@@ -280,6 +282,7 @@ class HostConnector {
|
|
|
280
282
|
if (message.type === 'session_start') this.startSession(message);
|
|
281
283
|
else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
|
|
282
284
|
else if (message.type === 'resize') this.resizeSession(message);
|
|
285
|
+
else if (message.type === 'paste_file') this.handlePasteFile(message);
|
|
283
286
|
}
|
|
284
287
|
|
|
285
288
|
/**
|
|
@@ -419,6 +422,28 @@ class HostConnector {
|
|
|
419
422
|
session.pty.resize(session.cols, session.rows);
|
|
420
423
|
}
|
|
421
424
|
|
|
425
|
+
handlePasteFile(message) {
|
|
426
|
+
const streamId = message.clientId || message.streamId;
|
|
427
|
+
try {
|
|
428
|
+
const savedPath = savePastedFile({
|
|
429
|
+
mime: message.mime,
|
|
430
|
+
dataBase64: message.dataBase64,
|
|
431
|
+
});
|
|
432
|
+
sendJson(this.ws, {
|
|
433
|
+
type: 'paste_file_ready',
|
|
434
|
+
clientId: streamId,
|
|
435
|
+
path: savedPath,
|
|
436
|
+
});
|
|
437
|
+
} catch (error) {
|
|
438
|
+
sendJson(this.ws, {
|
|
439
|
+
type: 'error',
|
|
440
|
+
clientId: streamId,
|
|
441
|
+
code: 'paste_file_write_failed',
|
|
442
|
+
message: error.message || 'Failed to save pasted file',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
422
447
|
destroySessions() {
|
|
423
448
|
for (const session of this.sessions.values()) session.pty.kill();
|
|
424
449
|
this.sessions.clear();
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Secure file storage and validation for pasted clipboard images.
|
|
5
|
+
*
|
|
6
|
+
* Security requirements:
|
|
7
|
+
* 1. Pinned directory: writes strictly to path.join(stateDir(), 'pasted') (directory mode 0o700).
|
|
8
|
+
* 2. Unpredictable filename: generated via crypto.randomUUID(), never using client-provided strings.
|
|
9
|
+
* 3. Whitelisted extension: derived exclusively from verified MIME type.
|
|
10
|
+
* 4. Host-side size check: re-validated to enforce <= 3 MB independently of relay checks.
|
|
11
|
+
* 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent file-type spoofing.
|
|
12
|
+
* 6. File mode: files are created with mode 0o600.
|
|
13
|
+
* 7. Automatic pruning: caps at 20 files and 50 MB (oldest deleted first), removes >24h stale files at startup.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { stateDir } = require('./config');
|
|
20
|
+
const { ensureDir } = require('./state');
|
|
21
|
+
|
|
22
|
+
const MAX_PASTE_BYTES = 3 * 1024 * 1024; // 3 MB raw payload
|
|
23
|
+
const MAX_SAVED_FILES = 20;
|
|
24
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
25
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
26
|
+
|
|
27
|
+
const MIME_CONFIG = {
|
|
28
|
+
'image/png': {
|
|
29
|
+
ext: '.png',
|
|
30
|
+
check: (buf) =>
|
|
31
|
+
buf.length >= 8 &&
|
|
32
|
+
buf[0] === 0x89 &&
|
|
33
|
+
buf[1] === 0x50 &&
|
|
34
|
+
buf[2] === 0x4e &&
|
|
35
|
+
buf[3] === 0x47,
|
|
36
|
+
},
|
|
37
|
+
'image/jpeg': {
|
|
38
|
+
ext: '.jpg',
|
|
39
|
+
check: (buf) =>
|
|
40
|
+
buf.length >= 3 &&
|
|
41
|
+
buf[0] === 0xff &&
|
|
42
|
+
buf[1] === 0xd8 &&
|
|
43
|
+
buf[2] === 0xff,
|
|
44
|
+
},
|
|
45
|
+
'image/webp': {
|
|
46
|
+
ext: '.webp',
|
|
47
|
+
check: (buf) =>
|
|
48
|
+
buf.length >= 12 &&
|
|
49
|
+
buf[0] === 0x52 &&
|
|
50
|
+
buf[1] === 0x49 &&
|
|
51
|
+
buf[2] === 0x46 &&
|
|
52
|
+
buf[3] === 0x46 && // 'RIFF'
|
|
53
|
+
buf[8] === 0x57 &&
|
|
54
|
+
buf[9] === 0x45 &&
|
|
55
|
+
buf[10] === 0x42 &&
|
|
56
|
+
buf[11] === 0x50, // 'WEBP'
|
|
57
|
+
},
|
|
58
|
+
'image/gif': {
|
|
59
|
+
ext: '.gif',
|
|
60
|
+
check: (buf) =>
|
|
61
|
+
buf.length >= 6 &&
|
|
62
|
+
buf[0] === 0x47 &&
|
|
63
|
+
buf[1] === 0x49 &&
|
|
64
|
+
buf[2] === 0x46 &&
|
|
65
|
+
buf[3] === 0x38, // 'GIF8'
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function getPastedDir() {
|
|
70
|
+
const dir = path.join(stateDir(), 'pasted');
|
|
71
|
+
ensureDir(dir);
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Prunes the pasted directory:
|
|
77
|
+
* - Removes files older than maxAgeMs (default 24 hours).
|
|
78
|
+
* - Caps total file count (default 20) and total disk footprint (default 50 MB),
|
|
79
|
+
* deleting oldest files first.
|
|
80
|
+
*/
|
|
81
|
+
function cleanPastedDir({
|
|
82
|
+
dir = getPastedDir(),
|
|
83
|
+
maxFiles = MAX_SAVED_FILES,
|
|
84
|
+
maxBytes = MAX_TOTAL_BYTES,
|
|
85
|
+
maxAgeMs = MAX_AGE_MS,
|
|
86
|
+
now = Date.now(),
|
|
87
|
+
} = {}) {
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fileInfos = [];
|
|
96
|
+
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isFile()) continue;
|
|
99
|
+
const fullPath = path.join(dir, entry.name);
|
|
100
|
+
try {
|
|
101
|
+
const stat = fs.statSync(fullPath);
|
|
102
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
103
|
+
try { fs.unlinkSync(fullPath); } catch {}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
fileInfos.push({ path: fullPath, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Sort oldest first
|
|
111
|
+
fileInfos.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
112
|
+
|
|
113
|
+
let totalSize = fileInfos.reduce((sum, f) => sum + f.size, 0);
|
|
114
|
+
|
|
115
|
+
while (fileInfos.length > maxFiles || totalSize > maxBytes) {
|
|
116
|
+
const oldest = fileInfos.shift();
|
|
117
|
+
if (!oldest) break;
|
|
118
|
+
try {
|
|
119
|
+
fs.unlinkSync(oldest.path);
|
|
120
|
+
totalSize -= oldest.size;
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validates base64 data, checks payload limits, sniffs magic bytes against declared MIME,
|
|
127
|
+
* writes to state storage with mode 0o600, and returns the absolute local path.
|
|
128
|
+
*/
|
|
129
|
+
function savePastedFile({ mime, dataBase64, dir = getPastedDir() }) {
|
|
130
|
+
if (typeof mime !== 'string' || !Object.hasOwn(MIME_CONFIG, mime)) {
|
|
131
|
+
throw new Error(`unsupported MIME type: ${mime}`);
|
|
132
|
+
}
|
|
133
|
+
if (typeof dataBase64 !== 'string') {
|
|
134
|
+
throw new Error('missing or invalid dataBase64 payload');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const buf = Buffer.from(dataBase64, 'base64');
|
|
138
|
+
if (buf.length > MAX_PASTE_BYTES) {
|
|
139
|
+
throw new Error(`file size (${buf.length} bytes) exceeds maximum limit of 3 MB`);
|
|
140
|
+
}
|
|
141
|
+
if (buf.length === 0) {
|
|
142
|
+
throw new Error('file payload is empty');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const config = MIME_CONFIG[mime];
|
|
146
|
+
if (!config.check(buf)) {
|
|
147
|
+
throw new Error(`file magic bytes do not match declared MIME type ${mime}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Prune before writing new file
|
|
151
|
+
cleanPastedDir({ dir });
|
|
152
|
+
|
|
153
|
+
const fileName = `${crypto.randomUUID()}${config.ext}`;
|
|
154
|
+
const filePath = path.join(dir, fileName);
|
|
155
|
+
|
|
156
|
+
fs.writeFileSync(filePath, buf, { mode: 0o600, flag: 'wx' });
|
|
157
|
+
try {
|
|
158
|
+
fs.chmodSync(filePath, 0o600);
|
|
159
|
+
} catch {}
|
|
160
|
+
|
|
161
|
+
// Prune again after writing to strictly observe count/size ceiling
|
|
162
|
+
cleanPastedDir({ dir });
|
|
163
|
+
|
|
164
|
+
return filePath;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
MAX_PASTE_BYTES,
|
|
169
|
+
MAX_SAVED_FILES,
|
|
170
|
+
MAX_TOTAL_BYTES,
|
|
171
|
+
MAX_AGE_MS,
|
|
172
|
+
MIME_CONFIG,
|
|
173
|
+
getPastedDir,
|
|
174
|
+
cleanPastedDir,
|
|
175
|
+
savePastedFile,
|
|
176
|
+
};
|