NAME
Punk::Plugin::ClamAV - scan Punk uploads with clamd
SYNOPSIS
package MyApp;
use Punk;
plugin 'ClamAV' => { socket => '/run/clamav/clamd.ctl' };
# upload_ok: the short form, applying the policy configured above
post '/avatar' => sub {
my ($c) = @_;
my $up = $c->upload('file') or return $c->text('no file', 400);
return $c->text('no thanks', 422) unless $c->upload_ok($up);
# NOT $up->filename - that is request bytes, not a path
$up->save('/var/lib/app/avatars/' . $c->auth_id);
$c->json({ ok => 1 });
};
# scan_upload: the verdict itself, when "no" is not a good enough answer
post '/document' => sub {
my ($c) = @_;
my $up = $c->upload('file') or return $c->text('no file', 400);
my $v = $c->scan_upload($up);
unless ($v->is_clean) {
$c->log->warn('rejected upload: ' . $v->state
. ($v->signature ? ' (' . $v->signature . ')' : ''));
return $c->text('that file cannot be accepted', 422) if $v->is_infected;
return $c->text('we could not scan that - if it is a '
. 'password-protected archive, send it unlocked', 422)
if $v->is_unscannable;
return $c->text('try again shortly', 503); # scanner unavailable
}
$up->save('/var/lib/app/docs/' . $c->auth_id);
$c->json({ ok => 1 });
};
DESCRIPTION
Punk::Upload streams anything over 64 KiB to a private temp file, so an upload arrives as attacker-controlled bytes already on your filesystem. This scans them, through ClamAV::Clamd.
It picks the cheap transport for you
A spilled upload is already a file, so it is scanned by descriptor: clamd is handed the open file, not a path, and therefore needs no permission on your spool directory at all. A small upload never touched disk and is sent as bytes rather than being written out just so it can be scanned.
OPTIONS
plugin 'ClamAV' => {
socket => '/run/clamav/clamd.ctl', # or host/port
max_size => 100 * 1024 * 1024,
on_infected => 'reject',
on_unscannable => 'reject',
on_error => 'reject',
status => 422,
auto => 0,
};
socket, or host and port, plus connect_timeout, reply_timeout, reply_max, chunk, max_size and frame are passed to "new" in ClamAV::Clamd.
loop takes an event loop to drive scans on, for the unusual case of wanting one that is not the worker's. Leave it alone and the plugin finds Hyperman->loop per request, which is the right answer inside a worker and undef everywhere else.
on_infected, on_unscannable and on_error take 'reject', 'allow', or a coderef called as $cb->($c, $verdict, $upload) whose return value becomes the response.
All three default to reject
This is the opposite call from a rate limiter, where failing open is correct because a broken limiter should not take the site down. A scanner that is down and lets everything through is the vulnerability.
on_unscannable defaulting to reject is the one worth thinking about, because it is the one that will reject uploads your users consider perfectly good - a password-protected zip, most obviously. Setting it to 'allow' is a real choice with a real consequence: it accepts files nothing has looked inside.
HELPERS
$c->scan_upload($upload_or_name)
Returns a ClamAV::Clamd::Verdict, or undef if there is no such upload.
$c->scan_uploads
Scan every upload on the request.
$c->upload_ok($upload_or_name)
True if the configured policy accepts it.
$c->clamd
The ClamAV::Clamd client, for anything this plugin does not cover.
AUTOMATIC MODE, AND WHY IT IS OFF
plugin 'ClamAV' => { socket => '...', auto => 1 };
Scans every upload on every multipart/form-data request and answers 422 rather than dispatching. Requests that cannot carry an upload are not parsed, so routes without files pay nothing.
It runs before your route's guards. Punk's before_dispatch phase is ahead of the guards an under scope puts on a route, and there is currently no public phase between them. So with auto on, an unauthenticated caller can make your server accept, spool and scan a 128 MiB file that your authentication guard was about to refuse.
WHAT THE CLIENT IS TOLD
A rejection says infected, could not be scanned, or virus scanner unavailable, and never the signature name. A signature name is chosen, in effect, by whoever supplied the file: a file crafted to match a given signature decides what string comes back. Log it; do not reflect it.
It does not block the worker
On a Hyperman worker the scan is driven on that worker's event loop. The descriptor is watched, the scan is stepped on readiness, and the wait happens inside a future whose get pumps the loop. The worker keeps serving every other connection it owns for the length of the scan.
SEE ALSO
ClamAV::Clamd, Punk, Punk::Upload, Punk::Plugin.
AUTHOR
LNATION, <email at lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under the Artistic License 2.0.