SvelteKit file upload security with pompelmi
SvelteKit offers two server-side patterns for handling form submissions and
file uploads: +server.ts endpoints (JSON/REST style) and
Form Actions in +page.server.ts (progressive
enhancement style). Both run in Node.js when you use the
@sveltejs/adapter-node adapter, which means pompelmi works in
both.
This guide shows how to integrate ClamAV virus scanning into each pattern. The underlying technique is the same: read the uploaded file bytes, write them to a temp path, scan, and clean up.
@sveltejs/adapter-node adapter is fully compatible.
Cloudflare Workers, Vercel Edge, and other edge-only adapters are not.
Runtime note
SvelteKit runs server-side code on Node.js when using
@sveltejs/adapter-node. Confirm this is in your
svelte.config.js:
// svelte.config.js
import adapter from '@sveltejs/adapter-node';
export default {
kit: {
adapter: adapter(),
},
};
If you need to deploy to a platform that only supports edge runtimes (Cloudflare Workers, Deno Deploy), you would need to proxy the scan through a separate Node.js microservice.
Install
npm install pompelmi
Install ClamAV on your development machine or server:
Linux (Debian / Ubuntu)sudo apt-get install -y clamav && sudo freshclammacOS
brew install clamav && freshclam
+server.ts endpoint
A +server.ts file exports named functions matching HTTP methods.
The POST export handles file uploads sent as
multipart/form-data:
// src/routes/api/upload/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { scan, Verdict } from 'pompelmi';
import { writeFile, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomBytes } from 'crypto';
export const POST: RequestHandler = async ({ request }) => {
let tmpPath: string | null = null;
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file || file.size === 0) {
throw error(400, 'No file provided.');
}
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
if (file.size > MAX_BYTES) {
throw error(413, 'File too large.');
}
// Convert Web API File → Buffer → disk
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'bin';
tmpPath = join(tmpdir(), randomBytes(16).toString('hex') + '.' + ext);
await writeFile(tmpPath, buffer);
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
throw error(400, 'Malware detected. Upload rejected.');
}
if (verdict === Verdict.ScanError) {
throw error(422, 'Scan could not complete. Upload rejected as a precaution.');
}
// File is clean — persist here
return json({ status: 'ok', name: file.name });
} catch (err) {
// Re-throw SvelteKit errors; wrap everything else
if (err instanceof Response) throw err;
const message = err instanceof Error ? err.message : 'Scan failed';
throw error(500, message);
} finally {
if (tmpPath && existsSync(tmpPath)) {
await unlink(tmpPath).catch(() => {});
}
}
};
Form Action approach
Form Actions live in +page.server.ts. They are ideal for
progressively enhanced forms that work without JavaScript. The scan logic is
identical — only the way you return errors differs:
// src/routes/upload/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { scan, Verdict } from 'pompelmi';
import { writeFile, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomBytes } from 'crypto';
export const actions: Actions = {
default: async ({ request }) => {
let tmpPath: string | null = null;
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file || file.size === 0) {
return fail(400, { error: 'No file provided.' });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'bin';
tmpPath = join(tmpdir(), randomBytes(16).toString('hex') + '.' + ext);
await writeFile(tmpPath, buffer);
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
return fail(400, { error: 'Malware detected. Upload rejected.' });
}
if (verdict === Verdict.ScanError) {
return fail(422, { error: 'Scan incomplete. Upload rejected as a precaution.' });
}
// Clean — proceed with storage …
} finally {
if (tmpPath && existsSync(tmpPath)) {
await unlink(tmpPath).catch(() => {});
}
}
throw redirect(303, '/upload/success');
},
};
The accompanying Svelte page to display errors:
<!-- src/routes/upload/+page.svelte -->
<script lang="ts">
import type { ActionData } from './$types';
export let form: ActionData;
</script>
{#if form?.error}
<p style="color: red">{form.error}</p>
{/if}
<form method="POST" enctype="multipart/form-data">
<input type="file" name="file" required />
<button type="submit">Upload & scan</button>
</form>
Next steps
- Running ClamAV in Docker alongside SvelteKit? See Running pompelmi with ClamAV in Docker Compose.
- Want the full upload security picture? Read Node.js file upload security checklist.
- Uploading clean files to S3 after scanning? See Scanning files before uploading to AWS S3.