Content Scanning
Content scanning hands a request body to a ClamAV daemon and refuses the request with 403 if it comes back with a signature. It runs in the proxy, in the request path, so infected content never reaches your storage, your queue or a downstream worker.
Which requests get scanned is decided by a rule, not by a config key.
Earlier releases had no working way to select traffic, and scanned every request body once enabled. If you are on an older build, upgrade before turning this on.
Scanning is rule-driven
Turning the scanner on scans nothing by itself. A WAF rule with the
content_scanning action is what selects a request:
starts_with(http.request.path, "/upload")
Give that rule the content_scanning action and bodies on /upload get scanned. Everything
else is untouched, and costs nothing.
That is the important property. The expense of body inspection lands only on traffic you named, so a JSON API with one upload endpoint pays for the upload endpoint rather than for every write.
enabled: true with no content_scanning rule is a configuration that looks armed and
inspects nothing, so the agent logs it on every load: content scanning is enabled but no WAF
rule uses the content_scanning action. If you see that line, the rule is missing.
content_scanning is terminal under first-match-wins, like every other action — a request
that matches an earlier rule never reaches it. It is also request-phase only; by the response
phase the body is gone.
What happens to a selected request
- The body is held while ClamAV inspects it — multipart requests field by field, anything else whole.
- A clean verdict forwards upstream.
- A signature hit returns 403, logs the event, and the upstream is never called.
The body is contained rather than streamed: it is held until the verdict arrives, so a flagged body does not reach the backend while the scan is still running.
Bodies that cannot be inspected
A rule can select a request whose body cannot actually be scanned — larger than
max_file_size, or chunked in a way that turns out to exceed it. unscannable decides what
happens then:
block (default) | Refuse with 413. A rule asked for this traffic to be scanned, and forwarding it unscanned is a hole that never announces itself. |
pass | Forward it unscanned, logging each time. Choose this where oversized uploads must keep working and you accept that they reach the backend uninspected. |
pass has a second effect worth knowing: it stops the proxy reading a chunked body up front,
because bytes read past the cap cannot be put back on the wire.
Memory, and why the ceiling is not a refusal
Containment means holding a body in memory until ClamAV answers, so max_file_size times the
number of simultaneous uploads is an out-of-memory condition waiting to happen.
scan_memory_budget (256 MB by default) caps the total held across all in-flight scans.
Crossing it does not refuse the request. The scan falls back to streaming — the client is still refused on a hit, but the bytes reach the backend while the scan runs.
unscannable is policy: you asked for a guarantee and it could not be provided, so the
default is to refuse. The memory ceiling is load shedding: refusing traffic because the proxy
is briefly busy would turn memory pressure into an outage. They are separate settings because
they answer different questions.
Verdict cache
A verdict is reused for an identical body for verdict_cache_ttl_secs (300 by default), so
re-uploading the same artifact skips the ClamAV round trip. verdict_cache_entries (8192)
bounds how many are held.
The TTL exists because a clean verdict is only as good as the signature database behind it —
it caps how long one can outlive a freshclam update. Failed scans are never cached. Set the
TTL to 0 to disable.
Configuration
proxy:
content_scanning:
enabled: true
clamav_server: "localhost:3310"
max_file_size: 10485760
unscannable: block
scan_memory_budget: 268435456
verdict_cache_ttl_secs: 300
verdict_cache_entries: 8192
scan_expression, scan_content_types and skip_extensions are gone. Selecting traffic is
the content_scanning rule action's job now.
The config struct does not reject unknown fields, so leaving them in place is silent: they parse, and they do nothing. If your config carries them, delete them and write the rule.
Limits worth knowing
- Request bodies only. Responses from your application are not scanned.
- Signature-based. It catches known malware, not a payload written for you. Pair it with the machine learning models.
- It needs a ClamAV daemon you run and keep updated. Detection is only as current as its signatures.
- It costs latency proportional to body size, plus a round trip — on the traffic your rule selected.
- Encrypted or archived content limits what a scanner sees. Archive contents are inspected; a password-protected archive is opaque.
If the scan errors or ClamAV cannot be reached, the request proceeds, and the failure is logged. That keeps an outage in the scanner from becoming an outage in your application, and it also means a scanner that is quietly down stops protecting you while everything looks healthy. Monitor scanner health and alert on scan failures rather than treating silence as clean.
This is distinct from unscannable, which covers a body that was reachable but too large.
See also
- WAF — the rule engine, and the
content_scanningaction - Block malware in uploads — the worked scenario
- Configuration — every key
- Security Event Export — the record a detection produces