Skip to main content

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.

Requires Synapse 0.8.3 or newer

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.

The agent tells you when nothing will be scanned

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

  1. The body is held while ClamAV inspects it — multipart requests field by field, anything else whole.
  2. A clean verdict forwards upstream.
  3. 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.
passForward 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.

That asymmetry is deliberate

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
Three keys were removed — and they still parse

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.
Scanning fails open when the scanner is unreachable

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