Skip to content

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 is clamd. Schemes, ports, paths, credentials, and whitespace are rejected. The local .env recipe sets it to clamav. The Compose server service sets the same value, and that override applies only inside the container. The deploy workflow defaults an unset host to clamav before writing the overlay.
  • FILE_SCREENING_CLAMD_PORT — TCP port, default 3310.
  • FILE_SCREENING_CONNECT_TIMEOUT_MS — connection deadline, default 1000.
  • FILE_SCREENING_SCAN_TIMEOUT_MS — whole-scan deadline, default 10000.
  • FILE_SCREENING_STARTUP_TIMEOUT_MS — startup PING deadline, default 5000.

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. _base ships without bundled signatures; they live in the clamav-db volume at /var/lib/clamav and survive container recreation.
  • No ports:. clamd TCP is unauthenticated, so it is reachable only as clamav:3310 on the stack's own Docker network.
  • A healthcheck that sends PING to 127.0.0.1:3310 and prints what came back on failure. The image's bundled clamdcheck.sh is not used: it targets localhost and reports only "Unable to contact server".
  • start_period: 10m, because the first start downloads the full signature set before clamd listens.
  • The server depends on clamav being healthy at startup only (required: false, no restart: true). With the profile off, the server starts exactly as before. A later clamav outage 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:

COMPOSE_PROFILES=file-screening
FILE_SCREENING_MODE=clamd
FILE_SCREENING_CLAMD_HOST=clamav

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:

  1. Rejects any mode other than disabled or clamd before writing a host file. Unset means disabled.
  2. When the mode is clamd and no host is set, uses clamav.
  3. Validates every FILE_SCREENING_* variable and appends it to ~/floh/public/ci.env on CI, or ~/floh/public/qa.env on QA, with scripts/append-file-screening-overlay.mjs. The settings go to the public overlay, not .env, because the server stops reading process.env for non-secret config once the overlay is loaded.
  4. Only when the host is clamav, appends COMPOSE_PROFILES=file-screening to ~/floh/.env so the bundled service starts. An external host leaves the profile off and the clamav container removed.
  5. Starts the bundled clamav service 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 422 and 503 bodies never carry a signature name, a clamd reply, a host, or an error message. Re-uploading a file that drew a 422 cannot succeed. A 503 says nothing about the file.
  • Audit event. ticket.attachment_rejected_malware is 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:manage check and the non-disclosing 404 run 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_MODE Actions variable to disabled and re-run Deploy. On QA, set QA_FILE_SCREENING_MODE to disabled on the qa environment. FILE_SCREENING_MODE on qa does not change that deploy. The explicit value is appended after the committed default and wins, so the workflow stops and removes the clamav container. Removing or unsetting the CI Actions variable is not a rollback: the committed ci.env value enables screening again. A restart alone keeps the value already written into the host's ci.env overlay.
  • Locally: set FILE_SCREENING_MODE=disabled (or remove the three file-screening lines) in .env, then run node scripts/compose-with-env.mjs rm --stop --force clamav and pnpm 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:

compose() { node scripts/compose-with-env.mjs "$@"; }

Steps 5–7 interrupt screening and restart the server, so run them on dev, not production.

  1. The profile controls the service. clamav is listed only while COMPOSE_PROFILES=file-screening is set:
compose config --services | grep -x clamav
  1. 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: '…').

  1. The port is not published. This prints nothing:
docker port "$(compose ps -q clamav)"
  1. 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.

  1. 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.

  1. Recovery needs no Floh restart. Start clamav again, then repeat step 4 and the docker inspect line from step 5:
compose up -d --wait --wait-timeout 300 clamav

Step 4 prints clean / infected again, and the server's restart count and start time are unchanged.

  1. Startup fails closed. With clamav stopped, restart the server and read its log. Startup aborts with File screening health check connection failed (or timed out while connecting). Then bring both back:
compose stop clamav
compose restart server
compose logs --tail=30 server
compose up -d --wait clamav server
  1. Signatures persist. Recreate the container. It becomes healthy in seconds, without a full download in its log:
compose up -d --force-recreate --wait --wait-timeout 300 clamav
compose logs --tail=30 clamav

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. Read compose 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 while FILE_SCREENING_MODE is unset. Fix the Actions variable and redeploy.
  • Server exits at startup with File screening health check …. Mode is clamd but clamd is not running, not healthy, or not reachable at the configured host. Start it, or roll back.
  • Locally, clamav never starts. Compose did not see COMPOSE_PROFILES; start the stack through pnpm dev:docker:up rather than a bare docker 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.