@pixelbyte-software/pixcode 1.54.0 → 1.54.2

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.
Files changed (74) hide show
  1. package/dist/assets/index-DvudRU5X.css +32 -0
  2. package/dist/assets/{index-CEw93JQ7.js → index-lqiCTrCl.js} +205 -205
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/claude-sdk.js +6 -0
  5. package/dist-server/server/claude-sdk.js.map +1 -1
  6. package/dist-server/server/index.js +224 -46
  7. package/dist-server/server/index.js.map +1 -1
  8. package/dist-server/server/middleware/account-lockout.js +101 -0
  9. package/dist-server/server/middleware/account-lockout.js.map +1 -0
  10. package/dist-server/server/middleware/auth.js +71 -9
  11. package/dist-server/server/middleware/auth.js.map +1 -1
  12. package/dist-server/server/middleware/rate-limiter.js +62 -0
  13. package/dist-server/server/middleware/rate-limiter.js.map +1 -0
  14. package/dist-server/server/routes/agent.js +3 -4
  15. package/dist-server/server/routes/agent.js.map +1 -1
  16. package/dist-server/server/routes/auth.js +75 -13
  17. package/dist-server/server/routes/auth.js.map +1 -1
  18. package/dist-server/server/routes/codex.js +1 -1
  19. package/dist-server/server/routes/codex.js.map +1 -1
  20. package/dist-server/server/routes/diagnostics.js +6 -3
  21. package/dist-server/server/routes/diagnostics.js.map +1 -1
  22. package/dist-server/server/routes/gemini.js +1 -1
  23. package/dist-server/server/routes/gemini.js.map +1 -1
  24. package/dist-server/server/routes/git.js +9 -9
  25. package/dist-server/server/routes/git.js.map +1 -1
  26. package/dist-server/server/routes/live-view.js +2 -2
  27. package/dist-server/server/routes/live-view.js.map +1 -1
  28. package/dist-server/server/routes/plugins.js +12 -12
  29. package/dist-server/server/routes/plugins.js.map +1 -1
  30. package/dist-server/server/routes/projects.js +2 -2
  31. package/dist-server/server/routes/projects.js.map +1 -1
  32. package/dist-server/server/routes/qwen.js +1 -1
  33. package/dist-server/server/routes/qwen.js.map +1 -1
  34. package/dist-server/server/routes/remote.js +1 -1
  35. package/dist-server/server/routes/remote.js.map +1 -1
  36. package/dist-server/server/routes/webhooks.js +1 -1
  37. package/dist-server/server/routes/webhooks.js.map +1 -1
  38. package/dist-server/server/services/startup-update.js +31 -6
  39. package/dist-server/server/services/startup-update.js.map +1 -1
  40. package/dist-server/server/services/telegram/bot.js +6 -4
  41. package/dist-server/server/services/telegram/bot.js.map +1 -1
  42. package/dist-server/server/setup-wizard.js +12 -6
  43. package/dist-server/server/setup-wizard.js.map +1 -1
  44. package/dist-server/server/utils/plugin-loader.js +3 -0
  45. package/dist-server/server/utils/plugin-loader.js.map +1 -1
  46. package/dist-server/server/utils/port-access.js +25 -8
  47. package/dist-server/server/utils/port-access.js.map +1 -1
  48. package/dist-server/server/utils/security-log.js +89 -0
  49. package/dist-server/server/utils/security-log.js.map +1 -0
  50. package/package.json +1 -1
  51. package/server/claude-sdk.js +7 -0
  52. package/server/index.js +232 -46
  53. package/server/middleware/account-lockout.js +112 -0
  54. package/server/middleware/auth.js +72 -9
  55. package/server/middleware/rate-limiter.js +82 -0
  56. package/server/routes/agent.js +3 -4
  57. package/server/routes/auth.js +86 -13
  58. package/server/routes/codex.js +1 -1
  59. package/server/routes/diagnostics.js +6 -3
  60. package/server/routes/gemini.js +1 -1
  61. package/server/routes/git.js +9 -9
  62. package/server/routes/live-view.js +2 -2
  63. package/server/routes/plugins.js +12 -12
  64. package/server/routes/projects.js +2 -2
  65. package/server/routes/qwen.js +1 -1
  66. package/server/routes/remote.js +1 -1
  67. package/server/routes/webhooks.js +1 -1
  68. package/server/services/startup-update.js +31 -6
  69. package/server/services/telegram/bot.js +6 -4
  70. package/server/setup-wizard.js +12 -6
  71. package/server/utils/plugin-loader.js +4 -0
  72. package/server/utils/port-access.js +26 -8
  73. package/server/utils/security-log.js +84 -0
  74. package/dist/assets/index-B6Ssy_IG.css +0 -32
package/server/index.js CHANGED
@@ -19,6 +19,7 @@ import cors from 'cors';
19
19
  import { AppError, createNormalizedMessage } from '@/shared/utils.js';
20
20
 
21
21
  import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
22
+ import { securityLog, getClientIp } from './utils/security-log.js';
22
23
 
23
24
 
24
25
 
@@ -94,7 +95,8 @@ function resolveMonacoAssetsPath() {
94
95
 
95
96
  import { c } from './utils/colors.js';
96
97
 
97
- console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
98
+ // Server port is logged after binding in startServer() avoid leaking
99
+ // env configuration to stdout on every import.
98
100
 
99
101
 
100
102
 
@@ -163,6 +165,7 @@ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, appConfigD
163
165
  import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
164
166
  import { configureWebPush } from './services/vapid-keys.js';
165
167
  import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
168
+ import { apiRateLimiter } from './middleware/rate-limiter.js';
166
169
  import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
167
170
  import { IS_PLATFORM } from './constants/config.js';
168
171
 
@@ -368,6 +371,7 @@ async function readLatestPixcodePackageMetadata() {
368
371
  return {
369
372
  latestVersion,
370
373
  tarballUrl: latestEntry?.dist?.tarball || null,
374
+ tarballIntegrity: latestEntry?.dist?.integrity || null,
371
375
  };
372
376
  }
373
377
 
@@ -380,7 +384,33 @@ function buildPixcodeTarballUrl(version) {
380
384
  return `https://registry.npmjs.org/@pixelbyte-software/pixcode/-/pixcode-${version.trim()}.tgz`;
381
385
  }
382
386
 
383
- async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl) {
387
+ /**
388
+ * Verify a downloaded tarball's integrity against the registry-provided
389
+ * Subresource Integrity (SRI) string. Throws if the hash doesn't match.
390
+ * @param {Buffer} buffer - The downloaded tarball bytes
391
+ * @param {string} integrity - SRI string from npm registry (e.g. "sha512-...")
392
+ */
393
+ function verifyTarballIntegrity(buffer, integrity) {
394
+ if (!integrity || typeof integrity !== 'string') {
395
+ throw new Error('Tarball integrity hash missing from registry metadata — refusing to install unverified package.');
396
+ }
397
+ const dashIndex = integrity.indexOf('-');
398
+ if (dashIndex < 0) {
399
+ throw new Error('Malformed integrity string from registry.');
400
+ }
401
+ const algo = integrity.slice(0, dashIndex);
402
+ const expectedHash = integrity.slice(dashIndex + 1);
403
+ const validAlgos = ['sha512', 'sha384', 'sha256'];
404
+ if (!validAlgos.includes(algo)) {
405
+ throw new Error(`Unsupported integrity algorithm: ${algo}`);
406
+ }
407
+ const actualHash = crypto.createHash(algo).update(buffer).digest('base64');
408
+ if (actualHash !== expectedHash) {
409
+ throw new Error(`Tarball integrity check failed: expected ${algo}-${expectedHash.slice(0, 16)}…, got ${algo}-${actualHash.slice(0, 16)}…`);
410
+ }
411
+ }
412
+
413
+ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl, tarballIntegrity) {
384
414
  appendUpdateJobLog(job, 'meta', `Update mode: runtime-dir\nRuntime: ${runtimeDir}\n`);
385
415
  appendUpdateJobLog(job, 'meta', `Downloading ${tarballUrl}\n`);
386
416
  const tarballRes = await fetch(tarballUrl);
@@ -388,6 +418,22 @@ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl
388
418
  throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
389
419
  }
390
420
 
421
+ // Download tarball to a buffer first so we can verify integrity before extracting.
422
+ const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
423
+
424
+ // Verify integrity hash against registry-provided SRI string.
425
+ if (tarballIntegrity) {
426
+ try {
427
+ verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
428
+ appendUpdateJobLog(job, 'meta', 'Tarball integrity verified.\n');
429
+ } catch (verifyError) {
430
+ appendUpdateJobLog(job, 'stderr', `Integrity verification failed: ${verifyError.message}\n`);
431
+ throw verifyError;
432
+ }
433
+ } else {
434
+ appendUpdateJobLog(job, 'meta', 'WARNING: No integrity hash from registry — extracting without verification.\n');
435
+ }
436
+
391
437
  const stagingDir = path.join(runtimeDir, '.staging');
392
438
  const backupDir = path.join(runtimeDir, '.previous');
393
439
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -399,10 +445,7 @@ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl
399
445
  if (!tarExtract) throw new Error('tar extractor not available');
400
446
 
401
447
  await new Promise((resolve, reject) => {
402
- const webStream = tarballRes.body;
403
- const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
404
- ? Readable.fromWeb(webStream)
405
- : webStream;
448
+ const nodeStream = Readable.from(tarballBuffer);
406
449
  const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
407
450
  nodeStream.pipe(extractor);
408
451
  extractor.on('finish', resolve);
@@ -514,7 +557,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
514
557
  const gitUpdateScript = path.join(APP_ROOT, 'scripts', 'update-git-install.mjs');
515
558
  const latest = await readLatestPixcodePackageMetadata().catch((error) => {
516
559
  appendUpdateJobLog(job, 'stderr', `Registry precheck failed: ${error.message}\n`);
517
- return { latestVersion: null, tarballUrl: null };
560
+ return { latestVersion: null, tarballUrl: null, tarballIntegrity: null };
518
561
  });
519
562
  const requestedVersion = isSafePackageVersion(options.targetVersion) ? options.targetVersion.trim() : null;
520
563
  const resolvedVersion = latest.latestVersion || requestedVersion || null;
@@ -536,7 +579,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
536
579
  if (!latest.latestVersion) {
537
580
  appendUpdateJobLog(job, 'meta', `Using requested target version ${resolvedVersion} after registry precheck failed.\n`);
538
581
  }
539
- job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl);
582
+ job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl, latest.tarballIntegrity);
540
583
  } else {
541
584
  const updateCommand = IS_PLATFORM
542
585
  ? 'npm run update:platform'
@@ -1687,7 +1730,52 @@ app.locals.installMode = installMode;
1687
1730
  app.locals.serverVersion = SERVER_VERSION;
1688
1731
  setNotificationWebSocketServer(wss);
1689
1732
 
1690
- app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
1733
+ // ── Security hardening ──────────────────────────────────────────────
1734
+ // Disable the X-Powered-By header so the framework isn't advertised.
1735
+ app.disable('x-powered-by');
1736
+ // Trust the first proxy hop so X-Forwarded-* headers are respected when
1737
+ // running behind nginx/Caddy/etc. (needed for correct protocol detection
1738
+ // in resolvePublicBaseUrl and for rate-limiting middleware if added later).
1739
+ app.set('trust proxy', 1);
1740
+
1741
+ // CORS: self-hosted tool accessed from various IPs/hostnames.
1742
+ // Reflect the requesting origin so IP-based access (e.g. http://85.235.74.198:3001)
1743
+ // works without configuration. Credentials needed for auth header passthrough.
1744
+ app.use(cors({
1745
+ origin: true,
1746
+ credentials: true,
1747
+ exposedHeaders: ['X-Refreshed-Token'],
1748
+ }));
1749
+
1750
+ // Security headers middleware (replaces helmet which isn't installed).
1751
+ app.use((req, res, next) => {
1752
+ res.setHeader('X-Content-Type-Options', 'nosniff');
1753
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
1754
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
1755
+ res.setHeader('X-XSS-Protection', '1; mode=block');
1756
+ res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
1757
+ // Strict-Transport-Security only makes sense over HTTPS; skip for plain HTTP
1758
+ // so local dev doesn't pin an HSTS policy on localhost.
1759
+ if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
1760
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
1761
+ }
1762
+ // CSP for the SPA shell — relaxed for self-hosted tool accessed from
1763
+ // various IPs/hostnames. 'unsafe-inline' + 'unsafe-eval' needed for Vite.
1764
+ // ws:/wss: in connect-src allows WebSocket from any origin (IP access).
1765
+ res.setHeader('Content-Security-Policy', [
1766
+ "default-src 'self'",
1767
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
1768
+ "style-src 'self' 'unsafe-inline'",
1769
+ "img-src 'self' data: blob: https:",
1770
+ "font-src 'self' data:",
1771
+ "connect-src 'self' ws: wss: http: https:",
1772
+ "frame-ancestors 'self'",
1773
+ "base-uri 'self'",
1774
+ "form-action 'self'",
1775
+ ].join('; '));
1776
+ next();
1777
+ });
1778
+
1691
1779
  app.use(express.json({
1692
1780
  limit: '50mb',
1693
1781
  type: (req) => {
@@ -1714,8 +1802,9 @@ app.get('/health', (req, res) => {
1714
1802
  });
1715
1803
  });
1716
1804
 
1717
- // Optional API key validation (if configured)
1805
+ // Optional API key validation (if configured) + rate limiting for all API routes
1718
1806
  app.use('/api', validateApiKey);
1807
+ app.use('/api', apiRateLimiter);
1719
1808
 
1720
1809
  app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
1721
1810
  const provider = req.body?.provider || 'claude';
@@ -2152,6 +2241,12 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
2152
2241
  });
2153
2242
 
2154
2243
  app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
2244
+ securityLog('system_update_initiated', {
2245
+ ip: getClientIp(req),
2246
+ userId: req.user?.id,
2247
+ username: req.user?.username,
2248
+ endpoint: req.path,
2249
+ });
2155
2250
  const job = createSystemUpdateJob(req.user, {
2156
2251
  targetVersion: req.body?.targetVersion || req.body?.latestVersion,
2157
2252
  });
@@ -2268,6 +2363,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2268
2363
  const latestVersion = metadata['dist-tags']?.latest;
2269
2364
  const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
2270
2365
  const tarballUrl = latestEntry?.dist?.tarball;
2366
+ const tarballIntegrity = latestEntry?.dist?.integrity;
2271
2367
  if (!latestVersion || !tarballUrl) throw new Error('Registry response missing latest/tarball');
2272
2368
 
2273
2369
  send('log', { stream: 'meta', chunk: `Current: ${SERVER_VERSION} → Latest: ${latestVersion}\n` });
@@ -2283,16 +2379,30 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2283
2379
  return;
2284
2380
  }
2285
2381
 
2286
- // 2. Download the tarball stream and pipe it through tar's
2287
- // extractor into a staging directory. Doing the extract
2288
- // under `.staging` first means the live runtime stays
2289
- // intact if the download fails partway through.
2382
+ // 2. Download the tarball to a buffer so we can verify integrity
2383
+ // before extracting. Doing the extract under `.staging` first
2384
+ // means the live runtime stays intact if the download fails.
2290
2385
  send('log', { stream: 'meta', chunk: `Downloading ${tarballUrl}\n` });
2291
2386
  const tarballRes = await fetch(tarballUrl);
2292
2387
  if (!tarballRes.ok || !tarballRes.body) {
2293
2388
  throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
2294
2389
  }
2295
2390
 
2391
+ const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
2392
+
2393
+ // Verify integrity hash against registry-provided SRI string.
2394
+ if (tarballIntegrity) {
2395
+ try {
2396
+ verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
2397
+ send('log', { stream: 'meta', chunk: 'Tarball integrity verified.\n' });
2398
+ } catch (verifyError) {
2399
+ send('log', { stream: 'stderr', chunk: `Integrity verification failed: ${verifyError.message}\n` });
2400
+ throw verifyError;
2401
+ }
2402
+ } else {
2403
+ send('log', { stream: 'meta', chunk: 'WARNING: No integrity hash from registry — extracting without verification.\n' });
2404
+ }
2405
+
2296
2406
  const stagingDir = path.join(runtimeDir, '.staging');
2297
2407
  const backupDir = path.join(runtimeDir, '.previous');
2298
2408
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -2307,10 +2417,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2307
2417
  // contents to the staging dir directly so paths match the
2308
2418
  // existing runtime layout.
2309
2419
  await new Promise((resolve, reject) => {
2310
- const webStream = tarballRes.body;
2311
- const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
2312
- ? Readable.fromWeb(webStream)
2313
- : webStream;
2420
+ const nodeStream = Readable.from(tarballBuffer);
2314
2421
  const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
2315
2422
  nodeStream.pipe(extractor);
2316
2423
  extractor.on('finish', resolve);
@@ -2541,6 +2648,11 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
2541
2648
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
2542
2649
  // Foreground installs without a wrapper will simply stop; the UI reports this.
2543
2650
  app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
2651
+ securityLog('system_restart_requested', {
2652
+ ip: getClientIp(req),
2653
+ userId: req.user?.id,
2654
+ username: req.user?.username,
2655
+ });
2544
2656
  const forceRestart = req.body?.force === true || req.query.force === 'true';
2545
2657
  const activeWork = getActiveWorkSummary();
2546
2658
  if (!forceRestart && activeWork.hasActiveWork) {
@@ -2569,7 +2681,7 @@ app.get('/api/projects', authenticateToken, async (req, res) => {
2569
2681
  const projects = await getProjects(broadcastProgress);
2570
2682
  res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
2571
2683
  } catch (error) {
2572
- res.status(500).json({ error: error.message });
2684
+ res.status(500).json({ error: "Internal server error" });
2573
2685
  }
2574
2686
  });
2575
2687
 
@@ -2583,7 +2695,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
2583
2695
  applyCustomSessionNames(result.sessions, 'claude');
2584
2696
  res.json(result);
2585
2697
  } catch (error) {
2586
- res.status(500).json({ error: error.message });
2698
+ res.status(500).json({ error: "Internal server error" });
2587
2699
  }
2588
2700
  });
2589
2701
 
@@ -2594,7 +2706,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAc
2594
2706
  await renameProject(req.params.projectName, displayName);
2595
2707
  res.json({ success: true });
2596
2708
  } catch (error) {
2597
- res.status(500).json({ error: error.message });
2709
+ res.status(500).json({ error: "Internal server error" });
2598
2710
  }
2599
2711
  });
2600
2712
 
@@ -2609,7 +2721,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
2609
2721
  res.json({ success: true });
2610
2722
  } catch (error) {
2611
2723
  console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
2612
- res.status(500).json({ error: error.message });
2724
+ res.status(500).json({ error: "Internal server error" });
2613
2725
  }
2614
2726
  });
2615
2727
 
@@ -2635,7 +2747,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
2635
2747
  res.json({ success: true });
2636
2748
  } catch (error) {
2637
2749
  console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
2638
- res.status(500).json({ error: error.message });
2750
+ res.status(500).json({ error: "Internal server error" });
2639
2751
  }
2640
2752
  });
2641
2753
 
@@ -2655,7 +2767,7 @@ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess
2655
2767
  // catch it and prompt the user to pass `?force=true` (or clean
2656
2768
  // sessions first) instead of treating it like a crash.
2657
2769
  const conflict = typeof error?.message === 'string' && error.message.includes('existing sessions');
2658
- res.status(conflict ? 409 : 500).json({ error: error.message });
2770
+ res.status(conflict ? 409 : 500).json({ error: conflict ? error.message : 'Internal server error' });
2659
2771
  }
2660
2772
  });
2661
2773
 
@@ -2892,7 +3004,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
2892
3004
  } else if (error.code === 'EACCES') {
2893
3005
  res.status(403).json({ error: 'Permission denied' });
2894
3006
  } else {
2895
- res.status(500).json({ error: error.message });
3007
+ res.status(500).json({ error: "Internal server error" });
2896
3008
  }
2897
3009
  }
2898
3010
  });
@@ -2952,7 +3064,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
2952
3064
  } catch (error) {
2953
3065
  console.error('Error serving binary file:', error);
2954
3066
  if (!res.headersSent) {
2955
- res.status(500).json({ error: error.message });
3067
+ res.status(500).json({ error: "Internal server error" });
2956
3068
  }
2957
3069
  }
2958
3070
  });
@@ -3005,7 +3117,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
3005
3117
  } else if (error.code === 'EACCES') {
3006
3118
  res.status(403).json({ error: 'Permission denied' });
3007
3119
  } else {
3008
- res.status(500).json({ error: error.message });
3120
+ res.status(500).json({ error: "Internal server error" });
3009
3121
  }
3010
3122
  }
3011
3123
  });
@@ -3045,7 +3157,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
3045
3157
  }, 'viewFiles'));
3046
3158
  } catch (error) {
3047
3159
  console.error('[ERROR] File tree error:', error.message);
3048
- res.status(500).json({ error: error.message });
3160
+ res.status(500).json({ error: "Internal server error" });
3049
3161
  }
3050
3162
  });
3051
3163
 
@@ -3171,7 +3283,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
3171
3283
  } else if (error.code === 'ENOENT') {
3172
3284
  res.status(404).json({ error: 'Parent directory not found' });
3173
3285
  } else {
3174
- res.status(500).json({ error: error.message });
3286
+ res.status(500).json({ error: "Internal server error" });
3175
3287
  }
3176
3288
  }
3177
3289
  });
@@ -3254,7 +3366,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
3254
3366
  } else if (error.code === 'EXDEV') {
3255
3367
  res.status(400).json({ error: 'Cannot move across different filesystems' });
3256
3368
  } else {
3257
- res.status(500).json({ error: error.message });
3369
+ res.status(500).json({ error: "Internal server error" });
3258
3370
  }
3259
3371
  }
3260
3372
  });
@@ -3322,7 +3434,7 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
3322
3434
  } else if (error.code === 'ENOTEMPTY') {
3323
3435
  res.status(400).json({ error: 'Directory is not empty' });
3324
3436
  } else {
3325
- res.status(500).json({ error: error.message });
3437
+ res.status(500).json({ error: "Internal server error" });
3326
3438
  }
3327
3439
  }
3328
3440
  });
@@ -3341,7 +3453,7 @@ const uploadFilesHandler = async (req, res) => {
3341
3453
  filename: (req, file, cb) => {
3342
3454
  // Use a unique temp name, but preserve original name in file.originalname
3343
3455
  // Note: file.originalname may contain path separators for folder uploads
3344
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
3456
+ const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
3345
3457
  // For temp file, just use a safe unique name without the path
3346
3458
  cb(null, `upload-${uniqueSuffix}`);
3347
3459
  }
@@ -3490,7 +3602,7 @@ const uploadFilesHandler = async (req, res) => {
3490
3602
  if (error.code === 'EACCES') {
3491
3603
  res.status(403).json({ error: 'Permission denied' });
3492
3604
  } else {
3493
- res.status(500).json({ error: error.message });
3605
+ res.status(500).json({ error: "Internal server error" });
3494
3606
  }
3495
3607
  }
3496
3608
  });
@@ -3831,10 +3943,15 @@ function handleChatConnection(ws, request) {
3831
3943
  });
3832
3944
  }
3833
3945
  } catch (error) {
3834
- console.error('[ERROR] Chat WebSocket error:', error.message);
3946
+ console.error('[ERROR] Chat WebSocket error:', error?.message || error);
3947
+ securityLog('ws_chat_error', {
3948
+ userId: request?.user?.id,
3949
+ username: request?.user?.username,
3950
+ reason: error?.name || 'UnknownError',
3951
+ });
3835
3952
  writer.send({
3836
3953
  type: 'error',
3837
- error: error.message
3954
+ error: 'An error occurred while processing your request.'
3838
3955
  });
3839
3956
  }
3840
3957
  });
@@ -4313,11 +4430,16 @@ function handleShellConnection(ws, request) {
4313
4430
  const termCols = data.cols || 80;
4314
4431
  const termRows = data.rows || 24;
4315
4432
  console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
4433
+ const isRunningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
4316
4434
  const shellEnv = {
4317
4435
  ...process.env,
4318
4436
  TERM: 'xterm-256color',
4319
4437
  COLORTERM: 'truecolor',
4320
4438
  FORCE_COLOR: '3',
4439
+ // When running as root, Claude CLI refuses --dangerously-skip-permissions
4440
+ // for security reasons. Setting IS_SANDBOX=1 tells it the environment is
4441
+ // already sandboxed, so the flag is safe to use.
4442
+ ...(isRunningAsRoot ? { IS_SANDBOX: '1' } : {}),
4321
4443
  };
4322
4444
 
4323
4445
  shellProcess = pty.spawn(shell, shellArgs, {
@@ -4435,7 +4557,7 @@ function handleShellConnection(ws, request) {
4435
4557
  console.error('[ERROR] Error spawning process:', spawnError);
4436
4558
  ws.send(JSON.stringify({
4437
4559
  type: 'output',
4438
- data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
4560
+ data: `\r\n\x1b[31mError: Failed to start terminal process.\x1b[0m\r\n`
4439
4561
  }));
4440
4562
  }
4441
4563
 
@@ -4450,11 +4572,11 @@ function handleShellConnection(ws, request) {
4450
4572
  }
4451
4573
  }
4452
4574
  } catch (error) {
4453
- console.error('[ERROR] Shell WebSocket error:', error.message);
4575
+ console.error('[ERROR] Shell WebSocket error:', error?.message || error);
4454
4576
  if (ws.readyState === WebSocket.OPEN) {
4455
4577
  ws.send(JSON.stringify({
4456
4578
  type: 'output',
4457
- data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
4579
+ data: `\r\n\x1b[31mError: An internal error occurred.\x1b[0m\r\n`
4458
4580
  }));
4459
4581
  }
4460
4582
  }
@@ -4517,7 +4639,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireP
4517
4639
  cb(null, uploadDir);
4518
4640
  },
4519
4641
  filename: (req, file, cb) => {
4520
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
4642
+ const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
4521
4643
  const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
4522
4644
  cb(null, uniqueSuffix + '-' + sanitizedName);
4523
4645
  }
@@ -4882,8 +5004,16 @@ app.get(/.*/, (req, res) => {
4882
5004
  });
4883
5005
 
4884
5006
  // global error middleware must be last
4885
- app.use((err, req, res, next) => {
5007
+ app.use((err, req, res, _next) => {
4886
5008
  if (err instanceof AppError) {
5009
+ securityLog('app_error', {
5010
+ ip: getClientIp(req),
5011
+ endpoint: req.path,
5012
+ method: req.method,
5013
+ statusCode: err.statusCode,
5014
+ userId: req.user?.id,
5015
+ reason: err.code,
5016
+ });
4887
5017
  return res.status(err.statusCode).json({
4888
5018
  success: false,
4889
5019
  error: {
@@ -4894,7 +5024,37 @@ app.use((err, req, res, next) => {
4894
5024
  });
4895
5025
  }
4896
5026
 
4897
- console.error(err);
5027
+ // Log the error internally but never expose stack traces, file paths,
5028
+ // or internal IPs to the client.
5029
+ if (err?.type === 'entity.too.large') {
5030
+ return res.status(413).json({
5031
+ success: false,
5032
+ error: {
5033
+ code: 'PAYLOAD_TOO_LARGE',
5034
+ message: 'Request payload exceeds size limit.',
5035
+ },
5036
+ });
5037
+ }
5038
+
5039
+ if (err?.type === 'entity.parse.failed' || err?.type === 'entity.parse.failed.utf8') {
5040
+ return res.status(400).json({
5041
+ success: false,
5042
+ error: {
5043
+ code: 'INVALID_JSON',
5044
+ message: 'Request body contains invalid JSON.',
5045
+ },
5046
+ });
5047
+ }
5048
+
5049
+ console.error('[UNHANDLED ERROR]', err?.message || err);
5050
+ securityLog('unhandled_error', {
5051
+ ip: getClientIp(req),
5052
+ endpoint: req.path,
5053
+ method: req.method,
5054
+ statusCode: 500,
5055
+ userId: req.user?.id,
5056
+ reason: err?.name || 'UnknownError',
5057
+ });
4898
5058
 
4899
5059
  return res.status(500).json({
4900
5060
  success: false,
@@ -5228,6 +5388,26 @@ async function maybeAutoDaemonBootstrapFromIndex() {
5228
5388
  }
5229
5389
  }
5230
5390
 
5391
+ // Process-level error handlers to prevent silent crashes and log security events.
5392
+ // These catch errors that escape Express's own error middleware (e.g. from
5393
+ // timers, WebSocket handlers, or un-awaited promises in route handlers).
5394
+ process.on('unhandledRejection', (reason, promise) => {
5395
+ console.error('[FATAL] Unhandled promise rejection:', reason);
5396
+ securityLog('unhandled_rejection', {
5397
+ reason: reason instanceof Error ? reason.name : String(reason).slice(0, 200),
5398
+ });
5399
+ });
5400
+
5401
+ process.on('uncaughtException', (error) => {
5402
+ console.error('[FATAL] Uncaught exception:', error?.message || error);
5403
+ securityLog('uncaught_exception', {
5404
+ reason: error?.name || 'UnknownError',
5405
+ });
5406
+ // Give the security log time to flush, then exit. A clean exit lets the
5407
+ // daemon manager (systemd/pm2) restart the server automatically.
5408
+ setTimeout(() => process.exit(1), 100);
5409
+ });
5410
+
5231
5411
  // Initialize database and start server
5232
5412
  async function startServer() {
5233
5413
  try {
@@ -5311,11 +5491,17 @@ async function startServer() {
5311
5491
  if (process.env.PIXCODE_NO_BROWSER !== '1') {
5312
5492
  const openUrl = `http://${DISPLAY_HOST}:${SERVER_PORT}`;
5313
5493
  try {
5314
- const { exec } = await import('node:child_process');
5315
- const opener = process.platform === 'darwin' ? `open "${openUrl}"`
5316
- : process.platform === 'win32' ? `start "" "${openUrl}"`
5317
- : `xdg-open "${openUrl}"`;
5318
- exec(opener, { stdio: 'ignore', timeout: 3000 });
5494
+ // Use spawn with an argument array instead of exec with a
5495
+ // shell string to prevent command injection through the
5496
+ // host/port values (which come from env vars).
5497
+ const { spawn: spawnBrowser } = await import('node:child_process');
5498
+ const browserBin = process.platform === 'darwin' ? 'open'
5499
+ : process.platform === 'win32' ? 'cmd'
5500
+ : 'xdg-open';
5501
+ const browserArgs = process.platform === 'win32'
5502
+ ? ['/c', 'start', '', openUrl]
5503
+ : [openUrl];
5504
+ spawnBrowser(browserBin, browserArgs, { stdio: 'ignore', timeout: 3000, detached: true, shell: false }).unref();
5319
5505
  console.log(`${c.ok('[OK]')} Opening browser at ${c.bright(openUrl)}`);
5320
5506
  } catch {
5321
5507
  console.log(`${c.tip('[TIP]')} Open ${c.bright(openUrl)} in your browser to start using Pixcode.`);
@@ -0,0 +1,112 @@
1
+ import { securityLog } from '../utils/security-log.js';
2
+
3
+ const MAX_FAILED_ATTEMPTS = 5;
4
+ const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
5
+ const failedAttempts = new Map();
6
+
7
+ function cleanupExpired() {
8
+ const now = Date.now();
9
+ for (const [key, entry] of failedAttempts.entries()) {
10
+ if (entry.lockedUntil && entry.lockedUntil <= now) {
11
+ failedAttempts.delete(key);
12
+ } else if (!entry.lockedUntil && entry.lastAttempt && now - entry.lastAttempt > LOCKOUT_DURATION_MS) {
13
+ failedAttempts.delete(key);
14
+ }
15
+ }
16
+ }
17
+
18
+ const cleanupInterval = setInterval(cleanupExpired, 5 * 60 * 1000);
19
+ cleanupInterval.unref();
20
+
21
+ export function checkAccountLockout(username, ip) {
22
+ const key = `${username}:${ip}`;
23
+ const entry = failedAttempts.get(key);
24
+ if (!entry) return { locked: false };
25
+
26
+ if (entry.lockedUntil && entry.lockedUntil > Date.now()) {
27
+ const remainingMs = entry.lockedUntil - Date.now();
28
+ const remainingMin = Math.ceil(remainingMs / 60000);
29
+ return {
30
+ locked: true,
31
+ remainingMs,
32
+ message: `Account locked due to too many failed attempts. Try again in ${remainingMin} minute(s).`,
33
+ };
34
+ }
35
+
36
+ if (entry.lockedUntil && entry.lockedUntil <= Date.now()) {
37
+ failedAttempts.delete(key);
38
+ return { locked: false };
39
+ }
40
+
41
+ return { locked: false };
42
+ }
43
+
44
+ export function recordFailedLogin(username, ip) {
45
+ const key = `${username}:${ip}`;
46
+ let entry = failedAttempts.get(key);
47
+ if (!entry) {
48
+ entry = { count: 0, lockedUntil: null, lastAttempt: Date.now() };
49
+ failedAttempts.set(key, entry);
50
+ }
51
+
52
+ entry.count += 1;
53
+ entry.lastAttempt = Date.now();
54
+
55
+ if (entry.count >= MAX_FAILED_ATTEMPTS) {
56
+ entry.lockedUntil = Date.now() + LOCKOUT_DURATION_MS;
57
+ securityLog('account_locked', {
58
+ username,
59
+ ip,
60
+ reason: `Exceeded ${MAX_FAILED_ATTEMPTS} failed login attempts`,
61
+ });
62
+ return {
63
+ locked: true,
64
+ message: `Account locked due to too many failed attempts. Try again in 15 minutes.`,
65
+ };
66
+ }
67
+
68
+ return {
69
+ locked: false,
70
+ remaining: MAX_FAILED_ATTEMPTS - entry.count,
71
+ };
72
+ }
73
+
74
+ export function recordSuccessfulLogin(username, ip) {
75
+ const key = `${username}:${ip}`;
76
+ failedAttempts.delete(key);
77
+ }
78
+
79
+ export function validatePasswordPolicy(password) {
80
+ if (typeof password !== 'string' || password.length < 8) {
81
+ return { valid: false, error: 'Password must be at least 8 characters long' };
82
+ }
83
+ if (password.length > 128) {
84
+ return { valid: false, error: 'Password must not exceed 128 characters' };
85
+ }
86
+ if (!/[a-z]/.test(password)) {
87
+ return { valid: false, error: 'Password must contain at least one lowercase letter' };
88
+ }
89
+ if (!/[A-Z]/.test(password)) {
90
+ return { valid: false, error: 'Password must contain at least one uppercase letter' };
91
+ }
92
+ if (!/[0-9]/.test(password)) {
93
+ return { valid: false, error: 'Password must contain at least one number' };
94
+ }
95
+ if (!/[^a-zA-Z0-9]/.test(password)) {
96
+ return { valid: false, error: 'Password must contain at least one special character' };
97
+ }
98
+ return { valid: true };
99
+ }
100
+
101
+ export function validateUsername(username) {
102
+ if (typeof username !== 'string' || username.length < 3) {
103
+ return { valid: false, error: 'Username must be at least 3 characters' };
104
+ }
105
+ if (username.length > 64) {
106
+ return { valid: false, error: 'Username must not exceed 64 characters' };
107
+ }
108
+ if (!/^[a-zA-Z0-9_.-]+$/.test(username)) {
109
+ return { valid: false, error: 'Username may only contain letters, numbers, dots, hyphens, and underscores' };
110
+ }
111
+ return { valid: true };
112
+ }