Skip to content

Commit 69116ee

Browse files
authored
release(v3.1.3): ClamAV exclude paths (Admin + env) for upload scanning (answers #94)
- add VIRUS_SCAN_EXCLUDE_DIRS (env) + Admin setting to exclude upload paths from ClamAV scanning - support comma/newline-separated exclude paths relative to the source root - allow per-source excludes via `sourceId:/path` prefixes (Pro Sources) - apply excludes in UploadModel scan flow (local + shared-folder uploads) and lock Admin field when env is set
1 parent 79f3872 commit 69116ee

6 files changed

Lines changed: 234 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
# Changelog
22

3+
## Changes 01/20/2026 (v3.1.3)
4+
5+
`release(v3.1.3): ClamAV exclude paths (Admin + env) for upload scanning (answers #94)`
6+
7+
**Commit message**
8+
9+
```text
10+
release(v3.1.3): ClamAV exclude paths (Admin + env) for upload scanning (answers #94)
11+
12+
- add VIRUS_SCAN_EXCLUDE_DIRS (env) + Admin setting to exclude upload paths from ClamAV scanning
13+
- support comma/newline-separated exclude paths relative to the source root
14+
- allow per-source excludes via `sourceId:/path` prefixes (Pro Sources)
15+
- apply excludes in UploadModel scan flow (local + shared-folder uploads) and lock Admin field when env is set
16+
```
17+
18+
**Added**
19+
20+
- **ClamAV exclude paths setting**
21+
- Admin setting: **Exclude upload paths** (`clamav.excludeDirs`)
22+
- Env override: `VIRUS_SCAN_EXCLUDE_DIRS` (locks the Admin field when set)
23+
- Input format: comma or newline-separated paths **relative to the source root**
24+
- Examples: `snapshot`, `tmp`
25+
- Pro Sources: prefix with a source id: `s3:/snapshot`, `gdrive:/tmp`
26+
27+
**Changed**
28+
29+
- **Upload virus scan now checks excludes before running ClamAV**
30+
- Exclude rules are normalized (trim, normalize slashes, strip leading/trailing `/`)
31+
- Rules can optionally target a specific source id; otherwise they apply to the active source
32+
- **Shared-folder uploads pass folder context into the scan**
33+
- Shared uploads now reuse the same exclude logic by providing the destination folder key.
34+
35+
**Notes**
36+
37+
- Excludes match against the *destination folder path* (relative to the source root). Keep patterns simple (short paths) for predictable behavior.
38+
- If `VIRUS_SCAN_EXCLUDE_DIRS` is set, it is treated as the source of truth and the Admin field is read-only.
39+
40+
---
41+
342
## Changes 01/20/2026 (v3.1.2)
443

544
`release(v3.1.2): configurable ignore rules for indexing/tree + admin UX polish (fixes #91, refs #92)`

public/js/adminPanel.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2378,6 +2378,7 @@ function captureInitialAdminConfig() {
23782378
ignoreRegex: (document.getElementById("ignoreRegex")?.value || "").trim(),
23792379

23802380
clamavScanUploads: !!document.getElementById("clamavScanUploads")?.checked,
2381+
clamavExcludeDirs: (document.getElementById("clamavExcludeDirs")?.value || "").trim(),
23812382
proSearchEnabled: !!document.getElementById("proSearchEnabled")?.checked,
23822383
proSearchLimit: (document.getElementById("proSearchLimit")?.value || "").trim(),
23832384
proAuditEnabled: !!document.getElementById("proAuditEnabled")?.checked,
@@ -2423,6 +2424,7 @@ function hasUnsavedChanges() {
24232424
getVal("fileListSummaryDepth") !== (o.fileListSummaryDepth || "") ||
24242425
getVal("ignoreRegex") !== (o.ignoreRegex || "") ||
24252426
getChk("clamavScanUploads") !== o.clamavScanUploads ||
2427+
getVal("clamavExcludeDirs") !== (o.clamavExcludeDirs || "") ||
24262428
getChk("proSearchEnabled") !== o.proSearchEnabled ||
24272429
getVal("proSearchLimit") !== o.proSearchLimit ||
24282430
getChk("proAuditEnabled") !== o.proAuditEnabled ||
@@ -4691,6 +4693,28 @@ export function openAdminPanel() {
46914693
</small>
46924694
</div>
46934695
4696+
<div class="form-group" style="margin-top:10px;">
4697+
<label for="clamavExcludeDirs">
4698+
${tf("clamav_exclude_dirs_label", "Exclude upload paths")}
4699+
</label>
4700+
<textarea
4701+
id="clamavExcludeDirs"
4702+
class="form-control"
4703+
rows="2"
4704+
placeholder="${escapeHTML(tf("clamav_exclude_dirs_placeholder", "snapshot, tmp"))}"
4705+
></textarea>
4706+
<small
4707+
id="clamavExcludeDirsHelp"
4708+
class="d-block text-muted"
4709+
style="margin-top:2px;"
4710+
>
4711+
${tf(
4712+
"clamav_exclude_dirs_help",
4713+
"Comma or newline separated paths relative to the source root (example: snapshot, tmp). For Pro sources you can prefix with source id (example: s3:/snapshot)."
4714+
)}
4715+
</small>
4716+
</div>
4717+
46944718
<div class="mt-2">
46954719
<button
46964720
type="button"
@@ -6018,6 +6042,18 @@ ${t("shared_max_upload_size_bytes")}
60186042
}
60196043
}
60206044
}
6045+
const clamExclude = document.getElementById("clamavExcludeDirs");
6046+
if (clamExclude) {
6047+
clamExclude.value = (cfgClam.excludeDirs || "").toString();
6048+
if (cfgClam.excludeLockedByEnv) {
6049+
clamExclude.disabled = true;
6050+
const help = document.getElementById("clamavExcludeDirsHelp");
6051+
if (help) {
6052+
help.textContent =
6053+
'Controlled by container env VIRUS_SCAN_EXCLUDE_DIRS. Change it in your Docker/host env.';
6054+
}
6055+
}
6056+
}
60216057
// Rebuild ONLYOFFICE section from fresh config
60226058
initOnlyOfficeUI({ config });
60236059

@@ -6121,6 +6157,23 @@ ${t("shared_max_upload_size_bytes")}
61216157
}
61226158
}
61236159
}
6160+
const clamExclude = document.getElementById("clamavExcludeDirs");
6161+
if (clamExclude) {
6162+
clamExclude.value = (cfgClam.excludeDirs || "").toString();
6163+
clamExclude.disabled = false;
6164+
const help = document.getElementById("clamavExcludeDirsHelp");
6165+
if (help) {
6166+
help.textContent =
6167+
'Comma or newline separated paths relative to the source root (example: snapshot, tmp). For Pro sources you can prefix with source id (example: s3:/snapshot).';
6168+
}
6169+
if (cfgClam.excludeLockedByEnv) {
6170+
clamExclude.disabled = true;
6171+
if (help) {
6172+
help.textContent =
6173+
'Controlled by container env VIRUS_SCAN_EXCLUDE_DIRS. Change it in your Docker/host env.';
6174+
}
6175+
}
6176+
}
61246177
const uploadScope = document.getElementById("uploadContent");
61256178
const headerSettingsScope = document.getElementById("headerSettingsContent");
61266179
wireIgnoreRegexPresetButton(headerSettingsScope);
@@ -6318,6 +6371,7 @@ function handleSave() {
63186371
},
63196372
clamav: {
63206373
scanUploads: document.getElementById("clamavScanUploads").checked,
6374+
excludeDirs: (document.getElementById("clamavExcludeDirs")?.value || "").trim(),
63216375
},
63226376
proSearch: {
63236377
enabled: !!document.getElementById("proSearchEnabled")?.checked,

src/controllers/AdminController.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,6 +1768,7 @@ public function updateConfig(): void
17681768
],
17691769
'clamav' => [
17701770
'scanUploads' => false,
1771+
'excludeDirs' => '',
17711772
],
17721773
'proAudit' => [
17731774
'enabled' => false,
@@ -2033,6 +2034,8 @@ public function updateConfig(): void
20332034
// --- ClamAV: store admin toggle only when not locked by env/constant ---
20342035
$envScanRaw = getenv('VIRUS_SCAN_ENABLED');
20352036
$clamLockedEnv = ($envScanRaw !== false && $envScanRaw !== '') || defined('VIRUS_SCAN_ENABLED');
2037+
$envExcludeRaw = getenv('VIRUS_SCAN_EXCLUDE_DIRS');
2038+
$clamExcludeLockedEnv = ($envExcludeRaw !== false && trim((string)$envExcludeRaw) !== '');
20362039

20372040
if (!$clamLockedEnv && isset($data['clamav']) && is_array($data['clamav'])) {
20382041
if (array_key_exists('scanUploads', $data['clamav'])) {
@@ -2042,6 +2045,13 @@ public function updateConfig(): void
20422045
);
20432046
}
20442047
}
2048+
if (!$clamExcludeLockedEnv && isset($data['clamav']) && is_array($data['clamav'])) {
2049+
if (array_key_exists('excludeDirs', $data['clamav'])) {
2050+
$rawExclude = (string)$data['clamav']['excludeDirs'];
2051+
$rawExclude = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $rawExclude);
2052+
$merged['clamav']['excludeDirs'] = trim((string)$rawExclude);
2053+
}
2054+
}
20452055

20462056
// --- Pro Search Everywhere: respect env lock, otherwise persist toggle/limit ---
20472057
$envProSearch = getenv('FR_PRO_SEARCH_ENABLED');

src/controllers/FolderController.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1652,7 +1652,17 @@ public function uploadToSharedFolder(): void
16521652
}
16531653

16541654
// ---- ClamAV: reuse UploadModel scan logic on the tmp file ----
1655-
$scan = UploadModel::scanSingleUploadIfEnabled($fileUpload);
1655+
$shareRecord = FolderModel::getShareFolderRecord($token);
1656+
$shareFolderKey = 'root';
1657+
if (is_array($shareRecord)) {
1658+
$rawFolder = trim((string)($shareRecord['folder'] ?? ''), "/\\ ");
1659+
$shareFolderKey = ($rawFolder === '' ? 'root' : $rawFolder);
1660+
}
1661+
$scan = UploadModel::scanSingleUploadIfEnabled($fileUpload, [
1662+
'folder' => $shareFolderKey,
1663+
'file' => $basename,
1664+
'source' => 'shared',
1665+
]);
16561666
if (is_array($scan) && isset($scan['error'])) {
16571667
// scanSingleUploadIfEnabled() already deletes the tmp file on infection
16581668
http_response_code(400);

src/models/AdminModel.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,15 @@ public static function buildPublicSubset(array $config): array
226226
$clamLockedByEnv = false;
227227
}
228228

229+
$envExcludeRaw = getenv('VIRUS_SCAN_EXCLUDE_DIRS');
230+
if ($envExcludeRaw !== false && trim((string)$envExcludeRaw) !== '') {
231+
$clamExcludeDirs = trim((string)$envExcludeRaw);
232+
$clamExcludeLockedByEnv = true;
233+
} else {
234+
$clamExcludeDirs = (string)($config['clamav']['excludeDirs'] ?? '');
235+
$clamExcludeLockedByEnv = false;
236+
}
237+
229238
// Pro search (public awareness + env lock)
230239
$proSearchCfg = isset($config['proSearch']) && is_array($config['proSearch'])
231240
? $config['proSearch']
@@ -255,6 +264,8 @@ public static function buildPublicSubset(array $config): array
255264
$public['clamav'] = [
256265
'scanUploads' => $clamScanUploads,
257266
'lockedByEnv' => $clamLockedByEnv,
267+
'excludeDirs' => $clamExcludeDirs,
268+
'excludeLockedByEnv' => $clamExcludeLockedByEnv,
258269
];
259270

260271
$public['proSearch'] = [
@@ -403,13 +414,20 @@ public static function updateConfig(array $configUpdate): array
403414
$configUpdate['uploads']['resumableChunkMb'] = max(0.5, min(100, $num));
404415
}
405416

406-
// ---- ClamAV (simple boolean flag) ----
417+
// ---- ClamAV (upload scan toggle + exclude list) ----
407418
if (!isset($configUpdate['clamav']) || !is_array($configUpdate['clamav'])) {
408419
$configUpdate['clamav'] = [
409420
'scanUploads' => false,
421+
'excludeDirs' => '',
410422
];
411423
} else {
412424
$configUpdate['clamav']['scanUploads'] = !empty($configUpdate['clamav']['scanUploads']);
425+
$rawExclude = $configUpdate['clamav']['excludeDirs'] ?? '';
426+
if (!is_string($rawExclude)) {
427+
$rawExclude = '';
428+
}
429+
$rawExclude = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $rawExclude);
430+
$configUpdate['clamav']['excludeDirs'] = trim((string)$rawExclude);
413431
}
414432

415433
// Normalize authBypass & authHeaderName
@@ -901,6 +919,7 @@ public static function getConfig(): array
901919
],
902920
'clamav' => [
903921
'scanUploads' => false,
922+
'excludeDirs' => '',
904923
],
905924
'publishedUrl' => '',
906925
'ffmpegPath' => '',

src/models/UploadModel.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,102 @@ private static function isVirusScanEnabled(): bool
211211
return !empty($cfg['clamav']['scanUploads']);
212212
}
213213

214+
private static function getVirusScanExcludeRules(): array
215+
{
216+
static $rules = null;
217+
if ($rules !== null) {
218+
return $rules;
219+
}
220+
221+
$raw = '';
222+
$env = getenv('VIRUS_SCAN_EXCLUDE_DIRS');
223+
if ($env !== false && trim((string)$env) !== '') {
224+
$raw = (string)$env;
225+
} elseif (class_exists('AdminModel')) {
226+
$cfg = AdminModel::getConfig();
227+
if (is_array($cfg) && !isset($cfg['error'])) {
228+
$raw = (string)($cfg['clamav']['excludeDirs'] ?? '');
229+
}
230+
}
231+
232+
$rules = [];
233+
if (trim($raw) !== '') {
234+
$parts = preg_split('/[,\r\n]+/', $raw);
235+
if (is_array($parts)) {
236+
foreach ($parts as $entry) {
237+
$entry = trim((string)$entry);
238+
if ($entry === '') {
239+
continue;
240+
}
241+
242+
$source = '';
243+
$path = $entry;
244+
if (strpos($entry, ':') !== false) {
245+
[$maybeSource, $rest] = explode(':', $entry, 2);
246+
$maybeSource = trim($maybeSource);
247+
if ($maybeSource !== '' && preg_match('/^[A-Za-z0-9_-]{1,64}$/', $maybeSource)) {
248+
$source = $maybeSource;
249+
$path = $rest;
250+
}
251+
}
252+
253+
$path = self::normalizeVirusScanExcludePath($path);
254+
if ($path === '') {
255+
continue;
256+
}
257+
258+
$rules[] = [
259+
'source' => $source,
260+
'path' => $path,
261+
];
262+
}
263+
}
264+
}
265+
266+
return $rules;
267+
}
268+
269+
private static function normalizeVirusScanExcludePath(string $path): string
270+
{
271+
$norm = str_replace('\\', '/', trim($path));
272+
return trim($norm, "/ \t\n\r\0\x0B");
273+
}
274+
275+
private static function isVirusScanExcluded(array $context): bool
276+
{
277+
$rules = self::getVirusScanExcludeRules();
278+
if (empty($rules)) {
279+
return false;
280+
}
281+
282+
$folder = trim((string)($context['folder'] ?? ''));
283+
if ($folder === '') {
284+
return false;
285+
}
286+
$folder = self::normalizeVirusScanExcludePath($folder);
287+
if ($folder === '') {
288+
return false;
289+
}
290+
291+
$activeSource = class_exists('SourceContext') ? SourceContext::getActiveId() : 'local';
292+
293+
foreach ($rules as $rule) {
294+
$ruleSource = (string)($rule['source'] ?? '');
295+
if ($ruleSource !== '' && $ruleSource !== $activeSource) {
296+
continue;
297+
}
298+
$path = (string)($rule['path'] ?? '');
299+
if ($path === '') {
300+
continue;
301+
}
302+
if ($folder === $path || str_starts_with($folder, $path . '/')) {
303+
return true;
304+
}
305+
}
306+
307+
return false;
308+
}
309+
214310
/**
215311
* Public helper: scan a single $_FILES-style upload array, if ClamAV is enabled.
216312
*
@@ -774,6 +870,10 @@ private static function scanFileIfEnabled(string $path, array $context = []): ?a
774870
return ['error' => 'Virus scan failed: uploaded file not found.'];
775871
}
776872

873+
if (self::isVirusScanExcluded($context)) {
874+
return null; // excluded path
875+
}
876+
777877
$cmd = defined('VIRUS_SCAN_CMD') ? VIRUS_SCAN_CMD : 'clamscan';
778878

779879
$cmdline = escapeshellcmd($cmd)

0 commit comments

Comments
 (0)