herdr-remote-relay 0.2.10 → 0.2.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote-relay",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Standalone relay server and web terminal for Herdr Remote",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,10 @@ const DEFAULTS = {
31
31
  host: '127.0.0.1',
32
32
  port: 8787,
33
33
  publicUrl: 'http://127.0.0.1:8787',
34
- maxPayloadBytes: 1024 * 1024,
34
+ // Sized for a pasted image, not for keystrokes. `ws` closes a connection
35
+ // that receives an oversized frame (1009) rather than dropping the frame,
36
+ // so this ceiling is what a client's own upload cap has to stay under.
37
+ maxPayloadBytes: 5 * 1024 * 1024,
35
38
  maxClientsPerHost: 16,
36
39
  maxHosts: 1024,
37
40
  maxPendingHandshakes: 1024,
@@ -71,6 +71,57 @@ function tokenMatches(candidate, expected) {
71
71
  return crypto.timingSafeEqual(candidateHash, expectedHash);
72
72
  }
73
73
 
74
+ function verifyImageMagicBytes(mime, dataBase64) {
75
+ if (typeof dataBase64 !== 'string' || dataBase64.length === 0) return false;
76
+ let headerBuf;
77
+ try {
78
+ headerBuf = Buffer.from(dataBase64.slice(0, 32), 'base64');
79
+ } catch {
80
+ return false;
81
+ }
82
+ if (headerBuf.length === 0) return false;
83
+
84
+ switch (mime) {
85
+ case 'image/png':
86
+ return (
87
+ headerBuf.length >= 8 &&
88
+ headerBuf[0] === 0x89 &&
89
+ headerBuf[1] === 0x50 &&
90
+ headerBuf[2] === 0x4e &&
91
+ headerBuf[3] === 0x47
92
+ );
93
+ case 'image/jpeg':
94
+ return (
95
+ headerBuf.length >= 3 &&
96
+ headerBuf[0] === 0xff &&
97
+ headerBuf[1] === 0xd8 &&
98
+ headerBuf[2] === 0xff
99
+ );
100
+ case 'image/webp':
101
+ return (
102
+ headerBuf.length >= 12 &&
103
+ headerBuf[0] === 0x52 &&
104
+ headerBuf[1] === 0x49 &&
105
+ headerBuf[2] === 0x46 &&
106
+ headerBuf[3] === 0x46 &&
107
+ headerBuf[8] === 0x57 &&
108
+ headerBuf[9] === 0x45 &&
109
+ headerBuf[10] === 0x42 &&
110
+ headerBuf[11] === 0x50
111
+ );
112
+ case 'image/gif':
113
+ return (
114
+ headerBuf.length >= 6 &&
115
+ headerBuf[0] === 0x47 &&
116
+ headerBuf[1] === 0x49 &&
117
+ headerBuf[2] === 0x46 &&
118
+ headerBuf[3] === 0x38
119
+ );
120
+ default:
121
+ return false;
122
+ }
123
+ }
124
+
74
125
  class RelayServer {
75
126
  constructor(config = loadRelayConfig().config, options = {}) {
76
127
  this.config = {
@@ -707,6 +758,11 @@ class RelayServer {
707
758
  client.session = null;
708
759
  }
709
760
  this.detachClient(client, { notify: false });
761
+ } else if (message.type === 'paste_file_ready') {
762
+ jsonSend(client.ws, {
763
+ type: 'paste_file_ready',
764
+ path: message.path,
765
+ });
710
766
  } else if (message.type === 'error') {
711
767
  jsonSend(client.ws, {
712
768
  type: 'error',
@@ -948,6 +1004,82 @@ class RelayServer {
948
1004
  // so beats a silence an older client would wait on.
949
1005
  jsonSend(client.ws, { type: 'control_state', role: 'controller', controllerId: null });
950
1006
  }
1007
+ else if (message.type === 'paste_file') {
1008
+ if (client.role === 'viewer') {
1009
+ jsonSend(client.ws, {
1010
+ type: 'error',
1011
+ code: 'viewer_mode',
1012
+ message: 'Viewer mode cannot paste to terminal',
1013
+ });
1014
+ return;
1015
+ }
1016
+ const allowedMimes = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
1017
+ if (typeof message.mime !== 'string' || !allowedMimes.has(message.mime)) {
1018
+ jsonSend(client.ws, {
1019
+ type: 'error',
1020
+ code: 'paste_file_unsupported',
1021
+ message: 'Unsupported paste image format',
1022
+ });
1023
+ return;
1024
+ }
1025
+ if (typeof message.dataBase64 !== 'string') {
1026
+ jsonSend(client.ws, {
1027
+ type: 'error',
1028
+ code: 'paste_file_unsupported',
1029
+ message: 'Invalid paste image payload',
1030
+ });
1031
+ return;
1032
+ }
1033
+ const MAX_PASTE_BYTES = 3 * 1024 * 1024;
1034
+ const rawLength = Buffer.byteLength(message.dataBase64, 'base64');
1035
+ if (rawLength === 0) {
1036
+ jsonSend(client.ws, {
1037
+ type: 'error',
1038
+ code: 'paste_file_empty',
1039
+ message: 'Pasted image payload is empty',
1040
+ });
1041
+ return;
1042
+ }
1043
+ if (rawLength > MAX_PASTE_BYTES) {
1044
+ jsonSend(client.ws, {
1045
+ type: 'error',
1046
+ code: 'paste_file_too_large',
1047
+ message: 'Pasted image exceeds 3 MB limit',
1048
+ });
1049
+ return;
1050
+ }
1051
+ if (!verifyImageMagicBytes(message.mime, message.dataBase64)) {
1052
+ jsonSend(client.ws, {
1053
+ type: 'error',
1054
+ code: 'paste_file_unsupported',
1055
+ message: 'Pasted image content does not match declared MIME type',
1056
+ });
1057
+ return;
1058
+ }
1059
+ if (!isOpen(host?.ws)) {
1060
+ jsonSend(client.ws, {
1061
+ type: 'error',
1062
+ code: 'host_offline',
1063
+ message: 'Host is offline or disconnected',
1064
+ });
1065
+ return;
1066
+ }
1067
+ if (!client.session) {
1068
+ jsonSend(client.ws, {
1069
+ type: 'error',
1070
+ code: 'no_session',
1071
+ message: 'Terminal session is not ready',
1072
+ });
1073
+ return;
1074
+ }
1075
+ jsonSend(host.ws, {
1076
+ type: 'paste_file',
1077
+ clientId: client.session.streamId,
1078
+ streamId: client.session.streamId,
1079
+ mime: message.mime,
1080
+ dataBase64: message.dataBase64,
1081
+ });
1082
+ }
951
1083
  }
952
1084
 
953
1085
  /**