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 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
- maxPayloadBytes: 1024 * 1024,
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.8" }) }),
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
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.8"
3
+ version = "0.2.9"
4
4
  min_herdr_version = "0.9.0"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- maxPayloadBytes: 1024 * 1024,
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,
@@ -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
+ };