In this post, we’ll build a production-grade guide to integrating ClamAV scanning into Spring Boot 4 uploads via the native INSTREAM protocol with Docker Compose.
Prerequisites
This is the list of all the prerequisites:
- Spring Boot 4.0+
- Maven 3.9.x
- Java 21
- Docker & Docker Compose
- Basic familiarity with Spring MVC multipart handling and Spring Data JPA
Overview
An unscanned upload endpoint is a free malware drop box wired directly into your infrastructure. It doesn’t matter if you validate content-type headers and cap file size — those checks say nothing about what’s actually inside the bytes. A .pdf can carry an embedded exploit. A .jpg can be a polyglot file that’s also a valid PHP shell. The only way to know if an uploaded file is malicious is to actually scan it, and the standard open-source way to do that is ClamAV — Cisco’s GPL antivirus engine, run as a daemon your application talks to over a socket.
This post builds a Spring Boot 4 upload service that streams every incoming file to a ClamAV daemon (clamd) for scanning before it’s written to disk or recorded in the database, using ClamAV’s native INSTREAM protocol directly — no third-party client wrapper, so you can see exactly what’s happening on the wire. You’ll come away with a production-shaped implementation: fail-closed error handling, an audit trail of every scan attempt (not just successful ones), a custom Actuator health indicator, and a test suite that runs the real EICAR test signature against a real clamd container.
What ClamAV actually is
ClamAV is an open-source antivirus engine originally built for scanning mail gateways, now used broadly for scanning any untrusted file input. It ships three main pieces:
clamd— a multi-threaded daemon that keeps the virus signature database loaded in memory and scans on request. This is what your application talks to.clamscan— a standalone CLI scanner that loads the database fresh on every invocation. Fine for cron jobs, wrong for a hot upload path (multi-second startup cost per scan).freshclam— the signature database updater. Signatures go stale within hours as new malware is published, sofreshclamruns continuously in the background pulling updates.
For a web application, the only piece that matters at request time is clamd. You run it once, keep it warm, and send it files over a socket for the lifetime of the process — which is exactly the shape of the Docker container you’ll run in production.
Why INSTREAM instead of file-path scanning
clamd supports two ways to scan a file: tell it a filesystem path to open (SCAN/CONTSCAN), or stream the bytes to it directly (INSTREAM). Path-based scanning means clamd needs read access to a path your application controls, which either means running clamd and your app as the same user with a shared volume (expands your attack surface) or trusting a path string that came, transitively, from user input.
INSTREAM avoids all of that: your application opens a TCP connection to clamd, writes the file as a sequence of length-prefixed chunks, and clamd scans the stream without ever touching your filesystem. It’s slightly more code than a SCAN /path/to/file call, but it means clamd never needs filesystem access to anything your users influence — a meaningfully smaller trust boundary.
The wire format is simple and documented in ClamAV’s clamd usage docs:
1. Client connects to clamd's TCP socket
2. Client sends: "zINSTREAM\0" (command, NUL-terminated)
3. Client sends zero or more chunks:
{4-byte big-endian length}{chunk bytes}
4. Client sends a zero-length chunk to signal EOF:
{0x00 0x00 0x00 0x00}
5. clamd replies with a NUL-terminated string:
"stream: OK" → clean
"stream: <signature name> FOUND" → infected
anything else → error (e.g. StreamMaxLength exceeded)
That’s the entire protocol. No handshake, no auth, no TLS by default — which is why clamd should never be exposed outside your private network (more on this in Common Pitfalls).
What We’re Building
A POST /api/files endpoint that stages an upload to a temp file, hashes it, scans it against clamd, and only then either persists it to disk and records it as CLEAN, or discards it and records it as INFECTED — the audit row is written either way.
┌──────────────────────────┐
POST /api/files │ │ zINSTREAM over TCP ┌────────┐
──────────────────▶ │ Spring Boot App │ ────────────────────▶│ clamd │
(multipart) │ (UploadService) │◀──────────────────── │ :3310 │
│ │ "stream: OK" / └────────┘
│ 1. validate size/type │ "... FOUND"
│ 2. stage to temp file │
│ 3. sha256 + dedupe check │
│ 4. scan via clamd │
│ 5a. CLEAN → persist file│──────▶ /data/uploads (volume)
│ 5b. INFECTED → discard │
│ 6. record attempt │──────▶ PostgreSQL (uploaded_file)
└──────────────────────────┘
Every scan attempt — clean, infected, or errored — becomes a row in uploaded_file. Only clean files ever touch the storage volume, and only clean files are retrievable through the API.
Database Modeling
Entity and relationships
There’s a single table here: uploaded_file. It isn’t related to anything else in this demo — no user_id, no foreign keys — because this is intentionally scoped to the upload/scan pipeline. In a real system you’d add a user_id bigint references app_user(id) for multi-tenant ownership; I’m leaving it out here rather than inventing a users table this article doesn’t otherwise need.
+----------------------------+
| uploaded_file |
+----------------------------+
| id (PK) |
| original_filename |
| stored_filename (nullable)|
| content_type |
| size_bytes |
| sha256 |
| scan_status |
| threat_signature (nullable)|
| uploaded_at |
+----------------------------+
stored_filename is nullable by design — infected and errored uploads never get written to disk, so there’s nothing to point at. That’s also why it’s a unique constraint rather than a not null unique: Postgres allows multiple NULLs in a unique column, so any number of rejected uploads can coexist without violating the constraint.
Schema
drop table if exists uploaded_file;
create table uploaded_file
(
id bigserial primary key,
original_filename varchar(255) not null,
stored_filename varchar(255),
content_type varchar(100) not null,
size_bytes bigint not null,
sha256 char(64) not null,
scan_status varchar(20) not null,
threat_signature varchar(255),
uploaded_at timestamptz not null default now(), constraint uq_uploaded_file_stored_filename unique (stored_filename),
constraint chk_uploaded_file_scan_status check (scan_status in ('CLEAN', 'INFECTED', 'ERROR'))
);
create index idx_uploaded_file_sha256 on uploaded_file (sha256);
create index idx_uploaded_file_scan_status on uploaded_file (scan_status);
The sha256 index backs a dedupe check (skip re-scanning a hash you’ve already verified clean — see Performance Considerations) and lets you quickly answer “have we seen this exact file before” for abuse investigations. The scan_status index supports querying “show me every infected attempt in the last 24 hours” without a sequential scan, which is the query your security team will actually run.
Seed data
insert into uploaded_file (original_filename, stored_filename, content_type, size_bytes, sha256, scan_status, uploaded_at)
values ('sample-welcome.pdf',
'11111111-1111-1111-1111-111111111111.pdf',
'application/pdf',
18320,
'9f2c1a7e4b3d6f8091a2b3c4d5e6f7089f2c1a7e4b3d6f8091a2b3c4d5e6f70',
'CLEAN',
now());
ORM mapping notes
UploadedFile is a mutable JPA entity but exposes no setters — only two static factories, clean(...) and rejected(...), plus a protected no-arg constructor JPA requires. Once built, a record never changes: there’s no “update scan status later” flow, so there’s nothing to gain from mutability and something to lose (accidental partial updates from unrelated code). ddl-auto is set to validate, not update — Hibernate checks the entity mapping matches schema.sql at startup and fails fast on drift, rather than silently altering a production table.
Let’s code
Let’s create a simple Spring Boot project from start.spring.io,
Step 1 — Project setup
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
The full pom.xml, with test dependencies (spring-boot-starter-test, testcontainers-junit-jupiter), is in the demo repo.
Step 2 — Configuration properties
Two @ConfigurationProperties records, both picked up automatically via @ConfigurationPropertiesScan on the main application class:
@ConfigurationProperties(prefix = "clamav")
public record ClamAvProperties(
String host,
int port,
int connectTimeoutMs,
int readTimeoutMs
) {
public ClamAvProperties {
if (connectTimeoutMs <= 0) connectTimeoutMs = 5000;
if (readTimeoutMs <= 0) readTimeoutMs = 60_000;
}
}
@ConfigurationProperties(prefix = "app.upload")
public record UploadProperties(
long maxFileSizeBytes,
Set<String> allowedContentTypes,
Path storageDirectory
) {
}
Using records here instead of a @Data-annotated class means the properties are genuinely immutable after binding, and the compact constructor gives a natural place for defaulting — no separate @PostConstruct needed.
Step 3 — The ClamAV client
This is the part most tutorials hide behind a third-party dependency. Implementing INSTREAM directly is about 40 lines and removes a dependency whose maintenance status you’d otherwise have to track:
@Component
public class ClamAvTcpClient implements ClamAvClient {
private static final int CHUNK_SIZE = 8192;
private static final byte[] ZERO_LENGTH_CHUNK = {0, 0, 0, 0};
private final ClamAvProperties properties;
public ClamAvTcpClient(ClamAvProperties properties) {
this.properties = properties;
}
@Override
public ScanResult scan(InputStream fileStream) {
try (Socket socket = new Socket()) {
socket.connect(
new InetSocketAddress(properties.host(), properties.port()),
properties.connectTimeoutMs());
socket.setSoTimeout(properties.readTimeoutMs());
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
out.writeBytes("zINSTREAM\0");
byte[] buffer = new byte[CHUNK_SIZE];
int bytesRead;
while ((bytesRead = fileStream.read(buffer)) != -1) {
out.writeInt(bytesRead); // 4-byte big-endian length prefix
out.write(buffer, 0, bytesRead);
}
out.write(ZERO_LENGTH_CHUNK);
out.flush();
String response = ClamAvWireFormat.readUntilNull(socket.getInputStream());
return ClamAvResponseParser.parse(response);
} catch (IOException e) {
throw new VirusScanException(
"Failed to reach ClamAV daemon at " + properties.host() + ":" + properties.port(), e);
}
}
}
A few details worth calling out:
DataOutputStream.writeInt()writes a 4-byte value in big-endian order, which is exactly the “network byte order” length prefix the protocol expects — no manual bit-shifting needed.writeBytes("zINSTREAM\0")writes the low byte of eachchar, including the embedded\0. That works here because the command is pure ASCII; don’t reach forwriteBytesfor anything that isn’t.- The response parsing is factored into a separate, package-private
ClamAvResponseParser.parse(String)specifically so it can be unit tested without a socket:
final class ClamAvResponseParser {
static ScanResult parse(String response) {
if (response == null || response.isBlank()) {
return new ScanResult.Error("Empty response from ClamAV");
}
if (response.endsWith("FOUND")) {
String signature = response
.replaceFirst("^stream:\\s*", "")
.replaceFirst("\\s*FOUND$", "");
return new ScanResult.Infected(signature);
}
if (response.endsWith("OK")) {
return new ScanResult.Clean();
}
return new ScanResult.Error(response);
}
}
ScanResult is a sealed interface with three variants, which lets every caller handle all three outcomes exhaustively via a switch, with the compiler enforcing it:
public sealed interface ScanResult {
record Clean() implements ScanResult {}
record Infected(String signature) implements ScanResult {}
record Error(String message) implements ScanResult {}
}
Step 4 — Orchestrating the upload
UploadService ties validation, staging, hashing, scanning, and persistence together:
public UploadedFile handleUpload(MultipartFile file) {
validate(file);
Path tempFile;
try {
tempFile = storageService.stageTempFile(file);
} catch (IOException e) {
throw new UploadFailedException("Could not buffer the upload to disk", e);
}
try {
String hash = storageService.sha256(tempFile);
if (repository.existsBySha256AndScanStatus(hash, ScanStatus.CLEAN)) {
return storeAndRecord(tempFile, file, hash);
}
ScanResult result = scan(tempFile);
if (result instanceof ScanResult.Clean) {
return storeAndRecord(tempFile, file, hash);
}
if (result instanceof ScanResult.Infected infected) {
storageService.discard(tempFile);
repository.save(UploadedFile.rejected(
file.getOriginalFilename(), file.getContentType(), file.getSize(), hash, infected.signature()));
throw new InfectedFileException(infected.signature());
}
ScanResult.Error error = (ScanResult.Error) result;
storageService.discard(tempFile);
throw new VirusScanException("ClamAV scan failed: " + error.message(), null);
} catch (IOException e) {
storageService.discard(tempFile);
throw new UploadFailedException("Failed to process the upload", e);
}
}
Notice this method is not annotated @Transactional. That’s deliberate, not an oversight: repository.save() commits in its own transaction (Spring Data’s save() is transactional by default), so the audit row for a rejected upload is durable even though the very next line throws. Wrapping the whole method in one @Transactional would roll that insert back along with the exception, silently erasing the record that an infected file was ever submitted — see Common Pitfalls for why that’s a real trap, not a theoretical one.
MultipartFile.transferTo() stages the upload to a temp file on disk rather than holding it in a byte array, so a large upload doesn’t sit fully in heap while it’s hashed and scanned:
public Path stageTempFile(MultipartFile file) throws IOException {
Path temp = Files.createTempFile("upload-", ".tmp");
file.transferTo(temp);
return temp;
}
Step 5 — Controller and error mapping
@RestController
@RequestMapping("/api/files")
public class FileUploadController {
private final UploadService uploadService;
public FileUploadController(UploadService uploadService) {
this.uploadService = uploadService;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<UploadResponse> upload(@RequestParam("file") MultipartFile file) {
UploadedFile saved = uploadService.handleUpload(file);
return ResponseEntity.status(HttpStatus.CREATED).body(UploadResponse.from(saved));
}
}
The @RestControllerAdvice maps each failure mode to a distinct status code — the important design decision is that an unreachable scanner returns 503, not 200:
@ExceptionHandler(InfectedFileException.class)
public ResponseEntity<ErrorResponse> handleInfected(InfectedFileException ex) {
return error(HttpStatus.UNPROCESSABLE_CONTENT, "FILE_CONTAINS_VIRUS", ex.getMessage());
}
@ExceptionHandler(VirusScanException.class)
public ResponseEntity<ErrorResponse> handleScanUnavailable(VirusScanException ex) {
// Fail closed: the scanner being unreachable is not the same as
// the file being clean. Never let this fall through to a 2xx.
log.error("ClamAV unreachable — rejecting upload", ex);
return error(HttpStatus.SERVICE_UNAVAILABLE, "SCAN_UNAVAILABLE",
"Virus scanning is temporarily unavailable. Please retry.");
}
FileTooLargeException and Spring’s own MaxUploadSizeExceededException both map to 413; UnsupportedFileTypeException maps to 415. The full handler is in the repo.
Two of these status constants have new names in Spring Framework 7: RFC 9110 renamed 413’s reason phrase from “Payload Too Large” to “Content Too Large” and 422’s from “Unprocessable Entity” to “Unprocessable Content,” so HttpStatus now exposes CONTENT_TOO_LARGE and UNPROCESSABLE_CONTENT as the current constants — the old PAYLOAD_TOO_LARGE/UNPROCESSABLE_ENTITY names still compile but are deprecated. The numeric status codes themselves (413, 422) are unchanged.
Step 6 — Health check
Because a dead clamd means “silently stop scanning,” not “app is down,” it deserves its own Actuator health indicator rather than being invisible until the first upload fails. One migration note if you’re coming from Spring Boot 3: Health and HealthIndicator moved out of spring-boot-actuator in Boot 4 — they now live in the separate spring-boot-health module, under org.springframework.boot.health.contributor. It’s pulled in transitively by spring-boot-starter-actuator, so no extra dependency is needed, only the updated import:
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
@Component
public class ClamAvHealthIndicator implements HealthIndicator {
private static final int PROBE_TIMEOUT_MS = 2000;
private final ClamAvProperties properties;
@Override
public Health health() {
String target = properties.host() + ":" + properties.port();
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(properties.host(), properties.port()), PROBE_TIMEOUT_MS);
socket.setSoTimeout(PROBE_TIMEOUT_MS);
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
out.writeBytes("zPING\0");
out.flush();
String response = ClamAvWireFormat.readUntilNull(socket.getInputStream());
return "PONG".equals(response)
? Health.up().withDetail("daemon", target).build()
: Health.down().withDetail("daemon", target).withDetail("reply", response).build();
} catch (Exception e) {
return Health.down(e).withDetail("daemon", target).build();
}
}
}
This uses zPING\0, the same NUL-terminated command family as INSTREAM, and shares the readUntilNull framing helper — a 2-second timeout keeps a stuck clamd from turning your readiness probe into a slow, cascading failure.
Step 7 — Docker Compose
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: uploads
POSTGRES_USER: uploads_app
POSTGRES_PASSWORD: uploads_app
volumes:
- pg_data:/var/lib/postgresql/data
- ./sql/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./sql/data.sql:/docker-entrypoint-initdb.d/02-data.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U uploads_app -d uploads"]
interval: 5s
retries: 10
clamav:
image: clamav/clamav:1.4
ports:
- "3310:3310"
volumes:
- clamav_data:/var/lib/clamav
- ./clamav/clamd.conf:/etc/clamav/clamd.conf:ro
healthcheck:
test: ["CMD", "/usr/local/bin/clamdcheck.sh"]
interval: 30s
retries: 10
start_period: 180s # first boot downloads the full signature DB
app:
build: .
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/uploads
CLAMAV_HOST: clamav
CLAMAV_PORT: 3310
APP_UPLOAD_STORAGE_DIRECTORY: /data/uploads
volumes:
- upload_data:/data/uploads
depends_on:
postgres:
condition: service_healthy
clamav:
condition: service_healthy
volumes:
pg_data:
clamav_data:
upload_data:
clamd.conf is fully replaced, not merged — the image doesn’t patch your mounted file on top of its defaults, so a partial config silently drops directives you didn’t repeat:
# Mounted over the image's default clamd.conf. clamd does not merge
# configs, so this file must be complete on its own — do not assume any
# directive not listed here keeps the image's default value.
# /dev/stdout is a symlink loop inside this image — clamd refuses it.
LogFile /var/log/clamav/clamd.log
LogFileMaxSize 0
LogTime yes
LogVerbose no
# The image's /init script waits for this socket before declaring clamd started.
LocalSocket /tmp/clamd.sock
TCPSocket 3310
# No TCPAddr: binding 0.0.0.0 is IPv4-only, and the image healthcheck
# pings "localhost", which resolves to ::1 first.
# Matches the multipart max-file-size configured in application.yml.
# Files larger than this are rejected by clamd itself before scanning.
StreamMaxLength 100M
MaxFileSize 100M
MaxScanSize 400M
MaxThreads 12
ReadTimeout 300
User clamav
Foreground yes
StreamMaxLength is the one directive that trips people up: it caps how much data INSTREAM will accept, independent of anything Spring’s multipart.max-file-size allows through. If they’re out of sync, you’ll see uploads pass your Spring size check, get staged and hashed, and then fail at the clamd socket with a size limit exceeded error string — the fix is keeping both limits deliberately aligned, not just picking one.
Testing
Three layers, each testing something the others can’t:
1. Pure unit test of the response parser — no network, runs in milliseconds:
@Test
void parsesInfectedResponseAndExtractsSignature() {
ScanResult result = ClamAvResponseParser.parse("stream: Win.Test.EICAR_HDB-1 FOUND");
assertThat(result).isInstanceOfSatisfying(ScanResult.Infected.class,
infected -> assertThat(infected.signature()).isEqualTo("Win.Test.EICAR_HDB-1"));
}
2. Testcontainers integration test against a real clamd, using the EICAR test file — a standard 68-byte string every antivirus engine is required to recognize as a test signature, so you can verify real detection without handling live malware:
@Testcontainers
class ClamAvTcpClientIT {
private static final String EICAR =
"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
@Container
static GenericContainer<?> clamav = new GenericContainer<>(DockerImageName.parse("clamav/clamav:1.4"))
.withExposedPorts(3310)
.withEnv("CLAMAV_NO_FRESHCLAMD", "true") // test speed only — never in production
.withStartupTimeout(Duration.ofMinutes(3));
@Test
void detectsEicarTestFile() {
ClamAvTcpClient client = clientUnderTest();
ScanResult result = client.scan(new ByteArrayInputStream(EICAR.getBytes(StandardCharsets.US_ASCII)));
assertThat(result).isInstanceOfSatisfying(ScanResult.Infected.class,
infected -> assertThat(infected.signature()).containsIgnoringCase("eicar"));
}
}
This is the test that actually proves the wire protocol implementation is correct — a mocked socket would only prove the code compiles against your own assumptions about the protocol.
3. @WebMvcTest slice for the controller, with UploadService mocked so the test only exercises multipart binding and exception-to-status mapping:
@WebMvcTest(FileUploadController.class)
class FileUploadControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UploadService uploadService;
@Test
void rejectsInfectedUploadWith422() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "eicar.txt", "text/plain", "X5O!...".getBytes());
given(uploadService.handleUpload(any())).willThrow(new InfectedFileException("Eicar-Signature"));
mockMvc.perform(multipart("/api/files").file(file))
.andExpect(status().isUnprocessableContent())
.andExpect(jsonPath("$.error").value("FILE_CONTAINS_VIRUS"));
}
}
Note @MockitoBean, not the older @MockBean — Spring Boot 4 (built on Spring Framework 7) deprecated @MockBean in favor of the framework-level @MockitoBean/@MockitoSpyBean annotations introduced in 6.2. @WebMvcTest moved too: it’s now org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest, shipped in a new spring-boot-webmvc-test module rather than bundled into spring-boot-test-autoconfigure — add it as a test-scoped dependency alongside spring-boot-starter-test, or the import simply won’t resolve.
4. End-to-end smoke test with Docker Compose — the full stack (app, Postgres, clamd) wired exactly as it runs in production, driven with curl. This is the only layer that catches config drift between services: a wrong CLAMAV_HOST, a StreamMaxLength lower than your Spring limit, or a schema that doesn’t match the entity.
Start the stack and wait for every service to report healthy. On first boot clamd downloads the full signature database, so expect a few minutes:
docker compose up --build -d
docker compose ps # wait until clamav and postgres show (healthy) and app is running
curl -s http://localhost:8080/actuator/health
# {"groups":["liveness","readiness"],"status":"UP"}
Create the test files, including the EICAR string:
echo "hello, clean world" > clean.txt
printf 'X5O!P%%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > eicar.txt
Upload a clean file. You should get 201 Created:

Upload EICAR. You should get 422, and nothing should be written to disk:

Check the validation paths, which reject the file before it ever reaches clamd:
# Disallowed content type -> 415
curl -s -o /dev/null -w 'HTTP %{http_code}\n' -F "file=@clean.txt;type=application/zip" http://localhost:8080/api/files
# Empty file -> 400
: > empty.txt
curl -s -o /dev/null -w 'HTTP %{http_code}\n' -F "file=@empty.txt;type=text/plain" http://localhost:8080/api/files
Check the audit trail. The infected upload should be recorded with its signature and no stored file:

Finally, prove the service fails closed. Stop the scanner and try again:
docker compose stop clamav
curl -s -w '\nHTTP %{http_code}\n' -F "file=@eicar.txt;type=text/plain" http://localhost:8080/api/files
# {"error":"SCAN_UNAVAILABLE","message":"Virus scanning is temporarily unavailable. Please retry.","timestamp":"..."}
# HTTP 503
curl -s -w '\nHTTP %{http_code}\n' http://localhost:8080/actuator/health
# {"groups":["liveness","readiness"],"status":"DOWN"}
# HTTP 503
docker compose start clamav
A 503 here is the result you want. A 201 would mean an unreachable scanner lets files through unscanned, which is the worst failure mode this design exists to prevent. Run docker compose down -v when you’re done to drop the volumes and start fresh next time.
The exact signature name (Eicar-Test-Signature above) depends on the ClamAV signature database version, so assert on “contains eicar” rather than an exact string if you script these checks in CI.
Edge cases worth adding beyond what’s shown here: a zero-byte upload, a file at exactly maxFileSizeBytes, a filename containing ../ (path traversal attempt), and a clamd connection that times out mid-scan.
Performance Considerations
Scanning is synchronous and adds real latency. A small file scans in low tens of milliseconds; a large one against a cold clamd cache can take longer. If your upload endpoint needs to stay fast under load, the honest answer is: accept the upload, return 202 Accepted with a pending status, scan asynchronously (a queue consumer calling the same ClamAvClient), and let clients poll or receive a webhook. This demo scans synchronously because it’s simpler to reason about and test end-to-end — don’t copy that choice into a system where p99 upload latency is a hard SLA.
The sha256 dedupe check trades correctness for speed, with a real caveat. Skipping the scan for a hash you’ve already recorded as CLEAN avoids a full clamd round trip for repeat uploads — useful if the same document gets re-uploaded across users. But it means a file cleared under yesterday’s signature database is never re-checked against today’s, even though freshclam updates constantly. For most applications that’s an acceptable tradeoff (the file’s own bytes haven’t changed). For anything security-sensitive, either disable the dedupe check or add a TTL so hashes older than a day are re-scanned rather than trusted forever.
clamd‘s memory floor is real. ClamAV’s own docs call out 3–4 GiB of RAM as a baseline for the loaded signature database, less only under specific reduced-feature configurations. Budget for that container size explicitly — it’s the most common “why did my container get OOM-killed” surprise when people first containerize ClamAV.
MaxThreads bounds concurrent scans, not your application’s concurrency. If ten uploads land on clamd simultaneously and MaxThreads is lower, the eleventh blocks server-side until a slot frees up — which surfaces to your app as a slower INSTREAM response, not an error. Size it against your actual peak concurrent-upload rate, not a guess.
Common Pitfalls
- Failing open when
clamdis unreachable. The single most dangerous mistake in this whole integration is catching the connection exception and letting the upload through “since we can’t scan it anyway.” That turns a transient network blip into an unscanned-upload window. Always fail closed — this demo maps everyVirusScanExceptionto503, never to a successful upload. - Rolling back the audit trail along with the rejection. Wrapping the whole
handleUploadflow in one@Transactionaland throwing after logging the infected attempt rolls back that insert too — you lose the one record that would tell you someone tried to upload malware. Keep the audit write in its own transaction boundary, as this demo does by leavinghandleUploadun-annotated and relying onrepository.save()‘s own transaction. - Assuming
clamd.confmerges with the image defaults. It doesn’t. A mounted config that only setsTCPSocket 3310silently reverts every other directive — includingStreamMaxLength— to the image’s built-in default, which may not match what your app expects. - Exposing
clamd‘s TCP port beyond a private network. The protocol has no authentication and no TLS.docker-compose.yml‘sports: - "3310:3310"is there so you can test locally withnc; in a real deployment,clamdshould only be reachable from the application’s own network segment, never bound to a public interface. - Scanning after storing. If you write the file to its final location and scan afterward, there’s a window — however small — where an infected file is already sitting in storage other processes might read from. Scan-then-store, not store-then-scan.
- Trusting the client-supplied content-type.
file.getContentType()comes from the multipart request headers, which the client controls entirely. It’s a reasonable first filter (this demo uses it as an allow-list gate), but it is not a security control on its own — ClamAV’s scan is what actually inspects the bytes.
Alternative Approaches

ClamAV alone is not a complete malware defense — it’s a well-known, well-signatured baseline that catches known threats cheaply. If your threat model includes targeted or zero-day malware, layer it with sandboxed detonation or a commercial engine rather than relying on ClamAV as the only gate.
Key Takeaways
- Scan before you store, not after — the window between “written to disk” and “scanned” is a window an infected file can be read from.
- Fail closed: an unreachable ClamAV daemon must reject the upload, never silently let it through.
- Audit every scan attempt, not just the successful ones — rejected uploads are the security-relevant signal, and an outer
@Transactionalcan silently erase them.
The complete source code is available on GitHub.
Support me through GitHub Sponsors.
Thank you for Reading !! See you in the next post.