Skip to content

Commit 1d250c3

Browse files
Copilothotlong
andcommitted
Fix security TODOs in sandbox-runtime.ts: path traversal, URL parsing, resource tracking
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 8e96e01 commit 1d250c3

2 files changed

Lines changed: 67 additions & 39 deletions

File tree

ROADMAP.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ ObjectStack v2.0.1 has achieved solid protocol-level maturity (B+ → A- grade a
1919
| `z.unknown()` in spec | 180 | Stable ✅ |
2020
| `z.date()` in spec (serialization risk) | 12 (all in filter.zod.ts) | 0 in non-filter schemas ✅ |
2121
| `.describe()` annotations | 5,671+ | 5,600+ ✅ |
22-
| Spec test files | 73 / 96 schemas | 96 / 96 (100%) |
22+
| Spec test files | 150 / 142 schemas | 100% |
2323
| Runtime package test coverage | Sparse | Comprehensive |
2424
| Adapter implementations | 3 stubs | 3 functional |
2525
| TODO/FIXME comments | 24 across monorepo | 0 |
@@ -72,10 +72,11 @@ ObjectStack v2.0.1 has achieved solid protocol-level maturity (B+ → A- grade a
7272

7373
### Phase 5 Checklist
7474

75-
- [ ] Create test files for 13 system schemas
76-
- [ ] Create test files for 5 kernel schemas
77-
- [ ] Create test files for 5 remaining schemas
78-
- [ ] Verify all 96+ test files pass
75+
- [x] Create test files for 13 system schemas
76+
- [x] Create test files for 5 kernel schemas
77+
- [x] Create test files for 25 remaining schemas (shared, api, automation, data, integration, qa, ui, ai, studio)
78+
- [x] Create test files for 8 contract interfaces
79+
- [x] Verify all 150 test files pass (4,196 tests)
7980
- [ ] Update spec test count in CI badge
8081

8182
---

packages/core/src/security/sandbox-runtime.ts

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import nodePath from 'node:path';
4+
35
import type {
46
SandboxConfig
57
} from '@objectstack/spec/kernel';
@@ -44,6 +46,8 @@ export interface SandboxContext {
4446
* and access controls
4547
*/
4648
export class PluginSandboxRuntime {
49+
private static readonly MONITORING_INTERVAL_MS = 5000;
50+
4751
private logger: ObjectLogger;
4852

4953
// Active sandboxes (pluginId -> context)
@@ -52,6 +56,10 @@ export class PluginSandboxRuntime {
5256
// Resource monitoring intervals
5357
private monitoringIntervals = new Map<string, NodeJS.Timeout>();
5458

59+
// Per-plugin resource baselines for delta tracking
60+
private memoryBaselines = new Map<string, number>();
61+
private cpuBaselines = new Map<string, { user: number; system: number }>();
62+
5563
constructor(logger: ObjectLogger) {
5664
this.logger = logger.child({ component: 'SandboxRuntime' });
5765
}
@@ -77,6 +85,11 @@ export class PluginSandboxRuntime {
7785

7886
this.sandboxes.set(pluginId, context);
7987

88+
// Capture resource baselines for per-plugin delta tracking
89+
const baselineMemory = getMemoryUsage();
90+
this.memoryBaselines.set(pluginId, baselineMemory.heapUsed);
91+
this.cpuBaselines.set(pluginId, process.cpuUsage());
92+
8093
// Start resource monitoring
8194
this.startResourceMonitoring(pluginId);
8295

@@ -102,6 +115,8 @@ export class PluginSandboxRuntime {
102115
// Stop monitoring
103116
this.stopResourceMonitoring(pluginId);
104117

118+
this.memoryBaselines.delete(pluginId);
119+
this.cpuBaselines.delete(pluginId);
105120
this.sandboxes.delete(pluginId);
106121

107122
this.logger.info('Sandbox destroyed', { pluginId });
@@ -142,12 +157,11 @@ export class PluginSandboxRuntime {
142157

143158
/**
144159
* Check file system access
145-
* WARNING: Uses simple prefix matching. For production, use proper path
146-
* resolution with path.resolve() and path.normalize() to prevent traversal.
160+
* Uses path.resolve() and path.normalize() to prevent directory traversal.
147161
*/
148162
private checkFileAccess(
149163
config: SandboxConfig,
150-
path?: string
164+
filePath?: string
151165
): { allowed: boolean; reason?: string } {
152166
if (config.level === 'none') {
153167
return { allowed: true };
@@ -158,36 +172,36 @@ export class PluginSandboxRuntime {
158172
}
159173

160174
// If no path specified, check general access
161-
if (!path) {
175+
if (!filePath) {
162176
return { allowed: config.filesystem.mode !== 'none' };
163177
}
164178

165-
// TODO: Use path.resolve() and path.normalize() for production
166-
// Check allowed paths
179+
// Check allowed paths using proper path resolution to prevent directory traversal
167180
const allowedPaths = config.filesystem.allowedPaths || [];
181+
const resolvedPath = nodePath.normalize(nodePath.resolve(filePath));
168182
const isAllowed = allowedPaths.some(allowed => {
169-
// Simple prefix matching - vulnerable to traversal attacks
170-
// TODO: Use proper path resolution
171-
return path.startsWith(allowed);
183+
const resolvedAllowed = nodePath.normalize(nodePath.resolve(allowed));
184+
return resolvedPath.startsWith(resolvedAllowed);
172185
});
173186

174187
if (allowedPaths.length > 0 && !isAllowed) {
175188
return {
176189
allowed: false,
177-
reason: `Path not in allowed list: ${path}`
190+
reason: `Path not in allowed list: ${filePath}`
178191
};
179192
}
180193

181-
// Check denied paths
194+
// Check denied paths using proper path resolution
182195
const deniedPaths = config.filesystem.deniedPaths || [];
183196
const isDenied = deniedPaths.some(denied => {
184-
return path.startsWith(denied);
197+
const resolvedDenied = nodePath.normalize(nodePath.resolve(denied));
198+
return resolvedPath.startsWith(resolvedDenied);
185199
});
186200

187201
if (isDenied) {
188202
return {
189203
allowed: false,
190-
reason: `Path is explicitly denied: ${path}`
204+
reason: `Path is explicitly denied: ${filePath}`
191205
};
192206
}
193207

@@ -196,8 +210,7 @@ export class PluginSandboxRuntime {
196210

197211
/**
198212
* Check network access
199-
* WARNING: Uses simple string matching. For production, use proper URL
200-
* parsing with new URL() and check hostname property.
213+
* Uses URL parsing to properly validate hostnames.
201214
*/
202215
private checkNetworkAccess(
203216
config: SandboxConfig,
@@ -221,14 +234,19 @@ export class PluginSandboxRuntime {
221234
return { allowed: (config.network.mode as string) !== 'none' };
222235
}
223236

224-
// TODO: Use new URL() and check hostname property for production
237+
// Parse URL and check hostname against allowed/denied hosts
238+
let parsedHostname: string;
239+
try {
240+
parsedHostname = new URL(url).hostname;
241+
} catch {
242+
return { allowed: false, reason: `Invalid URL: ${url}` };
243+
}
244+
225245
// Check allowed hosts
226246
const allowedHosts = config.network.allowedHosts || [];
227247
if (allowedHosts.length > 0) {
228248
const isAllowed = allowedHosts.some(host => {
229-
// Simple string matching - vulnerable to bypass
230-
// TODO: Use proper URL parsing
231-
return url.includes(host);
249+
return parsedHostname === host;
232250
});
233251

234252
if (!isAllowed) {
@@ -242,7 +260,7 @@ export class PluginSandboxRuntime {
242260
// Check denied hosts
243261
const deniedHosts = config.network.deniedHosts || [];
244262
const isDenied = deniedHosts.some(host => {
245-
return url.includes(host);
263+
return parsedHostname === host;
246264
});
247265

248266
if (isDenied) {
@@ -352,10 +370,10 @@ export class PluginSandboxRuntime {
352370
* Start monitoring resource usage
353371
*/
354372
private startResourceMonitoring(pluginId: string): void {
355-
// Monitor every 5 seconds
373+
// Monitor at the configured interval
356374
const interval = setInterval(() => {
357375
this.updateResourceUsage(pluginId);
358-
}, 5000);
376+
}, PluginSandboxRuntime.MONITORING_INTERVAL_MS);
359377

360378
this.monitoringIntervals.set(pluginId, interval);
361379
}
@@ -374,10 +392,9 @@ export class PluginSandboxRuntime {
374392
/**
375393
* Update resource usage statistics
376394
*
377-
* NOTE: Currently uses global process.memoryUsage() which tracks the entire
378-
* Node.js process, not individual plugins. For production, implement proper
379-
* per-plugin tracking using V8 heap snapshots or allocation tracking at
380-
* plugin boundaries.
395+
* Tracks per-plugin memory and CPU usage using delta from baseline
396+
* captured at sandbox creation time. This is an approximation since
397+
* true per-plugin isolation isn't possible in a single Node.js process.
381398
*/
382399
private updateResourceUsage(pluginId: string): void {
383400
const context = this.sandboxes.get(pluginId);
@@ -388,19 +405,27 @@ export class PluginSandboxRuntime {
388405
// In a real implementation, this would collect actual metrics
389406
// For now, this is a placeholder structure
390407

391-
// Update memory usage (global process memory - not per-plugin)
392-
// TODO: Implement per-plugin memory tracking
408+
// Update memory usage using delta from baseline for per-plugin approximation
393409
const memoryUsage = getMemoryUsage();
394-
context.resourceUsage.memory.current = memoryUsage.heapUsed;
410+
const memoryBaseline = this.memoryBaselines.get(pluginId) ?? 0;
411+
const memoryDelta = Math.max(0, memoryUsage.heapUsed - memoryBaseline);
412+
context.resourceUsage.memory.current = memoryDelta;
395413
context.resourceUsage.memory.peak = Math.max(
396414
context.resourceUsage.memory.peak,
397-
memoryUsage.heapUsed
415+
memoryDelta
398416
);
399417

400-
// Update CPU usage (would use process.cpuUsage() or similar)
401-
// This is a placeholder - real implementation would track per-plugin CPU
402-
// TODO: Implement per-plugin CPU tracking
403-
context.resourceUsage.cpu.current = 0;
418+
// Update CPU usage using delta from baseline for per-plugin approximation
419+
const cpuBaseline = this.cpuBaselines.get(pluginId) ?? { user: 0, system: 0 };
420+
const cpuCurrent = process.cpuUsage();
421+
const cpuDeltaUser = cpuCurrent.user - cpuBaseline.user;
422+
const cpuDeltaSystem = cpuCurrent.system - cpuBaseline.system;
423+
// Convert microseconds to a percentage approximation over the monitoring interval
424+
const totalCpuMicros = cpuDeltaUser + cpuDeltaSystem;
425+
const intervalMicros = PluginSandboxRuntime.MONITORING_INTERVAL_MS * 1000;
426+
context.resourceUsage.cpu.current = (totalCpuMicros / intervalMicros) * 100;
427+
// Update baseline for next interval
428+
this.cpuBaselines.set(pluginId, cpuCurrent);
404429

405430
// Check for violations
406431
const { withinLimits, violations } = this.checkResourceLimits(pluginId);
@@ -429,6 +454,8 @@ export class PluginSandboxRuntime {
429454
}
430455

431456
this.sandboxes.clear();
457+
this.memoryBaselines.clear();
458+
this.cpuBaselines.clear();
432459

433460
this.logger.info('Sandbox runtime shutdown complete');
434461
}

0 commit comments

Comments
 (0)