@kortix/agent-tunnel 0.1.0 → 0.1.1
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 +4 -2
- package/src/agent/agent.ts +60 -37
- package/src/agent/capabilities/filesystem.ts +7 -7
- package/src/agent/capabilities/shell.ts +29 -19
- package/src/agent/config.ts +21 -1
- package/src/agent/security/command-validator.ts +7 -39
- package/src/agent/security/path-validator.ts +16 -19
- package/src/agent/security/permission-guard.ts +2 -1
- package/src/client/cli.test.ts +758 -0
- package/src/client/cli.ts +716 -0
- package/src/client/index.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/relay.ts +50 -6
- package/src/server/routes.ts +15 -1
- package/src/server/server.ts +22 -22
- package/src/server/ws-handler.ts +71 -12
- package/src/shared/index.ts +2 -0
- package/src/shared/types.ts +30 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kortix/agent-tunnel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Tunnel relay between cloud AI agents and local machines — server relay, local agent, client SDK, JSON-RPC, HMAC signing",
|
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
"./shared": "./src/shared/index.ts",
|
|
12
12
|
"./server": "./src/server/index.ts",
|
|
13
13
|
"./client": "./src/client/index.ts",
|
|
14
|
+
"./client/cli": "./src/client/cli.ts",
|
|
14
15
|
"./agent": "./src/agent/index.ts"
|
|
15
16
|
},
|
|
16
17
|
"bin": {
|
|
17
|
-
"agent-tunnel": "src/agent/cli.ts"
|
|
18
|
+
"agent-tunnel": "src/agent/cli.ts",
|
|
19
|
+
"agent-tunnel-cli": "src/client/cli.ts"
|
|
18
20
|
},
|
|
19
21
|
"files": [
|
|
20
22
|
"src/"
|
package/src/agent/agent.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { TunnelConfig } from './config';
|
|
|
3
3
|
import { CapabilityRegistry, type RpcHandler } from './capabilities/index';
|
|
4
4
|
import { PermissionGuard } from './security/permission-guard';
|
|
5
5
|
import type { LocalPermission } from './security/permission-guard';
|
|
6
|
-
import { deriveSigningKey, verifyMessageSignature } from '../shared/crypto';
|
|
6
|
+
import { deriveSigningKey, signMessage, verifyMessageSignature } from '../shared/crypto';
|
|
7
7
|
|
|
8
8
|
interface JsonRpcRequest {
|
|
9
9
|
jsonrpc: '2.0';
|
|
@@ -56,6 +56,7 @@ export class TunnelAgent {
|
|
|
56
56
|
// HMAC signature verification
|
|
57
57
|
private signingKey: string;
|
|
58
58
|
private lastNonce = 0;
|
|
59
|
+
private responseNonce = 0;
|
|
59
60
|
|
|
60
61
|
constructor(config: TunnelConfig, registry: CapabilityRegistry) {
|
|
61
62
|
this.config = config;
|
|
@@ -113,9 +114,13 @@ export class TunnelAgent {
|
|
|
113
114
|
this.ws.addEventListener('open', () => {
|
|
114
115
|
this.reconnectAttempts = 0;
|
|
115
116
|
this.uptime = 0;
|
|
116
|
-
this.lastNonce = 0;
|
|
117
|
+
this.lastNonce = 0;
|
|
118
|
+
this.responseNonce = 0;
|
|
117
119
|
this.uptimeInterval = setInterval(() => { this.uptime++; }, 1000);
|
|
118
120
|
|
|
121
|
+
// Send auth handshake as first message (token never in URL)
|
|
122
|
+
this.send({ type: 'auth', token: this.config.token });
|
|
123
|
+
|
|
119
124
|
log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
|
|
120
125
|
});
|
|
121
126
|
|
|
@@ -130,6 +135,10 @@ export class TunnelAgent {
|
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
if (!this.isShuttingDown) {
|
|
138
|
+
if (event.code === 4001) {
|
|
139
|
+
log(`${c.red}✗${c.reset}`, `Authentication failed — check your token`);
|
|
140
|
+
return; // Don't reconnect on auth failure
|
|
141
|
+
}
|
|
133
142
|
log(`${c.yellow}○${c.reset}`, `Disconnected ${c.gray}(code: ${event.code})${c.reset}`);
|
|
134
143
|
this.scheduleReconnect();
|
|
135
144
|
}
|
|
@@ -149,20 +158,19 @@ export class TunnelAgent {
|
|
|
149
158
|
return;
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
// ── Heartbeat ping — no signature required ──────────────────────
|
|
153
|
-
if ('method' in msg && msg.method === 'tunnel.ping') {
|
|
154
|
-
this.sendPong();
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ── Verify HMAC signature on all other messages ─────────────────
|
|
159
161
|
if (!this.verifyIncomingSignature(msg, raw)) {
|
|
160
162
|
if ('id' in msg && msg.id) {
|
|
161
|
-
this.
|
|
163
|
+
this.sendSignedError(msg.id, -32000, 'Invalid message signature');
|
|
162
164
|
}
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
165
167
|
|
|
168
|
+
// ── Heartbeat ping (signature verified above) ────────────────────
|
|
169
|
+
if ('method' in msg && msg.method === 'tunnel.ping') {
|
|
170
|
+
this.sendPong();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
166
174
|
// ── Permission sync notification ────────────────────────────────
|
|
167
175
|
if ('method' in msg && msg.method === 'tunnel.permissions.sync') {
|
|
168
176
|
const permissions = (msg.params?.permissions || []) as LocalPermission[];
|
|
@@ -194,8 +202,6 @@ export class TunnelAgent {
|
|
|
194
202
|
// ── Token rotation notification ─────────────────────────────────
|
|
195
203
|
if ('method' in msg && msg.method === 'tunnel.token.rotated') {
|
|
196
204
|
log(`${c.yellow}!${c.reset}`, `Token rotated — reconnecting with new token`);
|
|
197
|
-
// The server will close the WS shortly; the reconnect logic handles the rest.
|
|
198
|
-
// Caller must update config.token before reconnect succeeds.
|
|
199
205
|
return;
|
|
200
206
|
}
|
|
201
207
|
|
|
@@ -208,7 +214,6 @@ export class TunnelAgent {
|
|
|
208
214
|
|
|
209
215
|
/**
|
|
210
216
|
* Verify HMAC signature on incoming messages (excluding pings).
|
|
211
|
-
* Returns true if valid, false if signature check fails.
|
|
212
217
|
*/
|
|
213
218
|
private verifyIncomingSignature(msg: IncomingMessage, _raw: string): boolean {
|
|
214
219
|
const sig = (msg as any)._sig as string | undefined;
|
|
@@ -219,13 +224,11 @@ export class TunnelAgent {
|
|
|
219
224
|
return false;
|
|
220
225
|
}
|
|
221
226
|
|
|
222
|
-
// Replay protection: nonce must be strictly increasing
|
|
223
227
|
if (nonce <= this.lastNonce) {
|
|
224
228
|
log(`${c.red}✗${c.reset}`, `Replay detected: nonce ${nonce} <= ${this.lastNonce}`);
|
|
225
229
|
return false;
|
|
226
230
|
}
|
|
227
231
|
|
|
228
|
-
// Build the payload to verify (message without _sig and _nonce)
|
|
229
232
|
const { _sig, _nonce, ...payloadObj } = msg as any;
|
|
230
233
|
const payload = JSON.stringify(payloadObj);
|
|
231
234
|
|
|
@@ -241,38 +244,65 @@ export class TunnelAgent {
|
|
|
241
244
|
private async handleRpcRequest(request: JsonRpcRequest): Promise<void> {
|
|
242
245
|
const { id, method, params = {} } = request;
|
|
243
246
|
|
|
244
|
-
// ── Permission enforcement (defense-in-depth) ───────────────────
|
|
245
247
|
const permissionId = params.permissionId as string | undefined;
|
|
246
248
|
if (!this.permissionGuard.checkPermission(permissionId)) {
|
|
247
|
-
this.
|
|
249
|
+
this.sendSignedError(id, -32000, `Permission denied: ${permissionId ? 'invalid or expired permission' : 'no permissionId provided'}`);
|
|
248
250
|
return;
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
const handler = this.registry.getHandler(method);
|
|
252
254
|
if (!handler) {
|
|
253
|
-
this.
|
|
255
|
+
this.sendSignedError(id, -32001, `Capability not registered for method: ${method}`);
|
|
254
256
|
return;
|
|
255
257
|
}
|
|
256
258
|
|
|
257
259
|
try {
|
|
258
260
|
const result = await handler(params);
|
|
259
|
-
this.
|
|
261
|
+
this.sendSignedResult(id, result);
|
|
260
262
|
} catch (err) {
|
|
261
263
|
const message = err instanceof Error ? err.message : String(err);
|
|
262
|
-
this.
|
|
264
|
+
this.sendSignedError(id, -32003, message);
|
|
263
265
|
}
|
|
264
266
|
}
|
|
265
267
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
+
/** Send HMAC-signed RPC result. */
|
|
269
|
+
private sendSignedResult(id: string, result: unknown): void {
|
|
270
|
+
const data = { jsonrpc: '2.0' as const, id, result };
|
|
271
|
+
this.sendSigned(data);
|
|
268
272
|
}
|
|
269
273
|
|
|
270
|
-
|
|
271
|
-
|
|
274
|
+
/** Send HMAC-signed RPC error. */
|
|
275
|
+
private sendSignedError(id: string, code: number, message: string): void {
|
|
276
|
+
const data = { jsonrpc: '2.0' as const, id, error: { code, message } };
|
|
277
|
+
this.sendSigned(data);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private sendSigned(data: Record<string, unknown>): void {
|
|
281
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
282
|
+
const nonce = ++this.responseNonce;
|
|
283
|
+
const payload = JSON.stringify(data);
|
|
284
|
+
const sig = signMessage(this.signingKey, payload, nonce);
|
|
285
|
+
const signed = { ...data, _sig: sig, _nonce: nonce };
|
|
286
|
+
try {
|
|
287
|
+
this.ws.send(JSON.stringify(signed));
|
|
288
|
+
} catch (err) {
|
|
289
|
+
log(`${c.red}✗${c.reset}`, `Send failed`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private send(data: unknown): void {
|
|
295
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
296
|
+
try {
|
|
297
|
+
this.ws.send(JSON.stringify(data));
|
|
298
|
+
} catch (err) {
|
|
299
|
+
log(`${c.red}✗${c.reset}`, `Send failed`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
272
302
|
}
|
|
273
303
|
|
|
274
304
|
private sendPong(): void {
|
|
275
|
-
this.
|
|
305
|
+
this.sendSigned({
|
|
276
306
|
jsonrpc: '2.0',
|
|
277
307
|
method: 'tunnel.pong',
|
|
278
308
|
params: {
|
|
@@ -283,22 +313,12 @@ export class TunnelAgent {
|
|
|
283
313
|
platform: platform(),
|
|
284
314
|
arch: arch(),
|
|
285
315
|
osVersion: release(),
|
|
286
|
-
agentVersion: '0.1.
|
|
316
|
+
agentVersion: '0.1.1',
|
|
287
317
|
},
|
|
288
318
|
},
|
|
289
319
|
});
|
|
290
320
|
}
|
|
291
321
|
|
|
292
|
-
private send(data: unknown): void {
|
|
293
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
294
|
-
try {
|
|
295
|
-
this.ws.send(JSON.stringify(data));
|
|
296
|
-
} catch (err) {
|
|
297
|
-
log(`${c.red}✗${c.reset}`, `Send failed`);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
322
|
private scheduleReconnect(): void {
|
|
303
323
|
if (this.isShuttingDown) return;
|
|
304
324
|
|
|
@@ -320,9 +340,12 @@ export class TunnelAgent {
|
|
|
320
340
|
.replace(/^http:/, 'ws:')
|
|
321
341
|
.replace(/^https:/, 'wss:');
|
|
322
342
|
|
|
343
|
+
if (base.startsWith('ws://') && !base.includes('localhost') && !base.includes('127.0.0.1')) {
|
|
344
|
+
log(`${c.red}!${c.reset}`, `${c.red}WARNING: Connecting over unencrypted ws:// to a remote host. Token will be sent in plaintext. Use https:// API URL for production.${c.reset}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
323
347
|
const wsPath = this.config.wsPath || '/ws';
|
|
324
348
|
const params = new URLSearchParams({
|
|
325
|
-
token: this.config.token,
|
|
326
349
|
tunnelId: this.config.tunnelId,
|
|
327
350
|
});
|
|
328
351
|
|
|
@@ -18,15 +18,15 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
|
|
|
18
18
|
const path = params.path as string;
|
|
19
19
|
const encoding = (params.encoding as BufferEncoding) || 'utf-8';
|
|
20
20
|
|
|
21
|
-
validatePath(path, config.allowedPaths);
|
|
21
|
+
validatePath(path, config.allowedPaths, config.blockedPaths);
|
|
22
22
|
|
|
23
|
-
const content = await readFile(path, { encoding });
|
|
24
23
|
const stats = await stat(path);
|
|
25
|
-
|
|
26
24
|
if (stats.size > config.maxFileSize) {
|
|
27
25
|
throw new Error(`File exceeds max size (${stats.size} > ${config.maxFileSize})`);
|
|
28
26
|
}
|
|
29
27
|
|
|
28
|
+
const content = await readFile(path, { encoding });
|
|
29
|
+
|
|
30
30
|
return {
|
|
31
31
|
content,
|
|
32
32
|
size: stats.size,
|
|
@@ -40,7 +40,7 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
|
|
|
40
40
|
const content = params.content as string;
|
|
41
41
|
const encoding = (params.encoding as BufferEncoding) || 'utf-8';
|
|
42
42
|
|
|
43
|
-
validatePath(path, config.allowedPaths);
|
|
43
|
+
validatePath(path, config.allowedPaths, config.blockedPaths);
|
|
44
44
|
|
|
45
45
|
if (content.length > config.maxFileSize) {
|
|
46
46
|
throw new Error(`Content exceeds max size (${content.length} > ${config.maxFileSize})`);
|
|
@@ -62,7 +62,7 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
|
|
|
62
62
|
const path = params.path as string;
|
|
63
63
|
const recursive = params.recursive as boolean || false;
|
|
64
64
|
|
|
65
|
-
validatePath(path, config.allowedPaths);
|
|
65
|
+
validatePath(path, config.allowedPaths, config.blockedPaths);
|
|
66
66
|
|
|
67
67
|
const entries = await readdir(path, { withFileTypes: true });
|
|
68
68
|
|
|
@@ -100,7 +100,7 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
|
|
|
100
100
|
methods.set('fs.stat', async (params) => {
|
|
101
101
|
const path = params.path as string;
|
|
102
102
|
|
|
103
|
-
validatePath(path, config.allowedPaths);
|
|
103
|
+
validatePath(path, config.allowedPaths, config.blockedPaths);
|
|
104
104
|
|
|
105
105
|
const stats = await stat(path);
|
|
106
106
|
|
|
@@ -119,7 +119,7 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
|
|
|
119
119
|
methods.set('fs.delete', async (params) => {
|
|
120
120
|
const path = params.path as string;
|
|
121
121
|
|
|
122
|
-
validatePath(path, config.allowedPaths);
|
|
122
|
+
validatePath(path, config.allowedPaths, config.blockedPaths);
|
|
123
123
|
|
|
124
124
|
await unlink(path);
|
|
125
125
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Security:
|
|
5
5
|
* - Commands are executed as array args (no shell interpolation)
|
|
6
|
-
* - First arg (executable) is validated against allowedCommands
|
|
7
|
-
* - Working directory is validated against allowedPaths
|
|
6
|
+
* - First arg (executable) is validated against allowedCommands / blockedCommands
|
|
7
|
+
* - Working directory is validated against allowedPaths / blockedPaths
|
|
8
8
|
* - Timeout enforcement
|
|
9
9
|
*/
|
|
10
10
|
|
|
@@ -14,9 +14,6 @@ import { validateCommand } from '../security/command-validator';
|
|
|
14
14
|
import { validatePath } from '../security/path-validator';
|
|
15
15
|
import type { TunnelConfig } from '../config';
|
|
16
16
|
|
|
17
|
-
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
18
|
-
const MAX_OUTPUT_SIZE = 1024 * 1024;
|
|
19
|
-
|
|
20
17
|
export function createShellCapability(config: TunnelConfig): Capability {
|
|
21
18
|
const methods = new Map<string, RpcHandler>();
|
|
22
19
|
|
|
@@ -25,19 +22,18 @@ export function createShellCapability(config: TunnelConfig): Capability {
|
|
|
25
22
|
const args = (params.args as string[]) || [];
|
|
26
23
|
const cwd = (params.cwd as string) || config.workingDir;
|
|
27
24
|
const timeout = Math.min(
|
|
28
|
-
(params.timeout as number) ||
|
|
29
|
-
|
|
25
|
+
(params.timeout as number) || config.shellTimeout,
|
|
26
|
+
config.shellMaxTimeout,
|
|
30
27
|
);
|
|
31
28
|
|
|
32
|
-
validateCommand(command, config.allowedCommands);
|
|
29
|
+
validateCommand(command, config.allowedCommands, config.blockedCommands);
|
|
33
30
|
|
|
34
31
|
if (cwd) {
|
|
35
|
-
validatePath(cwd, config.allowedPaths);
|
|
32
|
+
validatePath(cwd, config.allowedPaths, config.blockedPaths);
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
const SAFE_ENV_KEYS = ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'NODE_ENV', 'HOSTNAME'];
|
|
39
35
|
const safeEnv: Record<string, string> = { TERM: 'dumb' };
|
|
40
|
-
for (const key of
|
|
36
|
+
for (const key of config.shellEnvPassthrough) {
|
|
41
37
|
if (process.env[key]) {
|
|
42
38
|
safeEnv[key] = process.env[key]!;
|
|
43
39
|
}
|
|
@@ -57,18 +53,32 @@ export function createShellCapability(config: TunnelConfig): Capability {
|
|
|
57
53
|
let stderrTruncated = false;
|
|
58
54
|
|
|
59
55
|
proc.stdout?.on('data', (data: Buffer) => {
|
|
60
|
-
if (stdout.length
|
|
61
|
-
stdout += data.toString();
|
|
62
|
-
} else {
|
|
56
|
+
if (stdout.length >= config.shellMaxOutputSize) {
|
|
63
57
|
stdoutTruncated = true;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const chunk = data.toString();
|
|
61
|
+
const remaining = config.shellMaxOutputSize - stdout.length;
|
|
62
|
+
if (chunk.length > remaining) {
|
|
63
|
+
stdout += chunk.slice(0, remaining);
|
|
64
|
+
stdoutTruncated = true;
|
|
65
|
+
} else {
|
|
66
|
+
stdout += chunk;
|
|
64
67
|
}
|
|
65
68
|
});
|
|
66
69
|
|
|
67
70
|
proc.stderr?.on('data', (data: Buffer) => {
|
|
68
|
-
if (stderr.length
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
if (stderr.length >= config.shellMaxOutputSize) {
|
|
72
|
+
stderrTruncated = true;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const chunk = data.toString();
|
|
76
|
+
const remaining = config.shellMaxOutputSize - stderr.length;
|
|
77
|
+
if (chunk.length > remaining) {
|
|
78
|
+
stderr += chunk.slice(0, remaining);
|
|
71
79
|
stderrTruncated = true;
|
|
80
|
+
} else {
|
|
81
|
+
stderr += chunk;
|
|
72
82
|
}
|
|
73
83
|
});
|
|
74
84
|
|
|
@@ -80,8 +90,8 @@ export function createShellCapability(config: TunnelConfig): Capability {
|
|
|
80
90
|
resolve({
|
|
81
91
|
exitCode: code,
|
|
82
92
|
signal,
|
|
83
|
-
stdout
|
|
84
|
-
stderr
|
|
93
|
+
stdout,
|
|
94
|
+
stderr,
|
|
85
95
|
stdoutTruncated,
|
|
86
96
|
stderrTruncated,
|
|
87
97
|
});
|
package/src/agent/config.ts
CHANGED
|
@@ -6,12 +6,17 @@ export interface TunnelConfig {
|
|
|
6
6
|
token: string;
|
|
7
7
|
tunnelId: string;
|
|
8
8
|
apiUrl: string;
|
|
9
|
-
/** WS path on the server (default: '/ws'). Override for custom server mounts. */
|
|
10
9
|
wsPath: string;
|
|
11
10
|
maxFileSize: number;
|
|
12
11
|
allowedPaths: string[];
|
|
13
12
|
allowedCommands: string[];
|
|
13
|
+
blockedCommands: string[];
|
|
14
|
+
blockedPaths: string[];
|
|
14
15
|
workingDir: string;
|
|
16
|
+
shellTimeout: number;
|
|
17
|
+
shellMaxTimeout: number;
|
|
18
|
+
shellMaxOutputSize: number;
|
|
19
|
+
shellEnvPassthrough: string[];
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
const CONFIG_DIR = join(homedir(), '.agent-tunnel');
|
|
@@ -23,7 +28,22 @@ const DEFAULTS: Partial<TunnelConfig> = {
|
|
|
23
28
|
maxFileSize: 10 * 1024 * 1024,
|
|
24
29
|
allowedPaths: [homedir()],
|
|
25
30
|
allowedCommands: [],
|
|
31
|
+
blockedCommands: [],
|
|
32
|
+
blockedPaths: [
|
|
33
|
+
'/etc/shadow',
|
|
34
|
+
'/etc/passwd',
|
|
35
|
+
'/etc/sudoers',
|
|
36
|
+
'/etc/ssh',
|
|
37
|
+
'/root/.ssh',
|
|
38
|
+
'/proc',
|
|
39
|
+
'/sys',
|
|
40
|
+
'/dev',
|
|
41
|
+
],
|
|
26
42
|
workingDir: homedir(),
|
|
43
|
+
shellTimeout: 30_000,
|
|
44
|
+
shellMaxTimeout: 120_000,
|
|
45
|
+
shellMaxOutputSize: 1024 * 1024,
|
|
46
|
+
shellEnvPassthrough: ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'NODE_ENV', 'HOSTNAME'],
|
|
27
47
|
};
|
|
28
48
|
|
|
29
49
|
export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig {
|
|
@@ -1,42 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Command Validator — defense-in-depth command injection prevention.
|
|
3
|
-
*
|
|
4
|
-
* Validates that shell commands:
|
|
5
|
-
* 1. Are in the allowed commands list (if configured)
|
|
6
|
-
* 2. Don't contain shell metacharacters
|
|
7
|
-
* 3. Don't execute dangerous system commands
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** Commands that should never be executed via tunnel regardless of config. */
|
|
11
|
-
const BLOCKED_COMMANDS = new Set([
|
|
12
|
-
'rm',
|
|
13
|
-
'rmdir',
|
|
14
|
-
'mkfs',
|
|
15
|
-
'dd',
|
|
16
|
-
'shutdown',
|
|
17
|
-
'reboot',
|
|
18
|
-
'halt',
|
|
19
|
-
'poweroff',
|
|
20
|
-
'init',
|
|
21
|
-
'systemctl',
|
|
22
|
-
'sudo',
|
|
23
|
-
'su',
|
|
24
|
-
'passwd',
|
|
25
|
-
'chown',
|
|
26
|
-
'chmod',
|
|
27
|
-
'chgrp',
|
|
28
|
-
'mount',
|
|
29
|
-
'umount',
|
|
30
|
-
'fdisk',
|
|
31
|
-
'parted',
|
|
32
|
-
'iptables',
|
|
33
|
-
'ufw',
|
|
34
|
-
'firewall-cmd',
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
1
|
const SHELL_METACHAR_REGEX = /[;&|`$(){}[\]<>!#~]/;
|
|
38
2
|
|
|
39
|
-
export function validateCommand(
|
|
3
|
+
export function validateCommand(
|
|
4
|
+
command: string,
|
|
5
|
+
allowedCommands: string[],
|
|
6
|
+
blockedCommands: string[],
|
|
7
|
+
): void {
|
|
40
8
|
if (!command || typeof command !== 'string') {
|
|
41
9
|
throw new Error('Command is required');
|
|
42
10
|
}
|
|
@@ -49,8 +17,8 @@ export function validateCommand(command: string, allowedCommands: string[]): voi
|
|
|
49
17
|
|
|
50
18
|
const executable = trimmed.split(/\s+/)[0];
|
|
51
19
|
|
|
52
|
-
if (
|
|
53
|
-
throw new Error(`Command "${executable}" is blocked
|
|
20
|
+
if (blockedCommands.length > 0 && blockedCommands.includes(executable)) {
|
|
21
|
+
throw new Error(`Command "${executable}" is blocked`);
|
|
54
22
|
}
|
|
55
23
|
|
|
56
24
|
if (allowedCommands.length > 0) {
|
|
@@ -5,25 +5,17 @@
|
|
|
5
5
|
* 1. Are absolute
|
|
6
6
|
* 2. Resolve to an absolute path (follows symlinks)
|
|
7
7
|
* 3. Fall within allowed directories
|
|
8
|
-
* 4. Don't hit
|
|
8
|
+
* 4. Don't hit blocked paths (configurable)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { resolve, normalize } from 'path';
|
|
12
12
|
import { realpathSync } from 'fs';
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'/root/.ssh',
|
|
20
|
-
'/proc',
|
|
21
|
-
'/sys',
|
|
22
|
-
'/dev',
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export function validatePath(path: string, allowedPaths: string[]): void {
|
|
14
|
+
export function validatePath(
|
|
15
|
+
path: string,
|
|
16
|
+
allowedPaths: string[],
|
|
17
|
+
blockedPaths: string[] = [],
|
|
18
|
+
): void {
|
|
27
19
|
if (!path) {
|
|
28
20
|
throw new Error('Path is required');
|
|
29
21
|
}
|
|
@@ -32,13 +24,18 @@ export function validatePath(path: string, allowedPaths: string[]): void {
|
|
|
32
24
|
let resolved: string;
|
|
33
25
|
try {
|
|
34
26
|
resolved = realpathSync(normalized);
|
|
35
|
-
} catch {
|
|
36
|
-
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
29
|
+
if (code === 'ENOENT') {
|
|
30
|
+
resolved = normalized;
|
|
31
|
+
} else {
|
|
32
|
+
throw new Error(`Access denied: cannot resolve path "${path}" (${code})`);
|
|
33
|
+
}
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
for (const
|
|
40
|
-
if (resolved ===
|
|
41
|
-
throw new Error(`Access denied:
|
|
36
|
+
for (const blocked of blockedPaths) {
|
|
37
|
+
if (resolved === blocked || resolved.startsWith(blocked + '/')) {
|
|
38
|
+
throw new Error(`Access denied: blocked path "${path}"`);
|
|
42
39
|
}
|
|
43
40
|
}
|
|
44
41
|
|
|
@@ -50,7 +50,8 @@ export class PermissionGuard {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
if (perm.expiresAt) {
|
|
53
|
-
|
|
53
|
+
const expiry = new Date(perm.expiresAt).getTime();
|
|
54
|
+
if (isNaN(expiry) || expiry < Date.now()) {
|
|
54
55
|
this.permissions.delete(permissionId);
|
|
55
56
|
return false;
|
|
56
57
|
}
|