@msal95/fileguard 0.1.1
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 +530 -0
- package/index.js +2 -0
- package/package.json +122 -0
- package/src/adapters/express.js +86 -0
- package/src/adapters/fastify.js +105 -0
- package/src/adapters/index.js +3 -0
- package/src/adapters/nextjs.js +74 -0
- package/src/audit/logger.js +32 -0
- package/src/core/rateLimiter.js +55 -0
- package/src/core/sanitizer.js +62 -0
- package/src/core/validator.js +163 -0
- package/src/errors/UploadError.js +43 -0
- package/src/index.js +65 -0
- package/src/react/DropZone.jsx +146 -0
- package/src/react/FilePreview.jsx +152 -0
- package/src/react/ProgressBar.jsx +82 -0
- package/src/react/UploadButton.jsx +123 -0
- package/src/react/index.js +4 -0
- package/src/scanners/clamav.js +50 -0
- package/src/scanners/magicBytes.js +62 -0
- package/src/scanners/polyglot.js +58 -0
- package/src/scanners/virustotal.js +85 -0
- package/src/scanners/zipBomb.js +85 -0
- package/src/storage/cloudinary.js +56 -0
- package/src/storage/index.js +27 -0
- package/src/storage/local.js +39 -0
- package/src/storage/s3.js +62 -0
- package/types/index.d.ts +380 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import busboy from 'busboy'
|
|
2
|
+
import { validateFile } from '../core/validator.js'
|
|
3
|
+
import { getStorageAdapter } from '../storage/index.js'
|
|
4
|
+
import { failResult } from '../errors/UploadError.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create an Express middleware that handles multipart file uploads.
|
|
8
|
+
*
|
|
9
|
+
* Attaches the result to `req.uploadResult` and always calls `next()`.
|
|
10
|
+
* On an unexpected internal error, calls `next(err)`.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* app.post('/upload', createExpressMiddleware(config), (req, res) => {
|
|
14
|
+
* if (!req.uploadResult.success) return res.status(422).json(req.uploadResult)
|
|
15
|
+
* res.json(req.uploadResult)
|
|
16
|
+
* })
|
|
17
|
+
*
|
|
18
|
+
* @param {object} [config] - fileguard config (storage, allowedExtensions, etc.)
|
|
19
|
+
* @returns {Function} Express middleware (req, res, next) => void
|
|
20
|
+
*/
|
|
21
|
+
export function createExpressMiddleware(config = {}) {
|
|
22
|
+
return function uploadMiddleware(req, res, next) {
|
|
23
|
+
let bb
|
|
24
|
+
try {
|
|
25
|
+
bb = busboy({ headers: req.headers, limits: { files: 1 } })
|
|
26
|
+
} catch (err) {
|
|
27
|
+
// Malformed Content-Type header (e.g. not a multipart request)
|
|
28
|
+
req.uploadResult = failResult('STORAGE_ERROR', `Could not parse request: ${err.message}`)
|
|
29
|
+
return next()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let fileData = null
|
|
33
|
+
let done = false
|
|
34
|
+
const finish = (result) => {
|
|
35
|
+
if (done) return
|
|
36
|
+
done = true
|
|
37
|
+
req.uploadResult = result
|
|
38
|
+
next()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
bb.on('file', (fieldname, fileStream, info) => {
|
|
42
|
+
const { filename, mimeType } = info
|
|
43
|
+
const chunks = []
|
|
44
|
+
fileStream.on('data', (chunk) => chunks.push(chunk))
|
|
45
|
+
fileStream.on('end', () => {
|
|
46
|
+
fileData = { buffer: Buffer.concat(chunks), filename, mimeType }
|
|
47
|
+
})
|
|
48
|
+
fileStream.on('error', (err) => next(err))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
bb.on('finish', async () => {
|
|
52
|
+
try {
|
|
53
|
+
if (!fileData) {
|
|
54
|
+
return finish(failResult('STORAGE_ERROR', 'No file found in request'))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { buffer, filename, mimeType } = fileData
|
|
58
|
+
const validation = await validateFile(
|
|
59
|
+
{ buffer, filename, mimeType, size: buffer.length },
|
|
60
|
+
config
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if (!validation.success) return finish(validation)
|
|
64
|
+
|
|
65
|
+
const store = await getStorageAdapter(config.storage ?? 'local')
|
|
66
|
+
const result = await store(
|
|
67
|
+
{
|
|
68
|
+
buffer,
|
|
69
|
+
filename,
|
|
70
|
+
sanitizedFilename: validation.sanitizedFilename,
|
|
71
|
+
size: buffer.length,
|
|
72
|
+
mimeType,
|
|
73
|
+
},
|
|
74
|
+
config
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
finish(result)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (!done) { done = true; next(err) }
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
bb.on('error', (err) => { if (!done) { done = true; next(err) } })
|
|
84
|
+
req.pipe(bb)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import busboy from 'busboy'
|
|
2
|
+
import { validateFile } from '../core/validator.js'
|
|
3
|
+
import { getStorageAdapter } from '../storage/index.js'
|
|
4
|
+
import { failResult } from '../errors/UploadError.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse a raw Node.js http.IncomingMessage as a multipart upload using busboy.
|
|
8
|
+
* @param {import('http').IncomingMessage} rawRequest
|
|
9
|
+
* @param {object} config
|
|
10
|
+
* @returns {Promise<object>} fileguard result object
|
|
11
|
+
*/
|
|
12
|
+
async function processMultipart(rawRequest, config) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let bb
|
|
15
|
+
try {
|
|
16
|
+
bb = busboy({ headers: rawRequest.headers, limits: { files: 1 } })
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return resolve(failResult('STORAGE_ERROR', `Could not parse request: ${err.message}`))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let fileData = null
|
|
22
|
+
|
|
23
|
+
bb.on('file', (fieldname, fileStream, info) => {
|
|
24
|
+
const { filename, mimeType } = info
|
|
25
|
+
const chunks = []
|
|
26
|
+
fileStream.on('data', (chunk) => chunks.push(chunk))
|
|
27
|
+
fileStream.on('end', () => {
|
|
28
|
+
fileData = { buffer: Buffer.concat(chunks), filename, mimeType }
|
|
29
|
+
})
|
|
30
|
+
fileStream.on('error', reject)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
bb.on('finish', async () => {
|
|
34
|
+
try {
|
|
35
|
+
if (!fileData) {
|
|
36
|
+
return resolve(failResult('STORAGE_ERROR', 'No file found in request'))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { buffer, filename, mimeType } = fileData
|
|
40
|
+
const validation = await validateFile(
|
|
41
|
+
{ buffer, filename, mimeType, size: buffer.length },
|
|
42
|
+
config
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if (!validation.success) return resolve(validation)
|
|
46
|
+
|
|
47
|
+
const store = await getStorageAdapter(config.storage ?? 'local')
|
|
48
|
+
const result = await store(
|
|
49
|
+
{
|
|
50
|
+
buffer,
|
|
51
|
+
filename,
|
|
52
|
+
sanitizedFilename: validation.sanitizedFilename,
|
|
53
|
+
size: buffer.length,
|
|
54
|
+
mimeType,
|
|
55
|
+
},
|
|
56
|
+
config
|
|
57
|
+
)
|
|
58
|
+
resolve(result)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
reject(err)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
bb.on('error', reject)
|
|
65
|
+
rawRequest.pipe(bb)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a Fastify plugin for file uploads.
|
|
71
|
+
*
|
|
72
|
+
* Registers a content-type parser for multipart/form-data and adds an
|
|
73
|
+
* `uploadGuard()` decorator that returns a preHandler function.
|
|
74
|
+
*
|
|
75
|
+
* Usage:
|
|
76
|
+
* await fastify.register(createFastifyPlugin({ storage: 'local' }))
|
|
77
|
+
* fastify.post('/upload', { preHandler: fastify.uploadGuard() }, async (req, reply) => {
|
|
78
|
+
* reply.send(req.uploadResult)
|
|
79
|
+
* })
|
|
80
|
+
*
|
|
81
|
+
* @param {object} [config] - fileguard config
|
|
82
|
+
* @returns {Function} Fastify plugin async (fastify, opts) => void
|
|
83
|
+
*/
|
|
84
|
+
export function createFastifyPlugin(config = {}) {
|
|
85
|
+
return async function fastifyUploadPlugin(fastify) {
|
|
86
|
+
// Prevent Fastify from trying to JSON-parse multipart bodies.
|
|
87
|
+
fastify.addContentTypeParser('multipart/form-data', (_req, payload, done) => {
|
|
88
|
+
done(null, payload)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns a preHandler that validates + stores the upload and attaches
|
|
93
|
+
* the result to `request.uploadResult`.
|
|
94
|
+
* @param {object} [overrides] - per-route config overrides
|
|
95
|
+
* @returns {Function} async (request, reply) => void
|
|
96
|
+
*/
|
|
97
|
+
fastify.decorate('uploadGuard', function uploadGuard(overrides = {}) {
|
|
98
|
+
const mergedConfig = { ...config, ...overrides }
|
|
99
|
+
|
|
100
|
+
return async function preHandler(request) {
|
|
101
|
+
request.uploadResult = await processMultipart(request.raw, mergedConfig)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { validateFile } from '../core/validator.js'
|
|
2
|
+
import { getStorageAdapter } from '../storage/index.js'
|
|
3
|
+
import { failResult } from '../errors/UploadError.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a Next.js App Router route handler for file uploads.
|
|
7
|
+
*
|
|
8
|
+
* Usage in app/api/upload/route.js:
|
|
9
|
+
* import { createNextHandler } from 'fileguard/nextjs'
|
|
10
|
+
* export const POST = createNextHandler({ storage: 'local', localPath: './uploads' })
|
|
11
|
+
*
|
|
12
|
+
* @param {object} [config] - fileguard config
|
|
13
|
+
* @returns {Function} async (request: Request) => Response
|
|
14
|
+
*/
|
|
15
|
+
export function createNextHandler(config = {}) {
|
|
16
|
+
const fieldName = config.fieldName ?? 'file'
|
|
17
|
+
|
|
18
|
+
return async function handler(request) {
|
|
19
|
+
try {
|
|
20
|
+
let formData
|
|
21
|
+
try {
|
|
22
|
+
formData = await request.formData()
|
|
23
|
+
} catch {
|
|
24
|
+
return jsonResponse(failResult('STORAGE_ERROR', 'Failed to parse form data'), 400)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const fileEntry = formData.get(fieldName)
|
|
28
|
+
if (!fileEntry || typeof fileEntry === 'string') {
|
|
29
|
+
return jsonResponse(failResult('STORAGE_ERROR', `No file found in field "${fieldName}"`), 400)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const buffer = Buffer.from(await fileEntry.arrayBuffer())
|
|
33
|
+
const filename = fileEntry.name ?? 'upload'
|
|
34
|
+
const mimeType = fileEntry.type ?? 'application/octet-stream'
|
|
35
|
+
|
|
36
|
+
const validation = await validateFile(
|
|
37
|
+
{ buffer, filename, mimeType, size: buffer.length },
|
|
38
|
+
config
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if (!validation.success) {
|
|
42
|
+
return jsonResponse(validation, 422)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const store = await getStorageAdapter(config.storage ?? 'local')
|
|
46
|
+
const result = await store(
|
|
47
|
+
{
|
|
48
|
+
buffer,
|
|
49
|
+
filename,
|
|
50
|
+
sanitizedFilename: validation.sanitizedFilename,
|
|
51
|
+
size: buffer.length,
|
|
52
|
+
mimeType,
|
|
53
|
+
},
|
|
54
|
+
config
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return jsonResponse(result, result.success ? 200 : 500)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return jsonResponse(failResult('STORAGE_ERROR', err.message ?? 'Internal error'), 500)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {object} body
|
|
66
|
+
* @param {number} status
|
|
67
|
+
* @returns {Response}
|
|
68
|
+
*/
|
|
69
|
+
function jsonResponse(body, status) {
|
|
70
|
+
return new Response(JSON.stringify(body), {
|
|
71
|
+
status,
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
})
|
|
74
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'fs/promises'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create an audit logger that appends JSON-newline entries to a log file.
|
|
6
|
+
* If logging fails, the error is printed but the upload is NOT blocked.
|
|
7
|
+
*
|
|
8
|
+
* @param {{ logPath?: string, enabled?: boolean }} [config]
|
|
9
|
+
* @returns {{ log: (event: object) => Promise<void> }}
|
|
10
|
+
*/
|
|
11
|
+
export function createLogger(config = {}) {
|
|
12
|
+
const { logPath = './logs/uploads.log', enabled = true } = config
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
/**
|
|
16
|
+
* Append an audit event to the log file.
|
|
17
|
+
* @param {object} event - Arbitrary key/value data to record
|
|
18
|
+
*/
|
|
19
|
+
async log(event) {
|
|
20
|
+
if (!enabled) return
|
|
21
|
+
|
|
22
|
+
const entry = JSON.stringify({ timestamp: new Date().toISOString(), ...event }) + '\n'
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await mkdir(path.dirname(path.resolve(logPath)), { recursive: true })
|
|
26
|
+
await appendFile(logPath, entry, 'utf8')
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error('[fileguard audit] Failed to write log entry:', err.message)
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { failResult } from '../errors/UploadError.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a per-key in-memory rate limiter.
|
|
5
|
+
* Expired windows are cleaned up automatically on each check.
|
|
6
|
+
*
|
|
7
|
+
* @param {{ maxUploads?: number, windowMs?: number }} [config]
|
|
8
|
+
* @returns {{ check: (key: string) => object, reset: (key: string) => void }}
|
|
9
|
+
*/
|
|
10
|
+
export function createRateLimiter(config = {}) {
|
|
11
|
+
const { maxUploads = 10, windowMs = 60_000 } = config
|
|
12
|
+
const store = new Map()
|
|
13
|
+
|
|
14
|
+
function cleanup(now) {
|
|
15
|
+
for (const [key, entry] of store) {
|
|
16
|
+
if (now >= entry.resetAt) store.delete(key)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
/**
|
|
22
|
+
* Check and increment the counter for a given key.
|
|
23
|
+
* @param {string} key - User ID or IP address
|
|
24
|
+
* @returns {{ success: true } | { success: false, error: string, message: string }}
|
|
25
|
+
*/
|
|
26
|
+
check(key) {
|
|
27
|
+
const now = Date.now()
|
|
28
|
+
cleanup(now)
|
|
29
|
+
|
|
30
|
+
const entry = store.get(key)
|
|
31
|
+
if (!entry || now >= entry.resetAt) {
|
|
32
|
+
if (maxUploads <= 0) {
|
|
33
|
+
return failResult('RATE_LIMIT_EXCEEDED', 'Upload rate limit exceeded. Try again later.')
|
|
34
|
+
}
|
|
35
|
+
store.set(key, { count: 1, resetAt: now + windowMs })
|
|
36
|
+
return { success: true }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (entry.count >= maxUploads) {
|
|
40
|
+
return failResult('RATE_LIMIT_EXCEEDED', `Upload rate limit exceeded. Try again later.`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
entry.count++
|
|
44
|
+
return { success: true }
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Remove the counter for a key (useful in tests).
|
|
49
|
+
* @param {string} key
|
|
50
|
+
*/
|
|
51
|
+
reset(key) {
|
|
52
|
+
store.delete(key)
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
3
|
+
|
|
4
|
+
const MAX_LENGTH = 255
|
|
5
|
+
|
|
6
|
+
// Windows reserved device names — treated as unsafe regardless of extension.
|
|
7
|
+
const RESERVED = new Set([
|
|
8
|
+
'CON', 'PRN', 'AUX', 'NUL',
|
|
9
|
+
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
|
10
|
+
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return true when the filename contains characters or patterns that indicate
|
|
15
|
+
* path traversal, null bytes, or otherwise unsafe input.
|
|
16
|
+
* @param {string} filename
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function isUnsafe(filename) {
|
|
20
|
+
return (
|
|
21
|
+
filename.includes('..') ||
|
|
22
|
+
filename.includes('/') ||
|
|
23
|
+
filename.includes('\\') ||
|
|
24
|
+
filename.includes('\0') ||
|
|
25
|
+
/[\x00-\x1f\x7f]/.test(filename)
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sanitize a filename for safe storage.
|
|
31
|
+
*
|
|
32
|
+
* - Path traversal, null bytes, and control characters → replaced with UUID-based name.
|
|
33
|
+
* - Windows reserved names → replaced with UUID-based name.
|
|
34
|
+
* - Remaining special characters stripped from the stem.
|
|
35
|
+
* - Result truncated to 255 characters.
|
|
36
|
+
* - Original extension is always preserved (lowercased).
|
|
37
|
+
*
|
|
38
|
+
* Never rejects — always returns a safe filename.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} filename - Original filename from the upload
|
|
41
|
+
* @returns {{ sanitizedFilename: string, wasUnsafe: boolean }}
|
|
42
|
+
*/
|
|
43
|
+
export function sanitizeFilename(filename) {
|
|
44
|
+
const ext = path.extname(filename).toLowerCase()
|
|
45
|
+
const stem = path.basename(filename, ext)
|
|
46
|
+
|
|
47
|
+
const unsafe = isUnsafe(filename) || RESERVED.has(stem.toUpperCase())
|
|
48
|
+
if (unsafe) {
|
|
49
|
+
return { sanitizedFilename: `${uuidv4()}${ext}`, wasUnsafe: true }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Strip remaining problematic characters from the stem only.
|
|
53
|
+
const cleanStem = stem
|
|
54
|
+
.replace(/[^\w\-. ]/g, '') // keep word chars, hyphens, dots, spaces
|
|
55
|
+
.replace(/\s+/g, '_') // spaces → underscores
|
|
56
|
+
.replace(/^\.+/, '') // no leading dots
|
|
57
|
+
|
|
58
|
+
const safeName = cleanStem ? `${cleanStem}${ext}` : `${uuidv4()}${ext}`
|
|
59
|
+
const truncated = safeName.slice(0, MAX_LENGTH)
|
|
60
|
+
|
|
61
|
+
return { sanitizedFilename: truncated, wasUnsafe: false }
|
|
62
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import { failResult } from '../errors/UploadError.js'
|
|
3
|
+
import { validateMagicBytes } from '../scanners/magicBytes.js'
|
|
4
|
+
import { checkZipBomb } from '../scanners/zipBomb.js'
|
|
5
|
+
import { checkPolyglot } from '../scanners/polyglot.js'
|
|
6
|
+
import { sanitizeFilename } from './sanitizer.js'
|
|
7
|
+
import { scanWithClamAV } from '../scanners/clamav.js'
|
|
8
|
+
import { scanWithVirusTotal } from '../scanners/virustotal.js'
|
|
9
|
+
|
|
10
|
+
// MIME types that should trigger ZIP bomb detection.
|
|
11
|
+
const ARCHIVE_MIME_TYPES = new Set([
|
|
12
|
+
'application/zip',
|
|
13
|
+
'application/x-zip-compressed',
|
|
14
|
+
'application/x-zip',
|
|
15
|
+
'application/java-archive',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default configuration values.
|
|
20
|
+
*/
|
|
21
|
+
export const defaultConfig = {
|
|
22
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
23
|
+
allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx'],
|
|
24
|
+
allowedMimeTypes: [
|
|
25
|
+
'image/jpeg',
|
|
26
|
+
'image/png',
|
|
27
|
+
'image/gif',
|
|
28
|
+
'image/webp',
|
|
29
|
+
'application/pdf',
|
|
30
|
+
],
|
|
31
|
+
storage: 'local',
|
|
32
|
+
localPath: './uploads',
|
|
33
|
+
scan: {
|
|
34
|
+
magicBytes: true,
|
|
35
|
+
zipBomb: true,
|
|
36
|
+
polyglot: true,
|
|
37
|
+
clamav: false,
|
|
38
|
+
virustotal: false,
|
|
39
|
+
},
|
|
40
|
+
rateLimit: {
|
|
41
|
+
enabled: false,
|
|
42
|
+
maxUploads: 10,
|
|
43
|
+
windowMs: 60 * 1000,
|
|
44
|
+
},
|
|
45
|
+
audit: {
|
|
46
|
+
enabled: false,
|
|
47
|
+
logPath: './logs/uploads.log',
|
|
48
|
+
},
|
|
49
|
+
sanitizeFilename: true,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Merge user config over defaults (shallow for top-level, deep for nested objects).
|
|
54
|
+
* @param {object} userConfig
|
|
55
|
+
* @returns {object}
|
|
56
|
+
*/
|
|
57
|
+
export function mergeConfig(userConfig = {}) {
|
|
58
|
+
return {
|
|
59
|
+
...defaultConfig,
|
|
60
|
+
...userConfig,
|
|
61
|
+
scan: { ...defaultConfig.scan, ...(userConfig.scan ?? {}) },
|
|
62
|
+
rateLimit: { ...defaultConfig.rateLimit, ...(userConfig.rateLimit ?? {}) },
|
|
63
|
+
audit: { ...defaultConfig.audit, ...(userConfig.audit ?? {}) },
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Step 1: Validate file size.
|
|
69
|
+
* @param {number} size - File size in bytes
|
|
70
|
+
* @param {number} maxFileSize - Max allowed size in bytes
|
|
71
|
+
*/
|
|
72
|
+
export function checkFileSize(size, maxFileSize) {
|
|
73
|
+
if (size > maxFileSize) {
|
|
74
|
+
return failResult(
|
|
75
|
+
'FILE_TOO_LARGE',
|
|
76
|
+
`File size ${size} bytes exceeds the limit of ${maxFileSize} bytes`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
return { success: true }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Step 2: Validate file extension against the allowlist.
|
|
84
|
+
* @param {string} filename
|
|
85
|
+
* @param {string[]} allowedExtensions
|
|
86
|
+
*/
|
|
87
|
+
export function checkExtension(filename, allowedExtensions) {
|
|
88
|
+
const ext = path.extname(filename).toLowerCase().replace(/^\./, '')
|
|
89
|
+
if (!ext) {
|
|
90
|
+
return failResult('INVALID_EXTENSION', 'File has no extension')
|
|
91
|
+
}
|
|
92
|
+
if (!allowedExtensions.map((e) => e.toLowerCase()).includes(ext)) {
|
|
93
|
+
return failResult('INVALID_EXTENSION', `Extension ".${ext}" is not allowed`)
|
|
94
|
+
}
|
|
95
|
+
return { success: true }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Step 3: Validate declared MIME type against the allowlist.
|
|
100
|
+
* @param {string} mimeType
|
|
101
|
+
* @param {string[]} allowedMimeTypes
|
|
102
|
+
*/
|
|
103
|
+
export function checkMimeType(mimeType, allowedMimeTypes) {
|
|
104
|
+
if (!allowedMimeTypes.includes(mimeType)) {
|
|
105
|
+
return failResult('INVALID_MIME_TYPE', `MIME type "${mimeType}" is not allowed`)
|
|
106
|
+
}
|
|
107
|
+
return { success: true }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run the full validation pipeline on a file.
|
|
112
|
+
* Order: size → extension → MIME → magic bytes → ZIP bomb → polyglot → sanitize filename
|
|
113
|
+
*
|
|
114
|
+
* @param {{ buffer: Buffer, filename: string, mimeType: string, size: number }} file
|
|
115
|
+
* @param {object} [config]
|
|
116
|
+
* @returns {Promise<{ success: true, sanitizedFilename: string } | { success: false, error: string, message: string }>}
|
|
117
|
+
*/
|
|
118
|
+
export async function validateFile(file, config = {}) {
|
|
119
|
+
if (!file || !Buffer.isBuffer(file.buffer) || !file.filename || !file.mimeType || typeof file.size !== 'number') {
|
|
120
|
+
return failResult('STORAGE_ERROR', 'Invalid file input: buffer, filename, mimeType, and size are required')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cfg = mergeConfig(config)
|
|
124
|
+
const { buffer, filename, mimeType, size } = file
|
|
125
|
+
|
|
126
|
+
const sizeCheck = checkFileSize(size, cfg.maxFileSize)
|
|
127
|
+
if (!sizeCheck.success) return sizeCheck
|
|
128
|
+
|
|
129
|
+
const extCheck = checkExtension(filename, cfg.allowedExtensions)
|
|
130
|
+
if (!extCheck.success) return extCheck
|
|
131
|
+
|
|
132
|
+
const mimeCheck = checkMimeType(mimeType, cfg.allowedMimeTypes)
|
|
133
|
+
if (!mimeCheck.success) return mimeCheck
|
|
134
|
+
|
|
135
|
+
const magicCheck = await validateMagicBytes(buffer, mimeType, cfg.allowedMimeTypes)
|
|
136
|
+
if (!magicCheck.success) return magicCheck
|
|
137
|
+
|
|
138
|
+
if (cfg.scan.zipBomb && ARCHIVE_MIME_TYPES.has(magicCheck.detectedMime)) {
|
|
139
|
+
const bombCheck = checkZipBomb(buffer)
|
|
140
|
+
if (!bombCheck.success) return bombCheck
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (cfg.scan.polyglot) {
|
|
144
|
+
const polyglotCheck = checkPolyglot(buffer)
|
|
145
|
+
if (!polyglotCheck.success) return polyglotCheck
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { sanitizedFilename } = cfg.sanitizeFilename
|
|
149
|
+
? sanitizeFilename(filename)
|
|
150
|
+
: { sanitizedFilename: filename }
|
|
151
|
+
|
|
152
|
+
if (cfg.scan.clamav) {
|
|
153
|
+
const clamResult = await scanWithClamAV(buffer, cfg.clamavOptions)
|
|
154
|
+
if (!clamResult.success) return clamResult
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (cfg.scan.virustotal) {
|
|
158
|
+
const vtResult = await scanWithVirusTotal(buffer, cfg.virustotalOptions)
|
|
159
|
+
if (!vtResult.success) return vtResult
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { success: true, sanitizedFilename }
|
|
163
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const ERROR_CODES = {
|
|
2
|
+
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
|
|
3
|
+
INVALID_EXTENSION: 'INVALID_EXTENSION',
|
|
4
|
+
INVALID_MIME_TYPE: 'INVALID_MIME_TYPE',
|
|
5
|
+
INVALID_MAGIC_BYTES: 'INVALID_MAGIC_BYTES',
|
|
6
|
+
ZIP_BOMB_DETECTED: 'ZIP_BOMB_DETECTED',
|
|
7
|
+
POLYGLOT_DETECTED: 'POLYGLOT_DETECTED',
|
|
8
|
+
UNSAFE_FILENAME: 'UNSAFE_FILENAME',
|
|
9
|
+
VIRUS_DETECTED: 'VIRUS_DETECTED',
|
|
10
|
+
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
|
11
|
+
STORAGE_ERROR: 'STORAGE_ERROR',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class UploadError extends Error {
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} code - One of ERROR_CODES
|
|
17
|
+
* @param {string} message - Human-readable description
|
|
18
|
+
*/
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message)
|
|
21
|
+
this.name = 'UploadError'
|
|
22
|
+
this.code = code
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a standard failure result object.
|
|
28
|
+
* @param {string} code
|
|
29
|
+
* @param {string} message
|
|
30
|
+
* @returns {{ success: false, error: string, message: string }}
|
|
31
|
+
*/
|
|
32
|
+
export function failResult(code, message) {
|
|
33
|
+
return { success: false, error: code, message }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a standard success result object.
|
|
38
|
+
* @param {{ url: string, filename: string, size: number, mimeType: string, storage: string }} data
|
|
39
|
+
* @returns {{ success: true, data: object }}
|
|
40
|
+
*/
|
|
41
|
+
export function successResult(data) {
|
|
42
|
+
return { success: true, data }
|
|
43
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { validateFile, mergeConfig } from './core/validator.js'
|
|
2
|
+
import { failResult } from './errors/UploadError.js'
|
|
3
|
+
import { getStorageAdapter } from './storage/index.js'
|
|
4
|
+
import { createRateLimiter } from './core/rateLimiter.js'
|
|
5
|
+
import { createLogger } from './audit/logger.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a configured fileguard instance.
|
|
9
|
+
* @param {object} [config] - Optional configuration overrides
|
|
10
|
+
* @returns {{ process: Function }}
|
|
11
|
+
*/
|
|
12
|
+
export function createGuard(config = {}) {
|
|
13
|
+
const cfg = mergeConfig(config)
|
|
14
|
+
const rateLimiter = cfg.rateLimit.enabled ? createRateLimiter(cfg.rateLimit) : null
|
|
15
|
+
const logger = cfg.audit.enabled ? createLogger(cfg.audit) : null
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
/**
|
|
19
|
+
* Validate and store a file upload end-to-end.
|
|
20
|
+
* @param {{ buffer: Buffer, filename: string, mimeType: string, size: number }} file
|
|
21
|
+
* @param {{ key?: string }} [meta] - Optional meta (e.g. user IP for rate limiting)
|
|
22
|
+
* @returns {Promise<{ success: boolean, data?: object, error?: string, message?: string }>}
|
|
23
|
+
*/
|
|
24
|
+
async process(file, meta = {}) {
|
|
25
|
+
try {
|
|
26
|
+
if (rateLimiter) {
|
|
27
|
+
const rl = rateLimiter.check(meta.key ?? 'anonymous')
|
|
28
|
+
if (!rl.success) return rl
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const validation = await validateFile(file, cfg)
|
|
32
|
+
if (!validation.success) {
|
|
33
|
+
await logger?.log({ event: 'upload.rejected', error: validation.error, filename: file.filename })
|
|
34
|
+
return validation
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const store = await getStorageAdapter(cfg.storage)
|
|
38
|
+
const result = await store(
|
|
39
|
+
{
|
|
40
|
+
buffer: file.buffer,
|
|
41
|
+
filename: file.filename,
|
|
42
|
+
sanitizedFilename: validation.sanitizedFilename,
|
|
43
|
+
size: file.size,
|
|
44
|
+
mimeType: file.mimeType,
|
|
45
|
+
},
|
|
46
|
+
cfg
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
await logger?.log({
|
|
50
|
+
event: result.success ? 'upload.success' : 'upload.error',
|
|
51
|
+
filename: file.filename,
|
|
52
|
+
size: file.size,
|
|
53
|
+
storage: cfg.storage,
|
|
54
|
+
...(result.success ? { url: result.data.url } : { error: result.error }),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return result
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return failResult('STORAGE_ERROR', err.message ?? 'An unexpected error occurred')
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { createGuard as fileguard }
|