@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.
- package/dist/assets/index-DvudRU5X.css +32 -0
- package/dist/assets/{index-CEw93JQ7.js → index-lqiCTrCl.js} +205 -205
- 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 +224 -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 +232 -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
|
@@ -16,6 +16,7 @@ import { WebSocketServer, WebSocket } from 'ws';
|
|
|
16
16
|
import cors from 'cors';
|
|
17
17
|
import { AppError, createNormalizedMessage } from './shared/utils.js';
|
|
18
18
|
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
|
19
|
+
import { securityLog, getClientIp } from './utils/security-log.js';
|
|
19
20
|
const __dirname = getModuleDir(import.meta.url);
|
|
20
21
|
// The server source runs from /server, while the compiled output runs from /dist-server/server.
|
|
21
22
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
|
@@ -85,7 +86,8 @@ function resolveMonacoAssetsPath() {
|
|
|
85
86
|
return candidates.find((candidate) => fs.existsSync(path.join(candidate, 'loader.js'))) || null;
|
|
86
87
|
}
|
|
87
88
|
import { c } from './utils/colors.js';
|
|
88
|
-
|
|
89
|
+
// Server port is logged after binding in startServer() — avoid leaking
|
|
90
|
+
// env configuration to stdout on every import.
|
|
89
91
|
import pty from 'node-pty';
|
|
90
92
|
import mime from 'mime-types';
|
|
91
93
|
import { getProjects, getSessions, renameProject, deleteSession, deleteProject, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
|
|
@@ -131,6 +133,7 @@ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, appConfigD
|
|
|
131
133
|
import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
|
|
132
134
|
import { configureWebPush } from './services/vapid-keys.js';
|
|
133
135
|
import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
|
|
136
|
+
import { apiRateLimiter } from './middleware/rate-limiter.js';
|
|
134
137
|
import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
|
|
135
138
|
import { IS_PLATFORM } from './constants/config.js';
|
|
136
139
|
import { getConnectableHost } from '../shared/networkHosts.js';
|
|
@@ -320,6 +323,7 @@ async function readLatestPixcodePackageMetadata() {
|
|
|
320
323
|
return {
|
|
321
324
|
latestVersion,
|
|
322
325
|
tarballUrl: latestEntry?.dist?.tarball || null,
|
|
326
|
+
tarballIntegrity: latestEntry?.dist?.integrity || null,
|
|
323
327
|
};
|
|
324
328
|
}
|
|
325
329
|
function isSafePackageVersion(version) {
|
|
@@ -330,13 +334,54 @@ function buildPixcodeTarballUrl(version) {
|
|
|
330
334
|
return null;
|
|
331
335
|
return `https://registry.npmjs.org/@pixelbyte-software/pixcode/-/pixcode-${version.trim()}.tgz`;
|
|
332
336
|
}
|
|
333
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Verify a downloaded tarball's integrity against the registry-provided
|
|
339
|
+
* Subresource Integrity (SRI) string. Throws if the hash doesn't match.
|
|
340
|
+
* @param {Buffer} buffer - The downloaded tarball bytes
|
|
341
|
+
* @param {string} integrity - SRI string from npm registry (e.g. "sha512-...")
|
|
342
|
+
*/
|
|
343
|
+
function verifyTarballIntegrity(buffer, integrity) {
|
|
344
|
+
if (!integrity || typeof integrity !== 'string') {
|
|
345
|
+
throw new Error('Tarball integrity hash missing from registry metadata — refusing to install unverified package.');
|
|
346
|
+
}
|
|
347
|
+
const dashIndex = integrity.indexOf('-');
|
|
348
|
+
if (dashIndex < 0) {
|
|
349
|
+
throw new Error('Malformed integrity string from registry.');
|
|
350
|
+
}
|
|
351
|
+
const algo = integrity.slice(0, dashIndex);
|
|
352
|
+
const expectedHash = integrity.slice(dashIndex + 1);
|
|
353
|
+
const validAlgos = ['sha512', 'sha384', 'sha256'];
|
|
354
|
+
if (!validAlgos.includes(algo)) {
|
|
355
|
+
throw new Error(`Unsupported integrity algorithm: ${algo}`);
|
|
356
|
+
}
|
|
357
|
+
const actualHash = crypto.createHash(algo).update(buffer).digest('base64');
|
|
358
|
+
if (actualHash !== expectedHash) {
|
|
359
|
+
throw new Error(`Tarball integrity check failed: expected ${algo}-${expectedHash.slice(0, 16)}…, got ${algo}-${actualHash.slice(0, 16)}…`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl, tarballIntegrity) {
|
|
334
363
|
appendUpdateJobLog(job, 'meta', `Update mode: runtime-dir\nRuntime: ${runtimeDir}\n`);
|
|
335
364
|
appendUpdateJobLog(job, 'meta', `Downloading ${tarballUrl}\n`);
|
|
336
365
|
const tarballRes = await fetch(tarballUrl);
|
|
337
366
|
if (!tarballRes.ok || !tarballRes.body) {
|
|
338
367
|
throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
|
|
339
368
|
}
|
|
369
|
+
// Download tarball to a buffer first so we can verify integrity before extracting.
|
|
370
|
+
const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
|
|
371
|
+
// Verify integrity hash against registry-provided SRI string.
|
|
372
|
+
if (tarballIntegrity) {
|
|
373
|
+
try {
|
|
374
|
+
verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
|
|
375
|
+
appendUpdateJobLog(job, 'meta', 'Tarball integrity verified.\n');
|
|
376
|
+
}
|
|
377
|
+
catch (verifyError) {
|
|
378
|
+
appendUpdateJobLog(job, 'stderr', `Integrity verification failed: ${verifyError.message}\n`);
|
|
379
|
+
throw verifyError;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
appendUpdateJobLog(job, 'meta', 'WARNING: No integrity hash from registry — extracting without verification.\n');
|
|
384
|
+
}
|
|
340
385
|
const stagingDir = path.join(runtimeDir, '.staging');
|
|
341
386
|
const backupDir = path.join(runtimeDir, '.previous');
|
|
342
387
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
@@ -347,10 +392,7 @@ async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl
|
|
|
347
392
|
if (!tarExtract)
|
|
348
393
|
throw new Error('tar extractor not available');
|
|
349
394
|
await new Promise((resolve, reject) => {
|
|
350
|
-
const
|
|
351
|
-
const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
|
|
352
|
-
? Readable.fromWeb(webStream)
|
|
353
|
-
: webStream;
|
|
395
|
+
const nodeStream = Readable.from(tarballBuffer);
|
|
354
396
|
const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
|
|
355
397
|
nodeStream.pipe(extractor);
|
|
356
398
|
extractor.on('finish', resolve);
|
|
@@ -464,7 +506,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
|
|
|
464
506
|
const gitUpdateScript = path.join(APP_ROOT, 'scripts', 'update-git-install.mjs');
|
|
465
507
|
const latest = await readLatestPixcodePackageMetadata().catch((error) => {
|
|
466
508
|
appendUpdateJobLog(job, 'stderr', `Registry precheck failed: ${error.message}\n`);
|
|
467
|
-
return { latestVersion: null, tarballUrl: null };
|
|
509
|
+
return { latestVersion: null, tarballUrl: null, tarballIntegrity: null };
|
|
468
510
|
});
|
|
469
511
|
const requestedVersion = isSafePackageVersion(options.targetVersion) ? options.targetVersion.trim() : null;
|
|
470
512
|
const resolvedVersion = latest.latestVersion || requestedVersion || null;
|
|
@@ -484,7 +526,7 @@ function createSystemUpdateJob(actorUser, options = {}) {
|
|
|
484
526
|
if (!latest.latestVersion) {
|
|
485
527
|
appendUpdateJobLog(job, 'meta', `Using requested target version ${resolvedVersion} after registry precheck failed.\n`);
|
|
486
528
|
}
|
|
487
|
-
job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl);
|
|
529
|
+
job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, resolvedVersion, resolvedTarballUrl, latest.tarballIntegrity);
|
|
488
530
|
}
|
|
489
531
|
else {
|
|
490
532
|
const updateCommand = IS_PLATFORM
|
|
@@ -1506,7 +1548,49 @@ app.locals.wss = wss;
|
|
|
1506
1548
|
app.locals.installMode = installMode;
|
|
1507
1549
|
app.locals.serverVersion = SERVER_VERSION;
|
|
1508
1550
|
setNotificationWebSocketServer(wss);
|
|
1509
|
-
|
|
1551
|
+
// ── Security hardening ──────────────────────────────────────────────
|
|
1552
|
+
// Disable the X-Powered-By header so the framework isn't advertised.
|
|
1553
|
+
app.disable('x-powered-by');
|
|
1554
|
+
// Trust the first proxy hop so X-Forwarded-* headers are respected when
|
|
1555
|
+
// running behind nginx/Caddy/etc. (needed for correct protocol detection
|
|
1556
|
+
// in resolvePublicBaseUrl and for rate-limiting middleware if added later).
|
|
1557
|
+
app.set('trust proxy', 1);
|
|
1558
|
+
// CORS: self-hosted tool accessed from various IPs/hostnames.
|
|
1559
|
+
// Reflect the requesting origin so IP-based access (e.g. http://85.235.74.198:3001)
|
|
1560
|
+
// works without configuration. Credentials needed for auth header passthrough.
|
|
1561
|
+
app.use(cors({
|
|
1562
|
+
origin: true,
|
|
1563
|
+
credentials: true,
|
|
1564
|
+
exposedHeaders: ['X-Refreshed-Token'],
|
|
1565
|
+
}));
|
|
1566
|
+
// Security headers middleware (replaces helmet which isn't installed).
|
|
1567
|
+
app.use((req, res, next) => {
|
|
1568
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1569
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
1570
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
1571
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
1572
|
+
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
|
1573
|
+
// Strict-Transport-Security only makes sense over HTTPS; skip for plain HTTP
|
|
1574
|
+
// so local dev doesn't pin an HSTS policy on localhost.
|
|
1575
|
+
if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
|
|
1576
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
1577
|
+
}
|
|
1578
|
+
// CSP for the SPA shell — relaxed for self-hosted tool accessed from
|
|
1579
|
+
// various IPs/hostnames. 'unsafe-inline' + 'unsafe-eval' needed for Vite.
|
|
1580
|
+
// ws:/wss: in connect-src allows WebSocket from any origin (IP access).
|
|
1581
|
+
res.setHeader('Content-Security-Policy', [
|
|
1582
|
+
"default-src 'self'",
|
|
1583
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
1584
|
+
"style-src 'self' 'unsafe-inline'",
|
|
1585
|
+
"img-src 'self' data: blob: https:",
|
|
1586
|
+
"font-src 'self' data:",
|
|
1587
|
+
"connect-src 'self' ws: wss: http: https:",
|
|
1588
|
+
"frame-ancestors 'self'",
|
|
1589
|
+
"base-uri 'self'",
|
|
1590
|
+
"form-action 'self'",
|
|
1591
|
+
].join('; '));
|
|
1592
|
+
next();
|
|
1593
|
+
});
|
|
1510
1594
|
app.use(express.json({
|
|
1511
1595
|
limit: '50mb',
|
|
1512
1596
|
type: (req) => {
|
|
@@ -1531,8 +1615,9 @@ app.get('/health', (req, res) => {
|
|
|
1531
1615
|
lastAppliedUpdate: updateState.lastAppliedUpdate,
|
|
1532
1616
|
});
|
|
1533
1617
|
});
|
|
1534
|
-
// Optional API key validation (if configured)
|
|
1618
|
+
// Optional API key validation (if configured) + rate limiting for all API routes
|
|
1535
1619
|
app.use('/api', validateApiKey);
|
|
1620
|
+
app.use('/api', apiRateLimiter);
|
|
1536
1621
|
app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
|
|
1537
1622
|
const provider = req.body?.provider || 'claude';
|
|
1538
1623
|
const projectPath = req.body?.projectPath || os.homedir();
|
|
@@ -1914,6 +1999,12 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
|
|
|
1914
1999
|
});
|
|
1915
2000
|
});
|
|
1916
2001
|
app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
|
|
2002
|
+
securityLog('system_update_initiated', {
|
|
2003
|
+
ip: getClientIp(req),
|
|
2004
|
+
userId: req.user?.id,
|
|
2005
|
+
username: req.user?.username,
|
|
2006
|
+
endpoint: req.path,
|
|
2007
|
+
});
|
|
1917
2008
|
const job = createSystemUpdateJob(req.user, {
|
|
1918
2009
|
targetVersion: req.body?.targetVersion || req.body?.latestVersion,
|
|
1919
2010
|
});
|
|
@@ -2025,6 +2116,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2025
2116
|
const latestVersion = metadata['dist-tags']?.latest;
|
|
2026
2117
|
const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
|
|
2027
2118
|
const tarballUrl = latestEntry?.dist?.tarball;
|
|
2119
|
+
const tarballIntegrity = latestEntry?.dist?.integrity;
|
|
2028
2120
|
if (!latestVersion || !tarballUrl)
|
|
2029
2121
|
throw new Error('Registry response missing latest/tarball');
|
|
2030
2122
|
send('log', { stream: 'meta', chunk: `Current: ${SERVER_VERSION} → Latest: ${latestVersion}\n` });
|
|
@@ -2038,15 +2130,29 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2038
2130
|
endStream();
|
|
2039
2131
|
return;
|
|
2040
2132
|
}
|
|
2041
|
-
// 2. Download the tarball
|
|
2042
|
-
//
|
|
2043
|
-
//
|
|
2044
|
-
// intact if the download fails partway through.
|
|
2133
|
+
// 2. Download the tarball to a buffer so we can verify integrity
|
|
2134
|
+
// before extracting. Doing the extract under `.staging` first
|
|
2135
|
+
// means the live runtime stays intact if the download fails.
|
|
2045
2136
|
send('log', { stream: 'meta', chunk: `Downloading ${tarballUrl}\n` });
|
|
2046
2137
|
const tarballRes = await fetch(tarballUrl);
|
|
2047
2138
|
if (!tarballRes.ok || !tarballRes.body) {
|
|
2048
2139
|
throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
|
|
2049
2140
|
}
|
|
2141
|
+
const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
|
|
2142
|
+
// Verify integrity hash against registry-provided SRI string.
|
|
2143
|
+
if (tarballIntegrity) {
|
|
2144
|
+
try {
|
|
2145
|
+
verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
|
|
2146
|
+
send('log', { stream: 'meta', chunk: 'Tarball integrity verified.\n' });
|
|
2147
|
+
}
|
|
2148
|
+
catch (verifyError) {
|
|
2149
|
+
send('log', { stream: 'stderr', chunk: `Integrity verification failed: ${verifyError.message}\n` });
|
|
2150
|
+
throw verifyError;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
else {
|
|
2154
|
+
send('log', { stream: 'meta', chunk: 'WARNING: No integrity hash from registry — extracting without verification.\n' });
|
|
2155
|
+
}
|
|
2050
2156
|
const stagingDir = path.join(runtimeDir, '.staging');
|
|
2051
2157
|
const backupDir = path.join(runtimeDir, '.previous');
|
|
2052
2158
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
@@ -2060,10 +2166,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2060
2166
|
// contents to the staging dir directly so paths match the
|
|
2061
2167
|
// existing runtime layout.
|
|
2062
2168
|
await new Promise((resolve, reject) => {
|
|
2063
|
-
const
|
|
2064
|
-
const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
|
|
2065
|
-
? Readable.fromWeb(webStream)
|
|
2066
|
-
: webStream;
|
|
2169
|
+
const nodeStream = Readable.from(tarballBuffer);
|
|
2067
2170
|
const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
|
|
2068
2171
|
nodeStream.pipe(extractor);
|
|
2069
2172
|
extractor.on('finish', resolve);
|
|
@@ -2292,6 +2395,11 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2292
2395
|
// (systemd/pm2/daemon manager) can bring the server back on the new code.
|
|
2293
2396
|
// Foreground installs without a wrapper will simply stop; the UI reports this.
|
|
2294
2397
|
app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
|
|
2398
|
+
securityLog('system_restart_requested', {
|
|
2399
|
+
ip: getClientIp(req),
|
|
2400
|
+
userId: req.user?.id,
|
|
2401
|
+
username: req.user?.username,
|
|
2402
|
+
});
|
|
2295
2403
|
const forceRestart = req.body?.force === true || req.query.force === 'true';
|
|
2296
2404
|
const activeWork = getActiveWorkSummary();
|
|
2297
2405
|
if (!forceRestart && activeWork.hasActiveWork) {
|
|
@@ -2318,7 +2426,7 @@ app.get('/api/projects', authenticateToken, async (req, res) => {
|
|
|
2318
2426
|
res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
|
|
2319
2427
|
}
|
|
2320
2428
|
catch (error) {
|
|
2321
|
-
res.status(500).json({ error: error
|
|
2429
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2322
2430
|
}
|
|
2323
2431
|
});
|
|
2324
2432
|
app.get('/api/projects/:projectName/sessions', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
|
|
@@ -2332,7 +2440,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
|
|
|
2332
2440
|
res.json(result);
|
|
2333
2441
|
}
|
|
2334
2442
|
catch (error) {
|
|
2335
|
-
res.status(500).json({ error: error
|
|
2443
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2336
2444
|
}
|
|
2337
2445
|
});
|
|
2338
2446
|
// Rename project endpoint
|
|
@@ -2343,7 +2451,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAc
|
|
|
2343
2451
|
res.json({ success: true });
|
|
2344
2452
|
}
|
|
2345
2453
|
catch (error) {
|
|
2346
|
-
res.status(500).json({ error: error
|
|
2454
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2347
2455
|
}
|
|
2348
2456
|
});
|
|
2349
2457
|
// Delete session endpoint
|
|
@@ -2358,7 +2466,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
|
|
2358
2466
|
}
|
|
2359
2467
|
catch (error) {
|
|
2360
2468
|
console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
|
|
2361
|
-
res.status(500).json({ error: error
|
|
2469
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2362
2470
|
}
|
|
2363
2471
|
});
|
|
2364
2472
|
// Rename session endpoint
|
|
@@ -2384,7 +2492,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
|
|
|
2384
2492
|
}
|
|
2385
2493
|
catch (error) {
|
|
2386
2494
|
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
|
|
2387
|
-
res.status(500).json({ error: error
|
|
2495
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2388
2496
|
}
|
|
2389
2497
|
});
|
|
2390
2498
|
// Delete project endpoint
|
|
@@ -2404,7 +2512,7 @@ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess
|
|
|
2404
2512
|
// catch it and prompt the user to pass `?force=true` (or clean
|
|
2405
2513
|
// sessions first) instead of treating it like a crash.
|
|
2406
2514
|
const conflict = typeof error?.message === 'string' && error.message.includes('existing sessions');
|
|
2407
|
-
res.status(conflict ? 409 : 500).json({ error: error.message });
|
|
2515
|
+
res.status(conflict ? 409 : 500).json({ error: conflict ? error.message : 'Internal server error' });
|
|
2408
2516
|
}
|
|
2409
2517
|
});
|
|
2410
2518
|
// Search conversations content (SSE streaming)
|
|
@@ -2636,7 +2744,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
2636
2744
|
res.status(403).json({ error: 'Permission denied' });
|
|
2637
2745
|
}
|
|
2638
2746
|
else {
|
|
2639
|
-
res.status(500).json({ error: error
|
|
2747
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2640
2748
|
}
|
|
2641
2749
|
}
|
|
2642
2750
|
});
|
|
@@ -2688,7 +2796,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
|
|
|
2688
2796
|
catch (error) {
|
|
2689
2797
|
console.error('Error serving binary file:', error);
|
|
2690
2798
|
if (!res.headersSent) {
|
|
2691
|
-
res.status(500).json({ error: error
|
|
2799
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2692
2800
|
}
|
|
2693
2801
|
}
|
|
2694
2802
|
});
|
|
@@ -2736,7 +2844,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
2736
2844
|
res.status(403).json({ error: 'Permission denied' });
|
|
2737
2845
|
}
|
|
2738
2846
|
else {
|
|
2739
|
-
res.status(500).json({ error: error
|
|
2847
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2740
2848
|
}
|
|
2741
2849
|
}
|
|
2742
2850
|
});
|
|
@@ -2774,7 +2882,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
|
|
|
2774
2882
|
}
|
|
2775
2883
|
catch (error) {
|
|
2776
2884
|
console.error('[ERROR] File tree error:', error.message);
|
|
2777
|
-
res.status(500).json({ error: error
|
|
2885
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2778
2886
|
}
|
|
2779
2887
|
});
|
|
2780
2888
|
// ============================================================================
|
|
@@ -2893,7 +3001,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
|
|
|
2893
3001
|
res.status(404).json({ error: 'Parent directory not found' });
|
|
2894
3002
|
}
|
|
2895
3003
|
else {
|
|
2896
|
-
res.status(500).json({ error: error
|
|
3004
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2897
3005
|
}
|
|
2898
3006
|
}
|
|
2899
3007
|
});
|
|
@@ -2971,7 +3079,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
|
|
|
2971
3079
|
res.status(400).json({ error: 'Cannot move across different filesystems' });
|
|
2972
3080
|
}
|
|
2973
3081
|
else {
|
|
2974
|
-
res.status(500).json({ error: error
|
|
3082
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2975
3083
|
}
|
|
2976
3084
|
}
|
|
2977
3085
|
});
|
|
@@ -3036,7 +3144,7 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
|
|
|
3036
3144
|
res.status(400).json({ error: 'Directory is not empty' });
|
|
3037
3145
|
}
|
|
3038
3146
|
else {
|
|
3039
|
-
res.status(500).json({ error: error
|
|
3147
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3040
3148
|
}
|
|
3041
3149
|
}
|
|
3042
3150
|
});
|
|
@@ -3053,7 +3161,7 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
3053
3161
|
filename: (req, file, cb) => {
|
|
3054
3162
|
// Use a unique temp name, but preserve original name in file.originalname
|
|
3055
3163
|
// Note: file.originalname may contain path separators for folder uploads
|
|
3056
|
-
const uniqueSuffix = Date.now() + '-' +
|
|
3164
|
+
const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
|
|
3057
3165
|
// For temp file, just use a safe unique name without the path
|
|
3058
3166
|
cb(null, `upload-${uniqueSuffix}`);
|
|
3059
3167
|
}
|
|
@@ -3191,7 +3299,7 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
3191
3299
|
res.status(403).json({ error: 'Permission denied' });
|
|
3192
3300
|
}
|
|
3193
3301
|
else {
|
|
3194
|
-
res.status(500).json({ error: error
|
|
3302
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3195
3303
|
}
|
|
3196
3304
|
}
|
|
3197
3305
|
});
|
|
@@ -3537,10 +3645,15 @@ function handleChatConnection(ws, request) {
|
|
|
3537
3645
|
}
|
|
3538
3646
|
}
|
|
3539
3647
|
catch (error) {
|
|
3540
|
-
console.error('[ERROR] Chat WebSocket error:', error
|
|
3648
|
+
console.error('[ERROR] Chat WebSocket error:', error?.message || error);
|
|
3649
|
+
securityLog('ws_chat_error', {
|
|
3650
|
+
userId: request?.user?.id,
|
|
3651
|
+
username: request?.user?.username,
|
|
3652
|
+
reason: error?.name || 'UnknownError',
|
|
3653
|
+
});
|
|
3541
3654
|
writer.send({
|
|
3542
3655
|
type: 'error',
|
|
3543
|
-
error: error.
|
|
3656
|
+
error: 'An error occurred while processing your request.'
|
|
3544
3657
|
});
|
|
3545
3658
|
}
|
|
3546
3659
|
});
|
|
@@ -3991,11 +4104,16 @@ function handleShellConnection(ws, request) {
|
|
|
3991
4104
|
const termCols = data.cols || 80;
|
|
3992
4105
|
const termRows = data.rows || 24;
|
|
3993
4106
|
console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
|
|
4107
|
+
const isRunningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
3994
4108
|
const shellEnv = {
|
|
3995
4109
|
...process.env,
|
|
3996
4110
|
TERM: 'xterm-256color',
|
|
3997
4111
|
COLORTERM: 'truecolor',
|
|
3998
4112
|
FORCE_COLOR: '3',
|
|
4113
|
+
// When running as root, Claude CLI refuses --dangerously-skip-permissions
|
|
4114
|
+
// for security reasons. Setting IS_SANDBOX=1 tells it the environment is
|
|
4115
|
+
// already sandboxed, so the flag is safe to use.
|
|
4116
|
+
...(isRunningAsRoot ? { IS_SANDBOX: '1' } : {}),
|
|
3999
4117
|
};
|
|
4000
4118
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
4001
4119
|
name: 'xterm-256color',
|
|
@@ -4108,7 +4226,7 @@ function handleShellConnection(ws, request) {
|
|
|
4108
4226
|
console.error('[ERROR] Error spawning process:', spawnError);
|
|
4109
4227
|
ws.send(JSON.stringify({
|
|
4110
4228
|
type: 'output',
|
|
4111
|
-
data: `\r\n\x1b[31mError:
|
|
4229
|
+
data: `\r\n\x1b[31mError: Failed to start terminal process.\x1b[0m\r\n`
|
|
4112
4230
|
}));
|
|
4113
4231
|
}
|
|
4114
4232
|
}
|
|
@@ -4125,11 +4243,11 @@ function handleShellConnection(ws, request) {
|
|
|
4125
4243
|
}
|
|
4126
4244
|
}
|
|
4127
4245
|
catch (error) {
|
|
4128
|
-
console.error('[ERROR] Shell WebSocket error:', error
|
|
4246
|
+
console.error('[ERROR] Shell WebSocket error:', error?.message || error);
|
|
4129
4247
|
if (ws.readyState === WebSocket.OPEN) {
|
|
4130
4248
|
ws.send(JSON.stringify({
|
|
4131
4249
|
type: 'output',
|
|
4132
|
-
data: `\r\n\x1b[31mError:
|
|
4250
|
+
data: `\r\n\x1b[31mError: An internal error occurred.\x1b[0m\r\n`
|
|
4133
4251
|
}));
|
|
4134
4252
|
}
|
|
4135
4253
|
}
|
|
@@ -4185,7 +4303,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireP
|
|
|
4185
4303
|
cb(null, uploadDir);
|
|
4186
4304
|
},
|
|
4187
4305
|
filename: (req, file, cb) => {
|
|
4188
|
-
const uniqueSuffix = Date.now() + '-' +
|
|
4306
|
+
const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
|
|
4189
4307
|
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
4190
4308
|
cb(null, uniqueSuffix + '-' + sanitizedName);
|
|
4191
4309
|
}
|
|
@@ -4517,8 +4635,16 @@ app.get(/.*/, (req, res) => {
|
|
|
4517
4635
|
}
|
|
4518
4636
|
});
|
|
4519
4637
|
// global error middleware must be last
|
|
4520
|
-
app.use((err, req, res,
|
|
4638
|
+
app.use((err, req, res, _next) => {
|
|
4521
4639
|
if (err instanceof AppError) {
|
|
4640
|
+
securityLog('app_error', {
|
|
4641
|
+
ip: getClientIp(req),
|
|
4642
|
+
endpoint: req.path,
|
|
4643
|
+
method: req.method,
|
|
4644
|
+
statusCode: err.statusCode,
|
|
4645
|
+
userId: req.user?.id,
|
|
4646
|
+
reason: err.code,
|
|
4647
|
+
});
|
|
4522
4648
|
return res.status(err.statusCode).json({
|
|
4523
4649
|
success: false,
|
|
4524
4650
|
error: {
|
|
@@ -4528,7 +4654,35 @@ app.use((err, req, res, next) => {
|
|
|
4528
4654
|
},
|
|
4529
4655
|
});
|
|
4530
4656
|
}
|
|
4531
|
-
|
|
4657
|
+
// Log the error internally but never expose stack traces, file paths,
|
|
4658
|
+
// or internal IPs to the client.
|
|
4659
|
+
if (err?.type === 'entity.too.large') {
|
|
4660
|
+
return res.status(413).json({
|
|
4661
|
+
success: false,
|
|
4662
|
+
error: {
|
|
4663
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
4664
|
+
message: 'Request payload exceeds size limit.',
|
|
4665
|
+
},
|
|
4666
|
+
});
|
|
4667
|
+
}
|
|
4668
|
+
if (err?.type === 'entity.parse.failed' || err?.type === 'entity.parse.failed.utf8') {
|
|
4669
|
+
return res.status(400).json({
|
|
4670
|
+
success: false,
|
|
4671
|
+
error: {
|
|
4672
|
+
code: 'INVALID_JSON',
|
|
4673
|
+
message: 'Request body contains invalid JSON.',
|
|
4674
|
+
},
|
|
4675
|
+
});
|
|
4676
|
+
}
|
|
4677
|
+
console.error('[UNHANDLED ERROR]', err?.message || err);
|
|
4678
|
+
securityLog('unhandled_error', {
|
|
4679
|
+
ip: getClientIp(req),
|
|
4680
|
+
endpoint: req.path,
|
|
4681
|
+
method: req.method,
|
|
4682
|
+
statusCode: 500,
|
|
4683
|
+
userId: req.user?.id,
|
|
4684
|
+
reason: err?.name || 'UnknownError',
|
|
4685
|
+
});
|
|
4532
4686
|
return res.status(500).json({
|
|
4533
4687
|
success: false,
|
|
4534
4688
|
error: {
|
|
@@ -4801,6 +4955,24 @@ async function maybeAutoDaemonBootstrapFromIndex() {
|
|
|
4801
4955
|
}
|
|
4802
4956
|
}
|
|
4803
4957
|
}
|
|
4958
|
+
// Process-level error handlers to prevent silent crashes and log security events.
|
|
4959
|
+
// These catch errors that escape Express's own error middleware (e.g. from
|
|
4960
|
+
// timers, WebSocket handlers, or un-awaited promises in route handlers).
|
|
4961
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
4962
|
+
console.error('[FATAL] Unhandled promise rejection:', reason);
|
|
4963
|
+
securityLog('unhandled_rejection', {
|
|
4964
|
+
reason: reason instanceof Error ? reason.name : String(reason).slice(0, 200),
|
|
4965
|
+
});
|
|
4966
|
+
});
|
|
4967
|
+
process.on('uncaughtException', (error) => {
|
|
4968
|
+
console.error('[FATAL] Uncaught exception:', error?.message || error);
|
|
4969
|
+
securityLog('uncaught_exception', {
|
|
4970
|
+
reason: error?.name || 'UnknownError',
|
|
4971
|
+
});
|
|
4972
|
+
// Give the security log time to flush, then exit. A clean exit lets the
|
|
4973
|
+
// daemon manager (systemd/pm2) restart the server automatically.
|
|
4974
|
+
setTimeout(() => process.exit(1), 100);
|
|
4975
|
+
});
|
|
4804
4976
|
// Initialize database and start server
|
|
4805
4977
|
async function startServer() {
|
|
4806
4978
|
try {
|
|
@@ -4873,11 +5045,17 @@ async function startServer() {
|
|
|
4873
5045
|
if (process.env.PIXCODE_NO_BROWSER !== '1') {
|
|
4874
5046
|
const openUrl = `http://${DISPLAY_HOST}:${SERVER_PORT}`;
|
|
4875
5047
|
try {
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
5048
|
+
// Use spawn with an argument array instead of exec with a
|
|
5049
|
+
// shell string to prevent command injection through the
|
|
5050
|
+
// host/port values (which come from env vars).
|
|
5051
|
+
const { spawn: spawnBrowser } = await import('node:child_process');
|
|
5052
|
+
const browserBin = process.platform === 'darwin' ? 'open'
|
|
5053
|
+
: process.platform === 'win32' ? 'cmd'
|
|
5054
|
+
: 'xdg-open';
|
|
5055
|
+
const browserArgs = process.platform === 'win32'
|
|
5056
|
+
? ['/c', 'start', '', openUrl]
|
|
5057
|
+
: [openUrl];
|
|
5058
|
+
spawnBrowser(browserBin, browserArgs, { stdio: 'ignore', timeout: 3000, detached: true, shell: false }).unref();
|
|
4881
5059
|
console.log(`${c.ok('[OK]')} Opening browser at ${c.bright(openUrl)}`);
|
|
4882
5060
|
}
|
|
4883
5061
|
catch {
|