@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
|
@@ -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,65 @@ 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
|
+
// Restrict CORS to known origins instead of reflecting any requester.
|
|
1559
|
+
// In production the frontend is same-origin; in dev it's the Vite server.
|
|
1560
|
+
const ALLOWED_CORS_ORIGINS = (() => {
|
|
1561
|
+
const envOrigins = process.env.CORS_ALLOWED_ORIGINS;
|
|
1562
|
+
if (envOrigins) {
|
|
1563
|
+
return envOrigins.split(',').map((o) => o.trim()).filter(Boolean);
|
|
1564
|
+
}
|
|
1565
|
+
const devPort = process.env.VITE_PORT || 5173;
|
|
1566
|
+
return [
|
|
1567
|
+
`http://localhost:${devPort}`,
|
|
1568
|
+
`http://127.0.0.1:${devPort}`,
|
|
1569
|
+
];
|
|
1570
|
+
})();
|
|
1571
|
+
app.use(cors({
|
|
1572
|
+
origin(origin, callback) {
|
|
1573
|
+
// Allow same-origin requests (no Origin header) and allowlisted origins.
|
|
1574
|
+
if (!origin || ALLOWED_CORS_ORIGINS.includes(origin)) {
|
|
1575
|
+
return callback(null, true);
|
|
1576
|
+
}
|
|
1577
|
+
return callback(null, false);
|
|
1578
|
+
},
|
|
1579
|
+
credentials: true,
|
|
1580
|
+
exposedHeaders: ['X-Refreshed-Token'],
|
|
1581
|
+
}));
|
|
1582
|
+
// Security headers middleware (replaces helmet which isn't installed).
|
|
1583
|
+
app.use((req, res, next) => {
|
|
1584
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1585
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
1586
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
1587
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
1588
|
+
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
|
1589
|
+
// Strict-Transport-Security only makes sense over HTTPS; skip for plain HTTP
|
|
1590
|
+
// so local dev doesn't pin an HSTS policy on localhost.
|
|
1591
|
+
if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
|
|
1592
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
1593
|
+
}
|
|
1594
|
+
// CSP for the SPA shell — API responses are JSON so this mainly guards
|
|
1595
|
+
// the served index.html. 'unsafe-inline' is needed for Vite's inline
|
|
1596
|
+
// module preload polyfill; style-src unsafe-inline for Tailwind injected styles.
|
|
1597
|
+
res.setHeader('Content-Security-Policy', [
|
|
1598
|
+
"default-src 'self'",
|
|
1599
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
1600
|
+
"style-src 'self' 'unsafe-inline'",
|
|
1601
|
+
"img-src 'self' data: blob: https:",
|
|
1602
|
+
"font-src 'self' data:",
|
|
1603
|
+
"connect-src 'self' ws: wss:",
|
|
1604
|
+
"frame-ancestors 'self'",
|
|
1605
|
+
"base-uri 'self'",
|
|
1606
|
+
"form-action 'self'",
|
|
1607
|
+
].join('; '));
|
|
1608
|
+
next();
|
|
1609
|
+
});
|
|
1510
1610
|
app.use(express.json({
|
|
1511
1611
|
limit: '50mb',
|
|
1512
1612
|
type: (req) => {
|
|
@@ -1531,8 +1631,9 @@ app.get('/health', (req, res) => {
|
|
|
1531
1631
|
lastAppliedUpdate: updateState.lastAppliedUpdate,
|
|
1532
1632
|
});
|
|
1533
1633
|
});
|
|
1534
|
-
// Optional API key validation (if configured)
|
|
1634
|
+
// Optional API key validation (if configured) + rate limiting for all API routes
|
|
1535
1635
|
app.use('/api', validateApiKey);
|
|
1636
|
+
app.use('/api', apiRateLimiter);
|
|
1536
1637
|
app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
|
|
1537
1638
|
const provider = req.body?.provider || 'claude';
|
|
1538
1639
|
const projectPath = req.body?.projectPath || os.homedir();
|
|
@@ -1914,6 +2015,12 @@ app.get('/api/system/update-state', authenticateToken, (req, res) => {
|
|
|
1914
2015
|
});
|
|
1915
2016
|
});
|
|
1916
2017
|
app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
|
|
2018
|
+
securityLog('system_update_initiated', {
|
|
2019
|
+
ip: getClientIp(req),
|
|
2020
|
+
userId: req.user?.id,
|
|
2021
|
+
username: req.user?.username,
|
|
2022
|
+
endpoint: req.path,
|
|
2023
|
+
});
|
|
1917
2024
|
const job = createSystemUpdateJob(req.user, {
|
|
1918
2025
|
targetVersion: req.body?.targetVersion || req.body?.latestVersion,
|
|
1919
2026
|
});
|
|
@@ -2025,6 +2132,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2025
2132
|
const latestVersion = metadata['dist-tags']?.latest;
|
|
2026
2133
|
const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
|
|
2027
2134
|
const tarballUrl = latestEntry?.dist?.tarball;
|
|
2135
|
+
const tarballIntegrity = latestEntry?.dist?.integrity;
|
|
2028
2136
|
if (!latestVersion || !tarballUrl)
|
|
2029
2137
|
throw new Error('Registry response missing latest/tarball');
|
|
2030
2138
|
send('log', { stream: 'meta', chunk: `Current: ${SERVER_VERSION} → Latest: ${latestVersion}\n` });
|
|
@@ -2038,15 +2146,29 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2038
2146
|
endStream();
|
|
2039
2147
|
return;
|
|
2040
2148
|
}
|
|
2041
|
-
// 2. Download the tarball
|
|
2042
|
-
//
|
|
2043
|
-
//
|
|
2044
|
-
// intact if the download fails partway through.
|
|
2149
|
+
// 2. Download the tarball to a buffer so we can verify integrity
|
|
2150
|
+
// before extracting. Doing the extract under `.staging` first
|
|
2151
|
+
// means the live runtime stays intact if the download fails.
|
|
2045
2152
|
send('log', { stream: 'meta', chunk: `Downloading ${tarballUrl}\n` });
|
|
2046
2153
|
const tarballRes = await fetch(tarballUrl);
|
|
2047
2154
|
if (!tarballRes.ok || !tarballRes.body) {
|
|
2048
2155
|
throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
|
|
2049
2156
|
}
|
|
2157
|
+
const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
|
|
2158
|
+
// Verify integrity hash against registry-provided SRI string.
|
|
2159
|
+
if (tarballIntegrity) {
|
|
2160
|
+
try {
|
|
2161
|
+
verifyTarballIntegrity(tarballBuffer, tarballIntegrity);
|
|
2162
|
+
send('log', { stream: 'meta', chunk: 'Tarball integrity verified.\n' });
|
|
2163
|
+
}
|
|
2164
|
+
catch (verifyError) {
|
|
2165
|
+
send('log', { stream: 'stderr', chunk: `Integrity verification failed: ${verifyError.message}\n` });
|
|
2166
|
+
throw verifyError;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
else {
|
|
2170
|
+
send('log', { stream: 'meta', chunk: 'WARNING: No integrity hash from registry — extracting without verification.\n' });
|
|
2171
|
+
}
|
|
2050
2172
|
const stagingDir = path.join(runtimeDir, '.staging');
|
|
2051
2173
|
const backupDir = path.join(runtimeDir, '.previous');
|
|
2052
2174
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
@@ -2060,10 +2182,7 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2060
2182
|
// contents to the staging dir directly so paths match the
|
|
2061
2183
|
// existing runtime layout.
|
|
2062
2184
|
await new Promise((resolve, reject) => {
|
|
2063
|
-
const
|
|
2064
|
-
const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
|
|
2065
|
-
? Readable.fromWeb(webStream)
|
|
2066
|
-
: webStream;
|
|
2185
|
+
const nodeStream = Readable.from(tarballBuffer);
|
|
2067
2186
|
const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
|
|
2068
2187
|
nodeStream.pipe(extractor);
|
|
2069
2188
|
extractor.on('finish', resolve);
|
|
@@ -2292,6 +2411,11 @@ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope(
|
|
|
2292
2411
|
// (systemd/pm2/daemon manager) can bring the server back on the new code.
|
|
2293
2412
|
// Foreground installs without a wrapper will simply stop; the UI reports this.
|
|
2294
2413
|
app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
|
|
2414
|
+
securityLog('system_restart_requested', {
|
|
2415
|
+
ip: getClientIp(req),
|
|
2416
|
+
userId: req.user?.id,
|
|
2417
|
+
username: req.user?.username,
|
|
2418
|
+
});
|
|
2295
2419
|
const forceRestart = req.body?.force === true || req.query.force === 'true';
|
|
2296
2420
|
const activeWork = getActiveWorkSummary();
|
|
2297
2421
|
if (!forceRestart && activeWork.hasActiveWork) {
|
|
@@ -2318,7 +2442,7 @@ app.get('/api/projects', authenticateToken, async (req, res) => {
|
|
|
2318
2442
|
res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
|
|
2319
2443
|
}
|
|
2320
2444
|
catch (error) {
|
|
2321
|
-
res.status(500).json({ error: error
|
|
2445
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2322
2446
|
}
|
|
2323
2447
|
});
|
|
2324
2448
|
app.get('/api/projects/:projectName/sessions', authenticateToken, requireProjectAccess('viewFiles'), async (req, res) => {
|
|
@@ -2332,7 +2456,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
|
|
|
2332
2456
|
res.json(result);
|
|
2333
2457
|
}
|
|
2334
2458
|
catch (error) {
|
|
2335
|
-
res.status(500).json({ error: error
|
|
2459
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2336
2460
|
}
|
|
2337
2461
|
});
|
|
2338
2462
|
// Rename project endpoint
|
|
@@ -2343,7 +2467,7 @@ app.put('/api/projects/:projectName/rename', authenticateToken, requireProjectAc
|
|
|
2343
2467
|
res.json({ success: true });
|
|
2344
2468
|
}
|
|
2345
2469
|
catch (error) {
|
|
2346
|
-
res.status(500).json({ error: error
|
|
2470
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2347
2471
|
}
|
|
2348
2472
|
});
|
|
2349
2473
|
// Delete session endpoint
|
|
@@ -2358,7 +2482,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
|
|
2358
2482
|
}
|
|
2359
2483
|
catch (error) {
|
|
2360
2484
|
console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);
|
|
2361
|
-
res.status(500).json({ error: error
|
|
2485
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2362
2486
|
}
|
|
2363
2487
|
});
|
|
2364
2488
|
// Rename session endpoint
|
|
@@ -2384,7 +2508,7 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
|
|
|
2384
2508
|
}
|
|
2385
2509
|
catch (error) {
|
|
2386
2510
|
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
|
|
2387
|
-
res.status(500).json({ error: error
|
|
2511
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2388
2512
|
}
|
|
2389
2513
|
});
|
|
2390
2514
|
// Delete project endpoint
|
|
@@ -2404,7 +2528,7 @@ app.delete('/api/projects/:projectName', authenticateToken, requireProjectAccess
|
|
|
2404
2528
|
// catch it and prompt the user to pass `?force=true` (or clean
|
|
2405
2529
|
// sessions first) instead of treating it like a crash.
|
|
2406
2530
|
const conflict = typeof error?.message === 'string' && error.message.includes('existing sessions');
|
|
2407
|
-
res.status(conflict ? 409 : 500).json({ error: error.message });
|
|
2531
|
+
res.status(conflict ? 409 : 500).json({ error: conflict ? error.message : 'Internal server error' });
|
|
2408
2532
|
}
|
|
2409
2533
|
});
|
|
2410
2534
|
// Search conversations content (SSE streaming)
|
|
@@ -2636,7 +2760,7 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
2636
2760
|
res.status(403).json({ error: 'Permission denied' });
|
|
2637
2761
|
}
|
|
2638
2762
|
else {
|
|
2639
|
-
res.status(500).json({ error: error
|
|
2763
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2640
2764
|
}
|
|
2641
2765
|
}
|
|
2642
2766
|
});
|
|
@@ -2688,7 +2812,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
|
|
|
2688
2812
|
catch (error) {
|
|
2689
2813
|
console.error('Error serving binary file:', error);
|
|
2690
2814
|
if (!res.headersSent) {
|
|
2691
|
-
res.status(500).json({ error: error
|
|
2815
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2692
2816
|
}
|
|
2693
2817
|
}
|
|
2694
2818
|
});
|
|
@@ -2736,7 +2860,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
2736
2860
|
res.status(403).json({ error: 'Permission denied' });
|
|
2737
2861
|
}
|
|
2738
2862
|
else {
|
|
2739
|
-
res.status(500).json({ error: error
|
|
2863
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2740
2864
|
}
|
|
2741
2865
|
}
|
|
2742
2866
|
});
|
|
@@ -2774,7 +2898,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
|
|
|
2774
2898
|
}
|
|
2775
2899
|
catch (error) {
|
|
2776
2900
|
console.error('[ERROR] File tree error:', error.message);
|
|
2777
|
-
res.status(500).json({ error: error
|
|
2901
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2778
2902
|
}
|
|
2779
2903
|
});
|
|
2780
2904
|
// ============================================================================
|
|
@@ -2893,7 +3017,7 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
|
|
|
2893
3017
|
res.status(404).json({ error: 'Parent directory not found' });
|
|
2894
3018
|
}
|
|
2895
3019
|
else {
|
|
2896
|
-
res.status(500).json({ error: error
|
|
3020
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2897
3021
|
}
|
|
2898
3022
|
}
|
|
2899
3023
|
});
|
|
@@ -2971,7 +3095,7 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
|
|
|
2971
3095
|
res.status(400).json({ error: 'Cannot move across different filesystems' });
|
|
2972
3096
|
}
|
|
2973
3097
|
else {
|
|
2974
|
-
res.status(500).json({ error: error
|
|
3098
|
+
res.status(500).json({ error: "Internal server error" });
|
|
2975
3099
|
}
|
|
2976
3100
|
}
|
|
2977
3101
|
});
|
|
@@ -3036,7 +3160,7 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
|
|
|
3036
3160
|
res.status(400).json({ error: 'Directory is not empty' });
|
|
3037
3161
|
}
|
|
3038
3162
|
else {
|
|
3039
|
-
res.status(500).json({ error: error
|
|
3163
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3040
3164
|
}
|
|
3041
3165
|
}
|
|
3042
3166
|
});
|
|
@@ -3053,7 +3177,7 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
3053
3177
|
filename: (req, file, cb) => {
|
|
3054
3178
|
// Use a unique temp name, but preserve original name in file.originalname
|
|
3055
3179
|
// Note: file.originalname may contain path separators for folder uploads
|
|
3056
|
-
const uniqueSuffix = Date.now() + '-' +
|
|
3180
|
+
const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
|
|
3057
3181
|
// For temp file, just use a safe unique name without the path
|
|
3058
3182
|
cb(null, `upload-${uniqueSuffix}`);
|
|
3059
3183
|
}
|
|
@@ -3191,7 +3315,7 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
3191
3315
|
res.status(403).json({ error: 'Permission denied' });
|
|
3192
3316
|
}
|
|
3193
3317
|
else {
|
|
3194
|
-
res.status(500).json({ error: error
|
|
3318
|
+
res.status(500).json({ error: "Internal server error" });
|
|
3195
3319
|
}
|
|
3196
3320
|
}
|
|
3197
3321
|
});
|
|
@@ -3537,10 +3661,15 @@ function handleChatConnection(ws, request) {
|
|
|
3537
3661
|
}
|
|
3538
3662
|
}
|
|
3539
3663
|
catch (error) {
|
|
3540
|
-
console.error('[ERROR] Chat WebSocket error:', error
|
|
3664
|
+
console.error('[ERROR] Chat WebSocket error:', error?.message || error);
|
|
3665
|
+
securityLog('ws_chat_error', {
|
|
3666
|
+
userId: request?.user?.id,
|
|
3667
|
+
username: request?.user?.username,
|
|
3668
|
+
reason: error?.name || 'UnknownError',
|
|
3669
|
+
});
|
|
3541
3670
|
writer.send({
|
|
3542
3671
|
type: 'error',
|
|
3543
|
-
error: error.
|
|
3672
|
+
error: 'An error occurred while processing your request.'
|
|
3544
3673
|
});
|
|
3545
3674
|
}
|
|
3546
3675
|
});
|
|
@@ -3991,11 +4120,16 @@ function handleShellConnection(ws, request) {
|
|
|
3991
4120
|
const termCols = data.cols || 80;
|
|
3992
4121
|
const termRows = data.rows || 24;
|
|
3993
4122
|
console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);
|
|
4123
|
+
const isRunningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
3994
4124
|
const shellEnv = {
|
|
3995
4125
|
...process.env,
|
|
3996
4126
|
TERM: 'xterm-256color',
|
|
3997
4127
|
COLORTERM: 'truecolor',
|
|
3998
4128
|
FORCE_COLOR: '3',
|
|
4129
|
+
// When running as root, Claude CLI refuses --dangerously-skip-permissions
|
|
4130
|
+
// for security reasons. Setting IS_SANDBOX=1 tells it the environment is
|
|
4131
|
+
// already sandboxed, so the flag is safe to use.
|
|
4132
|
+
...(isRunningAsRoot ? { IS_SANDBOX: '1' } : {}),
|
|
3999
4133
|
};
|
|
4000
4134
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
4001
4135
|
name: 'xterm-256color',
|
|
@@ -4108,7 +4242,7 @@ function handleShellConnection(ws, request) {
|
|
|
4108
4242
|
console.error('[ERROR] Error spawning process:', spawnError);
|
|
4109
4243
|
ws.send(JSON.stringify({
|
|
4110
4244
|
type: 'output',
|
|
4111
|
-
data: `\r\n\x1b[31mError:
|
|
4245
|
+
data: `\r\n\x1b[31mError: Failed to start terminal process.\x1b[0m\r\n`
|
|
4112
4246
|
}));
|
|
4113
4247
|
}
|
|
4114
4248
|
}
|
|
@@ -4125,11 +4259,11 @@ function handleShellConnection(ws, request) {
|
|
|
4125
4259
|
}
|
|
4126
4260
|
}
|
|
4127
4261
|
catch (error) {
|
|
4128
|
-
console.error('[ERROR] Shell WebSocket error:', error
|
|
4262
|
+
console.error('[ERROR] Shell WebSocket error:', error?.message || error);
|
|
4129
4263
|
if (ws.readyState === WebSocket.OPEN) {
|
|
4130
4264
|
ws.send(JSON.stringify({
|
|
4131
4265
|
type: 'output',
|
|
4132
|
-
data: `\r\n\x1b[31mError:
|
|
4266
|
+
data: `\r\n\x1b[31mError: An internal error occurred.\x1b[0m\r\n`
|
|
4133
4267
|
}));
|
|
4134
4268
|
}
|
|
4135
4269
|
}
|
|
@@ -4185,7 +4319,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, requireP
|
|
|
4185
4319
|
cb(null, uploadDir);
|
|
4186
4320
|
},
|
|
4187
4321
|
filename: (req, file, cb) => {
|
|
4188
|
-
const uniqueSuffix = Date.now() + '-' +
|
|
4322
|
+
const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(8).toString('hex');
|
|
4189
4323
|
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
4190
4324
|
cb(null, uniqueSuffix + '-' + sanitizedName);
|
|
4191
4325
|
}
|
|
@@ -4517,8 +4651,16 @@ app.get(/.*/, (req, res) => {
|
|
|
4517
4651
|
}
|
|
4518
4652
|
});
|
|
4519
4653
|
// global error middleware must be last
|
|
4520
|
-
app.use((err, req, res,
|
|
4654
|
+
app.use((err, req, res, _next) => {
|
|
4521
4655
|
if (err instanceof AppError) {
|
|
4656
|
+
securityLog('app_error', {
|
|
4657
|
+
ip: getClientIp(req),
|
|
4658
|
+
endpoint: req.path,
|
|
4659
|
+
method: req.method,
|
|
4660
|
+
statusCode: err.statusCode,
|
|
4661
|
+
userId: req.user?.id,
|
|
4662
|
+
reason: err.code,
|
|
4663
|
+
});
|
|
4522
4664
|
return res.status(err.statusCode).json({
|
|
4523
4665
|
success: false,
|
|
4524
4666
|
error: {
|
|
@@ -4528,7 +4670,35 @@ app.use((err, req, res, next) => {
|
|
|
4528
4670
|
},
|
|
4529
4671
|
});
|
|
4530
4672
|
}
|
|
4531
|
-
|
|
4673
|
+
// Log the error internally but never expose stack traces, file paths,
|
|
4674
|
+
// or internal IPs to the client.
|
|
4675
|
+
if (err?.type === 'entity.too.large') {
|
|
4676
|
+
return res.status(413).json({
|
|
4677
|
+
success: false,
|
|
4678
|
+
error: {
|
|
4679
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
4680
|
+
message: 'Request payload exceeds size limit.',
|
|
4681
|
+
},
|
|
4682
|
+
});
|
|
4683
|
+
}
|
|
4684
|
+
if (err?.type === 'entity.parse.failed' || err?.type === 'entity.parse.failed.utf8') {
|
|
4685
|
+
return res.status(400).json({
|
|
4686
|
+
success: false,
|
|
4687
|
+
error: {
|
|
4688
|
+
code: 'INVALID_JSON',
|
|
4689
|
+
message: 'Request body contains invalid JSON.',
|
|
4690
|
+
},
|
|
4691
|
+
});
|
|
4692
|
+
}
|
|
4693
|
+
console.error('[UNHANDLED ERROR]', err?.message || err);
|
|
4694
|
+
securityLog('unhandled_error', {
|
|
4695
|
+
ip: getClientIp(req),
|
|
4696
|
+
endpoint: req.path,
|
|
4697
|
+
method: req.method,
|
|
4698
|
+
statusCode: 500,
|
|
4699
|
+
userId: req.user?.id,
|
|
4700
|
+
reason: err?.name || 'UnknownError',
|
|
4701
|
+
});
|
|
4532
4702
|
return res.status(500).json({
|
|
4533
4703
|
success: false,
|
|
4534
4704
|
error: {
|
|
@@ -4801,6 +4971,24 @@ async function maybeAutoDaemonBootstrapFromIndex() {
|
|
|
4801
4971
|
}
|
|
4802
4972
|
}
|
|
4803
4973
|
}
|
|
4974
|
+
// Process-level error handlers to prevent silent crashes and log security events.
|
|
4975
|
+
// These catch errors that escape Express's own error middleware (e.g. from
|
|
4976
|
+
// timers, WebSocket handlers, or un-awaited promises in route handlers).
|
|
4977
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
4978
|
+
console.error('[FATAL] Unhandled promise rejection:', reason);
|
|
4979
|
+
securityLog('unhandled_rejection', {
|
|
4980
|
+
reason: reason instanceof Error ? reason.name : String(reason).slice(0, 200),
|
|
4981
|
+
});
|
|
4982
|
+
});
|
|
4983
|
+
process.on('uncaughtException', (error) => {
|
|
4984
|
+
console.error('[FATAL] Uncaught exception:', error?.message || error);
|
|
4985
|
+
securityLog('uncaught_exception', {
|
|
4986
|
+
reason: error?.name || 'UnknownError',
|
|
4987
|
+
});
|
|
4988
|
+
// Give the security log time to flush, then exit. A clean exit lets the
|
|
4989
|
+
// daemon manager (systemd/pm2) restart the server automatically.
|
|
4990
|
+
setTimeout(() => process.exit(1), 100);
|
|
4991
|
+
});
|
|
4804
4992
|
// Initialize database and start server
|
|
4805
4993
|
async function startServer() {
|
|
4806
4994
|
try {
|
|
@@ -4873,11 +5061,17 @@ async function startServer() {
|
|
|
4873
5061
|
if (process.env.PIXCODE_NO_BROWSER !== '1') {
|
|
4874
5062
|
const openUrl = `http://${DISPLAY_HOST}:${SERVER_PORT}`;
|
|
4875
5063
|
try {
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
5064
|
+
// Use spawn with an argument array instead of exec with a
|
|
5065
|
+
// shell string to prevent command injection through the
|
|
5066
|
+
// host/port values (which come from env vars).
|
|
5067
|
+
const { spawn: spawnBrowser } = await import('node:child_process');
|
|
5068
|
+
const browserBin = process.platform === 'darwin' ? 'open'
|
|
5069
|
+
: process.platform === 'win32' ? 'cmd'
|
|
5070
|
+
: 'xdg-open';
|
|
5071
|
+
const browserArgs = process.platform === 'win32'
|
|
5072
|
+
? ['/c', 'start', '', openUrl]
|
|
5073
|
+
: [openUrl];
|
|
5074
|
+
spawnBrowser(browserBin, browserArgs, { stdio: 'ignore', timeout: 3000, detached: true, shell: false }).unref();
|
|
4881
5075
|
console.log(`${c.ok('[OK]')} Opening browser at ${c.bright(openUrl)}`);
|
|
4882
5076
|
}
|
|
4883
5077
|
catch {
|