pompelmi 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -5
- package/deno.json +5 -0
- package/llms.txt +12 -0
- package/package.json +13 -3
- package/packages/cloudflare/README.md +94 -0
- package/packages/cloudflare/index.d.ts +50 -0
- package/packages/cloudflare/index.js +116 -0
- package/packages/cloudflare/package.json +38 -0
- package/packages/cloudflare/wrangler.toml.example +13 -0
- package/packages/remix/README.md +142 -0
- package/packages/remix/index.d.ts +50 -0
- package/packages/remix/index.js +96 -0
- package/packages/remix/package.json +38 -0
- package/packages/sveltekit/README.md +138 -0
- package/packages/sveltekit/index.d.ts +59 -0
- package/packages/sveltekit/index.js +100 -0
- package/packages/sveltekit/package.json +38 -0
- package/src/index.mjs +19 -0
package/README.md
CHANGED
|
@@ -89,12 +89,15 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
89
89
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
90
90
|
- Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
|
|
91
91
|
- Built-in helpers to install ClamAV and update virus definitions programmatically
|
|
92
|
-
-
|
|
93
|
-
-
|
|
92
|
+
- **Native ESM support** — `import { scan } from 'pompelmi'` works out of the box (dual CJS/ESM build)
|
|
93
|
+
- **Deno support** — `import { scan } from 'npm:pompelmi'` — no install step required
|
|
94
|
+
- **Cloudflare Workers** — via `@pompelmi/cloudflare` — Web APIs only, no Node.js built-ins
|
|
95
|
+
- Works with Express, Fastify, NestJS, Hono, Remix, SvelteKit, and any other Node.js HTTP framework
|
|
96
|
+
- Works with **Node.js • Bun • Deno • Cloudflare Workers**
|
|
94
97
|
- Interactive demo at [pompelmi.app/demo](https://pompelmi.app/demo.html) — try before you install
|
|
95
98
|
- Zero runtime dependencies — ships nothing but source code
|
|
96
99
|
- Tested with EICAR standard antivirus test files
|
|
97
|
-
- CommonJS module; TypeScript type declarations available inline
|
|
100
|
+
- CommonJS + ESM module; TypeScript type declarations available inline
|
|
98
101
|
|
|
99
102
|
See [how pompelmi compares](./docs/comparison.html) to other Node.js ClamAV integrations.
|
|
100
103
|
|
|
@@ -109,7 +112,10 @@ Official integration packages for popular frameworks:
|
|
|
109
112
|
| [@pompelmi/nestjs](./packages/nestjs/) | NestJS | `npm i @pompelmi/nestjs` |
|
|
110
113
|
| [@pompelmi/fastify](./packages/fastify/) | Fastify | `npm i @pompelmi/fastify` |
|
|
111
114
|
| [@pompelmi/hono](./packages/hono/) | Hono | `npm i @pompelmi/hono` |
|
|
115
|
+
| [@pompelmi/remix](./packages/remix/) | Remix | `npm i @pompelmi/remix` |
|
|
116
|
+
| [@pompelmi/sveltekit](./packages/sveltekit/) | SvelteKit | `npm i @pompelmi/sveltekit` |
|
|
112
117
|
| [@pompelmi/testing](./packages/testing/) | Jest/Vitest/Node | `npm i -D @pompelmi/testing` |
|
|
118
|
+
| [@pompelmi/cloudflare](./packages/cloudflare/) | Cloudflare Workers | `npm i @pompelmi/cloudflare` |
|
|
113
119
|
|
|
114
120
|
### NestJS
|
|
115
121
|
|
|
@@ -155,12 +161,48 @@ app.use('/upload/*', pompelmiMiddleware({
|
|
|
155
161
|
app.post('/upload', async (c) => c.json({ ok: true }))
|
|
156
162
|
```
|
|
157
163
|
|
|
164
|
+
### Remix
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { unstable_parseMultipartFormData, json } from '@remix-run/node'
|
|
168
|
+
import { pompelmiUploadHandler } from '@pompelmi/remix'
|
|
169
|
+
|
|
170
|
+
export async function action({ request }) {
|
|
171
|
+
// Throws HTTP 422 automatically if malware is detected
|
|
172
|
+
const formData = await unstable_parseMultipartFormData(
|
|
173
|
+
request,
|
|
174
|
+
pompelmiUploadHandler({ host: 'localhost', port: 3310 })
|
|
175
|
+
)
|
|
176
|
+
const file = formData.get('file')
|
|
177
|
+
return json({ name: file.name, size: file.size, ok: true })
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### SvelteKit
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// +page.server.ts
|
|
185
|
+
import { scanUpload } from '@pompelmi/sveltekit'
|
|
186
|
+
import type { Actions } from './$types'
|
|
187
|
+
|
|
188
|
+
export const actions: Actions = {
|
|
189
|
+
default: async ({ request }) => {
|
|
190
|
+
const formData = await request.formData()
|
|
191
|
+
// Throws HTTP 422 automatically if malware is detected
|
|
192
|
+
await scanUpload(formData.get('file') as File, { host: 'localhost', port: 3310 })
|
|
193
|
+
return { success: true }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
158
198
|
---
|
|
159
199
|
|
|
160
200
|
## Requirements
|
|
161
201
|
|
|
162
202
|
- **Node.js** — any LTS release (no native addons, no C++ bindings)
|
|
163
203
|
- **Bun** — fully supported; uses `Bun.file()` for faster file reading
|
|
204
|
+
- **Deno** — import from `npm:pompelmi` — no install step required
|
|
205
|
+
- **Cloudflare Workers** — via `@pompelmi/cloudflare` — connects to a remote clamd over TCP
|
|
164
206
|
- **ClamAV** — must be installed on the host or reachable over TCP
|
|
165
207
|
|
|
166
208
|
pompelmi does not bundle or automatically download ClamAV. Install it once per machine (see [Installing ClamAV](#installing-clamav)).
|
|
@@ -572,8 +614,8 @@ Please read [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) before contributing. To r
|
|
|
572
614
|
|
|
573
615
|
## Coming soon
|
|
574
616
|
|
|
575
|
-
- [
|
|
576
|
-
- [
|
|
617
|
+
- [x] Cloudflare Workers support — `@pompelmi/cloudflare` ships in v1.17.0
|
|
618
|
+
- [x] NestJS official module — `PompelmiModule.forRoot()` with injectable `PompelmiService`
|
|
577
619
|
|
|
578
620
|
---
|
|
579
621
|
|
package/deno.json
ADDED
package/llms.txt
CHANGED
|
@@ -20,6 +20,18 @@ Options: `{ host?: string, port?: number, timeout?: number }`
|
|
|
20
20
|
- Local mode: spawns `clamscan`, maps exit codes to verdicts
|
|
21
21
|
- TCP mode: streams to clamd via INSTREAM protocol (set `host`)
|
|
22
22
|
|
|
23
|
+
## Framework integrations
|
|
24
|
+
|
|
25
|
+
Official packages for popular frameworks:
|
|
26
|
+
|
|
27
|
+
- `@pompelmi/nestjs` — NestJS module (`PompelmiModule`, `PompelmiService`, `PompelmiInterceptor`)
|
|
28
|
+
- `@pompelmi/fastify` — Fastify plugin (decorates `fastify.pompelmi` with scan/preHandler)
|
|
29
|
+
- `@pompelmi/hono` — Hono middleware (`pompelmiMiddleware`) for Node.js, Bun, Cloudflare Workers
|
|
30
|
+
- `@pompelmi/remix` — Remix upload handler (`pompelmiUploadHandler` for `unstable_parseMultipartFormData`)
|
|
31
|
+
- `@pompelmi/sveltekit` — SvelteKit helper (`scanUpload`, `scanFormData` for +page.server.ts and +server.ts)
|
|
32
|
+
- `@pompelmi/nextjs` — Next.js App Router / Pages Router helpers
|
|
33
|
+
- `@pompelmi/testing` — test utilities (`mockClean`, `mockInfected`, `withMockedPompelmi`)
|
|
34
|
+
|
|
23
35
|
## Installation
|
|
24
36
|
|
|
25
37
|
npm install pompelmi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pompelmi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "ClamAV for humans — scan any file and get back Clean, Malicious, or ScanError. No daemons. No cloud. No native bindings.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "pompelmi contributors",
|
|
@@ -28,16 +28,26 @@
|
|
|
28
28
|
"fastify",
|
|
29
29
|
"nestjs",
|
|
30
30
|
"multer",
|
|
31
|
-
"zero-dependencies"
|
|
31
|
+
"zero-dependencies",
|
|
32
|
+
"remix",
|
|
33
|
+
"sveltekit",
|
|
34
|
+
"svelte"
|
|
32
35
|
],
|
|
33
36
|
"type": "commonjs",
|
|
34
37
|
"main": "./src/index.js",
|
|
38
|
+
"module": "./src/index.mjs",
|
|
35
39
|
"types": "./types/index.d.ts",
|
|
40
|
+
"exports": {
|
|
41
|
+
".": {
|
|
42
|
+
"import": "./src/index.mjs",
|
|
43
|
+
"require": "./src/index.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
36
46
|
"bin": {
|
|
37
47
|
"pompelmi": "./bin/pompelmi.js"
|
|
38
48
|
},
|
|
39
49
|
"scripts": {
|
|
40
|
-
"test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node --test packages/hono/test/index.test.js && node --test packages/testing/test/index.test.js && node test/scan.test.js",
|
|
50
|
+
"test": "node --test test/unit.test.js && node --test test/esm.test.mjs && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node --test packages/hono/test/index.test.js && node --test packages/testing/test/index.test.js && node --test packages/remix/test/index.test.js && node --test packages/sveltekit/test/index.test.js && node --test packages/cloudflare/test/index.test.mjs && node test/scan.test.js",
|
|
41
51
|
"lint": "eslint src/"
|
|
42
52
|
},
|
|
43
53
|
"publishConfig": {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @pompelmi/cloudflare
|
|
2
|
+
|
|
3
|
+
Scan file uploads for malware inside **Cloudflare Workers** using a remote [ClamAV](https://www.clamav.net/) (clamd) instance.
|
|
4
|
+
|
|
5
|
+
Uses Web APIs only (`fetch`, `connect` from `cloudflare:sockets`) — no Node.js built-ins, fully compatible with the Workers Runtime.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
Cloudflare Workers cannot run clamd locally. You need a **publicly reachable** clamd instance. Options:
|
|
10
|
+
|
|
11
|
+
- A VPS running clamd with port 3310 open (add appropriate firewall rules).
|
|
12
|
+
- A Cloudflare Tunnel ([cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)) pointing to a private clamd instance.
|
|
13
|
+
|
|
14
|
+
> **Security note:** Restrict clamd access to your Worker's outbound IPs or use a shared secret at the application layer. Never expose clamd directly to the public internet without access controls.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
npm i @pompelmi/cloudflare
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { scanBuffer, scanRequest } from '@pompelmi/cloudflare';
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
async fetch(request, env) {
|
|
29
|
+
// Option A: scan the whole multipart form at once
|
|
30
|
+
const rejection = await scanRequest(request, {
|
|
31
|
+
host: env.CLAMAV_HOST,
|
|
32
|
+
port: parseInt(env.CLAMAV_PORT),
|
|
33
|
+
});
|
|
34
|
+
if (rejection) return rejection; // 422 or 500
|
|
35
|
+
|
|
36
|
+
return new Response('File accepted');
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or scan an `ArrayBuffer` directly:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import { scanBuffer } from '@pompelmi/cloudflare';
|
|
45
|
+
|
|
46
|
+
export default {
|
|
47
|
+
async fetch(request, env) {
|
|
48
|
+
const formData = await request.formData();
|
|
49
|
+
const file = formData.get('file');
|
|
50
|
+
const buffer = await file.arrayBuffer();
|
|
51
|
+
|
|
52
|
+
const result = await scanBuffer(buffer, {
|
|
53
|
+
host: env.CLAMAV_HOST,
|
|
54
|
+
port: parseInt(env.CLAMAV_PORT),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (result !== 'clean') {
|
|
58
|
+
return new Response('File rejected', { status: 422 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return new Response('OK');
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### `scanBuffer(buffer, options)`
|
|
69
|
+
|
|
70
|
+
| Parameter | Type | Description |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `buffer` | `ArrayBuffer` | The file bytes to scan |
|
|
73
|
+
| `options.host` | `string` | clamd hostname or IP (required) |
|
|
74
|
+
| `options.port` | `number` | clamd port, typically `3310` (required) |
|
|
75
|
+
| `options.timeout` | `number` | Read timeout in ms (default: `15000`) |
|
|
76
|
+
|
|
77
|
+
Returns `Promise<'clean' | 'malicious' | 'error'>`.
|
|
78
|
+
|
|
79
|
+
### `scanRequest(request, options)`
|
|
80
|
+
|
|
81
|
+
Reads the multipart form field (default: `file`), scans it, and returns:
|
|
82
|
+
- `null` — file is clean, proceed normally.
|
|
83
|
+
- `Response(422)` — malicious file detected.
|
|
84
|
+
- `Response(500)` — scan error (clamd unreachable, timeout, etc.).
|
|
85
|
+
|
|
86
|
+
Additional option: `options.field` — form field name (default: `'file'`).
|
|
87
|
+
|
|
88
|
+
## Wrangler configuration
|
|
89
|
+
|
|
90
|
+
Copy [`wrangler.toml.example`](./wrangler.toml.example) to `wrangler.toml` and fill in your clamd host details.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
ISC
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Result returned by scanBuffer */
|
|
2
|
+
export type ScanResult = 'clean' | 'malicious' | 'error';
|
|
3
|
+
|
|
4
|
+
/** Options for connecting to a remote clamd instance */
|
|
5
|
+
export interface CloudflareScanOptions {
|
|
6
|
+
/** Hostname or IP of the remote clamd instance */
|
|
7
|
+
host: string;
|
|
8
|
+
/** Port the remote clamd listens on (typically 3310) */
|
|
9
|
+
port: number;
|
|
10
|
+
/** Socket read timeout in milliseconds (default: 15000) */
|
|
11
|
+
timeout?: number;
|
|
12
|
+
/** Form field name to scan when using scanRequest (default: 'file') */
|
|
13
|
+
field?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scan an ArrayBuffer with a remote clamd instance using INSTREAM protocol.
|
|
18
|
+
* Uses Cloudflare's `connect()` socket API — no Node.js built-ins required.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const result = await scanBuffer(await file.arrayBuffer(), {
|
|
22
|
+
* host: env.CLAMAV_HOST,
|
|
23
|
+
* port: parseInt(env.CLAMAV_PORT),
|
|
24
|
+
* });
|
|
25
|
+
*/
|
|
26
|
+
export declare function scanBuffer(
|
|
27
|
+
buffer: ArrayBuffer,
|
|
28
|
+
options: CloudflareScanOptions
|
|
29
|
+
): Promise<ScanResult>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Scan a multipart form field from a Cloudflare Worker Request.
|
|
33
|
+
* Returns null if the file is clean, or an HTTP Response (422/500) otherwise.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* export default {
|
|
37
|
+
* async fetch(request, env) {
|
|
38
|
+
* const rejection = await scanRequest(request, {
|
|
39
|
+
* host: env.CLAMAV_HOST,
|
|
40
|
+
* port: parseInt(env.CLAMAV_PORT),
|
|
41
|
+
* });
|
|
42
|
+
* if (rejection) return rejection;
|
|
43
|
+
* return new Response('OK');
|
|
44
|
+
* }
|
|
45
|
+
* }
|
|
46
|
+
*/
|
|
47
|
+
export declare function scanRequest(
|
|
48
|
+
request: Request,
|
|
49
|
+
options: CloudflareScanOptions
|
|
50
|
+
): Promise<Response | null>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pompelmi/cloudflare
|
|
3
|
+
*
|
|
4
|
+
* Connects to a remote clamd instance over TCP using Cloudflare's socket API.
|
|
5
|
+
* Uses Web APIs only — no Node.js built-ins.
|
|
6
|
+
*
|
|
7
|
+
* Requires `connect` from `cloudflare:sockets` (available in Workers Runtime ≥ 2023-03-01).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const INSTREAM_CHUNK = 4096; // bytes per INSTREAM chunk
|
|
11
|
+
const CLEAN_RESPONSE = /^stream: OK/;
|
|
12
|
+
const MALICIOUS_RESPONSE = /^stream: (.+) FOUND/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Send a Buffer or ArrayBuffer to clamd via INSTREAM and return the raw
|
|
16
|
+
* response string.
|
|
17
|
+
*
|
|
18
|
+
* @param {ArrayBuffer} data
|
|
19
|
+
* @param {{ host: string, port: number, timeout?: number }} options
|
|
20
|
+
* @returns {Promise<string>}
|
|
21
|
+
*/
|
|
22
|
+
async function clamdInstream(data, { host, port, timeout = 15000 }) {
|
|
23
|
+
// eslint-disable-next-line no-undef
|
|
24
|
+
const { connect } = await import('cloudflare:sockets');
|
|
25
|
+
const socket = connect({ hostname: host, port });
|
|
26
|
+
|
|
27
|
+
const writer = socket.writable.getWriter();
|
|
28
|
+
const reader = socket.readable.getReader();
|
|
29
|
+
|
|
30
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
31
|
+
|
|
32
|
+
// Write INSTREAM command
|
|
33
|
+
const cmd = new TextEncoder().encode('zINSTREAM\0');
|
|
34
|
+
await writer.write(cmd);
|
|
35
|
+
|
|
36
|
+
// Stream data in chunks with 4-byte big-endian length prefix
|
|
37
|
+
let offset = 0;
|
|
38
|
+
while (offset < bytes.byteLength) {
|
|
39
|
+
const end = Math.min(offset + INSTREAM_CHUNK, bytes.byteLength);
|
|
40
|
+
const chunk = bytes.subarray(offset, end);
|
|
41
|
+
const lenBuf = new ArrayBuffer(4);
|
|
42
|
+
new DataView(lenBuf).setUint32(0, chunk.byteLength, false);
|
|
43
|
+
await writer.write(new Uint8Array(lenBuf));
|
|
44
|
+
await writer.write(chunk);
|
|
45
|
+
offset = end;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Write zero-length chunk to signal end of stream
|
|
49
|
+
const terminator = new Uint8Array([0, 0, 0, 0]);
|
|
50
|
+
await writer.write(terminator);
|
|
51
|
+
writer.releaseLock();
|
|
52
|
+
|
|
53
|
+
// Read response with timeout
|
|
54
|
+
const timeoutId = setTimeout(() => reader.cancel(), timeout);
|
|
55
|
+
let response = '';
|
|
56
|
+
const decoder = new TextDecoder();
|
|
57
|
+
try {
|
|
58
|
+
while (true) {
|
|
59
|
+
const { value, done } = await reader.read();
|
|
60
|
+
if (done) break;
|
|
61
|
+
response += decoder.decode(value, { stream: true });
|
|
62
|
+
if (response.includes('\n') || response.includes('\0')) break;
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timeoutId);
|
|
66
|
+
reader.releaseLock();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await socket.close();
|
|
70
|
+
return response.trim().replace(/\0/g, '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Scan an ArrayBuffer with a remote clamd instance.
|
|
75
|
+
*
|
|
76
|
+
* @param {ArrayBuffer} buffer
|
|
77
|
+
* @param {{ host: string, port: number, timeout?: number }} options
|
|
78
|
+
* @returns {Promise<'clean' | 'malicious' | 'error'>}
|
|
79
|
+
*/
|
|
80
|
+
export async function scanBuffer(buffer, options) {
|
|
81
|
+
if (!options || !options.host || !options.port) {
|
|
82
|
+
throw new Error('@pompelmi/cloudflare: options.host and options.port are required');
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const response = await clamdInstream(buffer, options);
|
|
86
|
+
if (CLEAN_RESPONSE.test(response)) return 'clean';
|
|
87
|
+
if (MALICIOUS_RESPONSE.test(response)) return 'malicious';
|
|
88
|
+
return 'error';
|
|
89
|
+
} catch {
|
|
90
|
+
return 'error';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Convenience Cloudflare Worker handler that reads a multipart form field,
|
|
96
|
+
* scans it, and returns HTTP 422 on malicious files.
|
|
97
|
+
*
|
|
98
|
+
* @param {Request} request
|
|
99
|
+
* @param {{ host: string, port: number, field?: string, timeout?: number }} options
|
|
100
|
+
* @returns {Promise<Response | null>} null if clean, Response if malicious/error
|
|
101
|
+
*/
|
|
102
|
+
export async function scanRequest(request, options) {
|
|
103
|
+
const field = options.field || 'file';
|
|
104
|
+
const formData = await request.formData();
|
|
105
|
+
const file = formData.get(field);
|
|
106
|
+
if (!file) return null;
|
|
107
|
+
const buffer = await file.arrayBuffer();
|
|
108
|
+
const result = await scanBuffer(buffer, options);
|
|
109
|
+
if (result === 'malicious') {
|
|
110
|
+
return new Response('File rejected: malicious content detected', { status: 422 });
|
|
111
|
+
}
|
|
112
|
+
if (result === 'error') {
|
|
113
|
+
return new Response('Scan error', { status: 500 });
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/cloudflare",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "pompelmi adapter for Cloudflare Workers — scan file uploads via a remote clamd instance using Web APIs only.",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "pompelmi contributors",
|
|
7
|
+
"homepage": "https://pompelmi.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/pompelmi/pompelmi.git",
|
|
11
|
+
"directory": "packages/cloudflare"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/pompelmi/pompelmi/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"clamav",
|
|
18
|
+
"antivirus",
|
|
19
|
+
"virus-scan",
|
|
20
|
+
"malware",
|
|
21
|
+
"cloudflare",
|
|
22
|
+
"cloudflare-workers",
|
|
23
|
+
"file-upload",
|
|
24
|
+
"security"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./index.js",
|
|
28
|
+
"types": "./index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"import": "./index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"registry": "https://registry.npmjs.org/",
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
name = "my-worker"
|
|
2
|
+
main = "src/worker.js"
|
|
3
|
+
compatibility_date = "2024-01-01"
|
|
4
|
+
|
|
5
|
+
[vars]
|
|
6
|
+
CLAMAV_HOST = "your-clamd-host.example.com"
|
|
7
|
+
CLAMAV_PORT = "3310"
|
|
8
|
+
|
|
9
|
+
# Cloudflare Workers require a publicly reachable clamd instance.
|
|
10
|
+
# Options:
|
|
11
|
+
# 1. Run clamd on a VPS and expose port 3310 (add firewall rules).
|
|
12
|
+
# 2. Use a Cloudflare Tunnel (cloudflared) to expose a private clamd instance.
|
|
13
|
+
# See: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# @pompelmi/remix
|
|
2
|
+
|
|
3
|
+
Remix upload handler for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
|
|
4
|
+
|
|
5
|
+
Works with **Remix v1 and v2** on Node.js, and is compatible with the `unstable_parseMultipartFormData` API.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @pompelmi/remix pompelmi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { unstable_parseMultipartFormData, json } from '@remix-run/node'
|
|
17
|
+
import { pompelmiUploadHandler } from '@pompelmi/remix'
|
|
18
|
+
import type { ActionFunctionArgs } from '@remix-run/node'
|
|
19
|
+
|
|
20
|
+
export async function action({ request }: ActionFunctionArgs) {
|
|
21
|
+
const formData = await unstable_parseMultipartFormData(
|
|
22
|
+
request,
|
|
23
|
+
pompelmiUploadHandler({ host: 'localhost', port: 3310 })
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const file = formData.get('file') as File
|
|
27
|
+
return json({ name: file.name, size: file.size, ok: true })
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If a malicious file is uploaded, `pompelmiUploadHandler` throws a `Response` with HTTP **422** — Remix catches it automatically and returns it to the client.
|
|
32
|
+
|
|
33
|
+
## With an inner handler
|
|
34
|
+
|
|
35
|
+
Chain with any Remix upload handler (e.g. `unstable_createFileUploadHandler`) to store clean files to disk:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import {
|
|
39
|
+
unstable_parseMultipartFormData,
|
|
40
|
+
unstable_createFileUploadHandler,
|
|
41
|
+
json,
|
|
42
|
+
} from '@remix-run/node'
|
|
43
|
+
import { pompelmiUploadHandler } from '@pompelmi/remix'
|
|
44
|
+
|
|
45
|
+
const uploadToTmp = unstable_createFileUploadHandler({ directory: '/tmp/uploads' })
|
|
46
|
+
|
|
47
|
+
export async function action({ request }) {
|
|
48
|
+
const formData = await unstable_parseMultipartFormData(
|
|
49
|
+
request,
|
|
50
|
+
pompelmiUploadHandler({
|
|
51
|
+
host: 'localhost',
|
|
52
|
+
port: 3310,
|
|
53
|
+
inner: uploadToTmp, // called only if file is clean
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
const file = formData.get('file') // NodeOnDiskFile from inner handler
|
|
57
|
+
return json({ ok: true })
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Scan a specific field only
|
|
62
|
+
|
|
63
|
+
Use `field` to restrict scanning to a single form field. Other file fields are passed through to `inner` (or returned as `File` objects) without scanning:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
pompelmiUploadHandler({
|
|
67
|
+
host: 'localhost',
|
|
68
|
+
port: 3310,
|
|
69
|
+
field: 'avatar', // only scan the 'avatar' field
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Custom error response
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
pompelmiUploadHandler({
|
|
77
|
+
host: 'localhost',
|
|
78
|
+
port: 3310,
|
|
79
|
+
onInfected: ({ filename }) => {
|
|
80
|
+
console.warn(`Blocked malicious upload: ${filename}`)
|
|
81
|
+
throw new Response(
|
|
82
|
+
JSON.stringify({ error: 'Malware detected', filename }),
|
|
83
|
+
{ status: 422, headers: { 'Content-Type': 'application/json' } }
|
|
84
|
+
)
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Route example (full)
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
// app/routes/upload.tsx
|
|
93
|
+
import {
|
|
94
|
+
unstable_parseMultipartFormData,
|
|
95
|
+
json,
|
|
96
|
+
type ActionFunctionArgs,
|
|
97
|
+
} from '@remix-run/node'
|
|
98
|
+
import { Form, useActionData } from '@remix-run/react'
|
|
99
|
+
import { pompelmiUploadHandler } from '@pompelmi/remix'
|
|
100
|
+
|
|
101
|
+
export async function action({ request }: ActionFunctionArgs) {
|
|
102
|
+
// Throws HTTP 422 automatically if malware is detected
|
|
103
|
+
const formData = await unstable_parseMultipartFormData(
|
|
104
|
+
request,
|
|
105
|
+
pompelmiUploadHandler({ host: 'localhost', port: 3310 })
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const file = formData.get('document') as File
|
|
109
|
+
if (!file) return json({ error: 'No file provided' }, { status: 400 })
|
|
110
|
+
|
|
111
|
+
return json({ name: file.name, size: file.size, ok: true })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default function Upload() {
|
|
115
|
+
const data = useActionData<typeof action>()
|
|
116
|
+
return (
|
|
117
|
+
<Form method="post" encType="multipart/form-data">
|
|
118
|
+
<input type="file" name="document" />
|
|
119
|
+
<button type="submit">Upload</button>
|
|
120
|
+
{data?.ok && <p>Uploaded: {data.name} ({data.size} bytes)</p>}
|
|
121
|
+
</Form>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Configuration Reference
|
|
127
|
+
|
|
128
|
+
| Option | Type | Default | Description |
|
|
129
|
+
|--------|------|---------|-------------|
|
|
130
|
+
| `field` | `string` | — | Only scan this field; others pass through unscanned |
|
|
131
|
+
| `inner` | `UploadHandler` | — | Inner handler for clean files (e.g. file-upload, memory) |
|
|
132
|
+
| `host` | `string` | — | clamd hostname (enables TCP mode) |
|
|
133
|
+
| `port` | `number` | `3310` | clamd port |
|
|
134
|
+
| `socket` | `string` | — | UNIX domain socket path |
|
|
135
|
+
| `timeout` | `number` | `15000` | Socket idle timeout in ms |
|
|
136
|
+
| `retries` | `number` | `0` | Retry attempts |
|
|
137
|
+
| `retryDelay` | `number` | `1000` | Delay between retries in ms |
|
|
138
|
+
| `onInfected` | `Function` | — | Called with `{ name, filename }` when malware detected |
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
ISC — see root [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ScanOptions } from 'pompelmi';
|
|
2
|
+
|
|
3
|
+
export interface UploadHandlerArgs {
|
|
4
|
+
name: string;
|
|
5
|
+
filename: string | undefined;
|
|
6
|
+
contentType: string;
|
|
7
|
+
data: AsyncIterable<Uint8Array>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type RemixUploadHandler = (args: UploadHandlerArgs) => Promise<File | string | undefined>;
|
|
11
|
+
|
|
12
|
+
export interface PompelmiRemixOptions extends ScanOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Only scan this form field name; other file fields are passed straight to `inner`.
|
|
15
|
+
* When omitted, all file fields are scanned.
|
|
16
|
+
*/
|
|
17
|
+
field?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Inner UploadHandler to call for clean files (e.g. unstable_createMemoryUploadHandler).
|
|
20
|
+
* When omitted, clean files are returned as Web API `File` objects.
|
|
21
|
+
*/
|
|
22
|
+
inner?: RemixUploadHandler;
|
|
23
|
+
/**
|
|
24
|
+
* Called with `{ name, filename }` when a malicious file is detected.
|
|
25
|
+
* Must throw a Response (or return one that will be thrown by the caller).
|
|
26
|
+
* If omitted, throws an HTTP 422 Response automatically.
|
|
27
|
+
*/
|
|
28
|
+
onInfected?: (args: { name: string; filename: string }) => never | Promise<never>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a Remix-compatible UploadHandler that scans each uploaded file with
|
|
33
|
+
* pompelmi before forwarding it to an optional inner handler.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* import { unstable_parseMultipartFormData } from '@remix-run/node'
|
|
37
|
+
* import { pompelmiUploadHandler } from '@pompelmi/remix'
|
|
38
|
+
*
|
|
39
|
+
* export async function action({ request }: ActionFunctionArgs) {
|
|
40
|
+
* const formData = await unstable_parseMultipartFormData(
|
|
41
|
+
* request,
|
|
42
|
+
* pompelmiUploadHandler({ host: 'localhost', port: 3310 })
|
|
43
|
+
* )
|
|
44
|
+
* const file = formData.get('file') as File
|
|
45
|
+
* return json({ name: file.name, size: file.size })
|
|
46
|
+
* }
|
|
47
|
+
*/
|
|
48
|
+
export function pompelmiUploadHandler(options?: PompelmiRemixOptions): RemixUploadHandler;
|
|
49
|
+
|
|
50
|
+
export { Verdict } from 'pompelmi';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanBuffer, Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
// File is a global in Node 20+; on Node 18 it lives in node:buffer
|
|
6
|
+
const NodeFile = globalThis.File ?? require('node:buffer').File;
|
|
7
|
+
|
|
8
|
+
const SCAN_KEYS = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
|
|
9
|
+
|
|
10
|
+
function buildScanOptions(options) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const k of SCAN_KEYS) {
|
|
13
|
+
if (options[k] !== undefined) out[k] = options[k];
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function collectStream(data) {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for await (const chunk of data) {
|
|
21
|
+
chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk));
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat(chunks);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a Remix UploadHandler that scans each uploaded file with pompelmi
|
|
28
|
+
* before passing it to an optional inner handler.
|
|
29
|
+
*
|
|
30
|
+
* Usage in a Remix action:
|
|
31
|
+
*
|
|
32
|
+
* import { unstable_parseMultipartFormData } from '@remix-run/node'
|
|
33
|
+
* import { pompelmiUploadHandler } from '@pompelmi/remix'
|
|
34
|
+
*
|
|
35
|
+
* export async function action({ request }) {
|
|
36
|
+
* const formData = await unstable_parseMultipartFormData(
|
|
37
|
+
* request,
|
|
38
|
+
* pompelmiUploadHandler({ host: 'localhost', port: 3310 })
|
|
39
|
+
* )
|
|
40
|
+
* // file is guaranteed clean here
|
|
41
|
+
* }
|
|
42
|
+
*
|
|
43
|
+
* @param {object} [options]
|
|
44
|
+
* @param {string} [options.field] - Only scan this field; pass others through (default: scan all files).
|
|
45
|
+
* @param {Function} [options.inner] - Inner UploadHandler for clean files. Defaults to storing as File.
|
|
46
|
+
* @param {string} [options.host] - clamd hostname.
|
|
47
|
+
* @param {number} [options.port=3310] - clamd port.
|
|
48
|
+
* @param {string} [options.socket] - UNIX socket path.
|
|
49
|
+
* @param {number} [options.timeout=15000] - Socket idle timeout in ms.
|
|
50
|
+
* @param {number} [options.retries=0] - Retry attempts.
|
|
51
|
+
* @param {number} [options.retryDelay=1000] - Delay between retries in ms.
|
|
52
|
+
* @param {Function} [options.onInfected] - Called with ({ name, filename }) when malware is detected.
|
|
53
|
+
* Must throw or return a Response. Default throws HTTP 422.
|
|
54
|
+
*/
|
|
55
|
+
function pompelmiUploadHandler(options) {
|
|
56
|
+
const { field, inner, onInfected } = options || {};
|
|
57
|
+
const scanOptions = buildScanOptions(options || {});
|
|
58
|
+
|
|
59
|
+
return async function remixUploadHandler({ name, filename, contentType, data }) {
|
|
60
|
+
// Skip non-file fields (no filename) or fields not targeted
|
|
61
|
+
if (!filename || (field && name !== field)) {
|
|
62
|
+
if (typeof inner === 'function') {
|
|
63
|
+
return inner({ name, filename, contentType, data });
|
|
64
|
+
}
|
|
65
|
+
const buf = await collectStream(data);
|
|
66
|
+
// Text field (no filename) → return as string; file field → return as File
|
|
67
|
+
if (!filename) return buf.toString('utf8');
|
|
68
|
+
return new NodeFile([buf], filename, { type: contentType });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const buffer = await collectStream(data);
|
|
72
|
+
|
|
73
|
+
if (buffer.length > 0) {
|
|
74
|
+
const result = await scanBuffer(buffer, scanOptions);
|
|
75
|
+
if (result === Verdict.Malicious) {
|
|
76
|
+
if (typeof onInfected === 'function') {
|
|
77
|
+
return onInfected({ name, filename });
|
|
78
|
+
}
|
|
79
|
+
throw new Response(
|
|
80
|
+
JSON.stringify({ error: 'Malware detected', filename }),
|
|
81
|
+
{ status: 422, headers: { 'Content-Type': 'application/json' } }
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof inner === 'function') {
|
|
87
|
+
// Replay the buffer through the inner handler via an async generator
|
|
88
|
+
async function* replay() { yield buffer; }
|
|
89
|
+
return inner({ name, filename, contentType, data: replay() });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new NodeFile([buffer], filename, { type: contentType });
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { pompelmiUploadHandler, Verdict };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/remix",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Remix upload handler for pompelmi — in-process ClamAV virus scanning with zero extra dependencies",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "pompelmi contributors",
|
|
7
|
+
"homepage": "https://pompelmi.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/pompelmi/pompelmi.git",
|
|
11
|
+
"directory": "packages/remix"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"remix",
|
|
15
|
+
"remix-run",
|
|
16
|
+
"upload-handler",
|
|
17
|
+
"clamav",
|
|
18
|
+
"antivirus",
|
|
19
|
+
"virus-scan",
|
|
20
|
+
"malware",
|
|
21
|
+
"file-upload",
|
|
22
|
+
"security",
|
|
23
|
+
"pompelmi",
|
|
24
|
+
"fullstack"
|
|
25
|
+
],
|
|
26
|
+
"main": "./index.js",
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test test/index.test.js"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"pompelmi": ">=1.15.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"registry": "https://registry.npmjs.org/",
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @pompelmi/sveltekit
|
|
2
|
+
|
|
3
|
+
SvelteKit helper for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
|
|
4
|
+
|
|
5
|
+
Works with both **+page.server.ts actions** and **+server.ts API routes** on Node.js.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @pompelmi/sveltekit pompelmi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> **Requires** the [`@sveltejs/adapter-node`](https://kit.svelte.dev/docs/adapter-node) adapter. ClamAV is a Node.js process and cannot run on edge runtimes.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### Form action (+page.server.ts)
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { scanUpload } from '@pompelmi/sveltekit'
|
|
21
|
+
import type { Actions } from './$types'
|
|
22
|
+
|
|
23
|
+
export const actions: Actions = {
|
|
24
|
+
default: async ({ request }) => {
|
|
25
|
+
const formData = await request.formData()
|
|
26
|
+
const file = formData.get('file') as File
|
|
27
|
+
|
|
28
|
+
// Throws HTTP 422 Response automatically if malware is detected
|
|
29
|
+
await scanUpload(file, { host: 'localhost', port: 3310 })
|
|
30
|
+
|
|
31
|
+
// File is guaranteed clean here — save to storage, etc.
|
|
32
|
+
return { success: true, name: file.name }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If a malicious file is uploaded, `scanUpload` throws a `Response` with HTTP **422** and a JSON body `{ error: 'Malware detected', filename }`. SvelteKit's error boundary catches it automatically.
|
|
38
|
+
|
|
39
|
+
### API route (+server.ts)
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { scanUpload } from '@pompelmi/sveltekit'
|
|
43
|
+
import { json } from '@sveltejs/kit'
|
|
44
|
+
import type { RequestHandler } from './$types'
|
|
45
|
+
|
|
46
|
+
export const POST: RequestHandler = async ({ request }) => {
|
|
47
|
+
const formData = await request.formData()
|
|
48
|
+
const file = formData.get('file') as File
|
|
49
|
+
|
|
50
|
+
await scanUpload(file, { host: 'localhost', port: 3310 })
|
|
51
|
+
|
|
52
|
+
return json({ ok: true, name: file.name, size: file.size })
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Scan all files in FormData
|
|
57
|
+
|
|
58
|
+
Use `scanFormData` to scan every file field at once. Throws on the first malicious file:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { scanFormData } from '@pompelmi/sveltekit'
|
|
62
|
+
|
|
63
|
+
export const actions: Actions = {
|
|
64
|
+
default: async ({ request }) => {
|
|
65
|
+
const formData = await request.formData()
|
|
66
|
+
|
|
67
|
+
// Scans all File/Blob values; throws 422 if any is malicious
|
|
68
|
+
await scanFormData(formData, { host: 'localhost', port: 3310 })
|
|
69
|
+
|
|
70
|
+
return { success: true }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Custom error handling
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { scanUpload } from '@pompelmi/sveltekit'
|
|
79
|
+
import { fail } from '@sveltejs/kit'
|
|
80
|
+
|
|
81
|
+
export const actions: Actions = {
|
|
82
|
+
default: async ({ request }) => {
|
|
83
|
+
const formData = await request.formData()
|
|
84
|
+
const file = formData.get('file') as File
|
|
85
|
+
|
|
86
|
+
await scanUpload(file, {
|
|
87
|
+
host: 'localhost',
|
|
88
|
+
port: 3310,
|
|
89
|
+
onInfected: ({ filename }) => {
|
|
90
|
+
console.warn(`Blocked malicious upload: ${filename}`)
|
|
91
|
+
// Use SvelteKit's fail() to return a form error
|
|
92
|
+
throw fail(422, { error: `${filename} contains malware` })
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return { success: true }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Route component example (full)
|
|
102
|
+
|
|
103
|
+
```svelte
|
|
104
|
+
<!-- src/routes/upload/+page.svelte -->
|
|
105
|
+
<script lang="ts">
|
|
106
|
+
import { enhance } from '$app/forms'
|
|
107
|
+
import type { ActionData } from './$types'
|
|
108
|
+
|
|
109
|
+
export let form: ActionData
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<form method="POST" enctype="multipart/form-data" use:enhance>
|
|
113
|
+
<input type="file" name="file" required />
|
|
114
|
+
<button type="submit">Upload & Scan</button>
|
|
115
|
+
|
|
116
|
+
{#if form?.error}
|
|
117
|
+
<p style="color: red">{form.error}</p>
|
|
118
|
+
{:else if form?.success}
|
|
119
|
+
<p>Uploaded: {form.name}</p>
|
|
120
|
+
{/if}
|
|
121
|
+
</form>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Configuration Reference
|
|
125
|
+
|
|
126
|
+
| Option | Type | Default | Description |
|
|
127
|
+
|--------|------|---------|-------------|
|
|
128
|
+
| `host` | `string` | — | clamd hostname (enables TCP mode) |
|
|
129
|
+
| `port` | `number` | `3310` | clamd port |
|
|
130
|
+
| `socket` | `string` | — | UNIX domain socket path |
|
|
131
|
+
| `timeout` | `number` | `15000` | Socket idle timeout in ms |
|
|
132
|
+
| `retries` | `number` | `0` | Retry attempts |
|
|
133
|
+
| `retryDelay` | `number` | `1000` | Delay between retries in ms |
|
|
134
|
+
| `onInfected` | `Function` | — | Called with `{ filename }` when malware detected; must throw |
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
ISC — see root [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ScanOptions } from 'pompelmi';
|
|
2
|
+
|
|
3
|
+
export interface PompelmiSvelteKitOptions extends ScanOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Called with `{ filename }` when a malicious file is detected.
|
|
6
|
+
* Must throw (e.g. throw error(422, { message: 'Malware detected' })).
|
|
7
|
+
* If omitted, throws an HTTP 422 Response automatically.
|
|
8
|
+
*/
|
|
9
|
+
onInfected?: (args: { filename: string }) => never | Promise<never>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scans an uploaded file with pompelmi inside a SvelteKit server action or
|
|
14
|
+
* API route (+page.server.ts / +server.ts). Throws HTTP 422 on malicious files.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // +page.server.ts
|
|
18
|
+
* import { scanUpload } from '@pompelmi/sveltekit'
|
|
19
|
+
* import type { Actions } from './$types'
|
|
20
|
+
*
|
|
21
|
+
* export const actions: Actions = {
|
|
22
|
+
* default: async ({ request }) => {
|
|
23
|
+
* const formData = await request.formData()
|
|
24
|
+
* await scanUpload(formData.get('file') as File, { host: 'localhost', port: 3310 })
|
|
25
|
+
* return { success: true }
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* // +server.ts (API route)
|
|
31
|
+
* import { scanUpload } from '@pompelmi/sveltekit'
|
|
32
|
+
* import { json } from '@sveltejs/kit'
|
|
33
|
+
* import type { RequestHandler } from './$types'
|
|
34
|
+
*
|
|
35
|
+
* export const POST: RequestHandler = async ({ request }) => {
|
|
36
|
+
* const formData = await request.formData()
|
|
37
|
+
* await scanUpload(formData.get('file') as File, { host: 'localhost', port: 3310 })
|
|
38
|
+
* return json({ ok: true })
|
|
39
|
+
* }
|
|
40
|
+
*/
|
|
41
|
+
export function scanUpload(
|
|
42
|
+
file: File | Blob | Buffer | null | undefined,
|
|
43
|
+
options?: PompelmiSvelteKitOptions
|
|
44
|
+
): Promise<symbol>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Scans all File/Blob values in a FormData object.
|
|
48
|
+
* Throws HTTP 422 on the first malicious file found.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const formData = await request.formData()
|
|
52
|
+
* await scanFormData(formData, { host: 'localhost', port: 3310 })
|
|
53
|
+
*/
|
|
54
|
+
export function scanFormData(
|
|
55
|
+
formData: FormData,
|
|
56
|
+
options?: PompelmiSvelteKitOptions
|
|
57
|
+
): Promise<void>;
|
|
58
|
+
|
|
59
|
+
export { Verdict } from 'pompelmi';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanBuffer, Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
// File is a global in Node 20+; on Node 18 it lives in node:buffer
|
|
6
|
+
const NodeFile = globalThis.File ?? require('node:buffer').File;
|
|
7
|
+
|
|
8
|
+
const SCAN_KEYS = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
|
|
9
|
+
|
|
10
|
+
function buildScanOptions(options) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const k of SCAN_KEYS) {
|
|
13
|
+
if (options[k] !== undefined) out[k] = options[k];
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function toBuffer(file) {
|
|
19
|
+
if (Buffer.isBuffer(file)) return file;
|
|
20
|
+
if (file instanceof Uint8Array) return Buffer.from(file);
|
|
21
|
+
// Web API File / Blob
|
|
22
|
+
if (file && typeof file.arrayBuffer === 'function') {
|
|
23
|
+
return Buffer.from(await file.arrayBuffer());
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scans an uploaded file with pompelmi inside a SvelteKit server action or
|
|
30
|
+
* API route. Throws an HTTP 422 Response automatically on malicious files.
|
|
31
|
+
*
|
|
32
|
+
* Use in a +page.server.ts action or +server.ts route:
|
|
33
|
+
*
|
|
34
|
+
* import { scanUpload } from '@pompelmi/sveltekit'
|
|
35
|
+
*
|
|
36
|
+
* export const actions = {
|
|
37
|
+
* default: async ({ request }) => {
|
|
38
|
+
* const formData = await request.formData()
|
|
39
|
+
* await scanUpload(formData.get('file'), { host: 'localhost', port: 3310 })
|
|
40
|
+
* // file is guaranteed clean here
|
|
41
|
+
* return { success: true }
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
*
|
|
45
|
+
* @param {File|Blob|Buffer} file - The uploaded file to scan.
|
|
46
|
+
* @param {object} [options]
|
|
47
|
+
* @param {string} [options.host] - clamd hostname (enables TCP mode).
|
|
48
|
+
* @param {number} [options.port=3310] - clamd port.
|
|
49
|
+
* @param {string} [options.socket] - UNIX domain socket path.
|
|
50
|
+
* @param {number} [options.timeout] - Socket idle timeout in ms.
|
|
51
|
+
* @param {number} [options.retries] - Retry attempts.
|
|
52
|
+
* @param {number} [options.retryDelay] - Delay between retries in ms.
|
|
53
|
+
* @param {Function} [options.onInfected] - Called with ({ filename }) when malware
|
|
54
|
+
* is detected. Must throw. Defaults to
|
|
55
|
+
* throwing HTTP 422 Response.
|
|
56
|
+
* @returns {Promise<symbol>} Verdict.Clean or Verdict.ScanError (never returns Verdict.Malicious).
|
|
57
|
+
*/
|
|
58
|
+
async function scanUpload(file, options) {
|
|
59
|
+
if (!file) return Verdict.Clean;
|
|
60
|
+
|
|
61
|
+
const scanOptions = buildScanOptions(options || {});
|
|
62
|
+
const buffer = await toBuffer(file);
|
|
63
|
+
|
|
64
|
+
if (!buffer || buffer.length === 0) return Verdict.Clean;
|
|
65
|
+
|
|
66
|
+
const result = await scanBuffer(buffer, scanOptions);
|
|
67
|
+
|
|
68
|
+
if (result === Verdict.Malicious) {
|
|
69
|
+
const filename = (file && typeof file.name === 'string') ? file.name : 'upload';
|
|
70
|
+
const onInfected = options && options.onInfected;
|
|
71
|
+
if (typeof onInfected === 'function') {
|
|
72
|
+
return onInfected({ filename });
|
|
73
|
+
}
|
|
74
|
+
throw new Response(
|
|
75
|
+
JSON.stringify({ error: 'Malware detected', filename }),
|
|
76
|
+
{ status: 422, headers: { 'Content-Type': 'application/json' } }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Scans all File/Blob values in a FormData object. Throws on the first
|
|
85
|
+
* malicious file found.
|
|
86
|
+
*
|
|
87
|
+
* @param {FormData} formData - The parsed form data.
|
|
88
|
+
* @param {object} [options] - Same options as scanUpload.
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
async function scanFormData(formData, options) {
|
|
92
|
+
if (!formData || typeof formData.entries !== 'function') return;
|
|
93
|
+
for (const [, value] of formData.entries()) {
|
|
94
|
+
if (value instanceof NodeFile || (value && typeof value.arrayBuffer === 'function')) {
|
|
95
|
+
await scanUpload(value, options);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { scanUpload, scanFormData, Verdict };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/sveltekit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SvelteKit helper for pompelmi — in-process ClamAV virus scanning with zero extra dependencies",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "pompelmi contributors",
|
|
7
|
+
"homepage": "https://pompelmi.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/pompelmi/pompelmi.git",
|
|
11
|
+
"directory": "packages/sveltekit"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"sveltekit",
|
|
15
|
+
"svelte",
|
|
16
|
+
"server-action",
|
|
17
|
+
"api-route",
|
|
18
|
+
"clamav",
|
|
19
|
+
"antivirus",
|
|
20
|
+
"virus-scan",
|
|
21
|
+
"malware",
|
|
22
|
+
"file-upload",
|
|
23
|
+
"security",
|
|
24
|
+
"pompelmi"
|
|
25
|
+
],
|
|
26
|
+
"main": "./index.js",
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test test/index.test.js"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"pompelmi": ">=1.15.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"registry": "https://registry.npmjs.org/",
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
const pompelmi = require('./index.js');
|
|
4
|
+
export const {
|
|
5
|
+
scan,
|
|
6
|
+
scanBuffer,
|
|
7
|
+
scanStream,
|
|
8
|
+
scanDirectory,
|
|
9
|
+
scanS3,
|
|
10
|
+
createPool,
|
|
11
|
+
watch,
|
|
12
|
+
middleware,
|
|
13
|
+
createScanner,
|
|
14
|
+
generateDashboard,
|
|
15
|
+
generateShareCard,
|
|
16
|
+
notify,
|
|
17
|
+
Verdict,
|
|
18
|
+
} = pompelmi;
|
|
19
|
+
export default pompelmi;
|