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