From c9341eda3fe1831862c7c672afb2970040fba010 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 04:09:09 +0200 Subject: [PATCH] fix: remove RCE vector from admin update endpoint. The POST /api/admin/update endpoint ran git pull, npm install, and npm run build via execSync, potentially giving any compromised admin account full code execution on the host in case repository is compromised. TREK ships as a Docker image so runtime self-updating is unnecessary. - Remove the /update route and child_process import from admin.ts - Remove the installUpdate API client method - Replace the live-update modal with an info-only panel showing docker pull instructions and a link to the GitHub release - Drop the updating/updateResult state and handleInstallUpdate handler --- client/src/api/client.ts | 1 - client/src/pages/AdminPage.tsx | 238 +++++++++------------------------ server/src/routes/admin.ts | 44 ------ 3 files changed, 60 insertions(+), 223 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 9c414d0..f42b0c3 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -163,7 +163,6 @@ export const adminApi = { addons: () => apiClient.get('/admin/addons').then(r => r.data), updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), - installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index b1c44db..3c5e928 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -16,7 +16,7 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager' import AuditLogPanel from '../components/Admin/AuditLogPanel' import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel' import PermissionsPanel from '../components/Admin/PermissionsPanel' -import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' +import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' interface AdminUser { @@ -121,8 +121,6 @@ export default function AdminPage(): React.ReactElement { // Version check & update const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) - const [updating, setUpdating] = useState(false) - const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null) const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() const navigate = useNavigate() @@ -177,26 +175,6 @@ export default function AdminPage(): React.ReactElement { } } - const handleInstallUpdate = async () => { - setUpdating(true) - setUpdateResult(null) - try { - await adminApi.installUpdate() - setUpdateResult('success') - // Server is restarting — poll until it comes back, then reload - const poll = setInterval(async () => { - try { - await authApi.getAppConfig() - clearInterval(poll) - window.location.reload() - } catch { /* still restarting */ } - }, 2000) - } catch { - setUpdateResult('error') - setUpdating(false) - } - } - const handleToggleRegistration = async (value) => { setAllowRegistration(value) try { @@ -394,23 +372,13 @@ export default function AdminPage(): React.ReactElement { {t('admin.update.button')} )} - {updateInfo.is_docker ? ( - - ) : ( - - )} + )} @@ -1303,78 +1271,37 @@ export default function AdminPage(): React.ReactElement { )} - {/* Update confirmation popup — matches backup restore style */} + {/* Update instructions popup */} {showUpdateModal && (
{ if (!updating) setShowUpdateModal(false) }} + onClick={() => setShowUpdateModal(false)} >
e.stopPropagation()} style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }} className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700" > - {updateResult === 'success' ? ( - <> -
-
- -
-
-

{t('admin.update.success')}

-
-
-
- -

{t('admin.update.reloadHint')}

-
- - ) : updateResult === 'error' ? ( - <> -
-
- -
-
-

{t('admin.update.failed')}

-
-
-
- -
- - ) : ( - <> - {/* Red header */} -
-
- -
-
-

{t('admin.update.confirmTitle')}

-

- v{updateInfo?.current} → v{updateInfo?.latest} -

-
-
+
+
+ +
+
+

{t('admin.update.howTo')}

+

+ v{updateInfo?.current} → v{updateInfo?.latest} +

+
+
- {/* Body */} -
- {updateInfo?.is_docker ? ( - <> -

- {t('admin.update.dockerText').replace('{version}', `v${updateInfo.latest}`)} -

+
+

+ {t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)} +

-
+
{`docker pull mauriceboe/nomad:latest docker stop nomad && docker rm nomad docker run -d --name nomad \\ @@ -1383,87 +1310,42 @@ docker run -d --name nomad \\ -v /opt/nomad/uploads:/app/uploads \\ --restart unless-stopped \\ mauriceboe/nomad:latest`} -
+
-
-
- - {t('admin.update.dataInfo')} -
-
- - ) : ( - <> -

- {updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)} -

- -
-
- - {t('admin.update.dataInfo')} -
-
- -
-
- - - {t('admin.update.backupHint')}{' '} - - -
-
- -
-
- - {t('admin.update.warning')} -
-
- - )} +
+
+ + {t('admin.update.dataInfo')}
+
- {/* Footer */} -
- - {!updateInfo?.is_docker && ( - - )} + {updateInfo?.release_url && ( + - - )} + )} +
+ +
+ +
)} diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 674aa31..eac08dd 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,7 +1,6 @@ import express, { Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; -import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import { db } from '../db/database'; @@ -326,49 +325,6 @@ router.get('/version-check', async (_req: Request, res: Response) => { } }); -router.post('/update', async (req: Request, res: Response) => { - const rootDir = path.resolve(__dirname, '../../..'); - const serverDir = path.resolve(__dirname, '../..'); - const clientDir = path.join(rootDir, 'client'); - const steps: { step: string; success?: boolean; output?: string; version?: string }[] = []; - - try { - const pullOutput = execSync('git pull origin main', { cwd: rootDir, timeout: 60000, encoding: 'utf8' }); - steps.push({ step: 'git pull', success: true, output: pullOutput.trim() }); - - execSync('npm install --production --ignore-scripts', { cwd: serverDir, timeout: 120000, encoding: 'utf8' }); - steps.push({ step: 'npm install (server)', success: true }); - - if (process.env.NODE_ENV === 'production') { - execSync('npm install --ignore-scripts', { cwd: clientDir, timeout: 120000, encoding: 'utf8' }); - execSync('npm run build', { cwd: clientDir, timeout: 120000, encoding: 'utf8' }); - steps.push({ step: 'npm install + build (client)', success: true }); - } - - delete require.cache[require.resolve('../../package.json')]; - const { version: newVersion } = require('../../package.json'); - steps.push({ step: 'version', version: newVersion }); - - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.system_update', - resource: newVersion, - ip: getClientIp(req), - }); - res.json({ success: true, steps, restarting: true }); - - setTimeout(() => { - console.log('[Update] Restarting after update...'); - process.exit(0); - }, 1000); - } catch (err: unknown) { - console.error(err); - steps.push({ step: 'error', success: false, output: 'Internal error' }); - res.status(500).json({ success: false, steps }); - } -}); - // ── Invite Tokens ─────────────────────────────────────────────────────────── router.get('/invites', (_req: Request, res: Response) => {