pompelmi 1.14.0 → 1.16.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 +64 -1
- package/llms.txt +12 -0
- package/package.json +6 -3
- package/packages/hono/README.md +160 -0
- package/packages/hono/index.d.ts +29 -0
- package/packages/hono/index.js +74 -0
- package/packages/hono/package.json +40 -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/packages/testing/README.md +126 -0
- package/packages/testing/index.d.ts +44 -0
- package/packages/testing/index.js +82 -0
- package/packages/testing/package.json +35 -0
- package/src/BufferScanner.js +3 -0
- package/src/ClamdScanner.js +38 -14
- package/src/StreamScanner.js +3 -0
package/README.md
CHANGED
|
@@ -89,11 +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
|
-
- Works with Express, Fastify, NestJS, and any other Node.js HTTP framework
|
|
92
|
+
- Works with Express, Fastify, NestJS, Hono, Remix, SvelteKit, and any other Node.js HTTP framework
|
|
93
|
+
- Works with Node.js and Bun — uses `Bun.file()` for faster file reading when available
|
|
94
|
+
- Interactive demo at [pompelmi.app/demo](https://pompelmi.app/demo.html) — try before you install
|
|
93
95
|
- Zero runtime dependencies — ships nothing but source code
|
|
94
96
|
- Tested with EICAR standard antivirus test files
|
|
95
97
|
- CommonJS module; TypeScript type declarations available inline
|
|
96
98
|
|
|
99
|
+
See [how pompelmi compares](./docs/comparison.html) to other Node.js ClamAV integrations.
|
|
100
|
+
|
|
97
101
|
---
|
|
98
102
|
|
|
99
103
|
## Framework Integrations
|
|
@@ -104,6 +108,10 @@ Official integration packages for popular frameworks:
|
|
|
104
108
|
|---------|-----------|---------|
|
|
105
109
|
| [@pompelmi/nestjs](./packages/nestjs/) | NestJS | `npm i @pompelmi/nestjs` |
|
|
106
110
|
| [@pompelmi/fastify](./packages/fastify/) | Fastify | `npm i @pompelmi/fastify` |
|
|
111
|
+
| [@pompelmi/hono](./packages/hono/) | Hono | `npm i @pompelmi/hono` |
|
|
112
|
+
| [@pompelmi/remix](./packages/remix/) | Remix | `npm i @pompelmi/remix` |
|
|
113
|
+
| [@pompelmi/sveltekit](./packages/sveltekit/) | SvelteKit | `npm i @pompelmi/sveltekit` |
|
|
114
|
+
| [@pompelmi/testing](./packages/testing/) | Jest/Vitest/Node | `npm i -D @pompelmi/testing` |
|
|
107
115
|
|
|
108
116
|
### NestJS
|
|
109
117
|
|
|
@@ -132,11 +140,63 @@ const result = await fastify.pompelmi.scanBuffer(buffer);
|
|
|
132
140
|
fastify.post('/upload', { preHandler: fastify.pompelmi.preHandler({ field: 'file' }) }, handler);
|
|
133
141
|
```
|
|
134
142
|
|
|
143
|
+
### Hono (Node.js, Bun, Cloudflare Workers)
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { Hono } from 'hono'
|
|
147
|
+
import { pompelmiMiddleware } from '@pompelmi/hono'
|
|
148
|
+
|
|
149
|
+
const app = new Hono()
|
|
150
|
+
|
|
151
|
+
app.use('/upload/*', pompelmiMiddleware({
|
|
152
|
+
host: 'localhost',
|
|
153
|
+
port: 3310,
|
|
154
|
+
onInfected: (c, filename) => c.json({ error: 'Malware detected' }, 422),
|
|
155
|
+
}))
|
|
156
|
+
|
|
157
|
+
app.post('/upload', async (c) => c.json({ ok: true }))
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Remix
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import { unstable_parseMultipartFormData, json } from '@remix-run/node'
|
|
164
|
+
import { pompelmiUploadHandler } from '@pompelmi/remix'
|
|
165
|
+
|
|
166
|
+
export async function action({ request }) {
|
|
167
|
+
// Throws HTTP 422 automatically if malware is detected
|
|
168
|
+
const formData = await unstable_parseMultipartFormData(
|
|
169
|
+
request,
|
|
170
|
+
pompelmiUploadHandler({ host: 'localhost', port: 3310 })
|
|
171
|
+
)
|
|
172
|
+
const file = formData.get('file')
|
|
173
|
+
return json({ name: file.name, size: file.size, ok: true })
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### SvelteKit
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
// +page.server.ts
|
|
181
|
+
import { scanUpload } from '@pompelmi/sveltekit'
|
|
182
|
+
import type { Actions } from './$types'
|
|
183
|
+
|
|
184
|
+
export const actions: Actions = {
|
|
185
|
+
default: async ({ request }) => {
|
|
186
|
+
const formData = await request.formData()
|
|
187
|
+
// Throws HTTP 422 automatically if malware is detected
|
|
188
|
+
await scanUpload(formData.get('file') as File, { host: 'localhost', port: 3310 })
|
|
189
|
+
return { success: true }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
135
194
|
---
|
|
136
195
|
|
|
137
196
|
## Requirements
|
|
138
197
|
|
|
139
198
|
- **Node.js** — any LTS release (no native addons, no C++ bindings)
|
|
199
|
+
- **Bun** — fully supported; uses `Bun.file()` for faster file reading
|
|
140
200
|
- **ClamAV** — must be installed on the host or reachable over TCP
|
|
141
201
|
|
|
142
202
|
pompelmi does not bundle or automatically download ClamAV. Install it once per machine (see [Installing ClamAV](#installing-clamav)).
|
|
@@ -156,6 +216,9 @@ yarn add pompelmi
|
|
|
156
216
|
|
|
157
217
|
# pnpm
|
|
158
218
|
pnpm add pompelmi
|
|
219
|
+
|
|
220
|
+
# bun
|
|
221
|
+
bun add pompelmi
|
|
159
222
|
```
|
|
160
223
|
|
|
161
224
|
### Docker
|
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.16.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,7 +28,10 @@
|
|
|
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",
|
|
@@ -37,7 +40,7 @@
|
|
|
37
40
|
"pompelmi": "./bin/pompelmi.js"
|
|
38
41
|
},
|
|
39
42
|
"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/scan.test.js",
|
|
43
|
+
"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 packages/remix/test/index.test.js && node --test packages/sveltekit/test/index.test.js && node test/scan.test.js",
|
|
41
44
|
"lint": "eslint src/"
|
|
42
45
|
},
|
|
43
46
|
"publishConfig": {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# @pompelmi/hono
|
|
2
|
+
|
|
3
|
+
Hono middleware for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
|
|
4
|
+
|
|
5
|
+
Works on **Node.js**, **Bun**, and **Cloudflare Workers** (simulation mode).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @pompelmi/hono pompelmi
|
|
11
|
+
# or
|
|
12
|
+
bun add @pompelmi/hono pompelmi
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { Hono } from 'hono'
|
|
19
|
+
import { pompelmiMiddleware } from '@pompelmi/hono'
|
|
20
|
+
|
|
21
|
+
const app = new Hono()
|
|
22
|
+
|
|
23
|
+
app.use('/upload/*', pompelmiMiddleware({
|
|
24
|
+
host: 'localhost',
|
|
25
|
+
port: 3310,
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
app.post('/upload', async (c) => {
|
|
29
|
+
// file is guaranteed clean here
|
|
30
|
+
return c.json({ ok: true })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export default app
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Custom infected handler
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
app.use('/upload/*', pompelmiMiddleware({
|
|
40
|
+
host: 'localhost',
|
|
41
|
+
port: 3310,
|
|
42
|
+
field: 'file',
|
|
43
|
+
onInfected: (c, filename) => {
|
|
44
|
+
console.warn(`Blocked malicious upload: ${filename}`)
|
|
45
|
+
return c.json({ error: 'Malware detected', filename }, 422)
|
|
46
|
+
},
|
|
47
|
+
}))
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Hono on Node.js
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const { serve } = require('@hono/node-server')
|
|
54
|
+
const { Hono } = require('hono')
|
|
55
|
+
const { pompelmiMiddleware } = require('@pompelmi/hono')
|
|
56
|
+
|
|
57
|
+
const app = new Hono()
|
|
58
|
+
|
|
59
|
+
app.use('/upload/*', pompelmiMiddleware({
|
|
60
|
+
host: '127.0.0.1',
|
|
61
|
+
port: 3310,
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
app.post('/upload', async (c) => {
|
|
65
|
+
const body = await c.req.parseBody()
|
|
66
|
+
const file = body['file']
|
|
67
|
+
return c.json({ name: file.name, size: file.size, ok: true })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
serve({ fetch: app.fetch, port: 3000 }, () => {
|
|
71
|
+
console.log('Server running on http://localhost:3000')
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Hono on Bun
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { Hono } from 'hono'
|
|
79
|
+
import { pompelmiMiddleware } from '@pompelmi/hono'
|
|
80
|
+
|
|
81
|
+
const app = new Hono()
|
|
82
|
+
|
|
83
|
+
app.use('/upload/*', pompelmiMiddleware({
|
|
84
|
+
socket: '/run/clamav/clamd.sock', // UNIX socket — faster on Bun
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
app.post('/upload', async (c) => {
|
|
88
|
+
return c.json({ ok: true })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
export default {
|
|
92
|
+
port: 3000,
|
|
93
|
+
fetch: app.fetch,
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Hono on Cloudflare Workers
|
|
98
|
+
|
|
99
|
+
> Note: clamd is not available inside Workers. Use pompelmi in a Node.js / Bun sidecar
|
|
100
|
+
> service and call it over HTTP, or use the UNIX socket approach with a co-located daemon.
|
|
101
|
+
>
|
|
102
|
+
> For Workers deployments without a sidecar, the middleware skips scanning gracefully
|
|
103
|
+
> and calls `next()` — you can gate the behaviour with an environment variable.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { Hono } from 'hono'
|
|
107
|
+
import { pompelmiMiddleware } from '@pompelmi/hono'
|
|
108
|
+
|
|
109
|
+
const app = new Hono<{ Bindings: { SCAN_HOST: string; SCAN_PORT: string } }>()
|
|
110
|
+
|
|
111
|
+
app.use('/upload/*', async (c, next) => {
|
|
112
|
+
if (!c.env.SCAN_HOST) return next() // skip if no clamd configured
|
|
113
|
+
return pompelmiMiddleware({
|
|
114
|
+
host: c.env.SCAN_HOST,
|
|
115
|
+
port: Number(c.env.SCAN_PORT) || 3310,
|
|
116
|
+
})(c, next)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
app.post('/upload', async (c) => {
|
|
120
|
+
return c.json({ ok: true })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
export default app
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Configuration Reference
|
|
127
|
+
|
|
128
|
+
All options are forwarded to pompelmi's `ScanOptions`:
|
|
129
|
+
|
|
130
|
+
| Option | Type | Default | Description |
|
|
131
|
+
|--------|------|---------|-------------|
|
|
132
|
+
| `field` | `string` | `'file'` | Form field name containing the uploaded file |
|
|
133
|
+
| `host` | `string` | — | clamd hostname (enables TCP mode) |
|
|
134
|
+
| `port` | `number` | `3310` | clamd port |
|
|
135
|
+
| `socket` | `string` | — | UNIX domain socket path |
|
|
136
|
+
| `timeout` | `number` | `15000` | Socket idle timeout in ms |
|
|
137
|
+
| `retries` | `number` | `0` | Number of retry attempts |
|
|
138
|
+
| `retryDelay` | `number` | `1000` | Delay between retries in ms |
|
|
139
|
+
| `onInfected` | `Function` | — | Called with `(c, filename)` when malware is detected |
|
|
140
|
+
|
|
141
|
+
## TypeScript
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { Hono } from 'hono'
|
|
145
|
+
import { pompelmiMiddleware } from '@pompelmi/hono'
|
|
146
|
+
import { Verdict } from 'pompelmi'
|
|
147
|
+
|
|
148
|
+
const app = new Hono()
|
|
149
|
+
|
|
150
|
+
app.use('/upload/*', pompelmiMiddleware({
|
|
151
|
+
host: 'localhost',
|
|
152
|
+
port: 3310,
|
|
153
|
+
onInfected: (c, filename) =>
|
|
154
|
+
c.json({ error: `${filename} is infected` }, 422),
|
|
155
|
+
}))
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
ISC — see root [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { MiddlewareHandler, Context } from 'hono';
|
|
2
|
+
import type { ScanOptions } from 'pompelmi';
|
|
3
|
+
|
|
4
|
+
export interface PompelmiHonoOptions extends ScanOptions {
|
|
5
|
+
/** Form field name that contains the uploaded file (default: 'file') */
|
|
6
|
+
field?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Called with (c, filename) when a malicious file is detected.
|
|
9
|
+
* Must return a Response (or Promise<Response>).
|
|
10
|
+
* If omitted, responds with HTTP 422 { error: 'Malware detected' }.
|
|
11
|
+
*/
|
|
12
|
+
onInfected?: (c: Context, filename: string) => Response | Promise<Response>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hono middleware that scans an uploaded file before the route handler runs.
|
|
17
|
+
* Reads the file from the parsed multipart body field (default: 'file').
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* import { Hono } from 'hono'
|
|
21
|
+
* import { pompelmiMiddleware } from '@pompelmi/hono'
|
|
22
|
+
*
|
|
23
|
+
* const app = new Hono()
|
|
24
|
+
* app.use('/upload/*', pompelmiMiddleware({ host: 'localhost', port: 3310 }))
|
|
25
|
+
* app.post('/upload', (c) => c.json({ ok: true }))
|
|
26
|
+
*/
|
|
27
|
+
export function pompelmiMiddleware(options?: PompelmiHonoOptions): MiddlewareHandler;
|
|
28
|
+
|
|
29
|
+
export { Verdict } from 'pompelmi';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanBuffer, Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
const SCAN_KEYS = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
|
|
6
|
+
|
|
7
|
+
function buildScanOptions(options) {
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const k of SCAN_KEYS) {
|
|
10
|
+
if (options[k] !== undefined) out[k] = options[k];
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function toBuffer(raw) {
|
|
16
|
+
if (Buffer.isBuffer(raw)) return raw;
|
|
17
|
+
if (raw instanceof Uint8Array) return Buffer.from(raw);
|
|
18
|
+
// Web API File / Blob (Hono on Bun, Cloudflare Workers, Node 20+)
|
|
19
|
+
if (raw && typeof raw.arrayBuffer === 'function') {
|
|
20
|
+
return Buffer.from(await raw.arrayBuffer());
|
|
21
|
+
}
|
|
22
|
+
if (typeof raw === 'string') return Buffer.from(raw);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hono middleware that scans an uploaded file with pompelmi before the route
|
|
28
|
+
* handler runs. The file is read from the parsed multipart body.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} [options]
|
|
31
|
+
* @param {string} [options.field='file'] - Form field name containing the file.
|
|
32
|
+
* @param {string} [options.host] - clamd hostname (enables TCP mode).
|
|
33
|
+
* @param {number} [options.port=3310] - clamd port.
|
|
34
|
+
* @param {string} [options.socket] - UNIX domain socket path.
|
|
35
|
+
* @param {number} [options.timeout=15000] - Socket idle timeout in ms.
|
|
36
|
+
* @param {number} [options.retries=0] - Number of retry attempts.
|
|
37
|
+
* @param {number} [options.retryDelay=1000] - Delay between retries in ms.
|
|
38
|
+
* @param {Function} [options.onInfected] - Called with (c, filename) when malware
|
|
39
|
+
* is detected; must return a Response.
|
|
40
|
+
* Defaults to 422 JSON error.
|
|
41
|
+
*/
|
|
42
|
+
function pompelmiMiddleware(options) {
|
|
43
|
+
const { field = 'file', onInfected } = options || {};
|
|
44
|
+
const scanOptions = buildScanOptions(options || {});
|
|
45
|
+
|
|
46
|
+
return async function pompelmiHonoMiddleware(c, next) {
|
|
47
|
+
let body;
|
|
48
|
+
try {
|
|
49
|
+
body = await c.req.parseBody();
|
|
50
|
+
} catch {
|
|
51
|
+
return next();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const raw = body[field];
|
|
55
|
+
if (raw == null) return next();
|
|
56
|
+
|
|
57
|
+
const buffer = await toBuffer(raw);
|
|
58
|
+
if (!buffer || buffer.length === 0) return next();
|
|
59
|
+
|
|
60
|
+
const result = await scanBuffer(buffer, scanOptions);
|
|
61
|
+
|
|
62
|
+
if (result === Verdict.Malicious) {
|
|
63
|
+
const filename = (raw && typeof raw.name === 'string') ? raw.name : field;
|
|
64
|
+
if (typeof onInfected === 'function') {
|
|
65
|
+
return onInfected(c, filename);
|
|
66
|
+
}
|
|
67
|
+
return c.json({ error: 'Malware detected' }, 422);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return next();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { pompelmiMiddleware, Verdict };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/hono",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Hono middleware 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/hono"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"hono",
|
|
15
|
+
"middleware",
|
|
16
|
+
"clamav",
|
|
17
|
+
"antivirus",
|
|
18
|
+
"virus-scan",
|
|
19
|
+
"malware",
|
|
20
|
+
"file-upload",
|
|
21
|
+
"security",
|
|
22
|
+
"pompelmi",
|
|
23
|
+
"bun",
|
|
24
|
+
"edge",
|
|
25
|
+
"cloudflare-workers"
|
|
26
|
+
],
|
|
27
|
+
"main": "./index.js",
|
|
28
|
+
"types": "./index.d.ts",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test test/index.test.js"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"hono": ">=3",
|
|
34
|
+
"pompelmi": ">=1.14.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"registry": "https://registry.npmjs.org/",
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -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';
|