Skip to content

Commit 1acbfa8

Browse files
author
manfredsteger
committed
Add a warning for ClamAV connection failures before saving
Implement a new backend endpoint to test ClamAV configuration and add frontend logic to warn users if the connection fails before saving, including EICAR test file detection in backend tests. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 1117a91e-7ac6-4005-bde2-487c64d5789f Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 53245065-701d-4a45-bb81-0897def75eff Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/afc5b6d1-cfc6-4564-802f-661c3d73f96b/1117a91e-7ac6-4005-bde2-487c64d5789f/LKYJprC
1 parent 6818109 commit 1acbfa8

File tree

6 files changed

+172
-8
lines changed

6 files changed

+172
-8
lines changed

client/src/components/admin/settings/SecuritySettingsPanel.tsx

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,9 @@ export function SecuritySettingsPanel({ onBack }: { onBack: () => void }) {
282282
const [clamavTimeout, setClamavTimeout] = useState(30);
283283
const [clamavMaxFileSize, setClamavMaxFileSize] = useState(25);
284284
const [clamavTestResult, setClamavTestResult] = useState<ClamAVTestResult | null>(null);
285+
const [showClamavWarning, setShowClamavWarning] = useState(false);
286+
const [pendingClamavSave, setPendingClamavSave] = useState<Partial<ClamAVConfig> | null>(null);
287+
const [isTesting, setIsTesting] = useState(false);
285288

286289
const [showScanLogs, setShowScanLogs] = useState(false);
287290
const [scanLogFilter, setScanLogFilter] = useState<string>('all');
@@ -344,14 +347,53 @@ export function SecuritySettingsPanel({ onBack }: { onBack: () => void }) {
344347
}
345348
});
346349

347-
const handleSaveClamav = () => {
348-
saveClamavMutation.mutate({
350+
const handleSaveClamav = async () => {
351+
const configData = {
349352
enabled: clamavEnabled,
350353
host: clamavHost,
351354
port: clamavPort,
352355
timeout: clamavTimeout * 1000,
353356
maxFileSize: clamavMaxFileSize * 1024 * 1024,
354-
});
357+
};
358+
359+
if (clamavEnabled) {
360+
setIsTesting(true);
361+
try {
362+
const response = await apiRequest('POST', '/api/v1/admin/clamav/test-config', {
363+
host: clamavHost,
364+
port: clamavPort,
365+
timeout: clamavTimeout * 1000,
366+
});
367+
const result = await response.json() as ClamAVTestResult;
368+
if (!result.success) {
369+
setPendingClamavSave(configData);
370+
setShowClamavWarning(true);
371+
setIsTesting(false);
372+
return;
373+
}
374+
} catch {
375+
setPendingClamavSave(configData);
376+
setShowClamavWarning(true);
377+
setIsTesting(false);
378+
return;
379+
}
380+
setIsTesting(false);
381+
}
382+
383+
saveClamavMutation.mutate(configData);
384+
};
385+
386+
const handleForceSaveClamav = () => {
387+
if (pendingClamavSave) {
388+
saveClamavMutation.mutate(pendingClamavSave);
389+
}
390+
setShowClamavWarning(false);
391+
setPendingClamavSave(null);
392+
};
393+
394+
const handleCancelClamavSave = () => {
395+
setShowClamavWarning(false);
396+
setPendingClamavSave(null);
355397
};
356398

357399
const [deprovisionEnabled, setDeprovisionEnabled] = useState(false);
@@ -716,8 +758,8 @@ export function SecuritySettingsPanel({ onBack }: { onBack: () => void }) {
716758
{testClamavMutation.isPending ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />{t('admin.security.testing')}</> : <><RefreshCw className="w-4 h-4 mr-2" />{t('admin.security.testConnection')}</>}
717759
</Button>
718760

719-
<Button onClick={handleSaveClamav} disabled={saveClamavMutation.isPending} className="polly-button-primary" data-testid="button-save-clamav">
720-
{saveClamavMutation.isPending ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />{t('common.saving')}</> : t('admin.security.saveClamav')}
761+
<Button onClick={handleSaveClamav} disabled={saveClamavMutation.isPending || isTesting} className="polly-button-primary" data-testid="button-save-clamav">
762+
{(saveClamavMutation.isPending || isTesting) ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />{isTesting ? t('admin.security.testingBeforeSave') : t('common.saving')}</> : t('admin.security.saveClamav')}
721763
</Button>
722764
</div>
723765

@@ -889,6 +931,29 @@ export function SecuritySettingsPanel({ onBack }: { onBack: () => void }) {
889931
)}
890932
</CardContent>
891933
</Card>
934+
935+
<AlertDialog open={showClamavWarning} onOpenChange={setShowClamavWarning}>
936+
<AlertDialogContent>
937+
<AlertDialogHeader>
938+
<AlertDialogTitle className="flex items-center gap-2">
939+
<AlertTriangle className="w-5 h-5 text-amber-500" />
940+
{t('admin.security.clamavWarningTitle')}
941+
</AlertDialogTitle>
942+
<AlertDialogDescription className="space-y-2">
943+
<p>{t('admin.security.clamavWarningDescription')}</p>
944+
<p className="font-medium text-destructive">{t('admin.security.clamavWarningConsequence')}</p>
945+
</AlertDialogDescription>
946+
</AlertDialogHeader>
947+
<AlertDialogFooter>
948+
<AlertDialogCancel onClick={handleCancelClamavSave}>
949+
{t('admin.security.clamavWarningCancel')}
950+
</AlertDialogCancel>
951+
<AlertDialogAction onClick={handleForceSaveClamav} className="bg-amber-600 hover:bg-amber-700">
952+
{t('admin.security.clamavWarningProceed')}
953+
</AlertDialogAction>
954+
</AlertDialogFooter>
955+
</AlertDialogContent>
956+
</AlertDialog>
892957
</div>
893958
);
894959
}

client/src/locales/de.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@
171171
"testConnection": "Verbindung testen",
172172
"testing": "Teste...",
173173
"saveClamav": "ClamAV speichern",
174+
"clamavWarningTitle": "ClamAV-Verbindung fehlgeschlagen",
175+
"clamavWarningDescription": "Die Verbindung zum ClamAV-Virenscanner konnte nicht hergestellt werden. Wenn Sie den Scanner trotzdem aktivieren, werden alle Datei-Uploads aus Sicherheitsgründen blockiert (Fail-Secure-Prinzip).",
176+
"clamavWarningConsequence": "Achtung: Kein Benutzer wird Bilder oder Dateien hochladen können, solange der Scanner aktiviert, aber nicht erreichbar ist!",
177+
"clamavWarningCancel": "Abbrechen und korrigieren",
178+
"clamavWarningProceed": "Trotzdem aktivieren",
179+
"testingBeforeSave": "Verbindung wird geprüft...",
174180
"dataRetentionDescription": "Konfigurieren Sie die automatische Bereinigung alter Daten getrennt nach Benutzertyp (DSGVO Art. 5)",
175181
"guestUsers": "Gastnutzer",
176182
"anonymous": "Anonym",

client/src/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@
171171
"testConnection": "Test connection",
172172
"testing": "Testing...",
173173
"saveClamav": "Save ClamAV",
174+
"clamavWarningTitle": "ClamAV Connection Failed",
175+
"clamavWarningDescription": "The connection to the ClamAV virus scanner could not be established. If you enable the scanner anyway, all file uploads will be blocked for security reasons (fail-secure principle).",
176+
"clamavWarningConsequence": "Warning: No user will be able to upload images or files as long as the scanner is enabled but unreachable!",
177+
"clamavWarningCancel": "Cancel and correct",
178+
"clamavWarningProceed": "Enable anyway",
179+
"testingBeforeSave": "Testing connection...",
174180
"dataRetentionDescription": "Configure automatic cleanup of old data by user type (GDPR Art. 5)",
175181
"guestUsers": "Guest Users",
176182
"anonymous": "Anonymous",

server/routes/admin.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,24 @@ router.post('/clamav/test', requireAdmin, async (req, res) => {
501501
}
502502
});
503503

504+
router.post('/clamav/test-config', requireAdmin, async (req, res) => {
505+
try {
506+
const { host, port, timeout } = req.body;
507+
if (!host || !port) {
508+
return res.status(400).json({ success: false, message: 'Host and port are required' });
509+
}
510+
const result = await clamavService.testConnectionWithConfig(
511+
host,
512+
parseInt(port, 10) || 3310,
513+
parseInt(timeout, 10) || 30000
514+
);
515+
res.json(result);
516+
} catch (error) {
517+
console.error('Error testing ClamAV config:', error);
518+
res.status(500).json({ success: false, message: 'Internal error' });
519+
}
520+
});
521+
504522
router.get('/clamav/scan-logs', requireAdmin, async (req, res) => {
505523
try {
506524
const { limit, offset, status, startDate, endDate } = req.query;

server/services/clamavService.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ export class ClamAVService {
9898
return { success: false, message: 'ClamAV is disabled' };
9999
}
100100

101+
return this.testConnectionWithConfig(config.host, config.port, config.timeout);
102+
}
103+
104+
async testConnectionWithConfig(host: string, port: number, timeout: number): Promise<{ success: boolean; message: string; responseTime?: number; unavailable?: boolean }> {
101105
const startTime = Date.now();
102106

103107
return new Promise((resolve) => {
@@ -111,14 +115,13 @@ export class ClamAVService {
111115
}
112116
};
113117

114-
client.setTimeout(config.timeout);
118+
client.setTimeout(timeout);
115119

116120
client.on('connect', () => {
117121
client.write('zPING\0');
118122
});
119123

120124
client.on('data', (data) => {
121-
// Remove null bytes and whitespace - ClamAV sends PONG\0
122125
const response = data.toString().replace(/\0/g, '').trim();
123126
cleanup();
124127
if (response === 'PONG') {
@@ -154,7 +157,7 @@ export class ClamAVService {
154157
});
155158
});
156159

157-
client.connect(config.port, config.host);
160+
client.connect(port, host);
158161
});
159162
}
160163

server/tests/services/clamavService.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,67 @@ describe('ClamAV Security - Fail-Secure Behavior', () => {
207207
});
208208
});
209209

210+
describe('EICAR Test Virus Detection', () => {
211+
it('should block EICAR test file when scanner is enabled (fail-secure)', async () => {
212+
await storage.setSetting({
213+
key: 'clamav_config',
214+
value: {
215+
enabled: true,
216+
host: '127.0.0.1',
217+
port: 19999,
218+
timeout: 2000,
219+
maxFileSize: 25 * 1024 * 1024,
220+
},
221+
});
222+
223+
const { clamavService } = await import('../../services/clamavService');
224+
clamavService.clearConfigCache();
225+
226+
const eicarSignature = 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*';
227+
const eicarBuffer = Buffer.from(eicarSignature);
228+
const result = await clamavService.scanBuffer(eicarBuffer, 'eicar.com.jpg');
229+
230+
expect(result.isClean).toBe(false);
231+
expect(result.scannerUnavailable).toBe(true);
232+
});
233+
234+
it('should allow EICAR test file when scanner is disabled', async () => {
235+
await storage.setSetting({
236+
key: 'clamav_config',
237+
value: {
238+
enabled: false,
239+
host: 'localhost',
240+
port: 3310,
241+
timeout: 5000,
242+
maxFileSize: 25 * 1024 * 1024,
243+
},
244+
});
245+
246+
const { ClamAVService } = await import('../../services/clamavService');
247+
const testService = new ClamAVService();
248+
(testService as any).configCache = null;
249+
(testService as any).configCacheTime = 0;
250+
251+
const eicarSignature = 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*';
252+
const eicarBuffer = Buffer.from(eicarSignature);
253+
const result = await testService.scanBuffer(eicarBuffer, 'eicar.com.jpg');
254+
255+
expect(result.isClean).toBe(true);
256+
});
257+
});
258+
259+
describe('testConnectionWithConfig', () => {
260+
it('should test connection with provided config (unreachable host)', async () => {
261+
const { ClamAVService } = await import('../../services/clamavService');
262+
const testService = new ClamAVService();
263+
264+
const result = await testService.testConnectionWithConfig('127.0.0.1', 19999, 2000);
265+
266+
expect(result.success).toBe(false);
267+
expect(result.unavailable).toBe(true);
268+
});
269+
});
270+
210271
describe('Admin API Endpoints', () => {
211272
it('should reject ClamAV scan-logs without auth', async () => {
212273
const response = await request(app).get('/api/v1/admin/clamav/scan-logs');
@@ -217,5 +278,10 @@ describe('ClamAV Security - Fail-Secure Behavior', () => {
217278
const response = await request(app).post('/api/v1/admin/clamav/test');
218279
expect(response.status).toBe(401);
219280
});
281+
282+
it('should reject ClamAV test-config without auth', async () => {
283+
const response = await request(app).post('/api/v1/admin/clamav/test-config');
284+
expect(response.status).toBe(401);
285+
});
220286
});
221287
});

0 commit comments

Comments
 (0)