Zero-Trust File Uploads: Scan Before You Store
Build a secure, in-memory antivirus pipeline using ClamAV, Quarkus REST, Reactive, and ByteArrayInputStream in Java
Welcome to the modern era of secure file uploads. In this hands-on guide, you’ll build a Quarkus application that scans uploaded files for malware before they ever hit persistent storage. This is not your typical “upload-then-scan” pattern. This is zero-trust, real-time defense.
We’ll build a reactive pipeline that streams file bytes directly from the HTTP request into a ClamAV virus scanner running as a Dev Service. If the scan passes, we proceed. If not, we reject the file before it ever becomes a liability.
Let’s get to it.
Why Traditional Uploads Are Dangerous
Most systems upload first, scan second. They write the file to disk—usually a temp directory—and then invoke a virus scanner. This sequence has a serious flaw: even a short-lived save creates a window where malicious files can exploit your system.
This tutorial avoids that. We’ll use Quarkus's quarkus-antivirus
extension to scan files in a reactive, non-blocking pipeline.
Because this pipeline loads the entire file into memory using a ByteArrayInputStream
, it’s important to limit upload sizes to avoid exhausting heap memory. In production, you should configure a strict file size limit in application.properties
to cap uploads and reject oversized files early:
quarkus.http.body.uploads.max-size=10M
But now its time to look at the implementation.
Bootstrap the Project
Make sure you have:
Java 17+
Maven 3.8+
Podman installed (required for Dev Services)
Now generate a Quarkus project with the necessary extensions:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=zero-trust-uploads \
-Dextensions="rest-jackson,antivirus"
cd zero-trust-uploads
The antivirus
extension provides a client for ClamAV and spins up a container automatically during development. No ClamAV setup required.
If you want to get a working example to play around with locally, go grab it from my github repository.
Build the Upload Endpoint
Rename the scaffolded GreetingResource and turn it into a REST resource to accept file uploads via multipart form data. Create src/main/java/org/acme/FileUploadResource.java
:
package org.acme;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import io.quarkiverse.antivirus.runtime.AntivirusScanResult;
import io.quarkus.logging.Log;
import io.smallrye.mutiny.Uni;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@jakarta.ws.rs.Path("/files")
public class FileUploadResource {
@Inject
VirusScannerService scannerService;
@POST
@jakarta.ws.rs.Path("/upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
public Uni<Response> uploadFile(@RestForm("file") FileUpload file) {
Log.infof("Received file upload request for: %s (size: %d bytes)",
file.fileName(), file.size());
return Uni.createFrom().<InputStream>item(() -> {
try {
// Read the entire file into memory for virus scanning
// ByteArrayInputStream fully supports mark/reset operations required by ClamAV
// This ensures we can scan the file content before any filesystem storage
InputStream fileStream = java.nio.file.Files.newInputStream(file.uploadedFile());
byte[] fileBytes = fileStream.readAllBytes();
fileStream.close(); // Close the file stream immediately
return new ByteArrayInputStream(fileBytes);
} catch (IOException e) {
throw new RuntimeException("Failed to read uploaded file: " + e.getMessage(), e);
}
})
.runSubscriptionOn(io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool())
.onItem().transformToUni(inputStream -> {
// Perform virus scanning reactively using the input stream
return scannerService.scanFileReactive(file.fileName(), inputStream)
.onItem().invoke(() -> {
try {
inputStream.close();
} catch (IOException e) {
Log.warn("Warning: Failed to close input stream: " + e.getMessage());
}
});
})
.onItem().transformToUni(scanResults -> {
// Process scan results reactively
return Uni.createFrom().item(() -> {
// Check if any scanner found a threat
for (AntivirusScanResult result : scanResults) {
if (result.getStatus() != Response.Status.OK.getStatusCode()) {
Log.warnf("THREAT DETECTED in %s: %s", file.fileName(), result.getMessage());
return Response.status(result.getStatus())
.entity("{\"status\": \"THREAT_DETECTED\", \"message\": \"" +
result.getMessage() + "\", \"filename\": \"" +
file.fileName() + "\"}")
.build();
}
}
// File is clean - now we can safely process it
Log.infof("File is clean: %s", file.fileName());
// Here you would typically:
// 1. Move the file to permanent storage
// 2. Save metadata to database
// 3. Process the file further
return Response.ok()
.entity("{\"status\": \"CLEAN\", \"filename\": \"" +
file.fileName() + "\", \"size\": " +
file.size() + ", \"contentType\": \"" +
file.contentType() + "\"}")
.build();
});
})
.onFailure().recoverWithItem(throwable -> {
Log.errorf("Error during file processing for %s: %s",
file.fileName(), throwable.getMessage());
return Response.serverError()
.entity("{\"status\": \"ERROR\", \"message\": \"File processing failed: " +
throwable.getMessage() + "\"}")
.build();
});
}
}
Reactive File Upload Handling
Uses RESTEasy Reactive with Uni<Response> to handle multipart file uploads non-blocking, keeping the event loop responsive while processing files asynchronously
In-Memory Virus Scanning Pipeline
Reads uploaded files into ByteArrayInputStream for ClamAV scanning with mark/reset support, ensuring files are verified before any permanent storage decisions
Worker Thread Pool Integration
Leverages Infrastructure.getDefaultWorkerPool() to move blocking I/O operations (file reading, virus scanning) off the main event loop thread for optimal performance
Zero-Trust Security Model
Implements fail-safe security where files are rejected on any scanning failure, with explicit HTTP status codes (200=clean, 400=threat, 500=error) and structured JSON responses
Implement the Virus Scanner Service
Now add the service that does the actual scanning. Create src/main/java/org/acme/VirusScannerService.java
:
package org.acme;
import java.io.InputStream;
import java.util.List;
import io.quarkiverse.antivirus.runtime.Antivirus;
import io.quarkiverse.antivirus.runtime.AntivirusScanResult;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class VirusScannerService {
@Inject
Antivirus antivirus;
public Uni<List<AntivirusScanResult>> scanFileReactive(String fileName, InputStream inputStream) {
System.out.println("Starting reactive virus scan for file: " + fileName);
// Wrap the blocking antivirus scan in a reactive context
// This moves the blocking operation to a worker thread
return Uni.createFrom().item(() -> {
System.out.println("Scanning file on worker thread: " + fileName);
return antivirus.scan(fileName, inputStream);
}).runSubscriptionOn(io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool());
}
}
Step 5: Run and Test the Application
Start the application:
./mvnw quarkus:dev
You should see:
socket found, clamd started.
This means your local ClamAV container is running. Time to test.
Upload a Clean File
echo "this is clean" > safe.txt
curl -X POST -F "file=@safe.txt" http://localhost:8080/files/upload
Expected result:
{"status": "CLEAN", "filename": "safe.txt", "size": 18, "contentType": "text/plain"}
Upload a "Virus"
Create a fake virus using the standard EICAR test string:
echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > virus.txt
curl -X POST -F "file=@virus.txt" http://localhost:8080/files/upload
Expected result:
{"status": "malware detected"}
The file is blocked and never saved. That’s zero-trust in action.
Going Further
Handle ClamAV Downtime
"Never trust, always verify" - If we can't verify (scanner down), we reject.
return antivirus.scan(filePath)
.onFailure().recoverWithItem(ScanResult.ERROR);
Or reject with a 503:
.onFailure().transform(failure ->
new WebApplicationException("Scanner unavailable", 503));
Final Thoughts: Stream First, Ask Questions Later
What you’ve built isn’t just a demo. It’s a foundational pattern for any system that handles user-uploaded content:
No temp disk writes before security is guaranteed
Non-blocking, scalable file scanning
Zero-trust upload design baked into the application flow
This kind of pattern isn’t just useful for malware scanning. You could extend it to:
Content moderation with AI
PDF validation
ZIP bomb protection
File type sniffing
Security is not a plugin. It’s an architecture. And with Quarkus, it’s also fast.
Here’s the full Mermaid diagram of the example flow:
Want to explore more?