File screening¶
Floh has a server-internal file-screening provider that can be disabled or
connected to a self-hosted clamd daemon. Both Compose stacks can run that
daemon as an opt-in clamav service. When the mode is clamd, ticket
attachment uploads are screened before anything is stored (see
Ticket attachment enforcement). Workflow
document uploads are not screened.
Configuration¶
File screening defaults to disabled in every environment. Enable it with
FILE_SCREENING_MODE=clamd and the settings below.
Local development (NODE_ENV=development) reads these from .env, because
non-secret process.env values still apply when the public bundle is loaded.
A deployed server runs with APP_ENV=ci and PUBLIC_CONFIG_DIR mounted, and
in that mode a process.env value is ignored. Set the same names as GitHub
Actions variables on the deploy environment. The deploy workflow appends them
to the public ci.env overlay. Leave the variables unset to keep the provider
disabled. Do not assign them in config/public/base.env; a non-blank value
there would also hide a local .env override.
FILE_SCREENING_CLAMD_HOST— required hostname or IP address when the mode isclamd. Schemes, ports, paths, credentials, and whitespace are rejected. The local.envrecipe sets it toclamav. The Composeserverservice sets the same value, and that override applies only inside the container. The deploy workflow defaults an unset host toclamavbefore writing the overlay.FILE_SCREENING_CLAMD_PORT— TCP port, default3310.FILE_SCREENING_CONNECT_TIMEOUT_MS— connection deadline, default1000.FILE_SCREENING_SCAN_TIMEOUT_MS— whole-scan deadline, default10000.FILE_SCREENING_STARTUP_TIMEOUT_MS— startup PING deadline, default5000.
Ports must be integers from 1 through 65535. Timeouts must be positive integer milliseconds no greater than 300000. The connect timeout cannot exceed either operation deadline.
Deployment¶
Both docker/docker-compose.yml and docker/docker-compose.deploy.yml define a
clamav service behind the file-screening Compose profile:
- Image
clamav/clamav:1.5_base, pinned to a minor line._baseships without bundled signatures; they live in theclamav-dbvolume at/var/lib/clamavand survive container recreation. - No
ports:. clamd TCP is unauthenticated, so it is reachable only asclamav:3310on the stack's own Docker network. - A healthcheck that sends
PINGto127.0.0.1:3310and prints what came back on failure. The image's bundledclamdcheck.shis not used: it targetslocalhostand reports only "Unable to contact server". start_period: 10m, because the first start downloads the full signature set before clamd listens.- The
serverdepends onclamavbeing healthy at startup only (required: false, norestart: true). With the profile off, the server starts exactly as before. A laterclamavoutage fails scans but never restarts or unhealthies the API.
To use the bundled service, the profile and the mode must be turned on
together. The profile alone starts a daemon nothing calls; the mode alone makes
the server refuse to start, because clamav does not exist.
Enabling locally¶
Add these lines to the repo-root .env:
The host has to be in that file. pnpm migrate:latest runs on the host and
calls loadConfig(), which rejects FILE_SCREENING_MODE=clamd when
FILE_SCREENING_CLAMD_HOST is unset. FILE_SCREENING_CLAMD_HOST: clamav in
docker/docker-compose.yml applies only inside the server container, so it
does not satisfy the host-side check.
Then start the stack with pnpm dev:docker:up. That script passes the root
.env to Compose as --env-file, which is what makes Compose read
COMPOSE_PROFILES; a bare docker compose -f docker/docker-compose.yml does
not read the root .env and will not start clamav.
Screening works only for the Compose server. A host-run server (pnpm dev)
cannot resolve clamav, and port 3310 is deliberately not published, so leave
FILE_SCREENING_MODE unset while developing outside Compose.
Enabling on the deployed host¶
On CI, set the Actions variable FILE_SCREENING_MODE to clamd and re-run
Deploy. On QA, set QA_FILE_SCREENING_MODE to clamd on the qa
environment and dispatch target qa. A QA deploy reads
QA_FILE_SCREENING_*. Setting FILE_SCREENING_MODE on qa leaves screening
off. Leave the host unset (FILE_SCREENING_CLAMD_HOST on CI,
QA_FILE_SCREENING_CLAMD_HOST on QA) to use the bundled clamav service, or
set that variable to an existing clamd on the private network. The workflow:
- Rejects any mode other than
disabledorclamdbefore writing a host file. Unset meansdisabled. - When the mode is
clamdand no host is set, usesclamav. - Validates every
FILE_SCREENING_*variable and appends it to~/floh/public/ci.envon CI, or~/floh/public/qa.envon QA, withscripts/append-file-screening-overlay.mjs. The settings go to the public overlay, not.env, because the server stops readingprocess.envfor non-secret config once the overlay is loaded. - Only when the host is
clamav, appendsCOMPOSE_PROFILES=file-screeningto~/floh/.envso the bundled service starts. An external host leaves the profile off and theclamavcontainer removed. - Starts the bundled
clamavservice and polls it for up to 12 minutes (180 × 4 seconds) before starting the API. The server's startup PING is 5 seconds, so waiting in the same loop as the other services would fail that PING while signatures are still downloading. The other services keep a 2-minute budget. The step itself allows 40 minutes.
The first enabled deploy is slow (see Sizing). Later deploys reuse the volume and become healthy in seconds.
Ticket attachment enforcement¶
POST /api/tickets/:id/attachments screens the uploaded bytes after the size
check and the magic-byte MIME check, and before the blob is written or the
metadata row is inserted. Only a file the scanner cleared is stored, so an
infected or unscanned new file can never be downloaded later.
| Scanner result | Response | Stored | Audit |
|---|---|---|---|
| Clean | 201, as before |
Yes | ticket.attachment_added |
Mode disabled |
201, as before |
Yes | ticket.attachment_added |
| Threat detected | 422 with code: "ATTACHMENT_MALWARE_DETECTED" |
No | ticket.attachment_rejected_malware |
| Timeout, connection, protocol or daemon failure | Generic 503; safe to retry once the scanner has recovered |
No | None (see the log and metrics below) |
- Fixed response text. The
422and503bodies never carry a signature name, a clamd reply, a host, or an error message. Re-uploading a file that drew a422cannot succeed. A503says nothing about the file. - Audit event.
ticket.attachment_rejected_malwareis written against the ticket. Its metadata holds only the ticket ID, the normalized MIME type, and the byte count. It never holds the filename, a hash, or a signature. - Scope of an outage. A scanner outage after startup affects only attachment uploads. Other routes, including listing and downloading attachments, and the health endpoint keep answering. Each scan opens a fresh connection, so uploads succeed again as soon as clamd answers, with no Floh restart.
- Authorization is unchanged. The requester-or-
ticket:managecheck and the non-disclosing404run before screening.
Metrics¶
Two instruments, with one attribute each. The floh.attachment.screening.outcome
attribute takes exactly five values: clean, infected, not_scanned
(screening disabled), timeout, and error (any other operational failure).
Filename, MIME type, ticket, and user are never attributes. See
Observability for the full inventory.
| Metric | Kind | Unit | Meaning |
|---|---|---|---|
floh.attachment.screening.completed |
Counter | 1 |
Screenings finished, by outcome |
floh.attachment.screening.duration |
Histogram | s |
Time from handing bytes to clamd to its verdict |
A rising share of timeout or error means clamd is unreachable or slow and
uploads are being refused. The server log carries one File screening failed;
refusing the attachment error line per refused upload, with the ticket ID and
a typed code (connection_failed, timeout, disconnected, protocol_error,
response_too_large, file_too_large, scanner_error).
Before you enable it¶
Enabling clamd changes upload acceptance, not just startup: from the next
deploy, uploads are refused while clamd is down. Roll back by setting the mode
to disabled, as described below.
Startup and rollback¶
When clamd mode is enabled, the server sends a bounded PING before opening its
database and Redis resources. Startup fails unless clamd returns PONG within
the configured deadline.
Rollback is configuration only, with no migration:
- Deployed host: on CI, set the
FILE_SCREENING_MODEActions variable todisabledand re-run Deploy. On QA, setQA_FILE_SCREENING_MODEtodisabledon theqaenvironment.FILE_SCREENING_MODEonqadoes not change that deploy. The explicit value is appended after the committed default and wins, so the workflow stops and removes theclamavcontainer. Removing or unsetting the CI Actions variable is not a rollback: the committedci.envvalue enables screening again. A restart alone keeps the value already written into the host'sci.envoverlay. - Locally: set
FILE_SCREENING_MODE=disabled(or remove the three file-screening lines) in.env, then runnode scripts/compose-with-env.mjs rm --stop --force clamavandpnpm dev:docker:up.
Disabled mode opens no scanner connection and preserves the pre-screening
startup behavior. The clamav-db volume is kept so re-enabling does not
re-download. To reclaim the space, remove it with
docker volume rm floh_clamav-db (both stacks use the Compose project floh).
Network and data handling¶
Clamd TCP has no authentication. Run it only on private operator-controlled networking, and never expose its port publicly. Floh accepts the destination only from startup configuration; callers cannot select or override it.
Each health check or scan uses a fresh TCP connection. INSTREAM requests have bounded input size, response size, and duration. Raw file bytes, clamd response text, and malware signatures are not logged or exposed outside the screening module.
End to end, file bytes enter only ClamdFileScreeningProvider.screen, travel
to clamav over the stack's Docker network, are scanned in memory, and are not
stored by clamd. The only traffic leaving the host from clamav is FreshClam
downloading signatures over outbound HTTPS. No file content is sent to a third
party.
Signature freshness¶
FreshClam runs inside the clamav container and updates the signatures in the
volume on the image's default schedule; clamd reloads them without a restart.
The host needs outbound HTTPS to the ClamAV mirrors. Do not delete the volume
routinely: the official mirrors rate-limit clients that re-download the full
set repeatedly.
To see the loaded engine and signature version, and the signature date:
compose exec -T clamav sh -c "printf 'VERSION\n' | nc -w 3 127.0.0.1 3310"
# ClamAV 1.5.x/<signature-version>/<signature-date>
Sizing¶
clamd holds about 1.2 GiB of signatures in memory and briefly doubles that
while reloading after an update. The deploy manifest reserves 1.5 GiB and caps
the container at 3 GiB. Confirm the host has that headroom (4 GB free is the
comfortable target) before enabling screening; an out-of-memory kill during a
reload shows up as clamav restarting and scans failing until it is healthy
again.
The first start downloads a few hundred MB of signatures. On the deployed host
this happens inside the Deploy workflow; locally, expect clamav to report
starting for several minutes.
Verification¶
Run these after enabling the bundled service, locally or on the deployed host.
On the deployed host, define the compose helper from
Running compose commands on the host.
Locally, use:
Steps 5–7 interrupt screening and restart the server, so run them on dev, not production.
- The profile controls the service.
clamavis listed only whileCOMPOSE_PROFILES=file-screeningis set:
- The healthcheck passes, and explains failure.
compose up -d --wait --wait-timeout 900 clamav
docker inspect --format '{{json .State.Health}}' "$(compose ps -aq clamav)"
Health.Log entries carry the probe's exit code and output. A failing probe
prints clamd did not answer PING on 127.0.0.1:3310 (reply: '…').
- The port is not published. This prints nothing:
- Floh's provider detects EICAR. This runs the provider shipped in the
server image against
clamav. The EICAR test string is assembled at run time so the command itself does not trip endpoint antivirus:
compose exec -T server node --input-type=module -e '
const { ClamdFileScreeningProvider } = await import("/app/packages/server/dist/modules/file-screening/clamd-provider.js");
const { resolveFileScreeningConfig } = await import("/app/packages/server/dist/config/file-screening-config.js");
const provider = new ClamdFileScreeningProvider(resolveFileScreeningConfig({ mode: "clamd", host: "clamav" }), 5242880);
const eicar = "X5O!P%@AP[4" + "\\" + "PZX54(P^)7CC)7}$EICAR-STANDARD-" + "ANTIVIRUS-TEST-FILE!$H+H*";
for (const [label, text] of [["benign", "hello from floh"], ["eicar", eicar]]) {
try { console.log(label, (await provider.screen(Buffer.from(text))).status); }
catch (error) { console.log(label, error.code ?? error.name); }
}'
Expected output is benign clean and eicar infected. It prints verdicts
and error codes only.
- An outage fails scans, not the API. Record the server's restart count,
stop
clamav, and check the health endpoint:
docker inspect --format '{{.RestartCount}} {{.State.StartedAt}}' "$(compose ps -q server)"
compose stop clamav
compose exec -T server wget -qO- http://127.0.0.1:7070/api/health
/api/health still answers, and re-running step 4 prints connection_failed
or timeout for both payloads.
- Recovery needs no Floh restart. Start
clamavagain, then repeat step 4 and thedocker inspectline from step 5:
Step 4 prints clean / infected again, and the server's restart count and
start time are unchanged.
- Startup fails closed. With
clamavstopped, restart the server and read its log. Startup aborts withFile screening health check connection failed(ortimed out while connecting). Then bring both back:
compose stop clamav
compose restart server
compose logs --tail=30 server
compose up -d --wait clamav server
- Signatures persist. Recreate the container. It becomes healthy in seconds, without a full download in its log:
Troubleshooting and signals¶
Application-level signals are the screening metrics and the refusal log line described under Ticket attachment enforcement. The container-level signals are:
| Signal | Where | Meaning |
|---|---|---|
| Screening outcomes | floh.attachment.screening.* |
Share of uploads that were infected, timeout, or error |
| Upload refusal log | compose logs server |
File screening failed; refusing the attachment with a typed screeningErrorCode |
clamav health |
compose ps clamav, Health.Log |
starting during the first download; unhealthy means clamd is not answering PING |
| FreshClam log lines | compose logs clamav |
Update successes and mirror or network failures |
| Signature date | VERSION command above |
How current the loaded signatures are |
| Server startup error | compose logs server |
File screening health check … means clamd was unreachable at boot |
clamav restart count |
docker inspect |
Repeated restarts usually mean memory pressure during a reload |
Common failures:
- Deploy fails with
clamav did not become healthy after startup. The first download did not finish in time or cannot reach the mirrors. Readcompose logs clamav, and confirm outbound HTTPS from the host. - Deploy fails with
vars.FILE_SCREENING_MODE must be 'disabled' or 'clamd'. The variable holds another value (values are case-sensitive and not trimmed). Nothing was written to the host. - Deploy fails with a
FILE_SCREENING_*message from the overlay step. A host, port, or timeout variable is malformed, or one is set whileFILE_SCREENING_MODEis unset. Fix the Actions variable and redeploy. - Server exits at startup with
File screening health check …. Mode isclamdbut clamd is not running, not healthy, or not reachable at the configured host. Start it, or roll back. - Locally,
clamavnever starts. Compose did not seeCOMPOSE_PROFILES; start the stack throughpnpm dev:docker:uprather than a baredocker compose.
Deliberate limitations¶
The provider does not call cloud scanners, persist scan results, rescan stored
files, or retry failed scans. Enforcement covers new ticket attachment uploads
only: workflow document uploads, attachments stored before screening was
enabled, and inbound email attachments are not screened. The deployment runs a
single clamav instance; several replicas
sharing one signature volume would contend on FreshClam updates and are not
supported.