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.

pompelmi requires the Node.js runtime. The @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 freshclam
macOS
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>

Shared scan helper

Both patterns share the same temp-file-and-scan logic. Extract it into src/lib/server/scanBuffer.ts:

// src/lib/server/scanBuffer.ts
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 type ScanResult = 'clean' | 'malicious' | 'error';

export async function scanBuffer(data: ArrayBuffer, filename: string): Promise<ScanResult> {
  const buffer  = Buffer.from(data);
  const ext     = filename.split('.').pop()?.toLowerCase() ?? 'bin';
  const tmpPath = join(tmpdir(), randomBytes(16).toString('hex') + '.' + ext);

  try {
    await writeFile(tmpPath, buffer);
    const verdict = await scan(tmpPath);
    if (verdict === Verdict.Clean)     return 'clean';
    if (verdict === Verdict.Malicious) return 'malicious';
    return 'error';
  } finally {
    if (existsSync(tmpPath)) {
      await unlink(tmpPath).catch(() => {});
    }
  }
}

Next steps