@pixelbyte-software/pixcode 1.54.0 → 1.54.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/dist/assets/{index-CEw93JQ7.js → index-CLO8YURv.js} +205 -205
- package/dist/assets/index-DvudRU5X.css +32 -0
- package/dist/index.html +2 -2
- package/dist-server/server/claude-sdk.js +6 -0
- package/dist-server/server/claude-sdk.js.map +1 -1
- package/dist-server/server/index.js +240 -46
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/middleware/account-lockout.js +101 -0
- package/dist-server/server/middleware/account-lockout.js.map +1 -0
- package/dist-server/server/middleware/auth.js +71 -9
- package/dist-server/server/middleware/auth.js.map +1 -1
- package/dist-server/server/middleware/rate-limiter.js +62 -0
- package/dist-server/server/middleware/rate-limiter.js.map +1 -0
- package/dist-server/server/routes/agent.js +3 -4
- package/dist-server/server/routes/agent.js.map +1 -1
- package/dist-server/server/routes/auth.js +75 -13
- package/dist-server/server/routes/auth.js.map +1 -1
- package/dist-server/server/routes/codex.js +1 -1
- package/dist-server/server/routes/codex.js.map +1 -1
- package/dist-server/server/routes/diagnostics.js +6 -3
- package/dist-server/server/routes/diagnostics.js.map +1 -1
- package/dist-server/server/routes/gemini.js +1 -1
- package/dist-server/server/routes/gemini.js.map +1 -1
- package/dist-server/server/routes/git.js +9 -9
- package/dist-server/server/routes/git.js.map +1 -1
- package/dist-server/server/routes/live-view.js +2 -2
- package/dist-server/server/routes/live-view.js.map +1 -1
- package/dist-server/server/routes/plugins.js +12 -12
- package/dist-server/server/routes/plugins.js.map +1 -1
- package/dist-server/server/routes/projects.js +2 -2
- package/dist-server/server/routes/projects.js.map +1 -1
- package/dist-server/server/routes/qwen.js +1 -1
- package/dist-server/server/routes/qwen.js.map +1 -1
- package/dist-server/server/routes/remote.js +1 -1
- package/dist-server/server/routes/remote.js.map +1 -1
- package/dist-server/server/routes/webhooks.js +1 -1
- package/dist-server/server/routes/webhooks.js.map +1 -1
- package/dist-server/server/services/startup-update.js +31 -6
- package/dist-server/server/services/startup-update.js.map +1 -1
- package/dist-server/server/services/telegram/bot.js +6 -4
- package/dist-server/server/services/telegram/bot.js.map +1 -1
- package/dist-server/server/setup-wizard.js +12 -6
- package/dist-server/server/setup-wizard.js.map +1 -1
- package/dist-server/server/utils/plugin-loader.js +3 -0
- package/dist-server/server/utils/plugin-loader.js.map +1 -1
- package/dist-server/server/utils/port-access.js +25 -8
- package/dist-server/server/utils/port-access.js.map +1 -1
- package/dist-server/server/utils/security-log.js +89 -0
- package/dist-server/server/utils/security-log.js.map +1 -0
- package/package.json +1 -1
- package/server/claude-sdk.js +7 -0
- package/server/index.js +249 -46
- package/server/middleware/account-lockout.js +112 -0
- package/server/middleware/auth.js +72 -9
- package/server/middleware/rate-limiter.js +82 -0
- package/server/routes/agent.js +3 -4
- package/server/routes/auth.js +86 -13
- package/server/routes/codex.js +1 -1
- package/server/routes/diagnostics.js +6 -3
- package/server/routes/gemini.js +1 -1
- package/server/routes/git.js +9 -9
- package/server/routes/live-view.js +2 -2
- package/server/routes/plugins.js +12 -12
- package/server/routes/projects.js +2 -2
- package/server/routes/qwen.js +1 -1
- package/server/routes/remote.js +1 -1
- package/server/routes/webhooks.js +1 -1
- package/server/services/startup-update.js +31 -6
- package/server/services/telegram/bot.js +6 -4
- package/server/setup-wizard.js +12 -6
- package/server/utils/plugin-loader.js +4 -0
- package/server/utils/port-access.js +26 -8
- package/server/utils/security-log.js +84 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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,69 @@ app.locals.installMode = installMode;
|
|
|
1687
1730
|
app.locals.serverVersion = SERVER_VERSION;
|
|
1688
1731
|
setNotificationWebSocketServer(wss);
|
|
1689
1732
|
|
|
1690
|
-
|
|
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
|
+
// Restrict CORS to known origins instead of reflecting any requester.
|
|
1742
|
+
// In production the frontend is same-origin; in dev it's the Vite server.
|
|
1743
|
+
const ALLOWED_CORS_ORIGINS = (() => {
|
|
1744
|
+
const envOrigins = process.env.CORS_ALLOWED_ORIGINS;
|
|
1745
|
+
if (envOrigins) {
|
|
1746
|
+
return envOrigins.split(',').map((o) => o.trim()).filter(Boolean);
|
|
1747
|
+
}
|
|
1748
|
+
const devPort = process.env.VITE_PORT || 5173;
|
|
1749
|
+
return [
|
|
1750
|
+
`http://localhost:${devPort}`,
|
|
1751
|
+
`http://127.0.0.1:${devPort}`,
|
|
1752
|
+
];
|
|
1753
|
+
})();
|
|
1754
|
+
|
|
1755
|
+
app.use(cors({
|
|
1756
|
+
origin(origin, callback) {
|
|
1757
|
+
// Allow same-origin requests (no Origin header) and allowlisted origins.
|
|
1758
|
+
if (!origin || ALLOWED_CORS_ORIGINS.includes(origin)) {
|
|
1759
|
+
return callback(null, true);
|
|
1760
|
+
}
|
|
1761
|
+
return callback(null, false);
|
|
1762
|
+
},
|
|
1763
|
+
credentials: true,
|
|
1764
|
+
exposedHeaders: ['X-Refreshed-Token'],
|
|
1765
|
+
}));
|
|
1766
|
+
|
|
1767
|
+
// Security headers middleware (replaces helmet which isn't installed).
|
|
1768
|
+
app.use((req, res, next) => {
|
|
1769
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1770
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
1771
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
1772
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
1773
|
+
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
|
1774
|
+
// Strict-Transport-Security only makes sense over HTTPS; skip for plain HTTP
|
|
1775
|
+
// so local dev doesn't pin an HSTS policy on localhost.
|
|
1776
|
+
if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
|
|
1777
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
1778
|
+
}
|
|
1779
|
+
// CSP for the SPA shell — API responses are JSON so this mainly guards
|
|
1780
|
+
// the served index.html. 'unsafe-inline' is needed for Vite's inline
|
|
1781
|
+
// module preload polyfill; style-src unsafe-inline for Tailwind injected styles.
|
|
1782
|
+
res.setHeader('Content-Security-Policy', [
|
|
1783
|
+
"default-src 'self'",
|
|
1784
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
1785
|
+
"style-src 'self' 'unsafe-inline'",
|
|
1786
|
+
"img-src 'self' data: blob: https:",
|
|
1787
|
+
"font-src 'self' data:",
|
|
1788
|
+
"connect-src 'self' ws: wss:",
|
|
1789
|
+
"frame-ancestors 'self'",
|
|
1790
|
+
"base-uri 'self'",
|
|
1791
|
+
"form-action 'self'",
|
|
1792
|
+
].join('; '));
|
|
1793
|
+
next();
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1691
1796
|
app.use(express.json({
|
|
1692
1797
|
limit: '50mb',
|
|
1693
1798
|
type: (req) => {
|
|
@@ -1714,8 +1819,9 @@ app.get('/health', (req, res) => {
|
|
|
1714
1819
|
});
|
|
1715
1820
|
});
|
|
1716
1821
|
|
|
1717
|
-
// Optional API key validation (if configured)
|
|
1822
|
+
// Optional API key validation (if configured) + rate limiting for all API routes
|
|
1718
1823
|
app.use('/api', validateApiKey);
|
|
1824
|
+
app.use('/api', apiRateLimiter);
|
|
1719
1825
|
|
|
1720
1826
|
app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
|
|
1721
1827
|
const provider = req.body?.provider || 'claude';
|
|
@@ -2152,6 +2258,12 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
|
|
|
2152
2258
|
});
|
|
2153
2259
|
|
|
2154
2260
|
app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
|
|
2261
|
+
securityLog('system_update_initiated', {
|
|
2262
|
+
ip: getClientIp(req),
|
|
2263
|
+
userId: req.user?.id,
|
|
2264
|
+
username: req.user?.username,
|
|
2265
|
+
endpoint: req.path,
|
|
2266
|
+
});
|
|
2155
2267
|
const job = createSystemUpdateJob(req.user, {
|
|
2156
2268
|
targetVersion: req.body?.targetVersion || req.body?.latestVersion,
|
|
2157
2269
|
});
|
|
@@ -2268,6 +2380,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2268
2380
|
const latestVersion = metadata['dist-tags']?.latest;
|
|
2269
2381
|
const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
|
|
2270
2382
|
const tarballUrl = latestEntry?.dist?.tarball;
|
|
2383
|
+
const tarballIntegrity = latestEntry?.dist?.integrity;
|
|
2271
2384
|
if (!latestVersion || !tarballUrl) throw new Error('Registry response missing latest/tarball');
|
|
2272
2385
|
|
|
2273
2386
|
send('log', { stream: 'meta', chunk: `Current: ${SERVER_VERSION} → Latest: ${latestVersion}\n` });
|
|
@@ -2283,16 +2396,30 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2283
2396
|
return;
|
|
2284
2397
|
}
|
|
2285
2398
|
|
|
2286
|
-
// 2. Download the tarball
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
// intact if the download fails partway through.
|
|
2399
|
+
// 2. Download the tarball to a buffer so we can verify integrity
|
|
2400
|
+
// before extracting. Doing the extract under `.staging` first
|
|
2401
|
+
// means the live runtime stays intact if the download fails.
|
|
2290
2402
|
send('log', { stream: 'meta', chunk: `Downloading ${tarballUrl}\n` });
|
|
2291
2403
|
const tarballRes = await fetch(tarballUrl);
|
|
2292
2404
|
if (!tarballRes.ok || !tarballRes.body) {
|
|
2293
2405
|
throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
|
|
2294
2406
|
}
|
|
2295
2407
|
|
|
2408
|
+
const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
|
|
2409
|
+
|
|
2410
|
+
// Verify integrity hash against registry-provided SRI string.
|
|
2411
|
+
if (tarballIntegrity) {
|
|
2412
|
+
try {
|
|
2413
|
+
verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
|
|
2414
|
+
send('log', { stream: 'meta', chunk: 'Tarball integrity verified.\n' });
|
|
2415
|
+
} catch (verifyError) {
|
|
2416
|
+
send('log', { stream: 'stderr', chunk: `Integrity verification failed: ${verifyError.message}\n` });
|
|
2417
|
+
throw verifyError;
|
|
2418
|
+
}
|
|
2419
|
+
} else {
|
|
2420
|
+
send('log', { stream: 'meta', chunk: 'WARNING: No integrity hash from registry — extracting without verification.\n' });
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2296
2423
|
const stagingDir = path.join(runtimeDir, '.staging');
|
|
2297
2424
|
const backupDir = path.join(runtimeDir, '.previous');
|
|
2298
2425
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
@@ -2307,10 +2434,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2307
2434
|
// contents to the staging dir directly so paths match the
|
|
2308
2435
|
// existing runtime layout.
|
|
2309
2436
|
await new Promise((resolve, reject) => {
|
|
2310
|
-
const
|
|
2311
|
-
const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
|
|
2312
|
-
? Readable.fromWeb(webStream)
|
|
2313
|
-
: webStream;
|
|
2437
|
+
const nodeStream = Readable.from(tarballBuffer);
|
|
2314
2438
|
const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
|
|
2315
2439
|
nodeStream.pipe(extractor);
|
|
2316
2440
|
extractor.on('finish', resolve);
|
|
@@ -2541,6 +2665,11 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2541
2665
|
// (systemd/pm2/daemon manager) can bring the server back on the new code.
|
|
2542
2666
|
// Foreground installs without a wrapper will simply stop; the UI reports this.
|
|
2543
2667
|
app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
|
|
2668
|
+
securityLog('system_restart_requested', {
|
|
2669
|
+
ip: getClientIp(req),
|
|
2670
|
+
userId: req.user?.id,
|
|
2671
|
+
username: req.user?.username,
|
|
2672
|
+
});
|
|
2544
2673
|
const forceRestart = req.body?.force === true || req.query.force === 'true';
|
|
2545
2674
|
const activeWork = getActiveWorkSummary();
|
|
2546
2675
|
if (!forceRestart && activeWork.hasActiveWork) {
|
|
@@ -2569,7 +2698,7 @@ app.get('/api/projects', authenticateToken, async (req, res) => {
|
|
|
2569
2698
|
const projects = await getProjects(broadcastProgress);
|
|
2570
2699
|
res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
|
|
2571
2700
|
} catch (error) {
|
|
2572
|
-
res.status(500).json({ error: error
|
|
2701
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2573
2702
|
}
|
|
2574
2703
|
});
|
|
2575
2704
|
|
|
@@ -2583,7 +2712,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
|
|
|
2583
2712
|
applyCustomSessionNames(result.sessions, 'claude');
|
|
2584
2713
|
res.json(result);
|
|
2585
2714
|
} catch (error) {
|
|
2586
|
-
res.status(500).json({ error: error
|
|
2715
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2587
2716
|
}
|
|
2588
2717
|
});
|
|
2589
2718
|
|
|
@@ -2594,7 +2723,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAc
|
|
|
2594
2723
|
await renameProject(req.params.projectName, displayName);
|
|
2595
2724
|
res.json({ success: true });
|
|
2596
2725
|
} catch (error) {
|
|
2597
|
-
res.status(500).json({ error: error
|
|
2726
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2598
2727
|
}
|
|
2599
2728
|
});
|
|
2600
2729
|
|
|
@@ -2609,7 +2738,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
|
|
2609
2738
|
res.json({ success: true });
|
|
2610
2739
|
} catch (error) {
|
|
2611
2740
|
console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
|
|
2612
|
-
res.status(500).json({ error: error
|
|
2741
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2613
2742
|
}
|
|
2614
2743
|
});
|
|
2615
2744
|
|
|
@@ -2635,7 +2764,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
|
|
|
2635
2764
|
res.json({ success: true });
|
|
2636
2765
|
} catch (error) {
|
|
2637
2766
|
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
|
|
2638
|
-
res.status(500).json({ error: error
|
|
2767
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2639
2768
|
}
|
|
2640
2769
|
});
|
|
2641
2770
|
|
|
@@ -2655,7 +2784,7 @@ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess
|
|
|
2655
2784
|
// catch it and prompt the user to pass `?force=true` (or clean
|
|
2656
2785
|
// sessions first) instead of treating it like a crash.
|
|
2657
2786
|
const conflict = typeof error?.message === 'string' && error.message.includes('existing sessions');
|
|
2658
|
-
res.status(conflict ? 409 : 500).json({ error: error.message });
|
|
2787
|
+
res.status(conflict ? 409 : 500).json({ error: conflict ? error.message : 'Internal server error' });
|
|
2659
2788
|
}
|
|
2660
2789
|
});
|
|
2661
2790
|
|
|
@@ -2892,7 +3021,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
2892
3021
|
} else if (error.code === 'EACCES') {
|
|
2893
3022
|
res.status(403).json({ error: 'Permission denied' });
|
|
2894
3023
|
} else {
|
|
2895
|
-
res.status(500).json({ error: error
|
|
3024
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2896
3025
|
}
|
|
2897
3026
|
}
|
|
2898
3027
|
});
|
|
@@ -2952,7 +3081,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
|
|
|
2952
3081
|
} catch (error) {
|
|
2953
3082
|
console.error('Error serving binary file:', error);
|
|
2954
3083
|
if (!res.headersSent) {
|
|
2955
|
-
res.status(500).json({ error: error
|
|
3084
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2956
3085
|
}
|
|
2957
3086
|
}
|
|
2958
3087
|
});
|
|
@@ -3005,7 +3134,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
3005
3134
|
} else if (error.code === 'EACCES') {
|
|
3006
3135
|
res.status(403).json({ error: 'Permission denied' });
|
|
3007
3136
|
} else {
|
|
3008
|
-
res.status(500).json({ error: error
|
|
3137
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3009
3138
|
}
|
|
3010
3139
|
}
|
|
3011
3140
|
});
|
|
@@ -3045,7 +3174,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
|
|
|
3045
3174
|
}, 'viewFiles'));
|
|
3046
3175
|
} catch (error) {
|
|
3047
3176
|
console.error('[ERROR] File tree error:', error.message);
|
|
3048
|
-
res.status(500).json({ error: error
|
|
3177
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3049
3178
|
}
|
|
3050
3179
|
});
|
|
3051
3180
|
|
|
@@ -3171,7 +3300,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
|
|
|
3171
3300
|
} else if (error.code === 'ENOENT') {
|
|
3172
3301
|
res.status(404).json({ error: 'Parent directory not found' });
|
|
3173
3302
|
} else {
|
|
3174
|
-
res.status(500).json({ error: error
|
|
3303
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3175
3304
|
}
|
|
3176
3305
|
}
|
|
3177
3306
|
});
|
|
@@ -3254,7 +3383,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
|
|
|
3254
3383
|
} else if (error.code === 'EXDEV') {
|
|
3255
3384
|
res.status(400).json({ error: 'Cannot move across different filesystems' });
|
|
3256
3385
|
} else {
|
|
3257
|
-
res.status(500).json({ error: error
|
|
3386
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3258
3387
|
}
|
|
3259
3388
|
}
|
|
3260
3389
|
});
|
|
@@ -3322,7 +3451,7 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
|
|
|
3322
3451
|
} else if (error.code === 'ENOTEMPTY') {
|
|
3323
3452
|
res.status(400).json({ error: 'Directory is not empty' });
|
|
3324
3453
|
} else {
|
|
3325
|
-
res.status(500).json({ error: error
|
|
3454
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3326
3455
|
}
|
|
3327
3456
|
}
|
|
3328
3457
|
});
|
|
@@ -3341,7 +3470,7 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
3341
3470
|
filename: (req, file, cb) => {
|
|
3342
3471
|
// Use a unique temp name, but preserve original name in file.originalname
|
|
3343
3472
|
// Note: file.originalname may contain path separators for folder uploads
|
|
3344
|
-
const uniqueSuffix = Date.now() + '-' +
|
|
3473
|
+
const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
|
|
3345
3474
|
// For temp file, just use a safe unique name without the path
|
|
3346
3475
|
cb(null, `upload-${uniqueSuffix}`);
|
|
3347
3476
|
}
|
|
@@ -3490,7 +3619,7 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
3490
3619
|
if (error.code === 'EACCES') {
|
|
3491
3620
|
res.status(403).json({ error: 'Permission denied' });
|
|
3492
3621
|
} else {
|
|
3493
|
-
res.status(500).json({ error: error
|
|
3622
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3494
3623
|
}
|
|
3495
3624
|
}
|
|
3496
3625
|
});
|
|
@@ -3831,10 +3960,15 @@ function handleChatConnection(ws, request) {
|
|
|
3831
3960
|
});
|
|
3832
3961
|
}
|
|
3833
3962
|
} catch (error) {
|
|
3834
|
-
console.error('[ERROR] Chat WebSocket error:', error
|
|
3963
|
+
console.error('[ERROR] Chat WebSocket error:', error?.message || error);
|
|
3964
|
+
securityLog('ws_chat_error', {
|
|
3965
|
+
userId: request?.user?.id,
|
|
3966
|
+
username: request?.user?.username,
|
|
3967
|
+
reason: error?.name || 'UnknownError',
|
|
3968
|
+
});
|
|
3835
3969
|
writer.send({
|
|
3836
3970
|
type: 'error',
|
|
3837
|
-
error: error.
|
|
3971
|
+
error: 'An error occurred while processing your request.'
|
|
3838
3972
|
});
|
|
3839
3973
|
}
|
|
3840
3974
|
});
|
|
@@ -4313,11 +4447,16 @@ function handleShellConnection(ws, request) {
|
|
|
4313
4447
|
const termCols = data.cols || 80;
|
|
4314
4448
|
const termRows = data.rows || 24;
|
|
4315
4449
|
console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
|
|
4450
|
+
const isRunningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
4316
4451
|
const shellEnv = {
|
|
4317
4452
|
...process.env,
|
|
4318
4453
|
TERM: 'xterm-256color',
|
|
4319
4454
|
COLORTERM: 'truecolor',
|
|
4320
4455
|
FORCE_COLOR: '3',
|
|
4456
|
+
// When running as root, Claude CLI refuses --dangerously-skip-permissions
|
|
4457
|
+
// for security reasons. Setting IS_SANDBOX=1 tells it the environment is
|
|
4458
|
+
// already sandboxed, so the flag is safe to use.
|
|
4459
|
+
...(isRunningAsRoot ? { IS_SANDBOX: '1' } : {}),
|
|
4321
4460
|
};
|
|
4322
4461
|
|
|
4323
4462
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
@@ -4435,7 +4574,7 @@ function handleShellConnection(ws, request) {
|
|
|
4435
4574
|
console.error('[ERROR] Error spawning process:', spawnError);
|
|
4436
4575
|
ws.send(JSON.stringify({
|
|
4437
4576
|
type: 'output',
|
|
4438
|
-
data: `\r\n\x1b[31mError:
|
|
4577
|
+
data: `\r\n\x1b[31mError: Failed to start terminal process.\x1b[0m\r\n`
|
|
4439
4578
|
}));
|
|
4440
4579
|
}
|
|
4441
4580
|
|
|
@@ -4450,11 +4589,11 @@ function handleShellConnection(ws, request) {
|
|
|
4450
4589
|
}
|
|
4451
4590
|
}
|
|
4452
4591
|
} catch (error) {
|
|
4453
|
-
console.error('[ERROR] Shell WebSocket error:', error
|
|
4592
|
+
console.error('[ERROR] Shell WebSocket error:', error?.message || error);
|
|
4454
4593
|
if (ws.readyState === WebSocket.OPEN) {
|
|
4455
4594
|
ws.send(JSON.stringify({
|
|
4456
4595
|
type: 'output',
|
|
4457
|
-
data: `\r\n\x1b[31mError:
|
|
4596
|
+
data: `\r\n\x1b[31mError: An internal error occurred.\x1b[0m\r\n`
|
|
4458
4597
|
}));
|
|
4459
4598
|
}
|
|
4460
4599
|
}
|
|
@@ -4517,7 +4656,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireP
|
|
|
4517
4656
|
cb(null, uploadDir);
|
|
4518
4657
|
},
|
|
4519
4658
|
filename: (req, file, cb) => {
|
|
4520
|
-
const uniqueSuffix = Date.now() + '-' +
|
|
4659
|
+
const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
|
|
4521
4660
|
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
4522
4661
|
cb(null, uniqueSuffix + '-' + sanitizedName);
|
|
4523
4662
|
}
|
|
@@ -4882,8 +5021,16 @@ app.get(/.*/, (req, res) => {
|
|
|
4882
5021
|
});
|
|
4883
5022
|
|
|
4884
5023
|
// global error middleware must be last
|
|
4885
|
-
app.use((err, req, res,
|
|
5024
|
+
app.use((err, req, res, _next) => {
|
|
4886
5025
|
if (err instanceof AppError) {
|
|
5026
|
+
securityLog('app_error', {
|
|
5027
|
+
ip: getClientIp(req),
|
|
5028
|
+
endpoint: req.path,
|
|
5029
|
+
method: req.method,
|
|
5030
|
+
statusCode: err.statusCode,
|
|
5031
|
+
userId: req.user?.id,
|
|
5032
|
+
reason: err.code,
|
|
5033
|
+
});
|
|
4887
5034
|
return res.status(err.statusCode).json({
|
|
4888
5035
|
success: false,
|
|
4889
5036
|
error: {
|
|
@@ -4894,7 +5041,37 @@ app.use((err, req, res, next) => {
|
|
|
4894
5041
|
});
|
|
4895
5042
|
}
|
|
4896
5043
|
|
|
4897
|
-
|
|
5044
|
+
// Log the error internally but never expose stack traces, file paths,
|
|
5045
|
+
// or internal IPs to the client.
|
|
5046
|
+
if (err?.type === 'entity.too.large') {
|
|
5047
|
+
return res.status(413).json({
|
|
5048
|
+
success: false,
|
|
5049
|
+
error: {
|
|
5050
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
5051
|
+
message: 'Request payload exceeds size limit.',
|
|
5052
|
+
},
|
|
5053
|
+
});
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
if (err?.type === 'entity.parse.failed' || err?.type === 'entity.parse.failed.utf8') {
|
|
5057
|
+
return res.status(400).json({
|
|
5058
|
+
success: false,
|
|
5059
|
+
error: {
|
|
5060
|
+
code: 'INVALID_JSON',
|
|
5061
|
+
message: 'Request body contains invalid JSON.',
|
|
5062
|
+
},
|
|
5063
|
+
});
|
|
5064
|
+
}
|
|
5065
|
+
|
|
5066
|
+
console.error('[UNHANDLED ERROR]', err?.message || err);
|
|
5067
|
+
securityLog('unhandled_error', {
|
|
5068
|
+
ip: getClientIp(req),
|
|
5069
|
+
endpoint: req.path,
|
|
5070
|
+
method: req.method,
|
|
5071
|
+
statusCode: 500,
|
|
5072
|
+
userId: req.user?.id,
|
|
5073
|
+
reason: err?.name || 'UnknownError',
|
|
5074
|
+
});
|
|
4898
5075
|
|
|
4899
5076
|
return res.status(500).json({
|
|
4900
5077
|
success: false,
|
|
@@ -5228,6 +5405,26 @@ async function maybeAutoDaemonBootstrapFromIndex() {
|
|
|
5228
5405
|
}
|
|
5229
5406
|
}
|
|
5230
5407
|
|
|
5408
|
+
// Process-level error handlers to prevent silent crashes and log security events.
|
|
5409
|
+
// These catch errors that escape Express's own error middleware (e.g. from
|
|
5410
|
+
// timers, WebSocket handlers, or un-awaited promises in route handlers).
|
|
5411
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
5412
|
+
console.error('[FATAL] Unhandled promise rejection:', reason);
|
|
5413
|
+
securityLog('unhandled_rejection', {
|
|
5414
|
+
reason: reason instanceof Error ? reason.name : String(reason).slice(0, 200),
|
|
5415
|
+
});
|
|
5416
|
+
});
|
|
5417
|
+
|
|
5418
|
+
process.on('uncaughtException', (error) => {
|
|
5419
|
+
console.error('[FATAL] Uncaught exception:', error?.message || error);
|
|
5420
|
+
securityLog('uncaught_exception', {
|
|
5421
|
+
reason: error?.name || 'UnknownError',
|
|
5422
|
+
});
|
|
5423
|
+
// Give the security log time to flush, then exit. A clean exit lets the
|
|
5424
|
+
// daemon manager (systemd/pm2) restart the server automatically.
|
|
5425
|
+
setTimeout(() => process.exit(1), 100);
|
|
5426
|
+
});
|
|
5427
|
+
|
|
5231
5428
|
// Initialize database and start server
|
|
5232
5429
|
async function startServer() {
|
|
5233
5430
|
try {
|
|
@@ -5311,11 +5508,17 @@ async function startServer() {
|
|
|
5311
5508
|
if (process.env.PIXCODE_NO_BROWSER !== '1') {
|
|
5312
5509
|
const openUrl = `http://${DISPLAY_HOST}:${SERVER_PORT}`;
|
|
5313
5510
|
try {
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5511
|
+
// Use spawn with an argument array instead of exec with a
|
|
5512
|
+
// shell string to prevent command injection through the
|
|
5513
|
+
// host/port values (which come from env vars).
|
|
5514
|
+
const { spawn: spawnBrowser } = await import('node:child_process');
|
|
5515
|
+
const browserBin = process.platform === 'darwin' ? 'open'
|
|
5516
|
+
: process.platform === 'win32' ? 'cmd'
|
|
5517
|
+
: 'xdg-open';
|
|
5518
|
+
const browserArgs = process.platform === 'win32'
|
|
5519
|
+
? ['/c', 'start', '', openUrl]
|
|
5520
|
+
: [openUrl];
|
|
5521
|
+
spawnBrowser(browserBin, browserArgs, { stdio: 'ignore', timeout: 3000, detached: true, shell: false }).unref();
|
|
5319
5522
|
console.log(`${c.ok('[OK]')} Opening browser at ${c.bright(openUrl)}`);
|
|
5320
5523
|
} catch {
|
|
5321
5524
|
console.log(`${c.tip('[TIP]')} Open ${c.bright(openUrl)} in your browser to start using Pixcode.`);
|