kiro-mobile-bridge 1.0.8 → 1.0.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 +9 -5
- package/package.json +1 -1
- package/src/public/index.html +258 -11
- package/src/routes/api.js +257 -76
- package/src/server.js +33 -17
- package/src/services/cdp.js +70 -16
- package/src/services/click.js +325 -74
- package/src/services/message.js +9 -1
- package/src/services/snapshot.js +64 -25
- package/src/utils/constants.js +116 -0
- package/src/utils/hash.js +12 -0
- package/src/utils/network.js +45 -1
- package/src/utils/security.js +160 -0
package/src/services/snapshot.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Snapshot capture service - captures DOM snapshots from Kiro via CDP
|
|
3
3
|
*/
|
|
4
|
+
import { getLanguageFromExtension } from '../utils/constants.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Capture chat metadata (title, active state)
|
|
@@ -60,7 +61,9 @@ export async function captureCSS(cdp) {
|
|
|
60
61
|
const allProps = [];
|
|
61
62
|
for (let i = 0; i < rootStyles.length; i++) allProps.push(rootStyles[i]);
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
// Safely iterate stylesheets with null check
|
|
65
|
+
const styleSheets = targetDoc.styleSheets || [];
|
|
66
|
+
for (const sheet of styleSheets) {
|
|
64
67
|
try {
|
|
65
68
|
if (sheet.cssRules) {
|
|
66
69
|
for (const rule of sheet.cssRules) {
|
|
@@ -72,7 +75,9 @@ export async function captureCSS(cdp) {
|
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
|
-
} catch (e) {
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// CORS restriction on external stylesheets, skip
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
let cssVars = ':root {\\n';
|
|
@@ -85,12 +90,14 @@ export async function captureCSS(cdp) {
|
|
|
85
90
|
cssVars += '}\\n\\n';
|
|
86
91
|
css += cssVars;
|
|
87
92
|
|
|
88
|
-
for (const sheet of
|
|
93
|
+
for (const sheet of styleSheets) {
|
|
89
94
|
try {
|
|
90
95
|
if (sheet.cssRules) {
|
|
91
96
|
for (const rule of sheet.cssRules) css += rule.cssText + '\\n';
|
|
92
97
|
}
|
|
93
|
-
} catch (e) {
|
|
98
|
+
} catch (e) {
|
|
99
|
+
// CORS restriction, skip
|
|
100
|
+
}
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
const styleTags = targetDoc.querySelectorAll('style');
|
|
@@ -144,45 +151,68 @@ export async function captureSnapshot(cdp) {
|
|
|
144
151
|
|
|
145
152
|
for (const container of scrollContainers) {
|
|
146
153
|
if (container.scrollHeight > container.clientHeight) {
|
|
147
|
-
// Only check class names for history detection - NOT date patterns
|
|
148
154
|
const isHistoryPanel = container.matches('[class*="history"], [class*="History"], [class*="session-list"], [class*="SessionList"]') ||
|
|
149
155
|
container.closest('[class*="history"], [class*="History"], [class*="session-list"], [class*="SessionList"]');
|
|
150
156
|
|
|
151
157
|
if (isHistoryPanel) {
|
|
152
|
-
container.scrollTop = 0;
|
|
158
|
+
container.scrollTop = 0;
|
|
153
159
|
} else {
|
|
154
|
-
container.scrollTop = container.scrollHeight;
|
|
160
|
+
container.scrollTop = container.scrollHeight;
|
|
155
161
|
}
|
|
156
162
|
}
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
const clone = targetBody.cloneNode(true);
|
|
160
166
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
// Capture any portal/overlay content that might be outside the body
|
|
168
|
+
// Radix UI and similar libraries render dropdowns in portals at document root
|
|
169
|
+
const portals = targetDoc.querySelectorAll('[data-radix-portal], [data-radix-popper-content-wrapper], [class*="portal"], [class*="Portal"]');
|
|
170
|
+
portals.forEach(portal => {
|
|
171
|
+
try {
|
|
172
|
+
const portalClone = portal.cloneNode(true);
|
|
173
|
+
clone.appendChild(portalClone);
|
|
174
|
+
} catch(e) {
|
|
175
|
+
// Clone failed, skip
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Also check for any floating/overlay elements at document level
|
|
180
|
+
const floatingSelectors = [
|
|
181
|
+
'body > [role="listbox"]',
|
|
182
|
+
'body > [role="menu"]',
|
|
183
|
+
'body > [data-state="open"]',
|
|
184
|
+
'body > [class*="dropdown"]',
|
|
185
|
+
'body > div[style*="position: absolute"]',
|
|
186
|
+
'body > div[style*="position: fixed"]'
|
|
167
187
|
];
|
|
168
188
|
|
|
169
|
-
|
|
189
|
+
floatingSelectors.forEach(sel => {
|
|
170
190
|
try {
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
if (isTooltip || !isImportantUI) el.remove();
|
|
191
|
+
targetDoc.querySelectorAll(sel).forEach(el => {
|
|
192
|
+
const elClone = el.cloneNode(true);
|
|
193
|
+
clone.appendChild(elClone);
|
|
175
194
|
});
|
|
176
|
-
} catch(e) {
|
|
195
|
+
} catch(e) {
|
|
196
|
+
// Selector failed, skip
|
|
197
|
+
}
|
|
177
198
|
});
|
|
178
199
|
|
|
200
|
+
// Remove ONLY tooltips from clone - preserve everything else
|
|
201
|
+
try {
|
|
202
|
+
clone.querySelectorAll('[role="tooltip"]').forEach(el => el.remove());
|
|
203
|
+
} catch(e) {
|
|
204
|
+
// Removal failed, continue
|
|
205
|
+
}
|
|
206
|
+
|
|
179
207
|
// Fix SVG currentColor
|
|
180
208
|
clone.querySelectorAll('svg').forEach(svg => {
|
|
181
209
|
try {
|
|
182
210
|
const computedColor = '#cccccc';
|
|
183
211
|
svg.querySelectorAll('[fill="currentColor"]').forEach(el => el.setAttribute('fill', computedColor));
|
|
184
212
|
svg.querySelectorAll('[stroke="currentColor"]').forEach(el => el.setAttribute('stroke', computedColor));
|
|
185
|
-
} catch(e) {
|
|
213
|
+
} catch(e) {
|
|
214
|
+
// SVG fix failed, continue
|
|
215
|
+
}
|
|
186
216
|
});
|
|
187
217
|
|
|
188
218
|
// Remove placeholder text
|
|
@@ -205,7 +235,9 @@ export async function captureSnapshot(cdp) {
|
|
|
205
235
|
if (!el.querySelector('[contenteditable], [data-lexical-editor], textarea, input')) el.remove();
|
|
206
236
|
}
|
|
207
237
|
});
|
|
208
|
-
} catch(e) {
|
|
238
|
+
} catch(e) {
|
|
239
|
+
// Placeholder removal failed, continue
|
|
240
|
+
}
|
|
209
241
|
|
|
210
242
|
return { html: clone.outerHTML, bodyBg, bodyColor };
|
|
211
243
|
})()`;
|
|
@@ -216,6 +248,7 @@ export async function captureSnapshot(cdp) {
|
|
|
216
248
|
contextId: cdp.rootContextId,
|
|
217
249
|
returnByValue: true
|
|
218
250
|
});
|
|
251
|
+
|
|
219
252
|
return result.result?.value || null;
|
|
220
253
|
} catch (err) {
|
|
221
254
|
console.error('[Snapshot] Failed to capture HTML:', err.message);
|
|
@@ -247,7 +280,9 @@ export async function captureEditor(cdp) {
|
|
|
247
280
|
result.fileName = tab.textContent.trim().split('\\n')[0].trim();
|
|
248
281
|
if (result.fileName) break;
|
|
249
282
|
}
|
|
250
|
-
} catch(e) {
|
|
283
|
+
} catch(e) {
|
|
284
|
+
// Selector failed, continue
|
|
285
|
+
}
|
|
251
286
|
}
|
|
252
287
|
|
|
253
288
|
// Try Monaco API
|
|
@@ -255,7 +290,7 @@ export async function captureEditor(cdp) {
|
|
|
255
290
|
const monacoEditors = targetDoc.querySelectorAll('.monaco-editor');
|
|
256
291
|
for (const editorEl of monacoEditors) {
|
|
257
292
|
const editorInstance = editorEl.__vscode_editor__ || editorEl._editor ||
|
|
258
|
-
(window.monaco && window.monaco.editor.getEditors && window.monaco.editor.getEditors()[0]);
|
|
293
|
+
(window.monaco && window.monaco.editor && window.monaco.editor.getEditors && window.monaco.editor.getEditors()[0]);
|
|
259
294
|
if (editorInstance && editorInstance.getModel) {
|
|
260
295
|
const model = editorInstance.getModel();
|
|
261
296
|
if (model) {
|
|
@@ -267,7 +302,9 @@ export async function captureEditor(cdp) {
|
|
|
267
302
|
}
|
|
268
303
|
}
|
|
269
304
|
}
|
|
270
|
-
} catch(e) {
|
|
305
|
+
} catch(e) {
|
|
306
|
+
// Monaco API not available, continue
|
|
307
|
+
}
|
|
271
308
|
|
|
272
309
|
// Fallback: Extract from view-lines
|
|
273
310
|
if (!result.content) {
|
|
@@ -309,7 +346,9 @@ export async function captureEditor(cdp) {
|
|
|
309
346
|
const extMap = {
|
|
310
347
|
'ts': 'typescript', 'tsx': 'typescript', 'js': 'javascript', 'jsx': 'javascript',
|
|
311
348
|
'py': 'python', 'java': 'java', 'html': 'html', 'css': 'css', 'json': 'json',
|
|
312
|
-
'md': 'markdown', 'yaml': 'yaml', 'yml': 'yaml', 'go': 'go', 'rs': 'rust'
|
|
349
|
+
'md': 'markdown', 'yaml': 'yaml', 'yml': 'yaml', 'go': 'go', 'rs': 'rust',
|
|
350
|
+
'c': 'c', 'cpp': 'cpp', 'h': 'c', 'cs': 'csharp', 'rb': 'ruby', 'php': 'php',
|
|
351
|
+
'sql': 'sql', 'sh': 'bash', 'vue': 'vue', 'svelte': 'svelte'
|
|
313
352
|
};
|
|
314
353
|
result.language = extMap[ext] || ext || '';
|
|
315
354
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants used across the application
|
|
3
|
+
* Centralizes configuration to eliminate duplication and improve maintainability
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CDP ports to scan for Kiro instances
|
|
8
|
+
* @type {number[]}
|
|
9
|
+
*/
|
|
10
|
+
export const CDP_PORTS = [9000, 9001, 9002, 9003, 9222, 9229];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Model names for AI model detection and matching
|
|
14
|
+
* Order matters: check specific names (opus, sonnet, haiku) BEFORE generic (claude)
|
|
15
|
+
* @type {string[]}
|
|
16
|
+
*/
|
|
17
|
+
export const MODEL_NAMES = ['auto', 'opus', 'sonnet', 'haiku', 'gpt', 'claude', 'gemini', 'llama'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Code file extensions for workspace file filtering
|
|
21
|
+
* @type {Set<string>}
|
|
22
|
+
*/
|
|
23
|
+
export const CODE_EXTENSIONS = new Set([
|
|
24
|
+
'.ts', '.tsx', '.js', '.jsx', '.py', '.java', '.go', '.rs',
|
|
25
|
+
'.html', '.css', '.scss', '.json', '.yaml', '.yml', '.md',
|
|
26
|
+
'.sql', '.sh', '.c', '.cpp', '.h', '.cs', '.vue', '.svelte', '.rb', '.php'
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* File extension to language mapping for syntax highlighting
|
|
31
|
+
* @type {Object<string, string>}
|
|
32
|
+
*/
|
|
33
|
+
export const EXTENSION_TO_LANGUAGE = {
|
|
34
|
+
'.ts': 'typescript',
|
|
35
|
+
'.tsx': 'typescript',
|
|
36
|
+
'.js': 'javascript',
|
|
37
|
+
'.jsx': 'javascript',
|
|
38
|
+
'.py': 'python',
|
|
39
|
+
'.html': 'html',
|
|
40
|
+
'.css': 'css',
|
|
41
|
+
'.scss': 'scss',
|
|
42
|
+
'.json': 'json',
|
|
43
|
+
'.md': 'markdown',
|
|
44
|
+
'.yaml': 'yaml',
|
|
45
|
+
'.yml': 'yaml',
|
|
46
|
+
'.go': 'go',
|
|
47
|
+
'.rs': 'rust',
|
|
48
|
+
'.java': 'java',
|
|
49
|
+
'.c': 'c',
|
|
50
|
+
'.cpp': 'cpp',
|
|
51
|
+
'.h': 'c',
|
|
52
|
+
'.cs': 'csharp',
|
|
53
|
+
'.rb': 'ruby',
|
|
54
|
+
'.php': 'php',
|
|
55
|
+
'.sql': 'sql',
|
|
56
|
+
'.sh': 'bash',
|
|
57
|
+
'.vue': 'vue',
|
|
58
|
+
'.svelte': 'svelte'
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get language from file extension
|
|
63
|
+
* @param {string} filename - File name or path
|
|
64
|
+
* @returns {string} - Language identifier or extension without dot
|
|
65
|
+
*/
|
|
66
|
+
export function getLanguageFromExtension(filename) {
|
|
67
|
+
const ext = filename.includes('.') ? '.' + filename.split('.').pop().toLowerCase() : '';
|
|
68
|
+
return EXTENSION_TO_LANGUAGE[ext] || ext.slice(1) || 'text';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a file has a code extension
|
|
73
|
+
* @param {string} filename - File name or path
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function isCodeFile(filename) {
|
|
77
|
+
const ext = filename.includes('.') ? '.' + filename.split('.').pop().toLowerCase() : '';
|
|
78
|
+
return CODE_EXTENSIONS.has(ext);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* CDP call timeout in milliseconds
|
|
83
|
+
* @type {number}
|
|
84
|
+
*/
|
|
85
|
+
export const CDP_CALL_TIMEOUT = 10000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* HTTP request timeout in milliseconds
|
|
89
|
+
* @type {number}
|
|
90
|
+
*/
|
|
91
|
+
export const HTTP_TIMEOUT = 2000;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Discovery polling intervals
|
|
95
|
+
*/
|
|
96
|
+
export const DISCOVERY_INTERVAL_ACTIVE = 10000; // 10 seconds when changes detected
|
|
97
|
+
export const DISCOVERY_INTERVAL_STABLE = 30000; // 30 seconds when stable
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Snapshot polling intervals
|
|
101
|
+
*/
|
|
102
|
+
export const SNAPSHOT_INTERVAL_ACTIVE = 1000; // 1 second when active
|
|
103
|
+
export const SNAPSHOT_INTERVAL_IDLE = 3000; // 3 seconds when idle
|
|
104
|
+
export const SNAPSHOT_IDLE_THRESHOLD = 10000; // 10 seconds before considered idle
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Maximum depth for recursive file search
|
|
108
|
+
* @type {number}
|
|
109
|
+
*/
|
|
110
|
+
export const MAX_FILE_SEARCH_DEPTH = 4;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Maximum depth for workspace file collection
|
|
114
|
+
* @type {number}
|
|
115
|
+
*/
|
|
116
|
+
export const MAX_WORKSPACE_DEPTH = 5;
|
package/src/utils/hash.js
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hash utilities for content change detection
|
|
3
|
+
*
|
|
4
|
+
* NOTE: MD5 is used here for change detection only, NOT for security purposes.
|
|
5
|
+
* MD5 is fast and sufficient for detecting content changes in snapshots.
|
|
6
|
+
* Do NOT use these functions for password hashing, authentication, or any security-sensitive operations.
|
|
3
7
|
*/
|
|
4
8
|
import crypto from 'crypto';
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* Generate a unique ID from a string (e.g., WebSocket URL)
|
|
12
|
+
* Used for cascade identification, not security
|
|
8
13
|
* @param {string} input - String to hash
|
|
9
14
|
* @returns {string} - 8-character hash ID
|
|
10
15
|
*/
|
|
11
16
|
export function generateId(input) {
|
|
17
|
+
if (typeof input !== 'string' || !input) {
|
|
18
|
+
return crypto.randomBytes(4).toString('hex');
|
|
19
|
+
}
|
|
12
20
|
return crypto.createHash('md5').update(input).digest('hex').substring(0, 8);
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
24
|
* Compute MD5 hash for change detection
|
|
25
|
+
* Used to detect content changes in snapshots, not for security
|
|
17
26
|
* @param {string} content - Content to hash
|
|
18
27
|
* @returns {string} - Full MD5 hash
|
|
19
28
|
*/
|
|
20
29
|
export function computeHash(content) {
|
|
30
|
+
if (typeof content !== 'string') {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
21
33
|
return crypto.createHash('md5').update(content).digest('hex');
|
|
22
34
|
}
|
package/src/utils/network.js
CHANGED
|
@@ -5,10 +5,32 @@ import { networkInterfaces } from 'os';
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Get local IP address for LAN access
|
|
8
|
-
*
|
|
8
|
+
* Returns the first non-internal IPv4 address found.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: On systems with multiple network interfaces, this returns the first one found.
|
|
11
|
+
* For more control, consider using environment variables or configuration.
|
|
12
|
+
*
|
|
13
|
+
* @returns {string} - Local IP or 'localhost' if no suitable interface found
|
|
9
14
|
*/
|
|
10
15
|
export function getLocalIP() {
|
|
11
16
|
const interfaces = networkInterfaces();
|
|
17
|
+
|
|
18
|
+
// Prioritize common interface names
|
|
19
|
+
const priorityInterfaces = ['eth0', 'en0', 'wlan0', 'Wi-Fi', 'Ethernet'];
|
|
20
|
+
|
|
21
|
+
// First, try priority interfaces
|
|
22
|
+
for (const name of priorityInterfaces) {
|
|
23
|
+
const ifaces = interfaces[name];
|
|
24
|
+
if (ifaces) {
|
|
25
|
+
for (const iface of ifaces) {
|
|
26
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
27
|
+
return iface.address;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fallback: return first non-internal IPv4
|
|
12
34
|
for (const name of Object.keys(interfaces)) {
|
|
13
35
|
for (const iface of interfaces[name]) {
|
|
14
36
|
if (iface.family === 'IPv4' && !iface.internal) {
|
|
@@ -16,5 +38,27 @@ export function getLocalIP() {
|
|
|
16
38
|
}
|
|
17
39
|
}
|
|
18
40
|
}
|
|
41
|
+
|
|
19
42
|
return 'localhost';
|
|
20
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get all available local IP addresses
|
|
47
|
+
* Useful for debugging or when user needs to choose interface
|
|
48
|
+
*
|
|
49
|
+
* @returns {Array<{name: string, address: string}>} - Array of interface names and addresses
|
|
50
|
+
*/
|
|
51
|
+
export function getAllLocalIPs() {
|
|
52
|
+
const interfaces = networkInterfaces();
|
|
53
|
+
const results = [];
|
|
54
|
+
|
|
55
|
+
for (const name of Object.keys(interfaces)) {
|
|
56
|
+
for (const iface of interfaces[name]) {
|
|
57
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
58
|
+
results.push({ name, address: iface.address });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security utilities for input validation and sanitization
|
|
3
|
+
* Prevents path traversal, XSS, and other security vulnerabilities
|
|
4
|
+
*/
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate that a file path resolves within an allowed root directory
|
|
9
|
+
* Prevents path traversal attacks (e.g., ../../etc/passwd)
|
|
10
|
+
*
|
|
11
|
+
* @param {string} filePath - The file path to validate (can be relative or absolute)
|
|
12
|
+
* @param {string} rootDir - The allowed root directory
|
|
13
|
+
* @returns {{valid: boolean, resolvedPath: string|null, error: string|null}}
|
|
14
|
+
*/
|
|
15
|
+
export function validatePathWithinRoot(filePath, rootDir) {
|
|
16
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
17
|
+
return { valid: false, resolvedPath: null, error: 'Invalid file path' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!rootDir || typeof rootDir !== 'string') {
|
|
21
|
+
return { valid: false, resolvedPath: null, error: 'Invalid root directory' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
// Normalize and resolve both paths to absolute paths
|
|
26
|
+
const normalizedRoot = path.resolve(rootDir);
|
|
27
|
+
const resolvedPath = path.resolve(rootDir, filePath);
|
|
28
|
+
|
|
29
|
+
// Ensure the resolved path starts with the root directory
|
|
30
|
+
// Add path.sep to prevent matching partial directory names
|
|
31
|
+
// e.g., /home/user vs /home/username
|
|
32
|
+
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
|
33
|
+
? normalizedRoot
|
|
34
|
+
: normalizedRoot + path.sep;
|
|
35
|
+
|
|
36
|
+
if (!resolvedPath.startsWith(rootWithSep) && resolvedPath !== normalizedRoot) {
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
resolvedPath: null,
|
|
40
|
+
error: 'Path traversal detected: path resolves outside allowed directory'
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { valid: true, resolvedPath, error: null };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { valid: false, resolvedPath: null, error: `Path validation error: ${err.message}` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Escape a string for safe inclusion in JavaScript code
|
|
52
|
+
* Handles all special characters that could break string literals or enable injection
|
|
53
|
+
*
|
|
54
|
+
* @param {string} str - The string to escape
|
|
55
|
+
* @returns {string} - Escaped string safe for JS inclusion
|
|
56
|
+
*/
|
|
57
|
+
export function escapeForJavaScript(str) {
|
|
58
|
+
if (typeof str !== 'string') {
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return str
|
|
63
|
+
.replace(/\\/g, '\\\\') // Backslashes first (must be first!)
|
|
64
|
+
.replace(/'/g, "\\'") // Single quotes
|
|
65
|
+
.replace(/"/g, '\\"') // Double quotes
|
|
66
|
+
.replace(/`/g, '\\`') // Backticks (template literals)
|
|
67
|
+
.replace(/\$/g, '\\$') // Dollar signs (template literal interpolation)
|
|
68
|
+
.replace(/\n/g, '\\n') // Newlines
|
|
69
|
+
.replace(/\r/g, '\\r') // Carriage returns
|
|
70
|
+
.replace(/\t/g, '\\t') // Tabs
|
|
71
|
+
.replace(/\0/g, '\\0') // Null bytes
|
|
72
|
+
.replace(/\u2028/g, '\\u2028') // Line separator
|
|
73
|
+
.replace(/\u2029/g, '\\u2029'); // Paragraph separator
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validate and sanitize click info object
|
|
78
|
+
* Ensures all properties are of expected types and within reasonable limits
|
|
79
|
+
*
|
|
80
|
+
* @param {object} clickInfo - The click info object to validate
|
|
81
|
+
* @returns {{valid: boolean, sanitized: object|null, error: string|null}}
|
|
82
|
+
*/
|
|
83
|
+
export function sanitizeClickInfo(clickInfo) {
|
|
84
|
+
if (!clickInfo || typeof clickInfo !== 'object') {
|
|
85
|
+
return { valid: false, sanitized: null, error: 'Click info must be an object' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sanitized = {};
|
|
89
|
+
|
|
90
|
+
// String properties with max length
|
|
91
|
+
const stringProps = [
|
|
92
|
+
{ name: 'tag', maxLength: 50 },
|
|
93
|
+
{ name: 'text', maxLength: 200 },
|
|
94
|
+
{ name: 'ariaLabel', maxLength: 200 },
|
|
95
|
+
{ name: 'role', maxLength: 50 },
|
|
96
|
+
{ name: 'className', maxLength: 500 },
|
|
97
|
+
{ name: 'tabLabel', maxLength: 100 },
|
|
98
|
+
{ name: 'parentTabLabel', maxLength: 100 },
|
|
99
|
+
{ name: 'filePath', maxLength: 500 },
|
|
100
|
+
{ name: 'toggleId', maxLength: 100 }
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (const { name, maxLength } of stringProps) {
|
|
104
|
+
if (clickInfo[name] !== undefined) {
|
|
105
|
+
if (typeof clickInfo[name] !== 'string') {
|
|
106
|
+
sanitized[name] = String(clickInfo[name]).substring(0, maxLength);
|
|
107
|
+
} else {
|
|
108
|
+
sanitized[name] = clickInfo[name].substring(0, maxLength);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Boolean properties
|
|
114
|
+
const boolProps = [
|
|
115
|
+
'isTab', 'isCloseButton', 'isToggle', 'isModelSelector', 'isModelOption',
|
|
116
|
+
'isSendButton', 'isFileLink', 'isNotificationButton', 'isIconButton', 'isHistoryItem'
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
for (const name of boolProps) {
|
|
120
|
+
if (clickInfo[name] !== undefined) {
|
|
121
|
+
sanitized[name] = Boolean(clickInfo[name]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { valid: true, sanitized, error: null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validate message text for injection
|
|
130
|
+
*
|
|
131
|
+
* @param {string} message - The message to validate
|
|
132
|
+
* @returns {{valid: boolean, error: string|null}}
|
|
133
|
+
*/
|
|
134
|
+
export function validateMessage(message) {
|
|
135
|
+
if (!message || typeof message !== 'string') {
|
|
136
|
+
return { valid: false, error: 'Message must be a non-empty string' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (message.length > 50000) {
|
|
140
|
+
return { valid: false, error: 'Message exceeds maximum length (50000 characters)' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { valid: true, error: null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Sanitize a file path by removing null bytes and normalizing
|
|
148
|
+
* Does NOT validate path traversal - use validatePathWithinRoot for that
|
|
149
|
+
*
|
|
150
|
+
* @param {string} filePath - The file path to sanitize
|
|
151
|
+
* @returns {string} - Sanitized file path
|
|
152
|
+
*/
|
|
153
|
+
export function sanitizeFilePath(filePath) {
|
|
154
|
+
if (typeof filePath !== 'string') {
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Remove null bytes which can be used to bypass security checks
|
|
159
|
+
return filePath.replace(/\0/g, '');
|
|
160
|
+
}
|